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

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

29 KiB
Raw Blame History

AGENTS.md — operating rules for this repository

1. Read this order, every time

  1. .agents/PLAN.md — master design. Its Architectural Anchors table holds the LOCKED DECISIONS, which are binding on every task and phase.
  2. This file.
  3. .agents/phases/todo/<phase>/00_phase.md, then the task file you are executing.
  4. The user story referenced by the phase (.agents/user_stories/US-XX_*.md).
  5. .agents/phases/complete/*/00_phase.md for what already exists (do not re-build it).

2. The phased protocol

  • Phase state lives in files, not chat. Pending work: .agents/phases/todo/NN_name/ (00_phase.md + NN_task.md). Finished work: .agents/phases/complete/.
  • Execution is owned by the phased-execution skill (run-task.sh, run-phase.sh, auto-phase.sh). Each task runs in a fresh subprocess and must pass .agents/validate.sh before it moves to complete/. That gate fails fast in this order: ruff check . → pyright → undercooked-levels validate (unconditional: the same loader + PLAN §8 rules create_app runs at startup, over $UNDERCOOKED_LEVELS_DIR) → the full pytest suite with the coverage floor → the slow sim stage (pytest -m slow tests/sim: the replay goldens and the perf smoke, named on their own) → repo-hygiene checks. It runs from the repo root or from .agents/.
  • New work is authored by the phase-authoring skill as a phase directory — not implemented straight from chat.
  • Never modify .agents/PLAN.md or anything under .agents/phases/complete/.
  • Ask the user before editing an existing file in .agents/phases/todo/.
  • Do not renumber or reuse phase/task numbers.

3. LOCKED DECISIONS (summary — the table in PLAN.md is authoritative)

  • Python 3.12+ / uv / FastAPI / native WebSockets (uvicorn[standard]). No Socket.IO, no SSE, no WebRTC, no REST for live game traffic.
  • Server-authoritative fixed-tick simulation (20 Hz) with client interpolation. The browser never decides anything true. src/undercooked/sim/ is pure: no FastAPI, no asyncio, no I/O, no wall-clock, no random without the round RNG.
  • No database, no ORM, no Alembic. Live state lives in the room supervisor; results/scoreboards live in memory and die with the process. User-approved deviation from the default FastAPI stack.
  • Data-driven content. Levels/recipes/hazards come from YAML in levels/ loaded through validated Pydantic models. No hard-coded level or recipe lists in Python. create_app() loads the bundle once (routes_content.load_validated_content) and refuses to start when content fails to load or violates a PLAN §8 rule — the named-rule report goes to stderr, ContentError propagates and the undercooked command exits 2 like it does for an impossible setting. The content API projects through extra="forbid" response models: grids, station ids and spawn coordinates never leave the server. undercooked-levels (tools/levels.py, its author CLI) obeys the same rule: it prints whatever the loaded directory holds. tests/unit/test_content_not_hardcoded.py is the lock — it adds a level YAML to a copy of levels/ and expects the loader, /api/levels and the CLI to carry it with no Python edited, and fails if any src/undercooked/**.py or browser asset names a shipped level or recipe id as a literal.
  • Client: Canvas 2D + vanilla ES modules + this repo's own SVG art. No bundler, no framework, no CDN, no analytics, no external fonts.
  • Tests: pytest + Playwright. One Playwright suite per user story; multi-player stories use two browser contexts against one room.
  • Quality gate: ruff + pyright + full pytest, >90% coverage on new/modified code, entire suite 100% passing before a phase may complete or commit.

4. Definition of done (every task)

  • Task's own work exists at the named paths and is importable/runnable.
  • Tests written for the task's logic (unit for pure logic, integration for rooms/protocol, e2e for user-visible flows) and passing.
  • Full project suite passes (uv run pytest), coverage gate met — including failures the task did not cause: fix them, do not skip, xfail, or delete tests.
  • uv run ruff check . and uv run pyright report zero errors.
  • uv run undercooked-levels validate passes — .agents/validate.sh runs it for every task, whether or not any content file changed.
  • README.md and AGENTS.md updated when the change is user-visible (setup, commands, config, protocol, features, conventions). Every phase's final pass re-reviews both in full, project-wide and fixes inconsistencies, including pre-existing ones.
  • App still launches: uv run undercooked serves http://127.0.0.1:8000/healthz with 200 and the client at /.
  • No behavior regression in any completed phase.

5. Hard rules

  • Configuration is read once, in config.py. src/undercooked/config.py is the only module that touches os.environ. To add a knob: add a Settings field + ENV_KEYS entry + a line in .env.example + a row in the README table (a test fails when those disagree), and read it from get_settings(). Values are validated at startup: an impossible one raises ConfigError rather than half-configuring the server (undercooked prints one line and exits 2). Precedence is CLI flag > environment > built-in default: __main__.py resolves it purely, then hands the flags to config.apply_cli_overrides(), which is the only writer of os.environ (so a uvicorn --reload child re-reads the same values). DEBUGPY=1 binds a debugger listener on 0.0.0.0 — dev-only, never on a public host.
  • Time flows through an injected Clock. Every time-dependent object in server/ (supervisor, rooms, the session store) receives a Clock (server/clock.py: SystemClock, FakeClock) and no module outside clock.py may import time; tests/unit/test_server_clock_discipline.py greps for the time.monotonic()/time.time() call literals and for stray time imports, and the GC loop's sleep is injectable — tests drive the whole lifecycle with a FakeClock and never sleep.
  • No external CDNs or remote assets. tests/unit/test_no_external_assets.py fails the build on http:///https://///host references, @import url(, srcset, an absolute-URL fetch()/import, an absolute-URL WebSocket, or EventSource anywhere in app/static/** (html/css/js/svg), and requires every <script src>/<link href>/<img src> in index.html to be a same-origin /static/... path. It scans the same STATIC_DIR server/app.py mounts, so it cannot pass by scanning the wrong folder; the exemptions are XML xmlns URIs, which are identifiers the browser never fetches, and a data: URL in an index.html element attribute where the CSP already allows it (img-src 'self' data:) — the app's own bytes either way. The US-01 e2e suite additionally fails if loading the page ever issues an off-origin request. CSP is default-src 'self' (plus ws:/wss: same-origin) and is applied by server/headers.py to every response, including errors. FastAPI's /docs, /redoc and /openapi.json stay disabled because their bundled HTML pulls Swagger assets from cdn.jsdelivr.net; the wire contract lives in docs/PROTOCOL.md (written by phase 06; its message tables are kept equal to server/protocol.py's models by tests/unit/test_protocol_doc_sync.py) instead.
  • Docs are guarded like code. tests/unit/test_docs_commands.py requires every command line in a bash fence of README.md/docs/*.md to start with a known-safe prefix (uv , podman , curl , cd , bash .agents/validate.sh, #, or a documented VAR=value lead) and to launch only a program declared in pyproject.toml, and keeps the UNDERCOOKED_*/DEBUGPY* names in the docs and the ones in config.py/.env.example equal in both directions. So: never document a command that does not run yet — describe it in prose until the phase that ships it. tests/unit/test_docs_index.py keeps PLAN §13's documentation index and the docs/ tree identical (no missing doc for a shipped phase, no doc nothing promises), and docs/TESTING.md is the reference for test layout, markers and artifact policy.
  • No new technology (dependency, framework, service) without explicit written user permission recorded in the phase's 00_phase.md and signed off into PLAN.md.
  • The simulation is the single source of truth. Client-side values are guesses. Never trust inbound coordinates, scores, timers, or level ids — validate every inbound field against the room's state (server/protocol.py).
  • Anonymous by design. No accounts, no emails, no cookies with personal data. Identity = 24 random bytes per browser tab (secrets.token_urlsafe(24) when the server mints one, crypto.getRandomValues + base64url in app/static/js/identity.js, which is the same entropy and the only option in a browser) held in sessionStorage. Names are sanitized, length-capped, non-authoritative.
  • Movement is swept, never corrected. Stage 3 (sim/movement.py) integrates one axis at a time and clamps each axis with sim/collision.sweep_axis() to the greatest legal delta, so a body is stopped before it intrudes — a cook never ends a tick inside a blocked tile, at any dt the kernel can hand it, and sliding needs no special case. Two rules belong to collision.py alone and must not be duplicated in a caller: which tiles and fixtures block (PASS_THROUGH_STATIONS = serve/sink/trash; shelf, chop board, stove and prep block), and which cells a body covers (body_cells(), _edge_cells()) — the footprint and the sweep have to answer with the same arithmetic or a cook ends a tick 1e-16 inside a wall, which is invisible and fatal for a replay golden. Cooks push cooks positionally only (no velocity is ever injected by a push), and only a genuine terrain deadlock drops an input — the later-received one, the same arbitration as resolve_inputs. PlayerInput.move is untrusted: intent_direction() clamps its length, so a client sending (50, 0) walks at MAX_SPEED like everyone else.
  • Frame and rate limits are enforced, not suggested: max_message_bytes, max_inputs_per_s, room caps, and message-type whitelists per room state.
  • Protocol changes need a version bump: editing a wire message means editing src/undercooked/server/protocol.py, docs/PROTOCOL.md, and the client in the same task, and bumping PROTOCOL_VERSION (PLAN §11).
  • Determinism is a contract. sim/step.py advances the round in whole STEP_DT (0.05 s) substeps with dt clamped to [0, MAX_DT], a fixed subsystem order, and no randomness outside sim/rng.py (splitmix64, seeded by KitchenState.rng_state) — so the same seed plus the same input script produces identical state. The guard is tests/sim/test_rng.py (published golden vectors) + tests/sim/test_step_kernel.py (clamping, accumulation, purity, cross-run hash equality); the committed replay goldens (tests/goldens/*.golden.json against tests/sim/test_replay.py, the input-script format and the python -m undercooked.sim.replay record|check CLI in src/undercooked/sim/replay.py) and the perf budgets (tests/sim/test_perf_smoke.py and tests/sim/test_perf_full_pipeline.py, both slow) close it out. tests/sim/test_purity.py holds the A5 boundary the whole thing rests on. The contract — constants, the 8-stage order, what each hook still owes — is docs/SIMULATION.md; change the tick, the RNG or the step order and that file and the goldens move in the same commit.
  • ntfy is outbound-only. Never use it to ask questions or wait for approval. If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop and let the user rewrite the task.
  • Commits are made by the pipeline harness (PHASE_COMMIT=1), one atomic commit per completed phase, --no-gpg-sign, subject phase: <NN_name>. Child executors never run git add/commit/mv/rm/stash. When a human asks for a commit, use the git-commit skill (--no-gpg-sign, LLM-Generated: true trailer).
  • Do not commit .env, logs, coverage output, .agents/phase-sessions/, .agents/pipeline.log, or .agents/reports/ (all gitignored).

6. Where things go

Change Location
Configuration (env vars → typed Settings), debugpy bootstrap src/undercooked/config.py, .env.example, src/undercooked/debug.py
Pure game rules (movement, stations, orders, scoring, hazards) src/undercooked/sim/
The fixed-step tick: step/build_state, the seeded RNG, the subsystem hooks (contract: docs/SIMULATION.md) src/undercooked/sim/step.py, state_build.py, rng.py, movement.py, interact.py, carry.py, stations.py, freshness.py, orders.py, hazards.py, round_state.py
Movement/collision: velocity integration, facing, stun, soft push, the ghost park (a disconnected cook's stale input does not move it; a stun that runs out on a disconnected cook revives it DISCONNECTED, not IDLE), and the pure geometry under it (what blocks, the swept stop, the body footprint) src/undercooked/sim/movement.py, src/undercooked/sim/collision.py (rules in docs/SIMULATION.md §"Movement and collision")
The action button: reach-zone targeting, the verb ladder, named DENIED reasons, the one-press-one-event gate, contested arbitration and the long-press trash src/undercooked/sim/interact.py (rules in docs/SIMULATION.md §"The action button")
The hands: one-item-per-hand carry, the marked set-down and the counter hand-off, the always-available long-press trash (contract: docs/SIMULATION.md §"The hand") src/undercooked/sim/carry.py
The ghost: what a dropped cook's state and hands do (sim/ghost.py — the sim's half, pure; the wall-clock grace is the room's): mark_ghost (→ connected=False, DISCONNECTED; idempotent), restore_player (→ connected=True, IDLE; keeps a still-carried item; idempotent), release_ghost_carry (the park's release — once, idempotent: onto the station on the cook's tile via place_item/DROPPED, or into counters["lost_items"] + GHOST_PARK; an item is never deleted silently), station_at, PARK_AFTER_S = 3.0 (guards: tests/unit/test_ghost_parking.py; the room's half in tests/integration/test_ws_disconnect.py) src/undercooked/sim/ghost.py
The station pipeline: the acceptance matrix, the transition timers, the worker hold and release, dirty props and the plate/pot stock (contract: docs/SIMULATION.md §"The station pipeline") src/undercooked/sim/stations.py
Freshness (stage 4b): the burn countdown (tick-exact, 8.0 s), the board's linear decay (20 s), the half-rate carry, the burnt WASTED push-out (a usable raw is never overwritten), and the dirty loop's stock (use dirties, a wash cleans, …_clean + …_dirty conserved; the no-dead-end guarantee) (contract: docs/SIMULATION.md §"Freshness") src/undercooked/sim/freshness.py
Serve resolution and scoring: the dish's named rejections (NO_ORDERS/WRONG_RECIPE/INCOMPLETE/BURNT/NO_TARGET), FIFO order matching, round(base × quality × streak × time_bonus) with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: docs/SIMULATION.md §"Serve scoring") src/undercooked/sim/scoring.py
Level/recipe content levels/*.yaml
Content schema, ASCII-grid parser, content loader, playability validator (contract: docs/LEVELS.md) src/undercooked/sim/recipe.py, level.py, content.py, validation.py
Rooms, lobby lifecycle, session records src/undercooked/server/rooms.py (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, start/shutdown — a room created while the GC loop is running already gets its tick task), room.py (the guarded FSM: the RoomState/ROOM_TRANSITIONS table, the round data, the per-tick input queue (capacity INPUT_QUEUE_PER_SEAT x len(seats), overflow drops the sender's oldest frame, a zero-substep tick keeps the queue so a frame is never dropped without being applied), stats() (the lobby/round counters plus inputs_received/inputs_dropped/stale_seqs/clamped_moves/large_seq_gaps), the one owned deadline-scheduled tick task and its crash isolation, tick_once, the ghost and seat-grace verbs (on_disconnect — mark + one GHOST_ON + the sim switch, a no-op if already a ghost; on_reconnect — mark + one GHOST_OFF only when it was a ghost + the sim restore; _park_ghosts — the per-PLAYING-tick park off seat.last_seen, one release per token per round; _abandon_expired/_abandon_seat — the SeatAbandoned at guest_idle_timeout_s in every state: forced release + GHOST_OFF + the seat out; the on_close hook awaited by close()) — contract: docs/ROOMS.md), clock.py (injectable time — the only server/ module that imports time; a grep test keeps the wall clock out of the rest), codes.py (Crockford code minting + input normalisation), seats.py (the lobby's data: Seat/Spectator, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), routes_rooms.py (POST /api/rooms, GET /api/rooms/{code}, POST /api/rooms/{code}/join, GET /api/session — the lifecycle over HTTP before the phase-06 transport), sessions.py (SessionRecord + BestRun + mint_token + the bounded LRU SessionStore: record_result's win-only unlock bump (monotonic), best-score replacement, is_unlocked / next_level_id, to_public — in memory; a process restart clears all progression)
Wire messages + validation src/undercooked/server/protocol.py (the message models, parse_client_message — never raises, the ErrorCode/CloseCode constants, MESSAGE_WHITELIST), docs/PROTOCOL.md (the contract in prose; tests/unit/test_protocol_doc_sync.py keeps the two equal)
The snapshot encoder (task 04): encode_snapshot (the PLAN §4 shape, quantized, explicit field maps per type — no pickle/asdict; no token/display_name/seed/rng_state/station-internal-id ever reach the wire), the delta mode (diff vs the previous frame, gone tombstones), encode_events (the stable k names + the EVENT_PAYLOAD_SHORT map, MAX_EVENTS_PER_FRAME = 24), snapshot_size (the UTF-8 budget hook; a 4p/4o/3h frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB), the SnapshotFanout (encode-once/fan-out-many, the per-audience prev + the FULL_SNAPSHOT_EVERY_TICKS = 20 cadence, the needs_full resync, the non-destructive event cursor, the events_dropped count) in server/ws.py, the room's pacing seam (_snapshot_pacing_tick, the seq-tagged event_buffer, the countdown/round/ev broadcasts) in server/room.py (contract: docs/PROTOCOL.md "The snap frame"; guards: tests/unit/test_encoding_no_leak.py, tests/integration/test_encoding.py) src/undercooked/server/encoding.py
The WebSocket transport: the pre-accept origin check, the hello handshake (timeout → 4400), the p gate, the supervisor seat/spectate (4401/4402; a CLOSED room is an err room_gone + close 4401 before any seat), the welcome's rejoined flag (judged before the join: a token that holds a disconnected seat is a rejoin), the ping/pong keep-alive (any inbound frame refreshes last_seen) and the heartbeat watchdog (HEARTBEAT_DEAD_S = 12 s of silence → the server closes 1000 and the standard teardown runs — the room sees the same drop as a hard close), the input ingestion (the seq discipline: duplicates dropped, stale → err + a resync event, gaps ≤ 3 tolerated, larger gaps accepted and counted; move clamped to unit length and quantized, face derived, act coerced, received_at_tick stamped server-side; a DISCONNECTED seat's frames are silently ignored, spectators get not_seated), the per-connection guardrails (size cap → too_big, the token bucket → rate_limited, the shape check, the per-state whitelist → wrong_state; 3 consecutive rate/oversize or 5 shape violations → 4404; a binary frame is a frame the cap and the parser apply to — a too_big / a bad_json, never a stack exception), one-socket-per-token eviction (4403, the evicted socket never marks the seat disconnected), the capped send queue (the oldest snapshot drops first; lobby/round/err never; a rate_limited coalesces to one) and the once-only on_disconnect teardown src/undercooked/server/ws.py (the Connection/ConnectionRegistry/TokenBucket/AbuseTracker, the seq_verdict/to_player_input input path, the pump_out/read_in/heartbeat_watchdog tasks, the registry's census (stats(): sockets, the send_queue_max high-water mark, memory_mb — the phase-06 abuse/budget numbers); contract: docs/PROTOCOL.md incl. the manual probe, "Heartbeats, drops, and seat grace" and "Testing the protocol" (the test seams + the budget table with the measured values); tests/integration/test_ws_handshake.py drives every guardrail over live sockets, tests/integration/test_ws_input.py the input rules over live sockets, tests/integration/test_ws_disconnect.py the heartbeat/disconnect/grace/rejoin end to end, tests/integration/test_ws_round_two_clients.py the complete two-client round (the live_server/ws_client fixtures, the levels_round campaign), tests/integration/test_ws_abuse.py the guardrails at scale (2000 malformed frames, the 500-burst, the 64 KB / 4 KB size cap, the host-check flood, 200 handshakes), tests/integration/test_ws_budgets.py the slow budgets (tick cost over 400 real ticks, snap size, downlink, echo), tests/unit/test_send_queue_policy.py the queue policy + bucket arithmetic, tests/unit/test_ws_input_path.py the ingestion unit)
HTTP app (/, /healthz, /static), lifespan, startup content gate, the room_close hook (a closing room closes its own sockets with err room_gone + close 4401) and the test-only seam POST /api/test/kill_socket/{code}/{token} (registered only when UNDERCOOKED_TEST_MODE=1) src/undercooked/server/app.py
Content HTTP API (/api/levels, /api/levels/{id}, /api/config) + strict response models src/undercooked/server/routes_content.py
Author CLI undercooked-levels (validate, list, show, new, axes); workflow in docs/LEVELS.md src/undercooked/tools/levels.py
In-repo SVG art: assets_src/ (source) served from app/static/assets/ (copies kept in sync by the generator's check) with index.json; the palette (assets/palette.css + js/render/palette.js — tests/unit/test_assets.py keeps the two files' hex values identical in both scopes and pins the SVG var() fallbacks to palette entries); the atlas loader (js/render/atlas.js — decodes each symbol through a data: URL because the page CSP blocks the art's style hooks and browsers do not render #symbol fragments; throws on unknown id); the index generator (a small stdlib script — uv run python -m undercooked.tools.gen_art_index — whose expected ids derive from the sim's enums + the sim/stations.py pipeline tables, and which fails naming a duplicated or missing id) assets_src/, app/static/assets/, app/static/js/render/, src/undercooked/tools/gen_art_index.py (guards: tests/unit/test_assets.py, tests/e2e/test_atlas_loads.py)
The replay harness: input scripts, golden hashes, run_script/compare, the record/check CLI (contract: docs/SIMULATION.md "Replaying and re-blessing goldens") src/undercooked/sim/replay.py, tests/fixtures/replays/, tests/goldens/, tests/sim/test_replay.py, tests/sim/test_perf_smoke.py (slow)
Security headers (CSP default-src 'self', no-sniff, frame-ancestors) src/undercooked/server/headers.py
CLI entry point (undercooked): flags → --help → serve src/undercooked/__main__.py
The client app: the composition root (app/static/js/main.js — the page's single entry module: the URL params, the snapshot buffer + event queue + the single render loop, the wire routing, the window.__uc census seam; main-flag.js sets the auto-boot guard before app.js), the screen state machine (app/static/js/app.js — show() clears #screen-root and mounts exactly one screen, the message→screen routing, the test seam window.__undercooked, the lobby-state re-broadcasts drive the lobby screen), the snapshot stream's client half (app/static/js/net/ — decode.js the applyDelta mirror of server/encoding.py, snapshots.js the keyed buffer + lerpState + the gap/resync/freeze rules (injected clock), events.js the per-kind TTL + dedup + tick-reset queue), the enum parity (app/static/js/shared/enums.js — the wire's enum sets, set-equal to the Python enums both directions, tests/unit/test_client_enum_parity.py), the seven screens (app/static/js/screens/ — landing / connecting / lobby / countdown / play / round-end / error, each mount/unmount; the dom.js in that directory is the shared no-innerHTML builder, not a screen), the WebSocket layer (app/static/js/net/connection.js — the reconnect loop, the stale-close guard), the identity (identity.js), the keyboard input + help overlay (task 05: app/static/js/input/keyboard.js — WASD/arrows → the 8-way intent, Space/Enter the action's rising edge, 1–4 the emotes, the key-hold pump (~48 ms ≈ 20.8 Hz, above the 20 Hz tick — a tick with no frame brakes the body) + the 10 Hz zero-intent keep-alive (the ≥ 5 Hz protocol rule), the ignore rules (input focus, document.hidden, modifiers), the blur/hidden/pagehide forced zero, ?controls=off binds nothing (the scripted-test mode — the e2e suites drive the wire through the seam) and getKeyMap() (the canonical key map the help overlay and the README's ### Controls render, tests/e2e/test_us04_move_and_cook.py keeps them identical), app/static/js/ui/help.js — the ?/Esc help dialog, the key-map rows, no external docs mid-game), the single render loop (app/static/js/render/loop.js — pauses on document.hidden, idle off-PLAYING, the self-measured frameMs census), the Canvas renderer + HUD (task 04: app/static/js/render/camera.js the fit-to-viewport / DPR / world⇄screen, app/static/js/render/canvas.js the layer-order drawFrame + the sprite mapping + renderStats + the target highlight, app/static/js/render/fx.js the event→effect particle pool + the reduced-motion fallbacks, app/static/js/ui/hud.js the DOM HUD — the order rail, the score/target, the non-colour-cue countdown, the streak, the dirty counter, the connection, the spectator; one diff-write per frame, no rAF of its own) — contract: docs/CLIENT.md (the module map, the machine, the message table, the buffer/loop section, the keyboard-input section, the renderer/HUD section; guards: tests/e2e/test_us01_landing_surface.py, test_us01_create_lobby.py, test_us02_join_by_code.py, test_us03_ready_and_start.py, test_atlas_loads.py, test_us04_render_smoke.py, test_us06_interp_math.py, test_us06_smoothness.py, tests/unit/test_client_sprite_map.py) app/static/js/, app/static/assets/, app/static/index.html
Documentation (design / testing / deployment; per-phase docs) docs/*.md, indexed by PLAN §13 and guarded by tests/unit/test_docs_index.py
The pipeline gate itself .agents/validate.sh
User-visible flow contract .agents/user_stories/US-XX_*.md

Tests mirror that: tests/unit/ (pure, no-I/O logic incl. HTTP-route unit tests), tests/integration/ (in-process app boot, live uvicorn + WebSocket clients), tests/sim/ (the pure kernel without a server: grid geometry, entity model, the A5 purity guard, the level validator, the seeded RNG's golden vectors and the tick's determinism — plus the replay harness: committed goldens in tests/goldens/, scripts in tests/fixtures/replays/, and the slow perf budgets — the random-walk smoke and the full-pipeline run), tests/e2e/ (Playwright, named test_usNN_*.py — plus the non-story asset guard test_atlas_loads.py — marked e2e). tests/ is not a package; shared fixtures live in tests/conftest.py, and the autouse fixture there points UNDERCOOKED_LEVELS_DIR at tests/fixtures/levels/ so no test silently reads the shipped levels/.

  • The browser suites use the playwright sync API directly with fixtures written in tests/e2e/conftest.py — pytest-playwright is deliberately not a dependency, so adding it needs the usual permission. Those browser/context/page fixtures are function-scoped on purpose: a long-lived sync driver keeps an asyncio loop flagged as running in the main thread and breaks every later async def test.
  • live_server (session-scoped, tests/conftest.py) is the only fixture that binds a socket: an ephemeral 127.0.0.1 port, never a routable interface.