Skip to content

State & persistence

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.

MechanicStatusWhy
Immutable treesettledEvery 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.
SerializabilitysettledNumbers, strings, booleans and arrays all the way down. A host persists a tank with nothing but a stringify.
The random seamsettledSeed and counter as a pair rather than a generator, because a pair serializes. Math.random appears nowhere in the source.
Tank-unique idssettledCut from a stream position that is never revisited, so an id is unique within its tank by construction.
PresetSeedsettledA 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 refusalsettledA non-finite number anywhere in the config or the seed throws. Every threshold in the engine is a comparison and NaN fails all of them, so one that gets in is never noticed and never leaves.
Declared rangessettledEvery 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 savessettledOne integer, no migration shims. A mismatch discards the stored tank rather than reading old numbers under new meanings.
Nitrogen-cycle boundsscaffoldingThe 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' seedingsettledResolved 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 streamsscaffoldingA 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 loadscaffoldingSections 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 persistencemissingLogs are outside the persisted shape. A reload returns the tank’s whole biology and none of its history.
BranchHolds
tickHours since the tank was built. One tick is one hour
tankCapacity and hardscape slots — the only physical constants
resourcesEvery 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
environmentRoom temperature, tap temperature, tap KH and GH — what the tank sits in
equipmentEleven fixtures, each with its own settings and schedule
plants · fish · clutchesThe organisms, as arrays of individuals. A plant carries its lineage — parentId, familyId — 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)
algaeOne bloom per kind, keyed by kind — greenWater, film — each its condition and its budget, its coverage read off the tissue against its habitat: a population, not an array of individuals
rngThe seed and the stream position every draw comes off
logsThe event stream
alertStateOne 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.

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.

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.

SeedableAccepts
bacteriaAbsolute AOB and NOB counts, or the literal 'cycled'
substrateThe bed’s remaining organic and KH reserves, and its nutrient store
resourcesThe three nitrogen compounds, the four nutrients, KH, GH, and both dissolved gases
fishGroups of species, count, size and sex; the shop that raised them sets their age
plantsGroups 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.

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.

WriterChecks against
The CLI’s config setthe declared range, plus a finite-number and known-path check
The tunables drawerthe same declared range, as the field’s own bounds
The save schemaa 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.

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.

StepBehaviour
Version checkA mismatch discards everything. There are no migration shims — a save format change is breaking on purpose
Full validationA strict schema that rejects unknown keys as well as bad values
Section recoveryIf the whole fails, simulation, config and preferences are each retried alone
Config mergeA 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
WriteDebounced, 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.

ConstantMeaningValue
PERSISTENCE_VERSIONThe one integer a stored tank is checked againstinteger
Save debounceDelay before a state change reaches storage2000 ms
CYCLED_AOB_COVERAGEShare 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_COVERAGENOB coverage, off the same measurement (0.7–⁠0.9 %) and rounded the same way0.01
CYCLED_RESERVE_FRACTIONShare of a fresh bed’s organics still in it on day 30 — one curve every bed type follows0.1
CYCLED_BED_NUTRIENT_FRACTIONShare of a fresh bed’s nutrient store left on day 30 — a plantless month of the leak, rounded down0.85
CYCLED_SOIL_KH_RESERVE_FRACTIONShare of a fresh soil bed’s KH reserve left on day 30 under weekly 25 % changes0.8
CYCLED_SOIL_KH_RETAINEDShare of the tap’s KH an aqua soil bed leaves a tank a month in, under weekly 25 % changes; inert beds take none0.15
CYCLED_WATER_RETAINEDShare 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 changed0.25
HARDINESS_OFFSET_SPANPer-individual hardiness spread, as a fraction of the species baseline0.15
Tank capacity boundsWhat the save schema will accept as a tank1–10000 L
NeighbourReadWritten
Nitrogen cycle—A seed writes both colonies, and 'cycled' writes the nitrate a month of the bed’s rot and leak left behind
Equipmentsubstrate.type, which decides what a 'cycled' bed resolves toThe 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 & gasestapKh and tapGh, which a 'cycled' KH and GH resolve againstBoth dissolved gases, KH and GH, where a seed names them
Environmenttank.capacity — every per-litre seed figure resolves against it—
Actions—Supplies the draw stream for ids, and takes back the advanced position
Alerts & logging—alertState crosses a save; logs do not

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.