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.
- The whole spagyric Great Work — grow or buy a herb, dry it, macerate it in spirit, strain to extract and marc, calcine the marc to ash, leach and crystallise the salt, ferment and distil a spirit, then recombine all three into a finished tincture.
- Temperature is the only axis — one hotplate, one power dial in watts, and every operation decided by where the heat takes the flask.
- A real garden — three raised beds, per-species botany, two-sided moisture stress (some herbs want to dry out, some want to stay damp), a diurnal climate, and deterministic weather.
- Consequence that compounds — metered electricity, monthly bills, a freshness clock on perishables, and a three-headed "discretion" antagonist that notices money, incidents and esoterica.
- It runs while you're gone — offline catch-up that is provably identical to live play, so there is nothing to scum and no save to reload your way out of.
- A diegetic phone and computer — eleven phone apps and an in-world desktop with a library reader and a searchable archive, all inside the fiction.
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 ·
tscproject 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.
- Eleven ordered passes per advance — stage processes, the physics integrator, product and in-vessel minting, spoilage, utilities, relationship decay, the discretion antagonist, plant growth, fermentation, drying and aging — each idempotent under re-folding.
- Execution order is load-bearing — the integrator runs before minting (so a run that just completed mints this same advance); the antagonist runs last (so it reads this advance's own sales, incidents and decayed relationships).
- Canonical mint ordering — lots minted across different passes are stable-sorted by completion hour, so offline's pass-order appends and online's hour-order appends converge to the same player-visible inventory.
- Watermark idempotence — every quantised system carries its own integer high-water mark; recomputation is skipped if the mark is already current, making every pass a safe no-op to replay.
- Proven by test — the suite drives identical processes through one big jump and thousands of one-second ticks and asserts the RNG state, the inventory and the process outcomes come out bit-identical.
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.956mass /0.8943mole / ≈ 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:
- Calcination — a muffle furnace drives the marc past its decomposition temperature; quality is a trapezoid in degree-hours, ramping to 100 by 180, holding flat across a 180–340 plateau, then over-burning. A hittable window that rewards patience.
- Dissolution — warm-leaching the ash's salts into a lixivium follows a saturating
1 − e^(−degree-hours/600)curve; a ~50°C bath reaches ~90% recovery in about 40 game-hours, hotter is faster but courts a boil-dry. - Coagulation — boiling the lixivium down to crystallise the salt is graded on supersaturation
σ = initial-water / water-remaining; the band 1.05–1.3 yields large pure crystals, beyond it fines to powder, and a boil-dry scorches it. - Maceration — extracting a dried herb into spirit is a triple product:
100 · extracted · (herb-quality/100) · min(1, ABV/0.4)— saturating extraction, the herb's provenance grade, and the solvent power of your spirit. - Fermentation — a forward-Euler accrual that gains ABV at full rate inside an 18–28°C band, ramps to nothing by ~42°C, caps at the 18% yeast-death ceiling, and docks quality for sustained heat stress above 32°C.
- Strain — a discrete, RNG-free split of a tincture into clarified extract (the Sulphur) and spent marc (80% of grade), closing the loop back to calcination.
- Recombination — blending the three principles into the finished tincture as a recipe-weighted average of their effective qualities — extract 0.45, spirit 0.30, salt 0.25 — the body weighted heaviest.
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
- The legacy stage-timer engine was demoted to a passive observer: physics-driven operations are driven entirely by the integrator, and a single observer is the sole writer of their outcome — a clean one-writer rule.
- Save format is versioned (v8) with a chained migration ladder; optional fields are tolerated by lenient guards, and a version mismatch with no backup wipes to a fresh game instead of erroring.
- The end-to-end test seam (
window.__aa) is double-gated by a build-time literal and a runtime flag, so it is dead-code-eliminated from any shipped build; Playwright frames the camera through it but performs every action through the real DOM. - Vapour superheat is wired into the purity formula but stays literally bit-zero for water (its term is gated on a vapour heat capacity water doesn't have), ready to switch on the day a volatile substance ships — without an engine change.
- An in-world computer hosts a read-only archive browser: board→thread navigation with client-side full-text search, the corpus lazy-loaded as code-split chunks (a light index up front, the bulk on first open) and produced by a deterministic offline build step that pseudonymises every author behind stable handles.
- Cosmetic jitter — bubbles, steam, condensate drips — uses a sine-hash, never
Math.random(), so even the visuals never threaten the determinism contract.