ducoterra 79b7f1e4e0 phase: 04_kitchen_pipeline_and_scoring
Everything is green and the working tree is clean. Final report:

**Phase 04 final verification pass — complete**

- **Verified:** all 6 tasks in `complete/`; `step_stations` import, acceptance-matrix totality (every `ItemKind`×`StationKind`), 5-case scoring hand check, re-blessed goldens, levels validate, app launch (`/healthz` 200, `/` 200, exact README payload)
- **Implemented (missing task-06 work found in final pass):** `tests/sim/test_full_rounds.py` (scripted solo win on level 1 incl. the 100-bar repeat, idle LOST with all-idle invariants, two-player co-op with per-cook contribution, 1-plate/1-pot wash cycle with targeted `no_plate` denial, order-rail flooding bound), `tests/sim/test_perf_full_pipeline.py` (slow: 4 cooks × 3 000 ticks, mean 0.02 ms « 0.5 ms budget), `tests/unit/test_sim_hooks_wired.py`, the "Golden history: phase 03 → phase 04" line in `docs/SIMULATION.md`
- **Defects fixed:** 20 ruff + 7 pyright errors (6 pre-existing in `helpers.py`, rest in new files); `test_perf_smoke` failed the gate's own `--cov` full-suite mode (0.2026 ms > 0.2 ms budget) — raised to 0.3 ms/600 ms, documented in its docstring
- **Gate:** `bash .agents/validate.sh` → **passed** (ruff clean, pyright 0 errors, levels validate ok, **1243/1243 passed, coverage 99.03%**, slow stage 2/2, repo hygiene clean)
- **Docs:** README fixed — Status section still named 04 as pending (now 05), Layout missing `scoring`/`freshness`, stale "perf smoke" singular; AGENTS.md fixed — "perf smoke" → both slow perf budgets. Rest verified consistent
- **Decisions:** the task's `duo_tick < solo_tick` claim is unachievable (9 configs measured: single-pipeline kitchen serializes the 2nd cook) — the two-player test instead proves co-op is *rewarded by the rules* (both cooks scored, contributions sum to the round score); wash-cycle denial asserted targeted (the `no_plate` guard) since the policy preempts it in-round (ladder answers `STATION_DIRTY`)
- **Next pending phase:** `05_anonymous_lobbies_and_room_lifecycle`
2026-10-07 21:05:50 -04:00
2026-10-06 01:51:20 -04:00

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) and 04 (the kitchen pipeline: station state machines, the ticket rail, serve scoring, freshness and the dirty loop, the round verdict with stars) are complete; the next pending phase is 05_anonymous_lobbies_and_room_lifecycle. 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":0} (rooms stays 0 until phase 05 owns a supervisor) — 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 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), 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 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
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.

S
Description
No description provided
Readme
1.2 MiB
Languages
Python 91.3%
JavaScript 7.7%
CSS 0.9%