# CLIENT.md — the browser half ## Purpose The browser half, in prose: what `app/static/` is, how one state machine owns the screens, how the wire contract (`docs/PROTOCOL.md`) maps onto them, and the rules the client code lives under. The code of record is `app/static/js/` (served from `/static/`, same-origin only, PLAN A12); `tests/e2e/` is the proof — every claim in this file is asserted by a browser test, not by eye. **No bundler, no framework, no build step (PLAN A10).** Every file in `app/static/js/` is a plain ES module served as-is: `index.html` loads one entry module (`/static/js/main.js` — the composition root, task 03; it imports `app.js` and the net/render modules), the rest of the graph is plain `import` statements the browser resolves. There is no `package.json`, no `node`, no transpile, no vendor directory, and no CDN (PLAN A12 — `tests/unit/test_no_external_assets.py` fails the build on a remote reference anywhere in `app/static/**`, and the e2e suites re-assert the same in a real browser: a full round makes requests to *nothing* but the page's own origin). Consequences the code obeys: - no `import` from a bare specifier (`./` and `/static/` only); - no dynamic import except the atlas's own same-origin `fetch`; - no TypeScript, no JSX, no `eval`, no dynamic string assembly of code; - every server-supplied string lands in `textContent` or an attribute — `innerHTML` with server data is never (the screens build through `screens/dom.js`'s `el()`/`frag()`, which make that the only path); - inline `style` attributes are never set either (the CSP is `style-src 'self'` — a blocked inline style is a console error); colours ride on CSS classes. ## The module map ``` app/static/ ├── index.html one page, one entry module, #screen-root ├── css/ │ ├── base.css the shared skin (palette, type scale, 44 px hit targets) │ └── screens.css the screens' layout (360 px → 4K, focus rings, reduced motion) ├── assets/ the generated SVG atlas + palette.css + index.json (task 01) └── js/ ├── main.js the composition root (task 03): URL params, the buffer, │ the event queue, the single loop, the wire routing ├── main-flag.js sets `__uc_main` before `app.js` evaluates (the auto-boot guard) ├── app.js createApp(): the state machine, the wire reducer, the verbs ├── identity.js the per-tab token + the last room code (sessionStorage, A16) ├── shared/ │ └── enums.js the wire's enum sets (the sim's StrEnums + `ErrorCode`; parity-guarded) ├── net/ │ ├── connection.js the WebSocket: hello, send, onMessage/onStatus, close-code table │ ├── decode.js `applyDelta` — the client's mirror of `server/encoding.py` │ ├── snapshots.js the snapshot buffer, `lerpState`, the resync rules (pure, injected clock) │ └── events.js the event queue: per-kind TTL, the dedup window, the tick-reset ├── input/ │ └── keyboard.js the desktop input (task 05): the key map, the send pump, `?controls=` ├── render/ │ ├── loop.js the single rAF loop (pauses on `document.hidden`, self-measured) │ ├── atlas.js the SVG atlas loader (ready/drawSync/meta; throws on an unknown id) │ ├── palette.js the palette's hex values (agrees with assets/palette.css, tested) │ ├── camera.js fit-to-viewport (DPR-aware, capped at 2), the world⇄screen math │ ├── canvas.js the layer renderer: `drawFrame`, the sprite mapping, `renderStats`, │ │ the target highlight (task 04) │ └── fx.js event→effect: the no-alloc particle pool, the reduced-motion fallbacks ├── ui/ │ ├── help.js the help overlay (task 05): the key-map rows, `?`/`Esc`, no backdrop blur │ └── hud.js the DOM HUD: order rail, score/target, timer, streak, connection └── screens/ one module per screen: { mount, unmount } (+ update/setBanner…) ├── dom.js el()/frag() — the no-innerHTML DOM builder ├── landing.js create/join, code validation, the last-code prefill ├── connecting.js the between (create in flight, cancel) ├── lobby.js code + copy, seats, ready, level picker, start, session panel ├── countdown.js 3…2…1 (reduced-motion-safe) ├── play.js #kcanvas host, #hud root, leave, emotes, reconnecting overlay ├── round_end.js result, stars, score/best, per-player, counters, the exits └── error.js per-code copy, the right retry affordance, the code for support ``` The `window.__undercooked` global is the app instance the e2e suites drive (`createApp()`'s return: the live state, the verbs, the `connection`). The composition root (task 03) attaches to it: `params` (the URL params), `seed` (when the URL carries one), `snapshotBuffer` and `eventQueue`. The loop's census and the pure modules live on `window.__uc` (`frameMs` p50/p95, `buffer`, `events`, `loop`, `params`, `lastView` — the last interpolated state, and — task 04 — `renderStats` (the renderer's `drawCalls` / `frameMs` census), `runtime` (the play-time runtime handle: `teardown`), and `fx` (the FX module's `stats`: the processed-event census, `poolInUse`, `shakeApplied`, the reduced-motion flag)). Both are test seams, not APIs — production code goes through the modules. The URL params `main.js` parses at boot: `room` (join this code on boot — a test-harness seam), `seed` (exposed for the deterministic suites; the server stays authoritative), `controls` (`keyboard` (default) / `touch` / `off` — task 05's input module reads it; `off` binds nothing), `freeze=1` (the loop holds the *sample time* at the first playing frame — a pixel-diff test sees a still image), `motion` (`1` (default) / `0` — the reduced-motion mode; task 04's FX and phase 14's a11y read it). ## The screen state machine One machine, in `app.js`'s `createApp()`: an explicit screen enum ``` LANDING → CONNECTING → LOBBY ⇄ (lobby re-broadcasts) → COUNTDOWN (3, 2, 1) → PLAYING (snap stream; the overlay on a drop) → ROUND_END → (next_level | retry | to_lobby) any → ERROR (a fatal err code, a refused handshake, a dead socket) any → LANDING (leave / back-to-landing — a clean close, no error screen) ``` `show(screen, data)`: - an unknown screen name (a bug) logs **once** and becomes `ERROR`; - the same screen re-shown in place calls the module's `update()` (a re-mount would drop focus off the live control); - a different screen unmounts the current module, **clears `#screen-root`**, and mounts the new one — a screen module appends, the machine clears, so the DOM holds exactly one screen. Every screen is a module exporting `{ mount(target, data, app), unmount() }` plus the hooks it needs (`update`, `setBanner`, `showReconnecting`, `renderBest`) — no global DOM lives outside the modules. ## The message → screen mapping The wire contract is `docs/PROTOCOL.md`; this is the screen half of it. The reducer is `connection.onMessage` in `app.js` — one `switch` on `frame.type`, every branch named: | server frame | app state it writes | screen it shows | |---|---|---| | `welcome` | `welcome`, `roomCode`, `you`, `spectator` | by `welcome.state`: `lobby`→LOBBY, `countdown`→COUNTDOWN, `playing`→PLAYING, `round_end`→ROUND_END, `closed`→ERROR | | `lobby` | `lobby`, `roomCode` | LOBBY (the room's state is `lobby`), else the state's screen | | `countdown` | — | COUNTDOWN (the numeral re-shows per frame) | | `snap` | `lastSnapshot`, the merged view (full resets, delta patches, `gone` tombstones drop) — **and the snapshot buffer** (the render path; the frame's `e` feeds the event queue) | PLAYING | | `round` | `round` | ROUND_END | | `err` (fatal: `room_gone`, `room_full`, `protocol_mismatch`, `evicted`, `internal`) | `error` | ERROR (the code's copy, the right retry) | | `err` (non-fatal) | `banner` | the mounted screen's banner region (e.g. a `start` refusal) | | `pong` | — | nothing (the keep-alive's answer) | | `ev` | the event log — **and the event queue** (the `ev` shape, no tick) | (task 04's renderer consumes them) | | socket death (`onStatus`) | by the close code | `mismatch`→ERROR (no retry, ever); `1000` mid-round→the PLAYING overlay (phase 08 reconnects); `1006`→ERROR "unreachable" (retry); `4401/4402/4403/4404`→ERROR by code; an intentional close (`leave`) shows nothing | | a refused `POST /api/rooms` (`503`) | `banner` | LANDING ("the server is at capacity") | The verbs the screens call are the app's: `createLobby` (`POST /api/rooms` then `hello`), `join(code)`, `leave`, `backToLanding`, `retryJoin`, `ready`, `start`, `setLevel`, `emote`, `toLobby`, `nextLevel`, `sendInput` (the `seq` is owned by `connection.js`). ## The screens, as the stories read them - **landing** — *Create lobby* (keyboard-first: Tab + Enter), the join form (`inputmode="text"`, `autocapitalize="characters"`, `spellcheck="false"`, `maxlength="5"`), the client-side code check (five Crockford characters — a short code never reaches the wire), the last-code prefill from `sessionStorage`, the "server at capacity" / "cannot reach the server" messages. - **lobby** — the code with a copy button (the clipboard API, the select-and-copy fallback), the seat list (index, name, colour chip — the server's 4-colour `SEAT_PALETTE` as CSS classes, ready, the crown, disconnected), the *Ready* toggle (a toggle on the wire), the host-only level picker (fed by `/api/levels`: difficulty pips, target, time, blurb in the option's tooltip), *Start* (enabled from `lobby.can_start` — the client's mirror of `Room.can_start` — disabled with the named reason otherwise: "Only the host can start" / "Pick a level first" / "Waiting for ready cooks"), the session panel (`/api/session`: unlocked, bests, counters), the spectator notice. - **countdown** — 3/2/1 in big numerals; the pulse is decorative and `prefers-reduced-motion`-gated (under reduced motion the numeral just changes). - **play** — `#kcanvas` (hidden until task 04's renderer sizes and unhides it), the `#hud` root (task 04), *Leave*, the reconnecting overlay (phase 08 populates it), the emote row (the `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`, `thumbs_down`, `angry`, `star`), the spectator notice, and — task 05 — the keyboard control (WASD/arrows, the action button, the emotes, the help overlay; below) and its `?controls=` gate. - **round_end** — the result word, the stars (1–3, the pop is `prefers-reduced-motion`-gated), score against target, the session's best for the level played, the per-player contribution list (display only), the counters (`served` / `missed` / `wasted`, the longest streak), *Next level* (only when `round.unlocked` names a level) / *Retry* / *Back to lobby*, and the outcome's `aria-live="polite"` announcement. - **error** — per-code copy: `room_gone` (no same-code retry), `room_full` (points at another room), `protocol_mismatch` (a client that must not retry — it is out of date), `evicted` (the other tab owns the seat), `internal`, `closed` / `unreachable` (a retry is offered), and the room code shown for support. ## Keyboard input and the help overlay (task 05) `input/keyboard.js` is the desktop control for the play screen, wired straight to the protocol's `input` frame (the `seq` stays the connection's; the module never touches it). The key map is one function — `getKeyMap()` (the canonical source; `ui/help.js` and the README's `### Controls` block render the same rows, and a test keeps them identical): | Keys | Action | |---|---| | `W` `A` `S` `D` (or the arrow keys) | move — 8-way intent; opposites cancel; a diagonal is normalised (`1/√2` — the server clamps the magnitude to 1) | | `Space` / `Enter` | the one action button — a press is a rising edge; the hold rides the frame's `act` flag (the sim's gate turns it into one event + the hold progress) | | `1`–`4` | the emote whitelist's first four (`wave`, `cheer`, `thumbs_up`, `thumbs_down`); legal in `LOBBY` and `PLAYING` (the server's per-state whitelist decides) | | `?` | the help overlay's toggle | | `Esc` | close the help, or — on the play screen with the help closed — leave the room (the *Leave* behaviour) | Key events are ignored while focus is in an input field, while `document.hidden`, or while a modifier is held (browser shortcuts survive — no `preventDefault` on Ctrl/Cmd/Meta combos). A `blur` / `visibilitychange` (hidden) / `pagehide` clears every held key and sends one zero-intent frame with the action released — no cook running away on an alt-tab. The send pump: while a key is down it re-sends the intent ~every 48 ms (≈ 20.8 Hz — above the 20 Hz tick; a tick with no frame brakes the body, so a held key must ride every tick window); idle it re-sends a zero-intent frame at 10 Hz (the protocol's ≥ 5 Hz keep-alive — it is also what asserts "no input" after a release). `?controls=off` (and `touch`, until phase 08 ships it) binds nothing: the module still exposes `getKeyMap()` and a `stats()` census so the suites can tell the modes apart — `off` is the scripted-test mode, where the e2e suites drive the wire through the `sendInput` seam and the pump's keep-alive would race the script's frames on the same seat. The help overlay (`ui/help.js`): a `role="dialog"` panel (a translucent backdrop, no blur) with the key-map rows, opened by `?` (toggled by it), closed by `Esc`, its *Close* button, or a backdrop click; no external docs mid-game. ## The snapshot buffer and the render loop (task 03) `main.js` (the composition root) owns the play-time pipeline: the `snap` stream feeds the **snapshot buffer** (`net/snapshots.js`) *and* the app's merged view (the DOM path the lobby/HUD/ choreography read); the buffer's frames drive the **single render loop** (`render/loop.js`) — one `requestAnimationFrame` instance per page, the only owner of the clock (`sample → lerpState → render → hud.update` per frame; task 04 installs the canvas layers and the HUD drawing in the render). The buffer, the decoder and the event queue are pure modules: no DOM, no network, an **injected clock** — `tests/e2e/test_us06_interp_math.py` drives them in the browser with synthetic streams, and `tests/e2e/test_us06_smoothness.py` proves the 60 ms-latency round is smooth end to end. **The buffer** (`createSnapshotBuffer({interpDelayMs=100, maxAgeMs=1000})`): - frames are keyed by `t` (the server tick) and arrive in order; an older or duplicate `t` is **dropped** (counted), a `t` jump is a **gap** (recorded with its size); - a gap *longer than* `MAX_GAP_TICKS` (6 — three 200 ms windows) sets the resync latch and fires `onResync`, **debounced to one request per `RESYNC_DEBOUNCE_MS` (500 ms)**. While the latch is set, delta frames are *skipped* (their changes were partly missed; applying them against a stale `prev` would freeze half-changed state in the view). The stream's full cadence (`FULL_SNAPSHOT_EVERY_TICKS`, phase 06) guarantees a full frame arrives, and it clears the latch — the whole resync contract is *never ask, just hold*: the next full frame is the resync; - `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms delay — it covers the injected 60 ms latency) and returns the two bracketing decoded frames `{a, b}` plus `alpha` (by the frames' arrival times); no frame for `maxAgeMs` and the sample **freezes** (`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen cook stays exactly where the last frame put it; - `stats()` is the census (`pushed`, `dropped`, `gaps`, `maxGapTicks`, `skippedUntilFull`, `resyncs`, `lastT`). **`lerpState(a, b, alpha)`** turns the bracket into a render-ready state: positions lerp along the chord (a cook never passes through a wall, so the chord is the path), progress / time-left / hazard life lerp, and the discrete fields (the facing, the carried item, the states) **snap at the `alpha >= 0.5` boundary**. Entities added at `b` appear for the whole interval (at `b`'s position); entities removed vanish exactly at the boundary (held at `a`'s values until `alpha >= 0.5`, then gone). Stations and orders diff by their wire identity (`k` + coordinates / `id`), as do players (`i`) and hazards (`t` + coordinates). **`applyDelta(frame, prev)`** (`net/decode.js`) is the client's mirror of `server/encoding.py`'s delta mode: a `full: false` frame's entities live under `d` (its `gone` tombstones are *not* part of the previous state), upserted by the encoder's identity keys onto the previous frame (full or delta — the encoder's own rule); the scalars (`t`, `s`, `tgt`, `tl`) and the tick's events ride every frame and are taken from the frame itself. `tests/e2e/test_us06_interp_math.py` proves the decoded frame is deep-equal to the full frame the server would have sent, over states encoded by the *real* `encode_snapshot` (a move, a turn, a tombstone, an append — and a delta-over-delta chain). The enum mapping goes through `shared/enums.js`, whose sets `tests/unit/test_client_enum_parity.py` keeps set-equal to the Python enums in both directions (a drift is a build failure, naming the missing value). **The event queue** (`net/events.js`) turns the wire's transient events (a frame's `e[]`, the standalone `ev`) into bounded FX work: per-kind `ttlMs` (a table, a default for the rest), a **dedup window** (the same event — kind + position + payload — inside 100 ms is counted, not re-queued: the 20 Hz cadence can echo one tick's events, and a chop plays once), `drift(now)` pruning the expired (called each frame, so an unattended queue shrinks to nothing), and **tick-reset detection**: a `push(t)` whose tick is below the last seen tick is a resync (a new round, a rejoin) — the queue clears first, so a `served` from the previous round cannot replay. **The loop's lifecycle**: *paused* when `document.hidden` (the rAF chain stops; the `visibilitychange` back to visible restarts it, and the delta spanning the pause is dropped, not measured); *idle* when the screen is not `PLAYING` (a non-active frame renders nothing but keeps the chain alive, so the `PLAYING` flip is picked up on the very next frame); `?freeze=1` holds the *sample time* at the first playing frame (the composition's business — a frozen render is a still image without the loop pausing). Every rendered frame's time lands in a sliding 120-sample window; `window.__uc.frameMs` is the self- measured census (`p50` / `p95` / `max`). ## The atlas (task 01) `render/atlas.js` loads `assets/index.json` and decodes each SVG symbol through a `data:` URL (the page CSP blocks the art's `style` hooks, and browsers do not render `#symbol` fragments from a cross-file reference in that path — the `data:` round-trip is what the engine renders). `atlas.ready()` resolves when every symbol has `naturalWidth`; `atlas.draw(ctx, id, x, y, w, h, {tint, alpha, flipX})` throws on an unknown id (never a silent nothing-drawn); `atlas.meta(id)` is the read-only probe. The hex values the canvas and the CSS both use live in `assets/palette.css` and `render/palette.js`, and `tests/unit/test_assets.py` asserts the two files agree. ## Adding a sprite (the asset convention) 1. Draw the symbol in `assets_src/.svg` (one `` per drawable, the ids namespaced — `tile-floor`, `item-tomato-chopped`, `cook-2-front-1`; the file stays ≤ 40 KB, `tests/unit/test_assets.py` guards it; no `xlink:href`/`http` reference, the same-origin guard covers it). 2. Run `uv run python -m undercooked.tools.gen_art_index` — it rewrites `app/static/assets/index.json` and **fails naming a duplicated or a missing expected id** (the expected set derives from the sim's enums: every `ItemKind` × reachable form, every station state, every cook facing/frame must exist, so a sim change that orphans a symbol is a build failure, not a blank tile). 3. Copy the SVG into `app/static/assets/` (the generator's check keeps the two trees in sync) and commit the generated `index.json`. 4. `tests/e2e/test_atlas_loads.py` proves the engine decodes it (a non-blank pixel assertion on a probe canvas) — run it before the phase completes. 5. Give the new id a **mapping entry** so the renderer knows how to draw it: for an *item* form, the pair is already covered if the kind is in `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (the sim's `raw`-guaranteed kinds are there by construction; the set is parity-guarded against the sim's `ItemKind` / `ItemForm`, which `shared/enums.js` mirrors); for a *stove state variant*, add it to `render/canvas.js`'s `STATION_STATE_VARIANT` (a non-stove station is state-blind, and a cook's id is derived by `cookSpriteId` — `cook---` — so a new facing/frame needs only the symbol, `gen_art_index`'s expected set covers it). `tests/unit/test_client_sprite_map.py` walks every reachable (kind, form) pair and every (station kind, state) pair against the shipped `index.json` — a new pair without its sprite, or a mapping that names a missing id, is a build failure naming the pair. ## The renderer and the HUD (task 04) `main.js`'s play-time pipeline installs the canvas and the HUD in the loop's `render` step: `sample → lerpState → renderer.drawFrame(state, now) → fx.update/draw → hud.update(state, view)`. A **play runtime** (`makePlayRuntime` in `main.js`) is created the first time `#kcanvas` exists in `PLAYING`, torn down when it vanishes, and its canvas stays `hidden` until the atlas is ready (the first `drawFrame` un-hides it). The runtime is exposed on `window.__uc.runtime` (the e2e suites call `teardown` to release the rAF-bound resources between runs). **The camera** (`render/camera.js`): fits the kitchen to the viewport with a min/max zoom in tiles-per-pixel, letterboxes the overflow, and is DPR-aware (`devicePixelRatio`, capped at 2) with integer-ish scaling to avoid shimmer. `resize()` re-derives the transform on resize / visibility; `worldToScreen(x, y)` / `screenToWorld(x, y)` are the world⇄CSS-pixel maps the renderer and the target highlight use. The renderer **reconciles the canvas size to the camera every frame** (`drawFrame`): if `#kcanvas` is connected and its `clientWidth/ Height` differ from the camera's `cssW/cssH`, it calls `camera.resize()` — so a camera attached while the canvas was `hidden` (a zero `clientWidth`) is re-fit the moment the canvas is un-hidden, and a window resize during play is picked up on the next frame. **The layer order** (`render/canvas.js`, `drawFrame` draws in this exact order — the order is the contract; a layer drawn earlier is occluded by the ones after it): | # | layer | source | |---|---|---| | 1 | floor | `tile-floor` per walkable cell of the level bounds | | 2 | floor decals (spills) | the `hz`-derived stain decals (faint, under everything) | | 3 | station bases | `station-` per station (y-sorted within the layer) | | 4 | carried-under items | the carried item drawn *under* its cook (so the cook overlaps it) | | 5 | players | `cook---` (body 1–4 by seat), y-sorted (a cook never draws through a wall) | | 6 | carried overlays | the carried item drawn *over* its cook (the held highlight) | | 7 | station progress rings | a `0..1` arc over a working/ready station (`progress` lerped) | | 8 | hazards | `hazard-` (the `hz` life lerps the fade) | | 9 | particles | the FX pool (above the world, under the HUD) | | 10 | selection / target highlight | the reach-zone tile the server would target (faint) | **Sprite selection** maps the interpolated state to atlas ids via the three `render/canvas.js` mappers: `stationSpriteId(kind, state)` (a non-stove station is state-blind; the stove's `s` — one of the `StationState` set — selects a variant through `STATION_STATE_VARIANT`, and a `fire` hazard on the stove's cell switches the base to the burning sprite in the hazard layer), `itemSpriteId(kind, form)` (the `h` wire field, mapped through `shared/enums.js`; an unreachable pair falls back to the kind's `raw` sprite so a protocol bug draws *something*, never a blank tile), and `cookSpriteId(seat, facing, frame)` (`cook--- `). An **unknown id throws** — the throw is surfaced to the console *once* (naming the id) and then suppressed, so a bad mapping is a one-line console error plus a fallback rect, never a silent blank tile and never a console flood. The walk animation is **fixed-step**: the frame is `floor(now / WALK_STEP_MS) % 2` while the cook's velocity is non-zero, so a 120 Hz display does not animate faster than a 60 Hz one; the clock is `now` (the loop's sample time), which the loop already drops when `document.hidden`, so the animation pauses with the render. **`renderStats()`** is the renderer's census — `drawCalls` (cumulative `atlas.drawSync` calls), `frames`, `frameMs` (a moving average), `frameMsP95`, `frameMsMax`, and `warnedSprites` (the set of ids that already logged their one-line console error). It is exposed on `window.__uc.renderStats` for the perf assertions (`tests/e2e/ test_us04_render_smoke.py` asserts `drawCalls` is within a sane band, < 4 000 per the budget, and the frame time ≤ 16 ms p95). **The target highlight** is a client-side approximation of `interact.candidate_cells`: `candidateCells(x, y, facing)` (the `CANDIDATE_OFFSETS` mirror in `render/canvas.js`) gives the four reach-zone cells — the front tile, the two diagonal-front tiles, and the tile under the cook, deduplicated in priority order — and the renderer draws them faintly, so reaching for a station is predictable. The grid / legend / station-ids never leave the server (PLAN §8), so the highlight is an approximation from the interpolated position and facing — it is a *hint*, not the server's truth. **The FX** (`render/fx.js`) maps the wire's transient events to effects: `CHOPPED` a spark, `COOKED`/`READY` steam, `SERVED` a pop + score float, `BURNT` smoke, `DENIED` a cross + nudge, `STUNNED` a spin, `ORDER_MISSED` a flash, `SCORE` a float. Particles come from a **fixed pool** (no allocation per event: a `spawn` reuses a slot, and when the pool is full it steals the oldest, so a burst cannot allocate unboundedly). The camera shake is a decaying offset the renderer adds to the camera; under `[data-motion="reduced"]` / `prefers-reduced-motion` the shake is suppressed (the `shakeApplied` census stays 0) and the effects are **static markers** (no particle animation), so a denied nudge and a hazard are still visible without motion. `fx.stats()` is the census the e2e suites read: `processed` (per-kind counts), `poolInUse`, `shakeApplied`, `reducedMotion`. **The HUD** (`ui/hud.js`) is **DOM, not canvas** — it stays crisp and is readable by assistive tech. The structure (all under `#hud`): the **order rail** (a ticket per order: a recipe-icon chain, a time bar, the points; the oldest ticket highlighted with an `oldest` class), the **score + target bar** (the current score over the target), the **countdown timer** (turns amber < 20 s, red < 10 s, with a non-colour cue — the timer's text label: `hurry` < 20 s, `now` < 10 s), the **streak indicator**, the **dirty-dish counter**, the **connection indicator**, and the **spectator banner**. Updates are **coalesced to one write per animation frame** (the HUD accumulates the frame's changes and writes the DOM once, so a 20 Hz event stream causes at most one DOM write per rAF). The HUD changes are visible within 200 ms of the server event (`tests/e2e/test_us04_render_smoke.py` asserts the score text changes within 200 ms of the `served` event). - The story suites (`tests/e2e/test_us01_*` … `test_us06_*`): a real browser against a real uvicorn server (the `e2e_server` factory in `tests/e2e/conftest.py` — an ephemeral `127.0.0.1` port, never a routable interface), two contexts = two players; they assert visible text and the `window.__undercooked` seam, not pixels, for the DOM screens. `test_us04_render_smoke.py` is the one exception that asserts *pixels and the perf budget*: the canvas is non-blank (variance over sampled pixels) and changes after a move, `renderStats ().drawCalls` is within a sane band (< 4 000), the frame time is ≤ 16 ms p95 over a 10 s scripted round, the HUD elements exist and update (the score text changes within 200 ms of the `served` event, the timer decreases), `document.hidden` pauses the loop, and the reduced-motion mode shakes nothing while the hazard markers stay visible. - The pure-module suites (`test_atlas_loads.py`, `test_us06_interp_math.py`): the browser imports the module directly (`import("/static/js/…")`) and drives it — no bundler, no jsdom. `test_us06_interp_math.py` covers the buffer's ordering / gap / resync / freeze contract, `lerpState`'s lerp-and-snap, the `applyDelta` decoder against the real encoder, the event queue's TTL / dedup / tick-reset, and the loop's pause / idle / census — all with injected clocks and captured rAFs. `test_us06_smoothness.py` is the end-to-end smoothness proof: the 60 ms-latency round, the per-tick displacement bounded by `MAX_SPEED * (1/snapshot_hz) * 1.35`, and both clients on the result screen within 1 s of each other. - The enum parity suite (`tests/unit/test_client_enum_parity.py`): the JS wire enums stay set-equal to the Python ones, in both directions (a removed value is a build failure, naming it). - The sprite-map suite (`tests/unit/test_client_sprite_map.py`): walks `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (its kind set is parity-guarded against the sim's `ItemKind` / `ItemForm`, which `shared/enums.js` mirrors) and the station-state / cook-frame tables, against the sim's enums and the shipped `assets/index.json` — every reachable (item kind, form) pair, every (station kind, state) pair, and every cook facing/frame must map to an id that exists, so a new sim pair without its sprite, or a mapping table naming a missing id, is a build failure naming the pair. - The same-origin rule is re-asserted in the browser (a full round's request census must equal the module graph + the API calls, all same-origin).