Skip to content

Web UI

The engine has a face: a browser dashboard that drives one tank in real time, deployed at engine.fishroom.app. It is the engine’s showcase and its calibration instrument — the surface where a month-long run becomes something you can watch, scrub back through, and explain.

PropertyValue
Sourcesrc/ui/
StackReact, Vite, Tailwind, client-side routing
Relationship to the engineA host with reach past the barrel. It calls tick, dispatches actions and reads state, and imports internals besides
Shipped in the packageNo. The npm package is the engine; the dashboard deploys as a static site
Session stateThe browser’s local storage. There is no server and no account
ScopeOne tank at a time
ModuleRouteWhat it shows
Overview/The tank as widgets, each a window onto its module: the nitrogen cycle as a chain of stocks, life, water, gear and nutrients. A phone stacks them; a tablet runs the chain, the life roster and the rack full width with water and nutrients side by side; a desk-wide stage gives life and nutrients a column of their own beside the chain, water and gear. Whatever needs the keeper is named above them, one line each, with the verb that answers it
Water/waterThe tank as a lab sheet, two columns of headed reading rows. Left: water (temperature, pH, KH, GH, level), the dissolved gases, and the four nutrients and the bed beside the plants’ stores of them. Right: the nitrogen compounds on their alert lines, then the biofilter — each guild against its ceiling, what it converts an hour, whether the tank reads as cycled and where the nitrite peak falls — and the waste pool feeding it, over what rots: food and waste in the water and mulm in the bed, each at its hour’s pace, the share that settles and the oxygen it takes. Any row opens its reading in the drawer
Life/lifeTwo tables on one row shape: the fish, and the plants with the two blooms — green water and film — riding along at their top as population rows — the roster, below. Bioload sits under the fish against the stocking guideline, and the plants’ header reads how much of the floor the planting claims, saying so once it has outgrown it. Under the fish rows sit the clutches, each with its eggs and how far it has developed, and one row for every fry in the tank, which is the unit the sell-all-fry verb takes. Any row opens the vitality ledger in the drawer, addressed by the URL (/life?inspect=…) so the Overview opens one too — helping against hurting, each factor at its rate per day, and the budget between them — a fish’s reserve read as filling, drawing down, healing, spawning or short of upkeep, the share of appetite its load holds back, how much of what a meal can build the food builds — and the element a lean food runs short of — and under them its load, each toxin it carries beside the water’s level, coral past its own edge — and the module’s ”+” stocks a species through the same drawer, a fish at the size the keeper names, from its hatchling’s rounded up to a whole percent to the 90 % a shop sells
Gear/gearA rack of nine device rows: a power switch, a silhouette, the name, and what it is set to — or, for the four that keep a clock, that day as a 24 h ribbon against the hour the tank is on. A row opens the device’s inspector in the drawer, addressed by the URL, so each inspector is a link and back walks between devices; closing one lands on the rack. Under the rack the scape — the substrate, and each piece of hardscape with the surface it adds — and under that what the fittings add up to, which is what the tick reads. The day’s light sits among them against the worst-lit plant’s rated day at its own height, and opens its reading
History/historyThe spine at full height: the four tracks stacked over one axis on the left, and on the right what the run cost — deaths, births, alerts, water changed — over the event log. A window switch scopes both. Dragging a track or the axis, or tapping a log line, parks the same playhead the spine writes
Setup/setupWhat the tank is, rather than how it is doing: the preset it came from and whether the tank still matches it, the capacity, the lid, the room and the tap water, each row carrying what it does to the tank. Beside them the display choices — units and theme — what the run has cost so far and the reset that ends it, and what this simulation claims to be. Capacity is read-only — resizing is a separate row, because a new size builds a new tank at hour zero; past a month’s run both it and the reset confirm first

A plant spreads by budding plantlets into its family — the founder and every unit down its line — so the plants table opens in three levels where the fish open in two, and every level starts collapsed.

RowCountsA dot perFiguresOpens onto
Fish speciesfishfishmass each, mean age, how full the guts areits fish
Plant speciesunits, beside how many familiesfamilythe sizes summed, oldest unit, worst-lit unit’s lightits families
Familyunitsunitthe sizes summed, oldest unit, worst-lit unit’s lightits units
Unit or fish——size or mass, age; a fish also its gut — fed, hungry or starving off its reserve and the upkeep it leaves unpaid; a unit also the unit it budded from, its light and its plantletits ledger

A unit is one plant. A group’s summed size — Σ 180 % — adds its units’ sizes, each a % of one full unit.

A group row’s word names the most urgent of what its dots stand for, counting every one at that tone — 2 sick, or 3 unwell where their reasons differ. Past two lines the dots fold into one bar, each tone at its share. Where none needs the keeper it reads its worst member’s condition, and a group of one reads as its member. A plant species’ dots are its families, each standing at its worst unit, so its 2 sick is two families. Its word opens the ledger on that worst member, read afresh while it stays open.

Fish are numbered within their species in the order they were stocked or born, fry apart from the fish grown past them, and the young carry their stage — Neon Tetra #3, Angelfish juvenile #2, Guppy fry #5. A juvenile sits in its species’ row; the fry row holds only fry. Families are numbered within their species in the order they were founded, and units within their family in the order they were born; a unit names the one it budded from the same way — family 2, #3 · from #1. A family keeps its number when its founder dies. The engine’s ids never reach the page.

A tablet-wide table shows the mass or size and the age; the gut, light and plantlet columns wait for a laptop-wide stage. On a phone a unit’s size, or a fish’s mass, takes the count cell its row has no count for, and at any width a name wraps to a second line before it truncates.

A unit’s light is the day’s light at its own height as a % of its species’ rated day — amber below 100 or while its crown takes more PAR than the species tolerates, red at none; a group reads its worst-lit unit’s figure at its worst unit’s tone. Its plantlet reads how far the next is built. The ledger reads both — the plantlet with when it cuts loose, or what it waits on — and the lamp’s path to its leaf: how many of the lamp’s PAR arrive while it is on, and what each taker — the water, the canopy, green water, film — took, or a sparse crown gave back. Under them sit the reserve, any upkeep unpaid and repair bought, and each store % full on its Droop band, the ruling one naming what it holds growth to.

Each bloom row reads its kind’s coverage and where the next tick takes it, under a word off its coverage, in the kind’s own terms. Green water reads as the water’s clarity — clear while the coverage prints as none, then hazy, cloudy, green and pea soup; film as how coated the glass is — clean, then dusted, filmed, coated and smothered. Its tone follows its level (Alerts & logging) — amber exactly where the bloom alerts, so a thin bloom over a deep carpet can read amber — and every surface, the preview among them, wears that one tone. Its ledger reads the bloom as the organism it is — its condition, what feeds and harms it, its reserve and stores, what exudates hold back — beside its coverage, the plants’ light it takes and its day of light, under the row’s word, with the verb that takes it out: a water change for green water, a scrub for film.

A family row carries its own trim, which opens the Trim sheet held to that family — the same rungs, counted over the family alone; the preview reads what that cut does to the tank. A unit’s ledger trims its family the same way. A unit row carries its removal; the module header’s Trim reaches every plant.

Four bands that never move, whichever module is on the stage, and the one drawer that lays over them.

ElementRole
Top barThe tank’s preset on the left, the transport centred — play/pause, step, speed, the clock — and on the right the count of what needs the keeper, the Act palette, the tunables badge, theme, docs and source. The right side gives way by the room it has, least needed first — the needs word, the ⌘K hint, docs and source, then the theme, the last three all in Setup too — so Act keeps its label; below the laptop breakpoint the step keeps only its glyph
Icon railThe six modules, fixed, never scrolling, carrying no figures. A dot on an icon says something in that module needs the keeper, in the tone of the worst of it
StageThe module itself, with its own title and summary line
SpineThe run along the bottom: day ticks, alert and action marks, and the playhead. Dragging parks the playhead in the address, so the spine and History are never on different ticks and back walks out of a scrub. The handle expands it onto the four tracks, except on History, which is already showing them; the link beside it opens History wherever the playhead is parked
DrawerThe one inspector, laid over the right of the stage — no scrim, and the tank keeps ticking behind it. One at a time; Escape, a pointer outside it, or its own close button dismisses it

Below the tablet breakpoint the rail folds into a five-tab bar with the last two modules behind More, and the drawer becomes a full-height sheet from the bottom. The top bar keeps the preset, play, the clock and Act; the More sheet carries the rest — the speed and the day’s step at its head, the two modules, then the tunables and the theme. Stepping from the sheet closes it, so the day it stepped is what shows.

Every reading in the dashboard is the same shape: name, value and unit, a range strip, a trend, and — where the number alone does not say it — a note. The strip is a 4 px track with the engine’s own reference band lit on it and the value as a marker — inside the band the marker and the number stay ink, outside they take the engine’s severity. Fullness never carries meaning, and a reading with no band shows an unlit track and stays quiet.

Where the engine has no threshold of its own, the band is the tank’s: the temperature and pH spans every stocked species tolerates, and the worst-lit plant’s rated day at its own height. An unstocked tank has neither, and those readings go quiet.

The plant foods read what each pool holds — ppm in the water, and the bed in root tabs’ worth of the carried nutrient it holds fewest of, on a scale a fresh bag of aqua soil in this tank fills — and beside each, on its own strip, the feeders’ stores: the one running its Droop limit lowest, banded from the fill its Droop edge sits at to full, coral under it. The stores take the tone; the ppm stays ink, nitrate aside. A narrow row gives up the stores’ word, then their strip. A pool is short where a feeder’s store has fallen under that edge and the pool holds less than its feeders’ stores lack of full at their weight on it — so a sword starving on an empty bed reads short on the bed, never on well-dosed water. The advised dose or tabs are that lack, offered whenever the pool holds less than it — tabs withheld over a bare bottom, or while the bed lacks a nutrient the tab carries none of. With a store under its edge and no pool short, the Nutrients widget reads “stores refilling”.

Nitrate is one reading with two readers, the fish and the plants, so it carries one band and one tone wherever it shows — in the cycle and among the plant foods. The band closes at the engine’s alert line, or where the least hardy plant starts to take harm if that comes first, and past it the reading is red.

A trend is what the reading did over the last whole day of the snapshot buffer the keeper left alone — a part-day is a slice of the tank’s daily swing, and an action is a step the tank did not take — so a reading shows none until the buffer holds such a day. A bloom’s trend, and a vitality trend, is what the next tick does instead, per day.

Tapping a reading anywhere — the Overview, the Water module, the day’s light on Gear — opens the same inspector in the drawer: the number at hero size, the band stated in the engine’s words, the last seven days for a reading the snapshot buffer records, and — for the stocks of the nitrogen cycle — what fills them and what drains them, at the rate the next tick will use. A flow arm moving less than the precision its unit is read to prints as none, and only a stock’s net — its fills against its drains — ever prints as steady; neither prints a signed zero.

Bands are stated at the precision their reading is, so a value sitting on the edge of its band reads as sitting on the edge, and its tone says which side of it the tank is on. A tolerance band is hard on both sides: the engine charges a temperature or pH stressor from the moment the value leaves the span, and the strip tints on the same step. A figure with no tone to carry that side — an alert’s log line, a verb’s refusal — prints on the side of its line the value is on, so a reading just past a line never rounds back onto it.

The dashboard owns the clock; the engine only knows how to advance an hour.

ControlBehaviour
Play / pauseAutoplay ticks on an interval — one, six or twenty-four simulated hours per real second
StepAdvances a full simulated day, whatever the speed, and takes the clock so autoplay cannot carry you past the day you asked to look at
SpaceSteps when paused, pauses when running
⌘KOpens the Act palette
⌘,Opens the tunables drawer
CtrlStands in for ⌘ on a keyboard without one; on a Mac it is left to the system and the text fields

Every tick is recorded as a compact vitals snapshot in a rolling buffer of the last thirty days. That buffer is what the tracks read, so scrubbing never touches the simulation — the tank can keep running while you look at last Tuesday. The buffer belongs to the session: a reloaded tank opens with nothing to draw, however old it is.

Nothing but the timeline honours the tick cursor: a snapshot carries the vitals the tracks draw, and none of the bacteria, waste, nutrients or roster a module would need to redraw itself at a historical cursor — the reading rows go on showing the tank as it is now.

The spine is that buffer along the bottom of every screen. Collapsed it is the axis alone; the handle expands it onto four tracks sharing that axis.

TrackLines
NitrogenNH₃, NO₂, NO₃
pH, KH, GH & CO₂pH, KH, GH, CO₂
O₂, temp & leveloxygen, temperature, water level
Populationsfish, fry, plants, green water, film

Each line is normalised to its own extent, so a compound at 0.1 ppm and one at 40 ppm share a band without sharing a scale. That extent is never narrower than a few steps of the precision the line is read to, so a line that prints the same figure end to end, or moves a step of it, lies near flat rather than stretching full height, and a line of zeros lies on the floor. The caption carries the value at the playhead — at the precision its reading is read to everywhere else — and on History the extent the line was drawn against. Behind the lines the hours the fixture ran are a faint band. Colour is spent on the series alone — a track never tints for status; the alert and action marks live on the axis under it, where the playhead stands.

A phone has room for two tracks at a time, and a chip row picks the pair.

Every husbandry verb has a home: Feed, Trim and Scrub on Life, Water change and Top-off on Water, Dose and Root tab on Nutrients. Each appears as a button in that widget’s footer and in its module header, carrying the amount it is standing on — a footer says what would happen rather than opening a menu to ask. The Needs you strip carries the same verb beside the condition that calls for it.

Needs youVerb
Every alert the engine latchesThe verb or the fitting that answers it — a water change, a top-off, a scrub, the air pump or the CO₂ injector. Each kind of bloom is its own line with its own verb: a water change for green water, a scrub for film
Fish starving — hunger alone outrunning what they earn; at alert once it makes one sickFeed
Fish sick but not starving — damage outrunning their healingThe worst one’s ledger

Each line states the reading behind it — its figure, its band in the engine’s words, its tone — so the strip, the rail’s dot and the top bar’s count never colour one condition two ways. The count leads with its worst tone.

The Act button in the top bar (and ⌘K) opens the palette: the seven verbs with their amounts and their modules, then the three construction verbs, filtered as you type. Either route opens the identical sheet in the drawer.

Part of the sheetWhat it is
Amount rungsThe engine’s own option sets — the water-change fractions it accepts, the trim targets, the feed, dose and tab presets, plus the dose that puts in the water what the plants’ stores lack past what it holds, capped at the 50 ml the engine takes in one dose, and the tabs that do the same for the bed, capped at the 10 it takes in one push
PreviewThe action applied to a throwaway state, read back as reading rows: the standing value as a ghost marker, the value the commit would leave as the live one, on the strip the reading is read on everywhere else. Only what moves is listed
CommitOne button, named for the verb and its amount — “Feed 0.5 g”, “Change 25 % water”. The sheet opens on it, so Enter commits without a click first
RefusalsThe engine’s own guards, and one display gate — a scrub whose glass prints clean — standing where the commit would be, so an unavailable verb is never a dead end
DispatchOne call into the engine, which works whether the tank is running or paused, and writes the log line the spine marks

The amount chosen for a verb outlives a dismissal: closing the sheet means “not now”, the footer buttons and the palette go on naming that amount, and the top bar’s Act button takes the name of the last verb committed. Both are kept with the tank, so a keeper who always feeds 1 g opens on 1 g after a reload.

Construction is not husbandry and does not go through this path. Swapping a filter, resizing the tank, adding a plant or a rock edits the tank directly and writes its own line into the log; the palette’s construction verbs hand the reader to the picker that owns them — each picker is a parameter of its module’s address (/life?add=fish, /gear?add=hardscape), so the palette opens one from anywhere and the rest of the query stays where it is.

Nothing the dashboard displays is stored twice. Every reading, breakdown and projection is derived from the state the last tick returned, so a reading and the tank can never disagree. The Overview widgets and the Water module draw their rows from one reading set, so the same reading cannot carry a different band or a different word depending on which surface is showing it.

SurfaceDerived from
Reading values and statusCurrent resources, against the thresholds the engine alerts on
Reading bandsAn engine threshold where there is one, the stocked species’ tolerance or the plants’ Droop edge where there is not
The cycle chainThe engine’s own rot, oxidation and clearance rates and the ammonia plants and the blooms take, read for the hour ahead
The waste poolThe engine’s own decomposition of the hour ahead: what the fish, plants and blooms put in, what rots and settles out, each pool’s pace and the oxygen its rot takes, and the level the pool holds at
The nitrite peakThe engine’s own chain run forward from the hour ahead, without the flora, so it reads as the cycle’s alone. Where plants stand, the lamp lights a bloom or a bloom’s reserve can grow it in the dark, it names them as what will shift it — either way, since they draw ammonia and shed tissue that rots back into it
Vitality breakdownsThe engine’s own per-factor stressor and benefit lists, per organism, read on the hour the next tick settles — plants and the blooms in their one pass, then fish — so a trend is the change that tick makes, a bloom’s coverage alike on its row, its reading and its ledger. An organism reads sick while that change shows as a fall at the trend’s precision
TracksThe snapshot buffer, each series normalised to its own extent
TrendsThe snapshot buffer’s last whole day since the keeper last acted; a bloom’s and a vitality trend off the hour ahead
Event logThe log entries the tick appends, filtered by category, exportable as text
Alert markersLog entries of alert severity, placed on the timeline axis at the tick they fired
Run talliesFolded from the event log’s lines — deaths and births, fish and plant units and blooms alike, alerts, water changed

Behind the top bar’s badge, ⌘, and — on a phone, where the bar has no room for the badge — the More sheet, is the drawer of the engine’s constants, grouped the way the config layer groups them and editable live. It lays over the stage like any other inspector, so a constant can be moved while the chart it moves is on screen. A changed value takes effect on the next tick — no rebuild, no reload.

BehaviourDetail
CoverageEvery config section, with a nested formula as a group inside the section that holds it
SearchOne box filters by name across the whole set, matching a constant’s label or the path config set names it with; matching sections open themselves
ValidationEach field is bounded by the range its tunable declares. Typing walks through values the range refuses, so a bad value holds the field and blur settles it rather than clamping mid-keystroke
Modified stateA dot marks a moved constant, its section carries the count of them, and the top bar’s badge carries the count for the whole config
ResetPer section, or the whole config at once

Tuned constants persist with the session, which means a tank reopened tomorrow is still running the constants you left it on.

Every value the engine holds is metric. The unit system is a display choice, converted at the edge and converted back on the way in, so switching it never touches the tank.

QuantityMetricImperial
Temperature°C°F
VolumeLgal
FlowL/hGPH

The toggle lives in Setup, beside the theme. The initial choice comes from the browser’s locale, and the tank-size picker offers round numbers in whichever system is active — litres in one, gallons in the other. A difference converts without the freezing-point offset, so a drift of 1 °C/h reads as 1.8 °F/h rather than a temperature.

KeptDropped
The tank: clock, resources, environment, equipment, plants, fish, clutches, the blooms, random stream, alert stateThe event log, which starts fresh each session
The preset the tank came fromPlay state
Tuned constants, the amounts the verbs are standing on, and the transport speedThe History view — it lives in the URL instead
Units, the theme, and whether the tunables drawer and the spine’s tracks were open

Saves are validated section by section against a strict schema, so one corrupt section falls back to its defaults rather than taking the rest with it. The format carries a version, and a version it does not recognise is discarded rather than migrated — the same no-backward-compatibility rule the engine holds itself to.