Files
undercooked/docs/CLIENT.md
T
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

30 KiB
Raw Blame History

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/<file>.svg (one <symbol id> 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-<body>-<facing>-<frame> — 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-<kind> 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>-<facing>-<frame> (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-<type> (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).