State & persistence
What it models
Section titled “What it models”A tank is a single JSON-shaped object: stocks, equipment, organisms, the log, and the position of its own random stream. Nothing in it is a class instance, a closure or a date. Every system returns a new tree rather than mutating the one it was handed, so the state a tick began with is still readable when it ends.
Status
Section titled “Status”| Mechanic | Status | Why |
|---|---|---|
| Immutable tree | settled | Every write goes through a producer. Systems emit bounded effects; only the orchestrators that add or remove organisms return state, because an effect cannot express an array growing. |
| Serializability | settled | Numbers, strings, booleans and arrays all the way down. A host persists a tank with nothing but a stringify. |
| The random seam | settled | Seed and counter as a pair rather than a generator, because a pair serializes. Math. appears nowhere in the source. |
| Tank-unique ids | settled | Cut from a stream position that is never revisited, so an id is unique within its tank by construction. |
Preset | settled | A seed sets starting stocks and nothing else — no new dynamics, no gates, no catch-up. Whatever it writes, the tick loop takes from there. |
| Construction refusal | settled | A non-finite number anywhere in the config or the seed throws. Every threshold in the engine is a comparison and Na fails all of them, so one that gets in is never noticed and never leaves. |
| Declared ranges | settled | Every tunable declares its bounds beside its label and unit, and a test walks every leaf — a new tunable without a range is a caught defect, not a silent one. |
| Versioned saves | settled | One integer, no migration shims. A mismatch discards the stored tank rather than reading old numbers under new meanings. |
| Nitrogen-cycle bounds | scaffolding | The one section with unbounded leaves: only its four half-saturation constants declare a range, so a writer asking for any of the others gets nothing to hold them to. |
'cycled' seeding | settled | Resolved against the tank: the colony sized to its stock and bed at rest, floored at a share of its surface ceiling; the bed, the water’s nutrients and hardness off its capacity and tap. |
| Unnamed streams | scaffolding | A tank with no named seed takes one off the clock plus a count of streams opened in this process. Two tanks in separate processes have only the millisecond between them. |
| Section recovery on load | scaffolding | Sections validate independently, but a refused config section is replaced wholesale rather than leaf by leaf — so one bad number costs every tuned constant in the file. |
| Log persistence | missing | Logs are outside the persisted shape. A reload returns the tank’s whole biology and none of its history. |
How it works
Section titled “How it works”The tree
Section titled “The tree”| Branch | Holds |
|---|---|
tick | Hours since the tank was built. One tick is one hour |
tank | Capacity and hardscape slots — the only physical constants |
resources | Every stock: water, temperature, the passives, food and waste, the nitrogen chain, the nutrients, the two gases, KH and GH, the two colonies — pH is derived, never stored |
environment | Room temperature, tap temperature, tap KH and GH — what the tank sits in |
equipment | Eleven fixtures, each with its own settings and schedule |
plants · fish · clutches | The organisms, as arrays of individuals. A plant carries its lineage — parent, family — its age and its vigour beside its condition and its budget — reserve, tissue, the plantlet’s buffer and the stores, its size read off the tissue (Plants) |
algae | One bloom per kind, keyed by kind — green, film — each its condition and its budget, its coverage read off the tissue against its habitat: a population, not an array of individuals |
rng | The seed and the stream position every draw comes off |
logs | The event stream |
alert | One latched flag per warning condition |
Organic matter — food and waste in resources, a fish’s gut, the bed’s mulm
in its organicReserve — is an Organics: grams of dry mass, the grams of
carbon in them, and the mg of each nutrient, nitrogen counted as the nitrate it
ends up as. Whatever enters a pool brings its composition, and whatever leaves
takes its share.
Passive resources — surface, flow, light and aeration — are stored on the tree
but derived: the tick recomputes all four from the equipment before anything
reads them. lightByHour is the one with memory: 24 slots of substrate PAR, the
tick rewriting the slot for its hour of the day, so the tree carries the last
day’s light. A new tank opens with the history of the schedule it was built on.
Randomness
Section titled “Randomness”RngState = { seed, counter }A pair rather than a closure, because a pair serializes and a generator identity would be lost on a save. Every draw mixes the position through splitmix32 and advances the counter by one, so the stream is a pure function of where it is.
Name a seed at construction and the tank runs the same life every time — organisms, individual variation and ids all replay. Leave it out and the tank takes a time-derived one.
Everything the engine cannot derive from physics comes off this stream: a fish’s sex, its hardiness offset, its health jitter, a plant’s vigour, and every organism’s name. That last one is what makes ids replay too.
The id draw cuts a name from the counter and advances it, so no position is used twice and an id is unique within its tank without a registry.
It is not unique across tanks. Two tanks on one seed emit the same id sequence, which means host state keyed by organism id has to be reset when the tank is replaced rather than trusted to differ. The engine gives a tank no identity of its own — that belongs to whoever is holding more than one.
Starting state
Section titled “Starting state”A seed writes stocks onto a freshly built tank and stops. It declares no
behaviour; the tick never reads it. The state keeps its stock half — bacteria,
substrate, resources — as seed, so a tank still at hour zero can be filled
again the way it first was: a retuned tap re-resolves KH and GH through it
(startingHardness), and a reset puts the water, bed and scape back on it.
| Seedable | Accepts |
|---|---|
bacteria | Absolute AOB and NOB counts, or the literal 'cycled' |
substrate | The bed’s remaining organic and KH reserves, and its nutrient store |
resources | The three nitrogen compounds, the four nutrients, KH, GH, and both dissolved gases |
fish | Groups of species, count, size and sex; the shop that raised them sets their age |
plants | Groups of species, count, size and age |
Nothing here is validated or clamped beyond the non-finite refusal. A seed can say anything the state shape can hold.
'cycled' is the exception that resolves against the tank rather than being
copied into it. It is a claim about a month of running, not only about the
filter, so it carries every figure that month moved: the colony it grew, the
bed reserves it spent, the nitrate it made net of the water changes a keeper
would have done, and the KH and GH the bed and that nitrate let it keep. A preset resized or
rebuilt at the door still gets all of them sized to fit. Hardscape starts as
bought.
Rosters go through the same factories stocking uses, so a seeded fish and a bought one are identical in shape and both cost the stream the same draws. The shipped tanks are on the presets page.
Config validation
Section titled “Config validation”TunableConfig is numbers all the way to its leaves, and each leaf declares its
own range alongside its label and unit. The nitrogen cycle is the one section
that only partly does: its four half-saturation constants are measured
concentrations and carry a range, while the other eight leaves were never given
bounds, so a writer asking for a range there gets nothing back.
| Writer | Checks against |
|---|---|
The CLI’s config set | the declared range, plus a finite-number and known-path check |
| The tunables drawer | the same declared range, as the field’s own bounds |
| The save schema | a second, independent set of bounds written into the schema itself |
Addressing is by dotted path, and a path that does not already name a number is refused — a stored key nothing reads is a setting the operator only believes they made. A coefficient outside its declared range is one the engine was never solved for: a negative attenuation turns Beer–Lambert into gain and every lit tick after it infinite.
Save and load
Section titled “Save and load”The persisted shape is a subset of the tree with the logs dropped and the current preset id added, wrapped alongside the tunable config and the host’s UI preferences under one version integer.
| Step | Behaviour |
|---|---|
| Version check | A mismatch discards everything. There are no migration shims — a save format change is breaking on purpose |
| Full validation | A strict schema that rejects unknown keys as well as bad values |
| Section recovery | If the whole fails, simulation, config and preferences are each retried alone |
| Config merge | A config that validates is overlaid on the defaults. The schema requires every leaf, though, so a save written before a tunable was added fails it outright and the whole config reloads as defaults rather than the one new leaf filling in |
| Write | Debounced, with an explicit flush for page unload |
The save surface itself is not part of the engine. SimulationState is plain
data and the host decides where it goes; the shipped implementation puts it in
browser storage.
Key tunables
Section titled “Key tunables”| Constant | Meaning | Value |
|---|---|---|
PERSISTENCE_ | The one integer a stored tank is checked against | integer |
| Save debounce | Delay before a state change reaches storage | 2000 ms |
CYCLED_ | Share of its surface ceiling a 'cycled' AOB colony covers, rounded up off fishless soil tanks on day 30 (1.3–1.8 %, a 5 gal sponge to a 150 gal canister) | 0.02 |
CYCLED_ | NOB coverage, off the same measurement (0.7–0.9 %) and rounded the same way | 0.01 |
CYCLED_ | Share of a fresh bed’s organics still in it on day 30 — one curve every bed type follows | 0.1 |
CYCLED_ | Share of a fresh bed’s nutrient store left on day 30 — a plantless month of the leak, rounded down | 0.85 |
CYCLED_ | Share of a fresh soil bed’s KH reserve left on day 30 under weekly 25 % changes | 0.8 |
CYCLED_ | Share of the tap’s KH an aqua soil bed leaves a tank a month in, under weekly 25 % changes; inert beds take none | 0.15 |
CYCLED_ | Share of what the bed’s mulm rotted and its store leaked still in the water, rounded to the low side because the handed-over tank has just been changed | 0.25 |
HARDINESS_ | Per-individual hardiness spread, as a fraction of the species baseline | 0.15 |
| Tank capacity bounds | What the save schema will accept as a tank | 1–10000 L |
| Neighbour | Read | Written |
|---|---|---|
| Nitrogen cycle | — | A seed writes both colonies, and 'cycled' writes the nitrate a month of the bed’s rot and leak left behind |
| Equipment | substrate., which decides what a 'cycled' bed resolves to | The bed’s remaining reserves |
| Livestock | — | Seeded rosters, built through the same factory stocking uses and drawing on the same stream |
| Plants | — | Seeded plantings, at a named plantable size and age, stocked on the plants config, each founding its own family at full condition; the draw stream for every unit’s vigour and id, plantlets included |
| Water & gases | tap and tap, which a 'cycled' KH and GH resolve against | Both dissolved gases, KH and GH, where a seed names them |
| Environment | tank. — every per-litre seed figure resolves against it | — |
| Actions | — | Supplies the draw stream for ids, and takes back the advanced position |
| Alerts & logging | — | alert crosses a save; logs do not |
Source
Section titled “Source”src/simulation/ — the state shape, the seed and the draw stream;
src/simulation/config/ — declared ranges and the lookup all three writers
share; src/ui/persistence/ — the versioned save format and its schema.