Files
undercooked/README.md
T
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

31 KiB
Raw Blame History

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":<live room count>} 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, Python ≥ 3.12, and podman/docker for container builds. There is no database — all live game state is in memory.

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/<CODE>    # inspect it: the seat list, no tokens
curl -fsS -H 'X-Undercooked-Token: <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):

DEBUGPY=1 uv run undercooked       # opens a debugpy listener on :5678, then serves as usual

Authoring content:

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 > <dir>/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; tests/unit/test_content_not_hardcoded.py is what keeps levels and recipes out of the Python.

Run the checks:

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 <tests/fixtures/replays/<name>.json> runs the committed input script and gates it on tests/goldens/<name>.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 ("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

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 Origins 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 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.