**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`.
30 KiB
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
importfrom 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
textContentor an attribute —innerHTMLwith server data is never (the screens build throughscreens/dom.js'sel()/frag(), which make that the only path); - inline
styleattributes are never set either (the CSP isstyle-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 fromsessionStorage, 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_PALETTEas 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 fromlobby.can_start— the client's mirror ofRoom.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#hudroot (task 04), Leave, the reconnecting overlay (phase 08 populates it), the emote row (thedocs/PROTOCOL.mdwhitelist: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 whenround.unlockednames a level) / Retry / Back to lobby, and the outcome'saria-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 duplicatetis dropped (counted), atjump is a gap (recorded with its size); - a gap longer than
MAX_GAP_TICKS(6 — three 200 ms windows) sets the resync latch and firesonResync, debounced to one request perRESYNC_DEBOUNCE_MS(500 ms). While the latch is set, delta frames are skipped (their changes were partly missed; applying them against a staleprevwould 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 atnow - interpDelayMs(the fixed 100 ms delay — it covers the injected 60 ms latency) and returns the two bracketing decoded frames{a, b}plusalpha(by the frames' arrival times); no frame formaxAgeMsand 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)
- 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.pyguards it; noxlink:href/httpreference, the same-origin guard covers it). - Run
uv run python -m undercooked.tools.gen_art_index— it rewritesapp/static/assets/index.jsonand fails naming a duplicated or a missing expected id (the expected set derives from the sim's enums: everyItemKind× 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). - Copy the SVG into
app/static/assets/(the generator's check keeps the two trees in sync) and commit the generatedindex.json. tests/e2e/test_atlas_loads.pyproves the engine decodes it (a non-blank pixel assertion on a probe canvas) — run it before the phase completes.- 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'sREACHABLE_ITEM_FORMS(the sim'sraw-guaranteed kinds are there by construction; the set is parity-guarded against the sim'sItemKind/ItemForm, whichshared/enums.jsmirrors); for a stove state variant, add it torender/canvas.js'sSTATION_STATE_VARIANT(a non-stove station is state-blind, and a cook's id is derived bycookSpriteId—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.pywalks every reachable (kind, form) pair and every (station kind, state) pair against the shippedindex.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---
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 (thee2e_serverfactory intests/e2e/conftest.py— an ephemeral127.0.0.1port, never a routable interface), two contexts = two players; they assert visible text and thewindow.__undercookedseam, not pixels, for the DOM screens.test_us04_render_smoke.pyis 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 ().drawCallsis 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 theservedevent, the timer decreases),document.hiddenpauses 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.pycovers the buffer's ordering / gap / resync / freeze contract,lerpState's lerp-and-snap, theapplyDeltadecoder 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.pyis the end-to-end smoothness proof: the 60 ms-latency round, the per-tick displacement bounded byMAX_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): walksrender/canvas.js'sREACHABLE_ITEM_FORMS(its kind set is parity-guarded against the sim'sItemKind/ItemForm, whichshared/enums.jsmirrors) and the station-state / cook-frame tables, against the sim's enums and the shippedassets/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).