# 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) are **complete**; the next pending phase is `06_realtime_websocket_protocol`. 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 before the phase-06 WebSocket transport exists: `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 client buttons still answer "Not implemented until phase 07." until phases 06/07 wire the transport. - **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: the hazards (phase 10), the WebSocket protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the playable Canvas renderer (07). 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 intended loop, once the room flow ships: 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). What actually works today is the shell of that plus the server-side room shell: the page loads, normalises a typed code to five uppercase alphanumerics (codes themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`, phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` / `POST /api/rooms/{code}/join` / `GET /api/session`), and the lobby buttons still answer "Not implemented until phase 07." until the phase-06 WebSocket transport and the phase-07 client wiring replace them. 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. Movement keys and touch controls are defined by the phase-07/08 client and documented in `docs/CLIENT.md` once that phase writes it. ## 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) ``` `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. 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_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/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) app/static/ browser client: the ES-module shell today; Canvas 2D renderer and this repo's SVG art arrive with phase 07 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), TESTING.md (how this project is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their own phases (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.