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`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+516
View File
@@ -0,0 +1,516 @@
# 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).