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.
What it is
Section titled “What it is”| Property | Value |
|---|---|
| Source | src/ |
| Stack | React, Vite, Tailwind, client-side routing |
| Relationship to the engine | A host with reach past the barrel. It calls tick, dispatches actions and reads state, and imports internals besides |
| Shipped in the package | No. The npm package is the engine; the dashboard deploys as a static site |
| Session state | The browser’s local storage. There is no server and no account |
| Scope | One tank at a time |
The six modules
Section titled “The six modules”| Module | Route | What 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 | / | The 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 | / | Two 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 (/) 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 | / | A 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 | / | The 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 | / | What 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 |
The Life roster
Section titled “The Life roster”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.
| Row | Counts | A dot per | Figures | Opens onto |
|---|---|---|---|---|
| Fish species | fish | fish | mass each, mean age, how full the guts are | its fish |
| Plant species | units, beside how many families | family | the sizes summed, oldest unit, worst-lit unit’s light | its families |
| Family | units | unit | the sizes summed, oldest unit, worst-lit unit’s light | its 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 plantlet | its 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.
The frame
Section titled “The frame”Four bands that never move, whichever module is on the stage, and the one drawer that lays over them.
| Element | Role |
|---|---|
| Top bar | The 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 rail | The 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 |
| Stage | The module itself, with its own title and summary line |
| Spine | The 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 |
| Drawer | The 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.
Reading a value
Section titled “Reading a value”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.
| Control | Behaviour |
|---|---|
| Play / pause | Autoplay ticks on an interval — one, six or twenty-four simulated hours per real second |
| Step | Advances a full simulated day, whatever the speed, and takes the clock so autoplay cannot carry you past the day you asked to look at |
| Space | Steps when paused, pauses when running |
| ⌘K | Opens the Act palette |
| ⌘, | Opens the tunables drawer |
| Ctrl | Stands 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.
| Track | Lines |
|---|---|
| Nitrogen | NH₃, NO₂, NO₃ |
| pH, KH, GH & CO₂ | pH, KH, GH, CO₂ |
| O₂, temp & level | oxygen, temperature, water level |
| Populations | fish, 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.
Acting on the tank
Section titled “Acting on the tank”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 you | Verb |
|---|---|
| Every alert the engine latches | The 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 sick | Feed |
| Fish sick but not starving — damage outrunning their healing | The 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 sheet | What it is |
|---|---|
| Amount rungs | The 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 |
| Preview | The 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 |
| Commit | One 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 |
| Refusals | The 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 |
| Dispatch | One 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.
Reading the engine
Section titled “Reading the engine”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.
| Surface | Derived from |
|---|---|
| Reading values and status | Current resources, against the thresholds the engine alerts on |
| Reading bands | An engine threshold where there is one, the stocked species’ tolerance or the plants’ Droop edge where there is not |
| The cycle chain | The engine’s own rot, oxidation and clearance rates and the ammonia plants and the blooms take, read for the hour ahead |
| The waste pool | The 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 peak | The 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 breakdowns | The 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 |
| Tracks | The snapshot buffer, each series normalised to its own extent |
| Trends | The snapshot buffer’s last whole day since the keeper last acted; a bloom’s and a vitality trend off the hour ahead |
| Event log | The log entries the tick appends, filtered by category, exportable as text |
| Alert markers | Log entries of alert severity, placed on the timeline axis at the tick they fired |
| Run tallies | Folded from the event log’s lines — deaths and births, fish and plant units and blooms alike, alerts, water changed |
The tunables
Section titled “The tunables”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.
| Behaviour | Detail |
|---|---|
| Coverage | Every config section, with a nested formula as a group inside the section that holds it |
| Search | One 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 |
| Validation | Each 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 state | A 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 |
| Reset | Per 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.
| Quantity | Metric | Imperial |
|---|---|---|
| Temperature | °C | °F |
| Volume | L | gal |
| Flow | L/h | GPH |
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.
What persists
Section titled “What persists”| Kept | Dropped |
|---|---|
| The tank: clock, resources, environment, equipment, plants, fish, clutches, the blooms, random stream, alert state | The event log, which starts fresh each session |
| The preset the tank came from | Play state |
| Tuned constants, the amounts the verbs are standing on, and the transport speed | The 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.