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`.
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, validatedSettingsobject (DEBUGPYsupport included), anduv run undercookedstarts a FastAPI server that serves the client shell at/, the browser assets fromapp/static/, andGET /healthz— which answers{"status":"ok","version":"0.1.0","rooms":<live room count>}from the room supervisor (phase 05) — with the security headers (CSPdefault-src 'self') on every response. - The content. The recipe book and the first two campaign levels live in
levels/;sim/level.pyparses them into typed models,sim/validation.pyruns PLAN §8's named rules, andcreate_apploads + validates that bundle at startup and refuses to boot broken content — the named-rule report goes to stderr andundercookedexits2instead of serving a half-working kitchen.GET /api/levels,GET /api/levels/{id}andGET /api/configanswer from it, andundercooked-levelsgives authors the same gate from the command line (docs/LEVELS.mdis the schema reference). - The tick.
sim/state_build.pybuilds a round from level + seats + seed andsim/step.pyadvances it in whole0.05s substeps (20 Hz, A4) with a splitmix64 RNG seeded from the round seed — pure (no clock, norandom, 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.mdis the contract,tests/sim/test_rng.pylocks the generator's published vectors. - Walking the kitchen.
sim/movement.py+sim/collision.pyare 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 legaldt, and cannot clip a diagonal corner. Walls, counters, void, off-map and the solid fixtures (spawn_shelf,chop_board,stove,prep) block;serve,sinkandtrashdo 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.6s) pins velocity and position and reports onestunnedevent 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 intests/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 wholestep()(tests/sim/test_movement_kernel.py). - The action button.
sim/interact.pyis the second half of stage 2 of the tick: the oneactionflag 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 (DENIEDwithout_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 toldcontested_lost, never left wondering. The work verbs (chop, cook, plate, wash) are spent bysim/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);SERVEis spent bysim/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 bytests/sim/test_targeting.py(hand-computed reach geometry, the press edge through the wholestep()) andtests/sim/test_verbs.py(a table that covers everyVerband everyDeniedReason, plus the gate's contested arbitration). - The hands.
sim/carry.pyis what the verbs do: one carried item per cook (a second pick ishands_full), no floor drops in v1 (a press at open floor is the namednothing_here), and a set-down is a placed object —RELEASE/PLACE_ON_STATION/PLATE_DISHput the item on the fixture and stamp it with a mark, so a teammate's pick of a set-down within 20 ticks is aHANDED_OFFwith 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−2score 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 bytests/sim/test_carry.pyandtests/sim/test_replay_carry.py. - The ticket rail.
sim/orders.pyis stage 5 of the tick: the level'sordersspec 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'sbase_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, oneORDER_MISSEDnaming 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'son_misscallback). 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 isdocs/SIMULATION.md§"Orders — the ticket rail". - Serve scoring.
sim/scoring.pyis the single owner of the round'sscoreandstreak, from both sides: aSERVEpress 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 namedDENIEDwith the dish left in the hand), and a matched dish is scoredround(base × quality × streak_mult × time_bonus)— the streak counted after the increment and capped at1.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'sscore/dishes_servedcredited (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_penaltyfloored at 0 and the streak reset, injected intostep_ordersas theon_misscallback soorders.pyandscoring.pynever 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 isdocs/SIMULATION.md§"Serve scoring". - Freshness — the clocks that create pressure.
sim/freshness.pyruns as substep stage 4b (after the station pipelines, before the orders) and decays the quality: a dish on a ready stove burns exactlyBURN_AFTER_S(8.0 s) after the ready flip — tick-exact, the 160th substep — with the station goingDIRTY(the burnt pot is stuck) and oneBURNTevent; a raw or chopped item left on a board rots linearly overROT_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 namedWASTED,counters["wasted"]) while a usable raw item is never silently overwritten (the namedstation_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 limitevery 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 isdocs/SIMULATION.md§"Freshness". - Round end — the verdict, the settle, the stars.
sim/round_state.pyruns as substep stage 7 and owns the round's clock and its verdict (which turns onscoreagainsttarget_scoreand nothing else): the round wins the moment the score reaches the target (the clock holds at that substep's countdown), or on the buzzer —WONif the score made it,LOSTif it did not — with exactly oneROUND_ENDevent. A decided round then settles: forSETTLE_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 % — andresult_payload(state)is the plain-JSON body of theroundmessage the protocol phase sends (PLAN §7):result,score,target,stars,time_left, theserved/missed/wastedcounters,longest_streak(owned bysim/scoring.py: a serve raises the mark, a miss never resets it) and the per-seatplayers[](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 bytests/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 %, theABORTEDseam, thelongest_streakowner/reader) andtests/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 isdocs/SIMULATION.md§"Round end — the verdict, the settle, the stars". - The room supervisor (phase 05). The server-side lobby shell is
live:
server/rooms.pymints 5-character Crockford base32 codes (collision- checked, never reused), enforcesmax_rooms/max_playerscapacity, 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 pastlobby_idle_timeout_s, stale tokens). Everything runs on an injected clock (server/clock.py), so the tests drive the garbage collector with aFakeClockand never sleep. The lifecycle is testable over HTTP before the phase-06 WebSocket transport exists:POST /api/rooms(201, the session from theX-Undercooked-Tokenheader or the body'stoken— or a server-minted token, returned once),GET /api/rooms/{code},POST /api/rooms/{code}/joinandGET /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). TheRoomitself 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(aWONround raises it toorder + 1, never decreases it),best[level_id](aBestRunofscore/stars/completed_at, replaced only when the score is higher), therounds_played/dishes_servedcounters — LRU-capped at 10 000 (evicting the stalest, never the session being created) and wiped on restart (A6);Room.set_levelasks the store'sis_unlocked,round_overrecords the result into every seated player's session, andGET /api/sessionanswersto_public(a fresh token readsunlocked: 1with an emptybest; 400no_tokenwithout the header) (contract:docs/ROOMS.md"Progression"). The lifecycle integration suite (phase 05 task 05) drives the whole lifecycle over HTTP plus directtick_once: create → 3 joins → ready-up → the host's start → the countdown → a scripted full-pipelineWON→ theROUND_ENDpayload →next_level→ back toLOBBYwith the same seats and colours; plus a solo win with the unlock advanced, the 5th join's and the process-capacity 503room_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_overrideseam onRoom; 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" istests/unit/test_content_not_hardcoded.py, the documentation set is guarded bytests/unit/test_docs_index.py, andbash .agents/validate.shis 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.