**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)** - Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`. - `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean. - Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`). - Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅. - Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed. - No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
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), and 03 (the deterministic simulation kernel: fixed-step tick,
movement and collision, the action button, the hands, the replay goldens) are
complete; the next pending phase is 04_kitchen_pipeline_and_scoring. 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":0}(roomsstays0until phase 05 owns a supervisor) — 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, serve, wash) are named by the ladder but no-ops until phase 04's pipeline spends them (the long-press trash is real — see "The hands" below);docs/SIMULATION.md§"The action button" is 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 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 rest of the simulation (the cooking pipeline, order spawning
and scoring in phase 04), rooms and the WebSocket
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") 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 is saved between rounds and nothing identifies you: the browser
keeps only a random per-tab token and the last code you typed, both in sessionStorage.
What actually works today is the shell of that: the page loads, normalises a typed code to
five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued
once phase 05 runs the room supervisor), and answers "Not implemented until phase 05."
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
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 smoke
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
smoke, 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`, `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
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 smoke);
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.