# Undercooked A cooperative, **in-browser**, Overcooked-style kitchen game: 1–4 players share one kitchen over WebSockets, cook orders against the clock, and climb a campaign of levels that get progressively harder. Lobbies are **anonymous** — no accounts, no email, no password: you create a room, share the 5-character code, play. > Working title. "Overcooked" is a trademark of Ghost Town Games/Studio MDG; this > project is an independent homage with its own art, code, and content. ## Status Phases **01** (runnable service scaffold), **02** (level content, schema and validation), **03** (the deterministic simulation kernel: fixed-step tick, movement and collision, the action button, the hands, the replay goldens), **04** (the kitchen pipeline: station state machines, the ticket rail, serve scoring, freshness and the dirty loop, the round verdict with stars) and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`, the guarded `Room` FSM, the in-memory session store, the lifecycle integration suite), **06** (the realtime WebSocket protocol: the `/ws/{code}` transport, the versioned JSON wire contract with inbound guardrails, input ingestion with sequence discipline, the quantized snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) and **07** (the canvas client, art and HUD: the in-repo SVG atlas, the screen state machine, the snapshot buffer with 100 ms interpolation, the DPR-aware Canvas renderer, the DOM HUD and the desktop keyboard input — `docs/CLIENT.md`) are **complete**; the next pending phase is `08_client_prediction_and_touch`. What runs today: - **The service.** The Python toolchain is locked (`uv.lock`), configuration is a single typed, validated `Settings` object (`DEBUGPY` support included), and `uv run undercooked` starts a FastAPI server that serves the client shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers `{"status":"ok","version":"0.1.0","rooms":}` from the room supervisor (phase 05) — with the security headers (CSP `default-src 'self'`) on every response. - **The content.** The recipe book and the first two campaign levels live in `levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and **refuses to boot** broken content — the named-rule report goes to stderr and `undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`, `GET /api/levels/{id}` and `GET /api/config` answer from it, and `undercooked-levels` gives authors the same gate from the command line (`docs/LEVELS.md` is the schema reference). - **The tick.** `sim/state_build.py` builds a round from level + seats + seed and `sim/step.py` advances it in whole `0.05` s substeps (20 Hz, A4) with a splitmix64 RNG seeded from the round seed — pure (no clock, no `random`, nothing mutated), replayable from a seed plus an input script, and 100 % covered. The clock, the input contract and the round's end condition are real. `docs/SIMULATION.md` is the contract, `tests/sim/test_rng.py` locks the generator's published vectors. - **Walking the kitchen.** `sim/movement.py` + `sim/collision.py` are stage 3 of the tick: intent is clamped server-side (`move=(50,0)` walks at 3.5 tiles/s, not 175), accelerated at 30 tiles/s² and integrated one axis at a time against an axis-aligned sweep, so a cook stops flush against a wall instead of inside it, slides along it for free, cannot tunnel a 1-tile wall even at the coarsest legal `dt`, and cannot clip a diagonal corner. Walls, counters, void, off-map and the solid fixtures (`spawn_shelf`, `chop_board`, `stove`, `prep`) block; `serve`, `sink` and `trash` do not. Cooks soft-push each other apart symmetrically and positionally — a bump never injects velocity — and only a genuine terrain deadlock drops an input, the later-received one, exactly like the kernel's arbitration. Stun (`0.6` s) pins velocity and position and reports one `stunned` event on entry; facing follows the last non-zero intent, so an action press while standing still does not spin the cook. Proven by hand-computed geometry in `tests/sim/test_movement.py`, by a 2000-substep four-cook random walk from the sim's own RNG that re-checks containment, non-overlap and hash equality after *every* substep (`tests/sim/test_movement_stress.py`), and by the same walk on the shipped levels through the whole `step()` (`tests/sim/test_movement_kernel.py`). - **The action button.** `sim/interact.py` is the second half of stage 2 of the tick: the one `action` flag per frame becomes one press per rising edge, so however long the button is held there is one effect — and one named refusal when there is nothing to do (`DENIED` with `out_of_reach`, `hands_full`, `station_busy`, `contested_lost`, … in the payload, which the client turns into a nudge; a silent no-op is the failure mode PLAN §5 bans by name). A press resolves a reach zone — the tile in front, then the two diagonal-front tiles (a 0.6-tile forgiveness beyond the 1-tile reach), then the tile underfoot — and a ladder picks the verb: a full hand sets its item down on the fixture (the spawn shelf is the escape hatch), a free hand picks from a ready fixture or shelf stock, a stunned cook is refused before the target is even read, and a fixture owns what is on it (an onion on a chop board is *to be chopped*, not grabbable). Two cooks pressing the same item in the same substep are arbitrated by arrival, `seq`, then seat index — the loser is told `contested_lost`, never left wondering. The work verbs (chop, cook, plate, wash) are spent by `sim/stations.py` (phase 04 task 01's pipeline — a set-down on a work surface starts the work, a release freezes the progress, a dirty fixture is washed in place); `SERVE` is spent by `sim/scoring.py` (phase 04 task 03 — see "Serve scoring" below). The long-press trash is real — see "The hands" below; `docs/SIMULATION.md` §"The action button" and §"The station pipeline" are the contract. Proven by `tests/sim/test_targeting.py` (hand-computed reach geometry, the press edge through the whole `step()`) and `tests/sim/test_verbs.py` (a table that covers every `Verb` and every `DeniedReason`, plus the gate's contested arbitration). - **The hands.** `sim/carry.py` is what the verbs *do*: one carried item per cook (a second pick is `hands_full`), no floor drops in v1 (a press at open floor is the named `nothing_here`), and a set-down is a *placed object* — `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the item on the fixture and stamp it with a mark, so a teammate's pick of a set-down within 20 ticks is a `HANDED_OFF` with both ids (the sanctioned pass) and an unmarked pipeline input stays the work press's. The trash is long-press and always available: holding the button over the can for 8 substeps destroys the carried item (`TRASHED`, the raw `−2` score nudge in the payload for phase 04's clamped scoring, `counters["trashed"]` up by one); a short press and a walk-away do nothing. A 100-tick fuzz of random simultaneous presses re-checks the conservation law (`hands + stations + trashed == initial`) after every tick, and a scripted two-player pass-and-contest reaches its committed golden hash. `docs/SIMULATION.md` §"The hand" is the contract (phase 09's co-op E2E asserts exactly this tie-break). Proven by `tests/sim/test_carry.py` and `tests/sim/test_replay_carry.py`. - **The ticket rail.** `sim/orders.py` is stage 5 of the tick: the level's `orders` spec is copied onto the state in tick units at round build (a replay stays self-contained), the first order arrives 1.5 s in, the recipe is a seeded draw from the level's pool (the order's value is the recipe's `base_score`), the concurrency cap holds, a due tick a full rail cannot serve retries 1 s later *without consuming the interval draw* (a full rail would otherwise silently warp the spawn cadence), and an order's clock — derived, never accumulated — expires on exactly the tick it reaches 0: removed, `counters["missed"]` up by one, one `ORDER_MISSED` naming the id and recipe (the penalty score deduction and the streak reset are task 03's scoring, the single owner of score and streak, injected as the stage's `on_miss` callback). Proven by 200 simulated seconds of interval bounds and cap under idle and busy rails, same-seed/different-seed reproducibility, and twenty randomized spec fuzz runs (`tests/sim/test_orders.py`, `tests/sim/test_orders_fuzz.py`); the contract is `docs/SIMULATION.md` §"Orders — the ticket rail". - **Serve scoring.** `sim/scoring.py` is the single owner of the round's `score` and `streak`, from both sides: a `SERVE` press at the window validates the carried dish (burnt → `burnt`, not a plated dish → `no_target`, an ingredient the declared recipe does not require → `incomplete`, no live order for the recipe → `wrong_recipe`, an empty rail → `no_orders` — each a named `DENIED` with the dish left in the hand), and a matched dish is scored `round(base × quality × streak_mult × time_bonus)` — the streak counted *after* the increment and capped at `1.5`, the bonus 1.5 with the full deadline unspent down to 1.0 at the edge — with the order off the rail and the cook's `score`/ `dishes_served` credited (per-player contribution, display only; a prop is dirty from its *use* — the station pipeline's stock bookkeeping — not from the serve). A deadline's expiry is the scoring half of the rail's miss: `score − order_penalty` floored at 0 and the streak reset, injected into `step_orders` as the `on_miss` callback so `orders.py` and `scoring.py` never import each other. Pinned by five hand-computed serves, the rejection paths, FIFO and the caps (`tests/sim/test_scoring.py`) and 500 seeded random serves (`tests/sim/test_scoring_property.py`); the contract is `docs/SIMULATION.md` §"Serve scoring". - **Freshness — the clocks that create pressure.** `sim/freshness.py` runs as substep stage 4b (after the station pipelines, before the orders) and decays the quality: a dish on a ready stove burns exactly `BURN_AFTER_S` (8.0 s) after the ready flip — tick-exact, the 160th substep — with the station going `DIRTY` (the burnt pot is stuck) and one `BURNT` event; a raw or chopped item left on a board rots linearly over `ROT_S` (20.0 s); a carried prepared item decays at half the board's rate (a hand is not a fridge); a new raw set-down on a board that holds a burnt item destroys it (a named `WASTED`, `counters["wasted"]`) while a usable raw item is never silently overwritten (the named `station_busy`); a burnt dish is refused by the serve window, never scored. The dirty loop's stock — a use moves a prop `…_clean → …_dirty`, a wash (the sink, or the in-place wash of a stuck prop) moves it back — holds `…_clean + …_dirty == the level's limit` every tick, and the no-dead-end guarantee (a worst-case cook who serves everything and never washes until forced never gets stuck: one wash restores the stock) is proven on a 2 000-tick run (`tests/sim/test_freshness.py`, `tests/sim/test_no_deadlock.py`); the contract is `docs/SIMULATION.md` §"Freshness". - **Round end — the verdict, the settle, the stars.** `sim/round_state.py` runs as substep stage 7 and owns the round's clock and its verdict (which turns on `score` against `target_score` and nothing else): the round wins the moment the score reaches the target (the clock holds at that substep's countdown), or on the buzzer — `WON` if the score made it, `LOST` if it did not — with exactly one `ROUND_END` event. A decided round then **settles**: for `SETTLE_S` (1.0 s, the room's, phase 05), the kernel freezes every gameplay stage — inputs ignored, no movement, stations, freshness, orders or hazards — and only the frozen clock and the tick keep running, so the verdict substep's events land and the result is a function of the state at the verdict alone. `stars(state)` grades a win from the frozen clock — 0 when lost, 1 for winning, 2 with ≥ 25 % of the clock left, 3 with ≥ 50 % — and `result_payload(state)` is the plain-JSON body of the `round` message the protocol phase sends (PLAN §7): `result`, `score`, `target`, `stars`, `time_left`, the `served`/`missed`/`wasted` counters, `longest_streak` (owned by `sim/scoring.py`: a serve raises the mark, a miss never resets it) and the per-seat `players[]` (`id`, `name`, `score`, `dishes_served`, `missed_contrib` — the round's missed count spread evenly, a display-only estimate: the sim does not attribute an expired order to a cook). Pinned by `tests/sim/test_round_state.py` (verdict on both sides of the epsilon, the settle freeze, the star boundaries one tick below and exactly at 25 %/50 %, the `ABORTED` seam, the `longest_streak` owner/reader) and `tests/sim/test_result_payload.py` (a recursive JSON-safety walk with zero leaks, the exact key sets, byte-stability across two identical runs and across the settle window); the contract is `docs/SIMULATION.md` §"Round end — the verdict, the settle, the stars". - **The room supervisor (phase 05).** The server-side lobby shell is live: `server/rooms.py` mints 5-character Crockford base32 codes (collision- checked, never reused), enforces `max_rooms` / `max_players` capacity, keeps a one-token-one-room index (a token joining a second room is evicted from the first; a *disconnected* seat is kept for the rejoin path), and runs the 5 s GC (empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale tokens). Everything runs on an injected clock (`server/clock.py`), so the tests drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is testable over HTTP, and it now also rides the phase-06 WebSocket transport (`/ws/{code}`; below): `POST /api/rooms` (201, the session from the `X-Undercooked-Token` header or the body's `token` — or a server-minted token, returned once), `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` and `GET /api/session` — seats never leak other players' tokens, a malformed code 404s exactly like an unknown one (US-02 criterion 4), and a full room or a full process is a 503 `{"code": "room_full"}` (US-01 criterion 7). The `Room` itself is the guarded FSM behind those routes — five states, a table of fourteen legal transitions, one 20 Hz deadline-scheduled tick task per room and crash isolation that closes only the room that crashed (contract: `docs/ROOMS.md`), and the ready-up/host flow rides on it. The in-memory **session store** (`server/sessions.py`) is live on top: per token it remembers the campaign progression — `unlocked_level` (a `WON` round raises it to `order + 1`, never decreases it), `best[level_id]` (a `BestRun` of `score`/`stars`/ `completed_at`, replaced only when the score is higher), the `rounds_played` / `dishes_served` counters — LRU-capped at 10 000 (evicting the stalest, never the session being created) and wiped on restart (A6); `Room.set_level` asks the store's `is_unlocked`, `round_over` records the result into every seated player's session, and `GET /api/session` answers `to_public` (a fresh token reads `unlocked: 1` with an empty `best`; 400 `no_token` without the header) (contract: `docs/ROOMS.md` "Progression"). The **lifecycle integration suite** (phase 05 task 05) drives the whole lifecycle over HTTP plus direct `tick_once`: create → 3 joins → ready-up → the host's start → the countdown → a scripted full-pipeline `WON` → the `ROUND_END` payload → `next_level` → back to `LOBBY` with the same seats and colours; plus a solo win with the unlock advanced, the 5th join's and the process-capacity 503 `room_full`, malformed ≡ unknown codes (US-02 criterion 4), the seat-grace and lobby-idle expiries through the GC, and one room's crash closing only itself (the test-only `_test_step_override` seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and "The lifecycle walkthrough"). - **The WebSocket transport (phase 06, task 02).** The room core is on the wire: `GET /api/stats` answers the ops census (`rooms` / `players` — the occupied seats / `sockets` — live WebSocket connections / `tick_hz`), and `WS /ws/{code}` is the game socket — an origin check *before* the accept (a disallowed `Origin` is an HTTP `403`, never a close frame that looks like a network error), a `hello` within `hello_timeout_s` (else close `4400`), a `p` gate (`protocol_mismatch` + `4400` before any seat is taken), the supervisor's seat/spectate (`room_gone` + `4401` for an unknown code, `room_full` + `4402` for a fresh token on a full room), the `welcome` + the current `lobby`, and the two per-connection tasks (a non-blocking `pump_out` over a capped send queue — the *oldest snapshot* drops first, `lobby` / `round` / `err` never — and the `read_in` guardrails: the `max_message_bytes` cap → `too_big`, the `max_inputs_per_s` token bucket → `rate_limited`, the shape check, the per-state whitelist → `wrong_state`; 3 consecutive rate/oversize violations or 5 shape violations close `4404`). One socket per token: a second `hello` with the same token evicts the first (`evicted` + `4403`, marked dead before the close so no ghost frames) — and an evicted socket never marks the seat disconnected, so the crown stays put. A *genuine* disconnect reaches the room exactly once (`mark_disconnected`), releases the registry slot, and leaves no error in the log — an abrupt transport close (no close handshake) included. The contract in prose is `docs/PROTOCOL.md` (message tables kept equal to the models by `tests/unit/test_protocol_doc_sync.py`, close/error codes, the limits, and a manual probe that reproduces the handshake without a browser); the guardrails are driven by real sockets in `tests/integration/test_ws_handshake.py` and the queue policy by `tests/unit/test_send_queue_policy.py`. - **Input ingestion (phase 06, task 03).** The `input` message is on the wire: the `seq` discipline (equal → dropped as an idempotent resend, backwards → `err` + a `resync` event the client hard-snapshots to, gaps ≤ 3 tolerated UDP-style, larger gaps accepted and counted in the room stats), the anti-cheat clamp (`move` clamped to unit length and quantized to 3 decimals server-side — a client sending `(1000, 1000)` walks at `MAX_SPEED` like everyone else, and the position never arrives because the message has no position field and `extra="forbid"` rejects one), `face` derived from `move` when absent, `act` coerced, and `received_at_tick` stamped by the server, never the client. The room's per-tick input queue caps at `4 x len(seats)` (overflow drops the sender's oldest frame, `inputs_dropped` bumped), a `DISCONNECTED` seat's frames are silently ignored, a spectator's get `not_seated`, and `Room.stats()` carries the input counters. A wall-clock tick too short to fill one substep keeps the queue, so a frame is never dropped without being applied. The rules are in `docs/PROTOCOL.md` ("Input rules") and driven by real sockets in `tests/integration/test_ws_input.py` (the hostile-move bound included). - **The snapshot stream (phase 06, task 04).** `PLAYING` now broadcasts: quantized snapshots (an explicit field map — no `pickle`/`asdict`; the grid, station ids, spawn coordinates and the round seed never leave the server) at `SNAPSHOT_HZ` for players and half that for spectators, encoded once per (frame, audience) and fanned out across every connection of it. The stream runs in delta mode (only the changed entities, plus a `gone` tombstone for each vanished one; a typical idle-kitchen delta is ≥ 40% smaller than a full), with a full frame on a fresh connection's first frame, on a dropped snapshot's next frame, and on the full cadence (every 20 encoded frames), so a client can always resync cheaply. The boundary's events ride the frame (the stable `k` names, an explicit short-key payload map, capped at 24 per frame with `events_dropped` on overflow); outside `PLAYING` they flush as the standalone `ev` message, and the `countdown` / `round` state frames reach every connection. `UNDERCOOKED_SIM_LATENCY_MS` applies the server-side send delay the netcode tests use to simulate RTT. The shape, the delta rules and a captured frame are `docs/PROTOCOL.md` ("The `snap` frame"), the no-leak/quantization guard is `tests/unit/test_encoding_no_leak.py`, and the budgets (a 4-player / 4-order frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB, the 100-tick downlink ≤ 60 KB/s) are measured over live sockets in `tests/integration/test_encoding.py`. - **Disconnects, the ghost, and the rejoin (phase 06, task 05).** A dropped socket (an abrupt close, a heartbeat timeout, or the test-only `kill_socket` seam) keeps the seat for `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s): the other players see one `ghost_on` event and a `connected: false` seat, the sim's cook switches to `disconnected`, and after 3 s the cook's carried item is released — onto the station on the cook's tile (a marked set-down, grabbable), or into the `lost_items` counter when no station is under the tile (an item is never deleted silently). A `hello` with the same token inside the window rejoins the same seat (`welcome.rejoined: true`, one `ghost_off`, the snapshot stream resumes within one tick); past it the seat is abandoned (one `ghost_off`, the spectate/join rules apply, an emptied room closes through the 30 s seat grace / the GC). The client keeps the socket honest with a `ping` at ≥ 5 Hz; 12 s of inbound silence is a dead client (the server closes `1000` — the room sees the same drop as an abrupt close), and a closing room closes its sockets with `err room_gone` + close `4401`, so a client can tell "the room died" apart from "you left" (`1000`). The grace, the park, the ghost events and the close-code decision table are `docs/PROTOCOL.md` ("Heartbeats, drops, and seat grace"); the park's rules are `sim/ghost.py` (driven in `tests/unit/test_ghost_parking.py`), the drop/rejoin/seam paths in `tests/integration/test_ws_disconnect.py`. - **The protocol, tested (phase 06, task 06).** The wire contract's final proof is three real-socket suites in `tests/integration/`: `test_ws_round_two_clients.py` (two scripted clients play a complete round — the countdown, the `snap` stream, the scripted soup, the shared `served` event and the byte-identical `round` frames, a mid-round spectator, the `to_lobby` return), `test_ws_abuse.py` (2000 malformed frames, the 500-message burst, the 64 KB refusal / 4 KB boundary, the host-check and emote-throttle flood, 200 concurrent handshakes) and `test_ws_budgets.py` (`slow`: the tick cost over 400 real ticks, the `snap` size, the downlink rate, the input→snapshot echo). The `live_server` / `ws_client` fixtures (`tests/integration/conftest.py`) drive them — a real `uvicorn` server on an ephemeral loopback port and a scripted client with a merged snapshot view (`expect` / `collect` / `send_model`). The contract, the test seams and the budget table with the measured values are `docs/PROTOCOL.md` ("Testing the protocol"). - **The canvas client, art, and the HUD (phase 07).** The browser half is live on top of all of this: the in-repo SVG atlas (`assets_src/` → the served copies in `app/static/assets/`; `gen_art_index` regenerates the `index.json`, and a missing or duplicated symbol fails the build, the expected ids deriving from the sim's own enums and the `sim/stations.py` pipeline tables), the screen state machine (the page's landing create/join, the lobby with its code copy, level picker, ready-up, start and seat chips, the 3-2-1 countdown, the round-end and the error screens, all on the same `WS /ws/{code}` socket), the snapshot buffer with the 100 ms interpolation and the event queue, the single render loop, the DPR-aware Canvas renderer (the kitchen floor, the stations, the y-sorted cooks, the carried items, the progress rings, the hazards, the particles, the target highlight) and the DOM HUD (the order rail, the score/target, the countdown with its non-colour cue, the streak, the dirty counter, the connection indicator, the spectator banner); the desktop keyboard input (WASD/arrows, the action button, the emotes, the `?` help overlay — the touch surface is phase 08) closes it out. `docs/CLIENT.md` is the module map and the contract; the proof is the e2e suites — `test_us01_create_lobby.py`, `test_us02_join_by_code.py`, `test_us03_ready_and_start.py` (the lobby flow), `test_us04_move_and_cook.py` (a scripted keyboard cook serves a soup) and `test_us04_render_smoke.py` (the canvas is provably not blank and moves after input; the HUD score changes within 200 ms of the `served` event), `test_us06_interp_math.py` and `test_us06_smoothness.py` (the interpolation math, exercised through a real page; per-tick displacement bounded with 60 ms of injected latency, both clients converge on `ROUND_END`), and `test_atlas_loads.py` (the atlas decodes with zero console errors; `test_us01_landing_surface.py` asserts the page's only network requests are same-origin). - **The rules are tests, not promises.** The no-external-asset rule is `tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is `tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by `tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the phase pipeline runs after every task. What does not exist yet: client prediction, reconciliation and the touch surface (phase 08), the two-browser co-op E2E with contested-resource arbitration (phase 09), the hazards and their client FX (phase 10), the rest of the ten-level campaign (phase 11), reconnect resilience and the 200-room stress run (phase 12), endless mode with the session scoreboard (phase 13), and the accessibility polish and hardening pass (phase 14). Execute phases with the `phased-execution` skill (`auto-phase.sh`); it always takes the lowest-numbered directory still in `.agents/phases/todo/`. ## How to play The loop, live in the browser today: open the site, **Create lobby**, read out the 5-character code, everyone else **Joins** with it, the host picks a level, the room readies up, and the kitchen starts. Each cook has one movement control and **one action button** that picks up, uses and puts down whatever is in reach — one carried item at a time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it at the window before the timer runs out. Levels escalate: more recipes, fewer stations, hostile floors. Nothing identifies you: the browser keeps only a random per-tab token and the last code you typed, both in `sessionStorage`. The campaign does remember per token — unlocks and best scores live server-side, in memory, and a server restart clears all progression (A6: no database). The page does that loop end to end: it loads, normalises a typed code to five uppercase alphanumerics (codes themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`, phase 05, also testable over `POST /api/rooms` / `GET /api/rooms/{code}` / `POST /api/rooms/{code}/join` / `GET /api/session`), the landing's **Create lobby** / **Join with code** open a `WS /ws/{code}` socket (`hello` → `welcome` + `lobby`), the lobby shows the code, the level picker (fed by `/api/levels`), the ready and start controls and the seat chips, the 3-2-1 countdown runs, and the round-end screen reports the verdict — the full contract, including the guardrails and the close codes, is `docs/PROTOCOL.md`, the client's own contract is `docs/CLIENT.md`. The playable Canvas renderer, the HUD and the desktop keyboard controls are live (below; the in-game `?` help overlay renders the same rows) — the touch controls arrive with phase 08. The ready-up, the 20 Hz tick ownership, the session store (the unlocks and best scores, in memory — a server restart clears all progression) and the lifecycle integration suite are live. ### Controls - **W / A / S / D** — move (or the arrow keys) - **Space / Enter** — action (pick, work, set down, serve) - **1 / 2 / 3 / 4** — emote (wave, cheer, thumbs up, thumbs down) - **?** — help overlay - **Esc** — close help / leave the room The goal is the HUD's target score before the level's deadline: an order ticket names a dish, and the action button does everything a hand does — pick the ingredient off a shelf, work it on a station (hold the button while the station works), plate it and serve it at the window. One carried item at a time; a set-down marks the item so a partner can pass it. `?` opens the help overlay (the same rows, rendered in-game); `Esc` closes it — or leaves the room. The `?controls=off` URL param disables the bindings (the touch surface's seam, phase 08). ## Quickstart (development) Requires [`uv`](https://docs.astral.sh/uv/), Python ≥ 3.12, and `podman`/`docker` for container builds. **There is no database** — all live game state is in memory. ```bash uv sync --extra dev uv run undercooked # http://127.0.0.1:8000 (shell page + /healthz) uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone uv run undercooked --reload # dev reload uv run undercooked --levels-dir levels # point at another content dir curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version curl -fsS -X POST http://127.0.0.1:8000/api/rooms # create a room: 5-char code + your token (201) curl -fsS http://127.0.0.1:8000/api/rooms/ # inspect it: the seat list, no tokens curl -fsS -H 'X-Undercooked-Token: ' http://127.0.0.1:8000/api/session # your session: unlocked, best (in memory; a restart clears it) ``` `python -m undercooked` is the same command (the container uses it). A flag always beats the environment, which beats the built-in default. There is no `/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which this project forbids. The validation the server runs at startup (every level file loads, and no rule of PLAN §8 is broken) is a command too — `undercooked-levels`, see **Authoring content** below. Debug the server with a real debugger attached (dev only - the listener binds `0.0.0.0`, so never enable it on a public host): ```bash DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual ``` Authoring content: ```bash uv run undercooked-levels list # the campaign, in campaign order uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reachability overlay uv run undercooked-levels validate # the content gate; --json for scripts uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book uv run undercooked-levels axes # escalation-axis table (phase 11's curve) uv run python -m undercooked.tools.gen_art_index # rebuild app/static/assets/index.json from assets_src/ ``` `undercooked-levels` is a thin shell over the loader and the PLAN §8 rules `create_app` runs at startup, so a green `validate` is a server that boots. It finds content through the settings (`--dir` > `UNDERCOOKED_LEVELS_DIR` > `levels`, plus `--recipes` > `UNDERCOOKED_RECIPES_PATH` > `/recipes.yaml`) and never from the current directory, so it works from any path. Exit codes: `0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load failure, unknown id, refused overwrite, usage error). The schema, every field, the overlay trick and a "which rule did I just hit" table live in [docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py` is what keeps levels and recipes out of the Python. The in-repo SVG art (phase 07 task 01) is guarded the same way: `gen_art_index` fails with the missing id named when a symbol is absent or duplicated, and its expected set is derived from the simulation's own enums and pipeline tables, so a new `ItemKind`/`ItemForm` the sim can produce breaks the build until a symbol is drawn for it (guards: `tests/unit/test_assets.py`, `tests/e2e/test_atlas_loads.py`). Run the checks: ```bash uv run ruff check . uv run pyright uv run pytest # everything, coverage floor included uv run pytest -m "not e2e" # the quick loop: no browser launches uv run playwright install chromium # once, before the first e2e run uv run pytest -m e2e # browser suites only uv run pytest -m slow tests/sim -q --no-cov # the gate's slow stage: goldens + perf budgets bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright ``` The replay goldens behind `tests/sim/test_replay.py` are checked the same way a level is validated: `uv run python -m undercooked.sim.replay check .json>` runs the committed input script and gates it on `tests/goldens/.golden.json` (exit `0` match, `1` divergence with the first divergent tick and the changed fields, `2` missing script/level/ golden). `record` writes a golden — the manual re-bless, legitimate only after a deliberate sim-contract change recorded in the phase task; the contract is [docs/SIMULATION.md](docs/SIMULATION.md) ("Replaying and re-blessing goldens"). `pytest` with no `-m` runs the browser suites too, so chromium has to be installed for the pipeline gate. Those suites start the app themselves on a free loopback port (`live_server` in `tests/conftest.py`) — nothing needs to be running first, and no test ever listens on a routable interface. `bash .agents/validate.sh` is the gate a phase task must pass before it moves to `.agents/phases/complete/`. It stops at the first failure, in this order: `ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor, the slow sim stage (`pytest -m slow tests/sim`: the replay goldens and the perf budgets — the random-walk smoke and the full-pipeline run — named on their own so a quick loop that skips them cannot let them slip through the gate), then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or key material committed). Run it from the repo root or from `.agents/`. ## Container ```bash podman build -t undercooked -f Containerfile . podman run --rm -p 8000:8000 undercooked # or podman compose up -d ``` ## Configuration All settings come from the environment, parsed once into `undercooked.config.Settings` — the only module that reads the environment, so nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI flags, then exported environment variables, then a local `.env` (found from the current directory upwards; never commit it), then the built-in defaults. A blank value counts as unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup instead of half-configuring the server, and unusable content is refused the same way — an unreadable `UNDERCOOKED_LEVELS_DIR` or a level that breaks a PLAN §8 rule prints the named-rule report and exits `2` (under `--reload` the reload child reports and restarts instead; docs/LEVELS.md). `.env.example` and the table below list every variable with its default; tests keep both in sync with `config.py`. | Variable | Default | Meaning | |---|---|---| | `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address | | `UNDERCOOKED_PORT` | `8000` | Bind port | | `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate (5-60) | | `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players (<= tick rate) | | `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) | | `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused | | `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) | | `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) | | `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) | | `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies | | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this | | `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size | | `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit | | `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) | | `UNDERCOOKED_HELLO_TIMEOUT_S` | `5` | A WebSocket that sends no `hello` within this many seconds is closed (code 4400) | | `UNDERCOOKED_TEST_MODE` | `0` | `1` = register the test-only seam route (`POST /api/test/kill_socket/...`); never set in production | | `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only | | `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup | | `DEBUGPY_PORT` | `5678` | debugpy listen port | ## Layout ``` src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation src/undercooked/__main__.py the `undercooked` command (CLI > env > default) src/undercooked/config.py typed env settings (the only `os.environ` reader) src/undercooked/debug.py DEBUGPY-gated debugger bootstrap src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geometry (`grid`), content schema + loader (`recipe`, `level`, `content` — the YAML contract is docs/LEVELS.md), the level validator (`validation` — PLAN §8 named rules; rule table in docs/LEVELS.md), the fixed-step tick (`step`, `state_build`, `rng`, and the subsystem hooks `movement` (+ `collision`, its pure geometry), `interact` (the action button — reach zone, verb ladder, named denials, contested arbitration, the long-press trash), `carry` (the hands — one item per hand, the marked set-down and counter hand-off, the always-available trash), `stations`, `freshness`, `scoring`, `orders`, `hazards`, `round_state` — contract in docs/SIMULATION.md), and the A5 purity guard (`purity`, test-enforced) src/undercooked/sim/replay.py the replay harness: the input-script format, the golden hashes, `run_script`/`compare`, and the record/check CLI (docs/SIMULATION.md "Replaying and re-blessing goldens") src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list, show [--grid], new, axes; same loader, same rules, no hard-coded content list (docs/LEVELS.md "Authoring workflow") src/undercooked/tools/gen_art_index.py the SVG atlas index generator — rebuilds app/static/assets/index.json from assets_src/; the expected ids derive from the sim's enums + the station pipeline tables, a missing or duplicated id fails (guards: tests/unit/test_assets.py) src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API and the room API; installs the supervisor src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock` (the only server module that imports `time`; a grep test keeps the wall clock out of the rest of `server/`) src/undercooked/server/codes.py Crockford room codes: the alphabet, collision-checked minting (`new_code`), input normalisation (confusables, malformed ≡ unknown, US-02 criterion 4) src/undercooked/server/room.py `Room`: code, seats (the rejoin path keeps disconnected seats), `RoomState`, one owned tick task src/undercooked/server/rooms.py `RoomSupervisor`: the registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown` src/undercooked/server/sessions.py `SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore` (`record_result`'s win-only unlock bump, best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) src/undercooked/server/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 WebSocket transport (session from the `X-Undercooked-Token` header or the body's `token`, minted otherwise; tokens returned once; seats never leak tokens) src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`, plus the startup gate: broken content refuses startup src/undercooked/server/headers.py the security-header middleware .env.example every configuration variable, with its default levels/ data-driven content (YAML): the recipe book (A8) and the first two campaign levels (01_first_kitchen, 02_double_shift) assets_src/ the hand-authored SVG art (tiles, stations, items, cooks, fx, ui) — source for the served copies in app/static/assets/ (a guard test keeps them in sync) app/static/ the browser client (ES modules, no bundler): the composition root (`js/main.js`), the screen state machine (`js/app.js`) and the seven screens (`js/screens/`, the no-innerHTML DOM builder `dom.js`), the snapshot buffer + 100 ms interpolation (`js/net/`), the single render loop (`js/render/loop.js`), the DPR-aware Canvas 2D renderer (`js/render/canvas.js` + `camera.js` + `fx.js`), the DOM HUD (`js/ui/hud.js`), the desktop keyboard input (`js/input/keyboard.js`) and the help overlay (`js/ui/help.js`); the in-repo SVG atlas with its `index.json`, the palette (`assets/palette.css` + `js/render/palette.js`, kept hex-identical by a test) and the atlas loader (`js/render/atlas.js`) (contract: docs/CLIENT.md) tests/ unit/, integration/, e2e/ (one Playwright suite per story) and sim/ (the pure kernel: grid, entities, purity guard, validation, the seeded RNG and the tick; plus the replay harness — committed goldens in goldens/, scripts in fixtures/replays/ — and the `slow` perf budgets (the random-walk smoke and the full-pipeline run)); fixtures/levels/ is the content dir tests see (a recipe book and no levels; `_bad/` holds the deliberately-broken campaign the `_` discovery rule ignores); conftest.py docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract), SIMULATION.md (the tick contract), ROOMS.md (rooms & lobby lifecycle), PROTOCOL.md (the v1 wire contract — message tables, error/close codes, limits, the full-round sequence, the test seams and the budget table), CLIENT.md (the client module map, the render loop, the snapshot buffer, the input, the assets), TESTING.md (how this project is verified), DEPLOYMENT.md (the index is PLAN.md §13) .agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan ``` **No external CDNs.** Every byte of JS, CSS, font, and image is served by this app from `app/static/`. `tests/unit/test_no_external_assets.py` enforces it, and the e2e suite fails if loading the page ever issues a request off-origin. ## Contributing Read [AGENTS.md](AGENTS.md) first: it points at `.agents/PLAN.md` (LOCKED decisions) and the phase protocol. Code changes land by executing phase task files, not by editing ad hoc.