Files
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

517 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<body>-<facing>-
<frame>`). 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).