Project · Fire

Salmethic

Real thermodynamics, dressed as the Great Work.

Essence

For the people who play it

Salmethic is a modern-day occult-alchemy game where an ordinary young adult stumbles into the real alchemical structure of matter. You grow herbs in a back-garden bed, dry and ferment them, distil spirits, and work the full spagyric chain — separating each plant into its three principles and recombining them into something greater than the parts. There is no recipe you unlock by clicking; there is a flask, a hotplate with a power dial, and the physics of heat.

What makes it unusual is that the chemistry is not a façade. The flask does not "finish" on a timer. It heats along a real curve toward a steady-state temperature set by your power and the room; it reaches a boil only if it physically can; it climbs the true ethanol–water distillation curve toward an azeotrope it can never cross. Boil it dry and you scorch the tail. Leave the receiver too small and the distillate spills. Over-water a Mediterranean herb and it rots. Quality is earned by running the process well, not by reading the answer.

The world keeps its own time at three times the clock, and it keeps running while you are away — but it can never be out-played by reloading. Close the game for a day and reopen it, and the fermentation, the decay, the bills and the antagonist's slow attention all advance to exactly the state you would have reached had you watched every minute. The save is the truth, and the truth is reproducible.

In Development Native desktop · playable vertical slice

Construction

For the engineers

Stack

Shell
Electron 33 — context-isolated, sandboxed renderer, no Node in the page
Interface
React 18 · TypeScript 5.6 · hand-written CSS
Scene
Phaser 3.85 — canvas/WebGL, four scenes under a React HUD
State
Zustand 5 — gameStore (the sim) + uiStore (view); no server, fully local
Core
A pure TypeScript simulation — zero IO, zero Date.now(), single seeded PRNG
Persistence
Versioned JSON saves (v8) — atomic temp→rename with one rotating backup, in the Electron main process
Build
electron-vite (Vite 5); a second Vite config targets the browser
Testing
Vitest (~800 unit tests, 75 files) · Playwright (72 end-to-end cases, 36 specs) · jsdom · Testing Library
Tooling
ESLint 9 · Prettier · tsc project references

Architecture

A pure core, with IO only at the edges. Everything that decides what happens — the clock, the thermodynamics, the chemistry, the economy — lives in a pure functional core that takes the current wall-clock time as a parameter and returns a new state. It never calls Date.now(), never touches the filesystem, never knows about React or Phaser. The renderer injects the time and the data tables; the core does the rest. That boundary is what makes the whole thing unit-testable and, more importantly, deterministic.

Two Zustand stores, cleanly split. gameStore holds the canonical SimulationState and dispatches every action through pure core functions; uiStore holds transient view state — which room you're in, the camera zoom tier, the focused object, whether the phone is open — and is never persisted. A 100 ms tick loop advances the simulation ten times a second; a separate 30-second autosave flushes to disk, with a pagehide/beforeunload flush so quitting can't lose more than the last half-minute.

Electron, locked down. The main process owns the window, the filesystem and the IPC handlers; the renderer is sandboxed with context isolation on and Node integration off. A minimal preload bridge exposes only schema-agnostic read/write/recover save methods. Writes are atomic — temp file, rotate the live save to a backup, rename — and the bootstrap recovery path handles every case: good save, missing save, corrupt save with a good backup, or a version mismatch with no backup (which wipes cleanly to a fresh game rather than erroring).

React HUD over a Phaser stage. Four Phaser scenes (the shed laboratory, the garden, the cellar, the office) subscribe directly to both stores and swap as you change rooms. The lab scene is drawn entirely from live physics — the hotplate glow scales with power, the flask drains with the boiler mass, bubbles and steam track the boil-off rate, the condenser drips at the real collection throughput. React components inspect, assemble and act on top of the canvas; the canvas only listens to its own clicks, so overlay clicks never leak through.

Determinism

The game is one deterministic function

The heart of Salmethic is a single function, advanceGame(state, nowMs), and a single invariant: fold-equivalence. Advancing the world by one big offline jump must produce a byte-for-byte identical state to advancing it through many small live ticks over the same span. The same function drives both the live loop and the offline catch-up on load, so an online session and a closed-game reconciliation cross the same game-hour boundaries in the same order and consume the random stream identically. This is the anti-save-scum guarantee — there is no path that reloading can exploit, because every path lands on the same answer.

Holding that invariant is mostly a fight against floating point and ordering. Live play accumulates the clock as a sum of thousands of tiny deltas, so a span an offline jump computes as exactly 30.0 can land online at 29.999999999 — and floor() of that is a whole game-hour short, which would silently desync every time-driven system. The fix is a clock that snaps up to an integer when it sits within 1×10⁻⁴ game-hours below one (about a tenth of a real second — far above any realistic drift, far below anything you could feel), and only ever upward, so time never runs backwards.

Each tick then derives the same ordered set of integer-hour sub-steps on both paths. Every time-driven subsystem either steps forward-Euler in ascending integer order, or recomputes a closed form from a fixed per-item baseline gated by a persisted integer "watermark" — so an offline catch-up that fast-forwards five hundred hours over an already-decayed span applies only the delta, never double-counts, and never depends on how the interval was partitioned. The PRNG is mulberry32, whose entire state is one unsigned 32-bit integer that persists straight into the save and resumes exactly where it left off.

Thermodynamics

The physics under the flask

Every heated operation runs on one analytic integrator. A vessel is a lump of thermal mass losing heat to the room by Newton's law, driven by the hotplate's wattage: C · dT/dt = P − k · (T − T_room), where the heat capacity C = mass · specific-heat and the loss coefficient k = 0.4 + 0.02 · V^(2/3) carries a real surface-area term (a bigger flask radiates more). Rather than step this numerically — which would diverge under different time partitions and break fold-equivalence — the integrator solves it in closed form and only ever advances to integer hours or to analytically computed physical crossings.

Below the boil (Regime A) the temperature climbs exponentially toward its steady state T∞ = T_room + P/k as T(τ) = T∞ + (T₀ − T∞) · e^(−(k/C)·τ). The exact time to reach a boil is τ_boil = −(C/k) · ln((bp − T∞)/(T₀ − T∞)), taken only when the steady state actually clears the boiling point. At the boil (Regime B) the bulk pins at bp and the surplus power drives vapour off at ṁ = (P − k·(bp − T_room)) / L_vap grams per second, until a tiny mass floor flags a scorched, boiled-dry run. When an operation needs a temperature integral (degree-hours above a threshold), it uses the exact analytic integral of that exponential, never a rectangle — because rectangles aren't additive when you re-partition the span, and that would quietly break the fold.

Distillation is the same engine, told apart only by what's in the flask. Plain water bottles as distilled water; a fermented wash climbs the real ethanol–water equilibrium curve. The published curve is in mole fractions, so the code converts the boiler's mass fraction to moles (molar masses 46.068 and 18.015 g/mol), interpolates a seventeen-knot table, and converts back. The azeotrope at 0.956 by mass (about 97.2% by volume) is a hard ceiling encoded as a y = x knot — vapour and liquid compositions meet, so no further enrichment is possible, exactly as in reality.

Quality is process cleanliness, computed from the physics and nothing else: purity = 100 · recovery · (1 − 0.5·overshoot) · (1 − 0.25·boilDry), where recovery is the fraction of generated vapour you actually caught. No condenser and it all vents to zero; too small a receiver and the overflow spills; boil it dry and you take the scorch penalty. None of that is scripted — it falls out of mass-flow over the rig's plumbing graph.

A few of the tuning constants

1:3
Wall-time to game-time — one real hour is three game-hours; offline catch-up caps at 48 real hours (≈ 6 game days)
k
0.4 + 0.02 · V^(2/3) W/°C — Newtonian loss with a surface-area term
Azeotrope
0.956 mass / 0.8943 mole / ≈ 97.2% vol — the hard distillation ceiling
Boil-dry
−25% purity, plus the run powers itself off rather than glow on a dry flask
Snap ε
1×10⁻⁴ game-hours — the integer-boundary clock-snap threshold

The Great Work

Seven operations, one kit

The spagyric suite is built on a single claim that the code makes true: adding an operation is adding configuration, not rewriting the engine. Each operation supplies an OutcomeModel — where the vapour goes, what accumulates, how the terminal state scores 0–100 — and reuses the same substance-agnostic integrator, the same observer that maps physics onto a process, and the same ingredient-minting paths. The thermal core never branches per operation.

So the chemistry composes into one long loop, each step a real process with a skill window:

The living world

Botany, climate and consequence

Gardening is a forward-Euler botanist. Each plant walks its own integer-hour grid, accruing development at three times the clock, modulated by a temperature response (full rate inside the species band, ramping over an 8°C margin) and a soil-moisture factor. The design's signature is two-sided moisture stress: dryness and waterlogging both accumulate, with asymmetric per-species weights, so woody Mediterranean herbs reward "let it dry" while leafy ones demand "keep it evenly moist." Harvest quality is the stress integral normalised by the plant's age — a forgiving window for the occasional bad day, ruin for chronic neglect.

The climate is a deterministic sinusoid. Ambient temperature is base + swing · cos(2π·(hour − 15)/24), peaking mid-afternoon: the garden swings 4–24°C, the thin-walled shed 8–24°C, the cellar is heater-locked at a fermentation-friendly 22°C. Weather is a per-day seeded hash, the daylight curve interpolates twelve keyframes around the 24-hour cycle — all pure, all reproducible, none of it touching the gameplay RNG.

Energy is money, metered hour by hour. A utilities pass integrates the rig's real wattage plus the always-on cellar heater (70 W per °C of gap to 22°C) and issues one monthly bill — energy at $0.001/Wh plus a flat property tax. Products price off a quality curve and inherit a provenance name from their inputs (so a "Rosemary Salt" is exactly what it says); perishables also fade with a freshness clock — about two weeks of cool-cellar shelf life on a closed-form decay.

And the world is watching. The "discretion" antagonist tracks three 0–100 buckets — financial, criminal and esoteric — that decay slowly and rise with sales, lab incidents on volatile substances, and esoteric exposure that bleeds into the others once it crosses a threshold. It's the pressure that turns money into risk, accrued the same fold-safe way as everything else.

Notable details

Things worth calling out