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`
68 KiB
SIMULATION.md — the tick contract
Status: current as of phase 04 task 05 (the kernel of task 01, real movement,
collision, facing and stun, the action button: reach-zone targeting, the verb ladder,
named denials and contested arbitration, the hands: one-item-per-hand carry, the
marked set-down with the counter hand-off, and the long-press trash, the station
pipelines: the acceptance matrix, the transition timers, the worker hold and release,
dirty props and the plate/pot stock, the ticket rail: seeded order spawning with
the concurrency cap and the full-rail retry, tick-precise deadlines and the miss
bookkeeping, serve scoring: the named serve rejections, FIFO order matching,
the quality/streak/time-bonus math and the miss penalty, freshness: the burn
countdown, the quality decay, the push-out and the dirty loop — and the round's
end conditions: the early win, the buzzer verdict, the post-verdict settle,
the stars and the plain-JSON result payload, plus the replay harness: the
input-script format, the committed golden hashes, the record/check CLI, and
the headless perf smoke). Filled in by phase 10 for the hazards —
that replaces the identity hook named in the order table below.
Purpose
This document is the contract between the deterministic game rules and everyone who calls them: how a tick is born, how much simulation one call may buy, the order subsystems run in and why that order is gameplay, how player input is turned into authoritative change, what purity and determinism cost in code, and what a golden hash is allowed to mean. It exists because the simulation is the single source of truth (A4) — a rule that reads differently in two places is a bug in the rules, not in the client.
The rules of the kitchen live in src/undercooked/sim/ and nowhere else. This document
is the contract those modules owe every caller — the room supervisor (phase 05), the
protocol layer (phase 06) and the client (phase 07) included. .agents/PLAN.md decides
what the simulation is (A4: server-authoritative fixed tick; A5: purity; A21: replay);
this file says how that reads in code.
Write here, not in a docstring, whenever a later phase changes: the tick contract, movement and collision, the pipeline rules, contested arbitration, or hazard rules.
The tick
one call -> step(state, dt, inputs) -> (state', events)
│
dt clamped to [0, MAX_DT], added to state.accumulator
│
while accumulator >= STEP_DT: exactly one substep, accumulator -= STEP_DT
The simulation is driven in whole substeps of STEP_DT = 0.05 s, i.e. 20 Hz, the rate
A4 locks. UNDERCOOKED_TICK_HZ (config.py) decides how many substeps the room asks for
per wall second; it never changes what one substep contains. A caller may hand step()
any dt it likes — the client's frame time, a batched timer, a test harness — and the
simulation still advances in the same 0.05 s increments, so physics never depends on how
noisy the host's clock is.
Leftover time is not lost: KitchenState.accumulator carries the fraction that did not
add up to a whole substep into the next call. 17 calls of 0.05 s are exactly 17 ticks;
two calls of 0.03 s are one tick plus 0.01 s of credit.
Constants
| Constant | Value | Lives in | Means |
|---|---|---|---|
STEP_DT |
0.05 s |
sim/step.py |
One substep — one tick. The only dt a subsystem ever sees. |
MAX_DT |
0.1 s |
sim/step.py |
Ceiling on one step() call: at most 2 substeps, so a stall cannot spiral. |
TICK_EPS |
1e-9 s |
sim/step.py |
Tolerance on the substep bound; 0.85 − 17 × 0.05 is 2.2e-16, not 0. |
TIME_EPS |
1e-9 s |
sim/round_state.py |
Same idea for the round clock: below this the round is over, not "0.0 s left". |
SETTLE_S |
1.0 s |
sim/round_state.py |
The settle window: how long the room (phase 05) keeps a decided round ticking, so the verdict substep's events land. The sim is a frozen stage-7-and-tick machine for the whole window. |
SEAT_COLORS |
4 hex colours | sim/state_build.py |
The fixed cook palette, indexed by seat % 4. |
Movement constants
The numbers PLAN §5 fixes for a cook, now that stage 3 is real. They live in
sim/movement.py (RADIUS and CONTACT_EPS are the collision geometry, and live in
sim/collision.py for the second one because both modules need them):
| Constant | Value | Means |
|---|---|---|
MAX_SPEED |
3.5 tiles/s |
Speed ceiling. No diagonal bonus: DIAGONAL_NORM makes a diagonal a direction, not extra throttle. |
ACCEL |
30 tiles/s² |
How fast intent becomes velocity. |
DECEL |
40 tiles/s² |
How fast it goes away when the stick centres — deliberately harder than ACCEL: overshooting a station is the mistake players make most. |
RADIUS |
0.35 tile |
Half-width of the cook's body square (an AABB, not a circle) in tile units. This is PLAN §5's PLAYER_RADIUS, named for what the collision module reads it as. |
CONTACT_EPS |
1e-4 tile |
The gap a swept body stops short of a blocked cell, and the inset a footprint is measured with. See "One geometry, two questions". |
DIAGONAL_NORM |
True |
Set to False and a diagonal is √2 faster. Read at call time so the test can prove which rule is in force. |
STUN_S |
0.6 s |
How long a hit keeps a cook still. Whoever stuns writes it to Player.stun; apply_stun() ticks it down. |
STUN_EPS |
1e-9 s |
A stun is over at this remainder: 0.6 − 12 × 0.05 is 1.4e-17, not 0. |
MOVE_EPS |
1e-6 tiles/s |
Below this the cook is IDLE, not MOVING. |
PUSH_ITERATIONS |
2 |
Soft-push passes. Two propagates a separation through a chain of four cooks. |
PUSH_SLOP |
1e-6 tile |
Overlap below this is float noise, not a collision — without it, shoulder-to-shoulder cooks jitter forever. |
PLAN §5 fixes the numbers the rest of this phase implements. They are recorded here so a constant is written down once, before the code that reads it:
| Constant | Value | Arrives in | Means |
|---|---|---|---|
REACH |
1.0 tile |
task 03 (real) | First targeting tier: the tile in front of the cook, centre-to-centre. |
FORGIVE |
0.6 tile |
task 03 (real) | Forgiveness on top of REACH: why the diagonal-front tier (√2 ≈ 1.414 centre-to-centre) is in reach and the diagonal behind the far shoulder (2.12) is not. |
HANDLING_S |
0.0 s |
task 04 (settled) | Minimum seconds between two accepted actions by one cook: the press edge is the gate — a new press is accepted the substep after a release, so the floor is 0.0 and PLAN's unspecified value is settled here, not in a knob. |
Orders constants
The ticket rail's numbers (PLAN §5 "Orders, scoring, results"), in sim/orders.py:
| Constant | Value | Means |
|---|---|---|
TICK_SECONDS |
0.05 s |
One substep in seconds — restated here because the state's time unit is the tick, and sim/step.py (which owns STEP_DT) imports this module, so a runtime import back would be a cycle. |
FIRST_ORDER_DELAY_TICKS |
30 ticks |
The first order arrives 1.5 s into the round: the countdown-to-cook beat. build_state starts next_order_at here. |
MIN_RETRY_TICKS |
20 ticks |
The full-rail retry gap (1 s): a due tick the rail cannot serve retries MIN_RETRY_TICKS later, without spending the interval draw. |
A level's orders spec (seconds) is converted to ticks by to_ticks() — whole ticks,
clamped to at least one, because a zero-tick span is how a second spawn would land
on the first's tick — at round build (sim/state_build.py copies the spec onto the
state as scalars, so a replay is self-contained without the Level object).
MAX_DT exists because a fixed tick without a ceiling is a denial-of-service against
yourself: if the process stalls for ten seconds, "catch up" means 200 substeps in one go,
which takes longer than 200 substeps should, which delays the next call, and so on.
Clamping means a stall costs the round at most 0.1 s of simulation and the round keeps
going — the correct trade for a co-op game where being behind is better than being dead.
The substep order
Order is gameplay: two subsystems reading each other's output produce different rounds if they run in a different sequence. Every substep runs these stages, in this order, with no I/O and no reordering:
| # | Stage | Module | Status |
|---|---|---|---|
| 1 | Clamp dt, accumulate, decide how many substeps run |
sim/step.py |
real |
| 2 | Resolve inputs to one frame per seat; err for unknown ids |
sim/step.py |
real |
| 2b | Action gate: hold counters, one press per rising edge, verb ladder, named denials, contested arbitration, the long-press trash | sim/interact.py + sim/carry.py |
real (task 03/04) |
| 3 | Movement and collision | sim/movement.py |
real (task 02) |
| 4 | Station pipelines | sim/stations.py |
real (phase 04 task 01) |
| 4b | Freshness: the burn countdown, the quality decay, the push-out's clock | sim/freshness.py |
real (phase 04 task 04) |
| 5 | Orders (spawn, deadline, miss — the miss's penalty and streak reset ride on the injected on_miss) |
sim/orders.py (+ sim/scoring.py) |
real (phase 04 task 02, scoring task 03) |
| 6 | Hazards | sim/hazards.py |
identity → phase 10 |
| 7 | Round timer, end conditions and the settle (the countdown, the early win, the buzzer, the one ROUND_END, the post-verdict freeze) |
sim/round_state.py |
real (phase 04 task 05) |
| 8 | tick += 1 |
sim/step.py |
real |
Stage 2 happens once per step() call, not once per substep: intent is resolved, then
the same resolved intent is offered to every substep that call produces. That is what
makes step(state, 0.1) and two calls of step(state, 0.05) agree, and it matches the
client's side — a cook keeps moving in the direction last pressed until a newer frame
says otherwise.
A resolved frame per seat is chosen by earliest received_at_tick, tie-break lowest
seq, first-seen on a full tie, and the mapping is returned in seat order. Two
clients sending inputs for the same seat in the same batch (a retransmit after a
reconnect, a hostile tab) therefore resolve identically on every machine, and effects
that touch more than one cook apply in an order nobody has to guess. An unknown
player_id is a EventKind.ERROR event with {"player_id", "reason": "unknown_player", "seq"} and nothing else: the round does not care who sent it, and a hostile client
cannot make a step raise.
Movement and collision
Stage 3 turns the resolved frame into a position, in two halves that never mix: velocity comes from intent, and position comes from velocity.
PlayerInput.move (whatever the client sent, e.g. Vec2(50, 0))
→ intent_direction() clamp to length ≤ 1, so a hostile value is a direction, not a
teleport, and (1,1) is not √2 faster than (1,0)
→ desired_velocity() accelerate toward it, brake when the stick centres, 0 if stunned
→ sweep_axis() × 2 x then y, each clamped to the greatest legal delta
→ _separate() soft push, twice; a genuine deadlock reverts one input
→ Player(pos, vel, facing, state, stun)
The clamp is the server refusing to negotiate (A4): step() carries PlayerInput.move
verbatim, and intent_direction() is the one place it becomes authoritative. A client that
sends move=(50, 0) walks exactly as fast as one that sends (1, 0) — there is a test for
exactly that, through the whole step(), not the movement helper.
A cook with no frame this tick brakes to a stop rather than drifting: step() hands every
substep of one call the same frame, so releasing a key stops the cook within one tick
(DECEL) no matter how many substeps that call ran.
What blocks
sim/collision.py answers one question — may a body stand here — for the whole sim:
| Tile or fixture | Blocks a body |
|---|---|
wall, counter, void, out-of-bounds |
yes |
floor |
no |
spawn_shelf, chop_board, stove, prep |
yes — you walk around a fixture |
serve, sink, trash |
no — you lean over these to use them |
The pass-through set is collision.PASS_THROUGH_STATIONS, and it is the collision answer
only: the reach rule (task 03) is allowed to want a different set, because reaching a
serve window from the tile in front of it is a separate question from walking over it. A
hazard or a crate adds cells through cell_blocked(extra_blocked=...) / body_blocker(...)
without either of those rules learning about the other.
A body is a square of side 2 × RADIUS = 0.7 tiles, so it fits a 1-tile corridor with
0.3 tiles to spare and cannot fit a gap it would have to squeeze through.
One geometry, two questions
Two things must agree about which cells a body covers: the footprint (body_cells(), what
phase 07's reach will ask about) and the sweep (sweep_axis(), what decides where a move
ends). They agree because both call _edge_cells() on a centre, never on a pre-added
edge: (lead + delta) − eps and (centre + delta) + radius − eps differ by one float ulp
exactly at the moment a body becomes flush with a boundary, and one ulp is enough for the
sweep to say "not entered" while the footprint says "occupied" — a cook 2e-16 inside a
wall, invisible on screen and fatal for a replay golden. So the sweep clamps the
destination centre to cell − radius − CONTACT_EPS, two epsilons clear of the boundary
that would make the cell occupied, and float noise cannot close that gap.
The sweep tests every cell the body would newly overlap, across its whole cross-axis
span — not the destination cell only. That buys three things at once. Nothing can be
walked through: at MAX_SPEED and the coarsest call the kernel allows (MAX_DT = 0.1 s) a
cook covers 0.35 tiles, which a destination test would catch too, but the sweep does not
care what speed or dt a caller hands it, so a doubled ceiling, a longer catch-up tick or a
future hazard that throws a cook across the room still cannot cross a 1-tile wall (there is
a test that doubles MAX_SPEED specifically to hold that). A diagonal move cannot clip the
corner of a wall, because the last axis moved is checked against the body's full span on
the other one. And a cell the body already sits in is never re-tested, so a cook that starts
a substep somewhere impossible — a spawn a level author got wrong — is not wedged by it.
Cooks push cooks
Bodies do not collide with terrain and each other in one pass; terrain is resolved on the way out and cooks afterwards, because a push must never shove a cook into a wall.
- Symmetric. Each cook gives half the separation, and whatever one cannot take (a wall behind it) the other takes. Two cooks meeting head-on both move; one pinned by a wall moves the other the full amount.
- Positional only. The push edits
pos, nevervel. Being bumped does not fling anybody, and a bumped cook keeps walking in the direction they pressed. - Iterated.
PUSH_ITERATIONSpasses so a chain of four drains apart instead of leaving the last cook inside the third. - Reverted only on a real deadlock. When terrain stops both cooks and not a tile of
separation is possible — two cooks stacked in a 1×1 alcove is the only layout that can do
it — the later-received input is dropped and that cook is put back where the substep
started. Same arbitration rule
resolve_inputsuses (earliestreceived_at_tickwins, tie-break lowestseq), and a cook that sent no frame at all is never the loser, because it has no input to discard. A jam that could separate — four cooks in a doorway — is not a deadlock, it is a queue, and it drains over the next few ticks.
The revert restores the post-apply_stun player, so a revert can never undo the stun tick
that already happened.
The action button
PLAN §5 gives the cook one button; sim/interact.py decides, purely and
deterministically, what it means right now — which entity is targeted, which verb
applies, or the named reason nothing happens. Stage 2b runs it once per substep, in
seat order, on the frames resolve_inputs already reduced to one per seat.
The reach zone: discrete first, continuous second
candidate_cells() names the tiles the hand walks, in priority order, deduplicated:
- the tile directly in front (the
REACHtier — always in reach from any point of the tile, worst case √(0.5² + 1.5²) ≈ 1.581); - the two diagonal-front tiles (the
FORGIVEtier exists for them: √2 ≈ 1.414 from the tile centre, so a purely orthogonal reach would refuse every diagonal press, and a purely discrete walk would hand over the diagonal behind your far shoulder at 2.12); - the tile under the cook — because a serve window, sink and trash can are walkable
(
PASS_THROUGH_STATIONS), a cook can be standing on the thing they want to use with nothing in front.
REACH + FORGIVE from the cook's position to the tile's centre then decides whether the
cook is close enough to the tile the walk found. The two halves answer different
questions: the cell walk is why a fixture two tiles away is never even a candidate (there
is no "through the wall" mode — a wall in the front cell simply means the tile beyond it
is not walked), and the distance check is why the same diagonal fixture is reachable from
the near corner of the cook's tile and not from the far one. A candidate out of reach is
skipped, not refused, so a nearer tile still gets its turn. The four facings are 90°
rotations of one row, so a diagonal press feels the same whichever way the cook points.
The target, then the verb
select_target() returns the first usable cell in that walk:
- a grabbable item, but only with a free hand — a cook who is carrying something is here to empty their hand, so preferring the item would make the placement ladder unreachable (PLAN §5's "carrying → drop/transfer onto target station" comes first);
- the serve window, as a
ServeTarget, so the score path is distinguishable from merely standing at a fixture; - the fixture itself;
None.
A fixture owns what is on it: station.contents is not a shelf of grabbable things. A
chop board with an onion on it is an onion to be chopped — pressing it works the item,
which is the only way PLAN §5's "hold to chop" can mean anything. An item becomes
grabbable when it is output: on a spawn shelf (stock, even while contents is empty —
the kind comes from the level's legend), or on a station whose state says the work is
done (READY). That rule lives in one private helper and nowhere else, so phase 04's
pipelines have exactly one place to change their minds. The serve window is never a
set-down target: a full hand at it gets the serve ladder's WRONG_ITEM, not a RELEASE
(the set-down escape hatch is the spawn shelf, where any carried item can be put down).
select_verb() is the priority ladder, with the cook's own condition first. There is no
(verb, reason) combination and no unexplained no-op: a legal press is (verb, None), a
refused one is (Verb.NONE, reason) — the client shows the reason as a nudge, and the
server never answers a press with a silent nothing.
The denied reasons
Every refusal names itself. These are the DeniedReason constants, and the path that
produces each:
| Reason | String | Produced when |
|---|---|---|
STUNNED |
stunned |
The cook's stun is down or their state is STUNNED, regardless of target — the client's "you got hit" outranks every other nudge. Checked before the target is even read. |
NOTHING_HERE |
nothing_here |
No station in the reach zone (the whiff). A press at a fixture beyond a wall surfaces as this: the zone has no "through the wall" mode. |
OUT_OF_REACH |
out_of_reach |
The target's centre is more than REACH + FORGIVE from the cook's position (re-measured for targets a caller kept from an earlier substep). |
NOT_FACING |
not_facing |
The target's centre is behind the plane of the cook's facing (a target level with the cook — a serve window they are standing on — passes). |
HANDS_FULL |
hands_full |
An item target with a full hand, a ready fixture's output with a full hand, or a loaded work surface with a full hand (one item at a time on a surface). |
HANDS_EMPTY |
hands_empty |
Serving, washing or trashing with nothing in the hand. |
WRONG_ITEM |
wrong_item |
A non-plated item at the serve window, a non-dirty item at the sink. The difference between "go fetch a plate" and "that is not a dish" is HANDS_EMPTY vs WRONG_ITEM. |
STATION_DIRTY |
station_dirty |
A dirty fixture with a full hand — the player needs "this board is filthy", not "you are holding the wrong thing". A free hand is answered with WASH (the in-place wash, the work station's fix for its own dirt) instead. Dirt outranks every other state. |
STATION_BUSY |
station_busy |
A fixture another cook is working, a BUSY fixture, or one whose worker-release grace is running (its cooldown bookkeeping, RELEASE_TICKS substeps without the worker in reach). The cook who started it gets HOLD_WORK instead. |
INVALID_STATE |
invalid_state |
An idle fixture with nothing on it, a BLOCKED fixture, or an item whose holder is not in the state (task 04's floor drops are named this way until they are liftable). |
CONTESTED_LOST |
contested_lost |
The cook lost a claim another cook arrived for first — the only reason the gate produces; the ladder cannot emit it. |
A press that would otherwise succeed emits exactly one event however long the button stays
down, and a refusal emits exactly one DENIED with the reason in the payload — never one
per tick.
One button, one press, one effect
action_gate() turns each frame's action flag into the cook's hold counters
(Player.action_held_ticks, Player.action_pressed_tick) and spends the rising edge:
- a frame without
actionclears both counters (the button is up; the nextTrueis a new press); - the first frame of a hold is the press: it is resolved with
held_for = 0.0; - every later substep of the same hold increments the counter and passes the seconds the
button has been down — the seam the long-press
TRASHspends ("The hand"); theHOLD_WORKthreshold is phase 04's. Until phase 04, a hold that is not a trash hold changes nothing: one press, one effect, however long the button is held.
resolve_action() spends the pick and the set-down (both of which are
sim/carry.py's now): PICK_ITEM takes the item off the fixture and into the hand
(one PICKED — one HANDED_OFF when the pick lifts a set-down inside the window, see
"The hand"), RELEASE / PLACE_ON_STATION / PLATE_DISH put the carried item on the
fixture (one DROPPED, and the set-down's mark), and every denial is one DENIED
each. The work verbs — START_WORK, HOLD_WORK, SERVE, WASH — are named by the
ladder today and deliberately unspent: a press that selects one is a no-op with no
DENIED, because the player did nothing wrong and phase 04's pipelines will spend the
press. The TRASH press is the same shape at the press: it starts the hold, and the
gate spends it at the 8-tick threshold ("The hand"). The set-down leaves the
fixture's state untouched on purpose: what a set-down means to a pipeline is
phase 04's transition, and inventing it here is how a golden ends up encoding a rule
nobody chose.
The hand
The button decides; the hand acts. sim/carry.py owns what a verb does, and it owns
three constants with it:
| Constant | Value | Means |
|---|---|---|
HANDOFF_WINDOW_TICKS |
20 ticks |
The hand-off window, inclusive: a pick of a set-down is a pass while pick_tick − placed_tick ≤ 20; one tick past, it is a plain pick. |
TRASH_HOLD_TICKS |
8 substeps |
The trash threshold: the 8th substep of one hold is the tick that trashes, and never the 7th. |
TRASH_SCORE_DELTA |
−2 |
The raw score nudge a trashed item is worth; the TRASHED payload carries it, and phase 04's scoring spends it (clamped at 0 overall). The state's score is not touched before then. |
One item per hand. A full hand is the ladder's HANDS_FULL; a hand is empty, holds
an item, or trashes it. Items live in hands and on fixtures — there is no floor drop
in v1 (PLAN §5), so a press at open floor with a full hand is the named
NOTHING_HERE, not a drop.
A set-down is a placed object. RELEASE (the shelf), PLACE_ON_STATION (a work
surface) and PLATE_DISH (the prep counter) all do the same thing: the item goes onto
the fixture's contents, the hand empties, and the set-down is marked —
(placer id, placed-at tick) — in KitchenState.item_marks, a tuple parallel to the
fixture's contents (None where no hand put the entry down: level stock, and phase
04's pipeline inputs and outputs). The mark is state truth (a replay must reproduce
the HANDED_OFF it causes) and never a snapshot concern.
Grabbability is the mark. A free hand may take off a fixture:
- a spawn shelf — whatever it holds, stock or set-down;
- anything else — a marked entry, the moment it is set down (the hand-off exists for exactly that);
- an unmarked entry only when the station's state is
READY(a pipeline's output — phase 04). Everything else — an onion on an idle board — isSTART_WORK's, notPICK_ITEM's.
The hand-off. A pick that lifts a marked item placed by another cook within the
window is a HANDED_OFF with both ids (from, to) instead of a PICKED — that is
the sanctioned pass, the whole of "place it on the counter and let a teammate grab
it". A self-pick is a plain PICKED; a pick outside the window is a plain PICKED;
a same-substep place-then-pick is inside the window (zero ticks old). The mark is
removed with the entry, and a place anywhere stamps a fresh mark, so a pass chains:
A → B (B's pick) → B places (new mark) → C's pick is a B → C pass.
The trash is long-press and always available. A TRASH press starts a hold; the
hold is re-derived each substep from the current state (the press is not remembered —
the counters are the hold), so walking off the can voids it and a release resets it.
On the 8th substep of a hold the carried item is destroyed: one TRASHED event whose
payload carries the raw TRASH_SCORE_DELTA, counters["trashed"] += 1, and an empty
hand. A short press trashes nothing. A threshold met with an empty hand is
TRASHED_ALREADY_EMPTY — a named event, never a silent no-op.
Contested targets
Two cooks pressing the same entity in the same substep are arbitrated the way the
movement deadlock is: claim_rank is received_at_tick, then seq, then seat index —
a tie is impossible because two cooks cannot share a seat. Only a press that would
otherwise succeed enters the scan (a stunned or empty-handed cook keeps their own
reason; blaming the other cook for a refusal the stun caused would be a second lie).
And only a press that takes something only one cook can have is a claim at all:
PICK_ITEM lifts a fixture's item, START_WORK takes its single worker slot, and
PICK_FROM_STATION claims the finished thing only while there is one in contents —
two cooks tapping a shelf with nothing in it both take stock from the level's legend
and neither was ever in the other's way. The winner picks; every loser is told
CONTESTED_LOST instead of watching the item vanish for no stated reason. The claim
scan runs against the pre-action state, so every cook is judged on the same kitchen, and
its bookkeeping — active_claims, the target_key → (rank, player_id) map — is
substep-local: built, arbitrated, and discarded at the substep's end, never written
into the state or the snapshot, so a replay reproduces the arbitration from the frames
alone.
What proves it
tests/sim/test_targeting.py pins the reach zone with hand-computed geometry: the
candidate walk per facing (and its invariance to where in the tile the cook stands),
the forgiveness boundary (0.99 in, 1.6 inclusive, 1.61 out), the same diagonal fixture in
and out of reach from the near and far corners, the priority order (item over fixture,
full hand skips the item, a work surface holds its contents, the serve window's own
target kind), the wall case, and the press edge through the whole step() — one press,
one named refusal, one hold that changes nothing, one release that clears. tests/sim/ test_verbs.py is the table-driven ladder: every (carrying, target) -> (verb, denial)
covers every Verb and every DeniedReason at least once (a coverage test asserts the
table cannot quietly lose a row), the resolve_action implementations and no-ops, and
the gate's arbitration with two real cooks in one substep. tests/sim/test_carry.py
pins the hands themselves: the grabbability split (a set-down is grabbable at once, an
unmarked pipeline input is not), the one-item-per-hand refusal, the pick/place round
trip with its Item identity, the hand-off window (inclusive at 20, a same-tick pass,
a self-pick and a third party outside the window), the trash (destroyed on the 8th
substep and not the 7th, short press, release-resets, walk-away voids, the named
TRASHED_ALREADY_EMPTY), the contested pick with the item count conserved, and a
100-tick fuzz of random simultaneous presses that re-checks the conservation law after
every tick. tests/sim/test_replay_carry.py runs the scripted two-player pass-and-
contest against its committed golden hash (tests/goldens/carry_two_player.golden.json).
Stun, facing, traction
apply_stun() runs at the top of the stage: it ticks Player.stun down by dt, pins
vel to zero while it lasts, holds the position, and forces PlayerState.STUNNED — then
IDLE again from the substep the stun runs out, so a cook is never stuck in the state. It
emits one EventKind.STUNNED per cook entering a stun (at the cook's position, payload
player_id and the stun duration), which is how the client plays the hit once instead of
twenty times a second. Whoever causes a stun — phase 10's moving obstacle — sets
Player.stun = STUN_S; this is the only place it is spent.
Facing is a rule about intent, never about position:
- an explicit
facein the frame (d-pad, touch stick) wins; - otherwise the last non-zero intent, dominant axis, ties resolve to horizontal;
- standing still keeps the facing, so an
actionpress with no direction does not spin the cook around (PLAN §5 "one action button"); - a pushed cook does not turn: being moved is not the same as deciding to go somewhere.
MoveModifiers(speed_scale, accel_scale, slide) is the seam phase 10 plugs into: a spill
scales the ceiling (speed_scale) and the grip in both directions (accel_scale, which is
a different feel from merely being slower), and slide removes braking but not throttle —
so no if hazard ever appears in this module. Until hazards are real the kernel passes
none, and the traction a cook gets is the floor.
What proves it
tests/sim/test_movement.py is the table-driven half: hand-computed geometry per rule —
walking into each tile kind, each pass-through and blocking fixture, the double-speed
tunnel attempt, the diagonal-parity check, push symmetry, the alcove deadlock, the four-cook
chain, stun entry and expiry, the facing table. tests/sim/test_movement_stress.py is the
half nobody hand-computes: four cooks, 2000 substeps, a scripted random walk from the sim's
own splitmix64 over a pillar maze with doorways, a void tile, a counter run and two
fixtures, asserting containment, non-overlap, bounded per-tick displacement and progress
after every substep, plus hash equality between two runs of the same script — at
STEP_DT and at MAX_DT. tests/sim/test_movement_kernel.py runs the same walk through
the whole step() on the levels in levels/, so authored content cannot quietly become
un-walkable.
The station pipeline
sim/stations.py spends the work verbs the ladder names, and stage 4 advances the
work. The rules live in data, not if ladders:
ACCEPTS— station kind × item kind, total over both enums: does this fixture's pipeline ever work this item (in some form)? The chop board works every raw ingredient except rice (PLAN §5: "raw rice needs no board") and never a plate; the stove works every ingredient but not a plate; the prep plates every ingredient (a plate is part of what it operates on); the serve window consumes a plated dish only; the sink washes a dirty prop (the plate — the pot is a stuck prop, washed in place at the stove); the trash and the shelf take anything.TRANSITIONS—(station kind, item form) -> (output form, seconds): chop boardRAW → CHOPPEDin 0.9 s, stoveCHOPPED → COOKEDin 6 s, stoveRAW → COOKEDin 8 s (refined per kind bySTOVE_RAW_SECONDS: rice 8 s, the pan 5 s for patty/fish), prepCOOKED | CHOPPED → PLATEDin 0.6 s, sinkDIRTY → RAWin 1.2 s. A pair with no row is not worked — the one lookup (transition_for) is the whole of "does this fixture take this item into its pipeline".
step_stations runs as substep stage 4. It advances progress on a WORKING
station while its worker is within WORKER_REACH (REACH + FORGIVE — a worker
stands exactly where a press reaches) and still holding the action button
(action_held_ticks > 0 after the gate's increment — a release freezes the progress,
a resume continues it, it never rolls back), flips the station to READY at the
duration (emitting the stage event — CHOPPED / COOKED / WASHED — then
READY), and lets a READY station whose contents have been consumed fall back to
IDLE on the next substep. When the worker leaves the reach, a release grace of
RELEASE_TICKS (4) consecutive substeps runs on the station's cooldown field (its
only user); when the grace expires the worker_id is released — the work stays
frozen, a new cook's HOLD_WORK press claims the slot. BLOCKED is cleared the
moment its cause (a hazard naming the station, phase 10) is gone.
apply_station_verb is what interact.resolve_action routes. A set-down on a
work surface that works the item becomes the pipeline input — the work starts
on the spot, the set-down mark goes, the prop's stock decrements; a set-down the
station does not work stays a marked grabbable object (a raw ingredient at the prep
is not a dish, and the set-down itself does happen — the hand-off's whole memory
rests on it). PLATE_DISH takes the carried cooked/chopped ingredient plus a plate
out of the plates_clean stock; WASH takes a carried dirty item into the sink,
or is the ladder's free-hand answer at a dirty fixture — the in-place wash of 1.2 s
that clears the dirt and returns the prop to stock. Lifting a PREP's plated dish
or a STOVE's cooked item leaves the fixture DIRTY — its prop (the plate, the
pot) is stuck on it. The shelf's stock is the level's legend (phase 05's room seeds
it from level.station_items); until then a pick from an empty shelf is a no-op, not
a refusal. The SERVE press is spent by sim/scoring.py (below): a matched
dish is consumed and scored, a rejected one stays in the hand with a named
DENIED (no_orders / wrong_recipe / incomplete / burnt / no_target).
The TRASH press still only starts the hold the gate spends.
Dirty props and stock: a DIRTY station refuses the use verbs (STATION_DIRTY) and
answers a free hand with WASH. Stock is decremented when the work that needs the
prop starts and incremented when the wash completes; a missing counter reads as
DEFAULT_STOCK (99, the schema's "effectively unlimited") — phase 05's room seeds
the counters from level.station_limits at round start, and this default is what a
round built without them sees. A zero stock is a named refusal: NOTHING_HERE with
{"why": "no_plate"} / {"why": "no_pot"}.
tests/sim/test_stations.py pins the matrix cell by cell against an explicit
expectation table (a new StationKind / ItemKind without a cell fails the build),
one test per transition row at the tick-granularity boundary (substep duration − 1
is still WORKING, substep duration is READY), the hold freeze/resume, the
release grace and re-claim, the dirty gating and the stock; tests/sim/test_station_events.py
pins the event kinds, payloads, positions and order.
Freshness — the clocks that create pressure
sim/freshness.py runs as substep stage 4b (after the station pipelines,
before the orders) and owns the quality: food decays on clocks the kitchen
itself starts. No state fields are added — the burn countdown reuses
Item.ttl, and the decay is a per-tick decrement of the existing
Item.quality — so the replay goldens committed before this stage keep
their hashes (a state with nothing decaying comes back the same object, no
events: hash-neutral).
Where food decays:
| Where | What | Rate |
|---|---|---|
| A ready stove (on the heat) | the COOKED dish — the countdown starts at the ready flip (ttl = BURN_AFTER_S) |
the countdown; at BURN_AFTER_S (8.0 s) the dish is BURNT, its quality 0.0, the station DIRTY (the burnt pot is stuck) and one BURNT event |
| A chop board | a RAW or CHOPPED left on the board |
linear over ROT_S (20.0 s) to 0.0, then BURNT — a raw onion left there is the one that rots |
| The hand | a carried CHOPPED / COOKED |
half the board's rate — a hand is not a fridge; a carried RAW and a carried PLATED dish do not decay (a PLATED dish keeps the quality it was plated with) |
| Elsewhere | a set-down object off the board | not decaying — the board, the hand and the heat are the clocks |
The burn boundary is tick-exact: a dish on the heat burns on the 160th
substep after the ready flip (8.0 s / 0.05 s), not the 159th — the
BURNT event, the zero quality and the station's DIRTY flip land on the
same substep. A lifted dish pauses the countdown; a set-down on the ready
stove resumes it. A stove's work completed in a substep starts the
countdown in the same one (the flip's first tick is the stage 4b's).
The push-out. A new raw item must be placed on a board that holds a
BURNT item — the raw that rotted there: the burnt item yields, it
despawns with a named WASTED event and counters["wasted"] += 1 per
item destroyed, and the set-down proceeds (the work starts on the new raw).
A usable raw item is never silently overwritten: a board holding one
refuses the set-down with the named station_busy — the ladder's own
guard (stations.push_out_allowed) is the same predicate the set-down
path (_push_burnt_board_items) answers with.
Burnt is refused. A burnt dish never reaches a score: a PLATED dish
at quality 0 is the scoring's named burnt refusal, and a BURNT-form
item is refused one step earlier — the ladder's wrong_item (it is not a
dish at all). The dish stays in the hand either way; the order is
untouched.
The dirty loop, and the no-dead-end guarantee. The props move between
the clean pool and the dirty pool, and the sum is the level's limit
every tick: a use (the work's start — the plate's PLATE_DISH, the
pot's raw set-down) moves one prop …_clean → …_dirty, and a wash
(the sink, or the in-place wash of a stuck prop) moves it back. Lifting a
PREP's plated dish or a STOVE's cooked item leaves the fixture DIRTY
(its prop is stuck on it); a zero stock is a named refusal
(NOTHING_HERE with the why), and the counters never go below 0. The
no-dead-end guarantee — a worst-case cook who serves everything possible
and never washes until forced never gets stuck: one wash (1.2 s)
restores the stock, a plates_clean == 0 never outlives the wash plus 5
s of walking, and the validator's STATION_LIMIT_ZERO rule keeps
zero-prop levels out of the campaign before the first tick.
tests/sim/test_freshness.py pins the tick-exact burn boundary (a ready
stove, and a stove driven through its whole work), the board's linear
decay at 25/50/75/100 % of ROT_S, the half-rate carry, the push-out
(the WASTED, the usable refusals, the counter), the burnt refusals
through the whole step(), the dirty loop's stock round trips (the
in-place wash, the sink's wash, the zero stock), the no-op neutrality, and
the 1 000-tick conservation sequence; tests/sim/test_no_deadlock.py
runs the 2 000-tick worst case (per-tick conservation, the wash window,
one wash restores the plate, the validator's half).
Orders — the ticket rail
sim/orders.py runs as substep stage 5. A due tick does exactly one of three
things, and the whole of the randomness contract is the state threading:
step_orders rebuilds the Rng from state.rng_state, draws, and writes the
counter back into the state it returns — a spawn is exactly two next_u64s
(the choice over the pool, then the interval range_int), and a replay
reproduces the sequence from the seed alone. Stage 6 (hazards, phase 10)
continues the same stream from the post-orders counter, so one substep's
draws are a function of the pre-step rng_state and nothing else.
Spawning. An order is due when tick >= next_order_at (a pre-increment
tick). build_state starts next_order_at at FIRST_ORDER_DELAY_TICKS (1.5 s)
so the countdown-to-cook beat feels right. When the rail has room
(len(orders) < order_concurrent), the recipe is Rng(state.rng_state) .choice(pool) — the level's pool, copied onto the state at build — the new
order carries value = recipe.base_score from the RecipeBook the caller
passes to step (a None book on a state that would spawn is a ValueError
at the spawn: a caller bug, named, not a silent zero), deadline the absolute
tick tick + order_deadline, and its id from the monotonic next_order_id
(from 1). The next spawn is scheduled at tick + range_int(min, max) — the
draw is clamped to at least one tick ahead, which is what makes "never two
spawns on one tick" a construction fact.
The full-rail retry — and a retry is not a draw. When the due tick arrives
with the rail at its cap, the next attempt is tick + MIN_RETRY_TICKS (1 s)
and neither the recipe draw nor the interval draw is consumed: the
rng_state is untouched. The rule exists because without it a full rail would
silently warp the cadence — every blocked due tick would spend a draw the next
spawn never used, and a replay could not tell a retry from a real gap. (An
empty pool is a no-op, not a retry: nothing can ever be ordered.)
Deadlines, to the tick. An order's clock is
(deadline - tick) * TICK_SECONDS, derived, never accumulated — so it expires
on exactly the substep whose tick reaches its deadline: it has lived
order_deadline substeps, order_deadline * 0.05 s. On that substep the
order is removed, counters["missed"] += 1, and one ORDER_MISSED carries its
id and recipe (x/y are 0 — the rail is HUD, not floor). The penalty
score deduction and the streak reset are scoring.py's (the single owner of
score and streak), injected as the stage's on_miss callback — the kernel
passes scoring.apply_miss, so this stage records the miss and names it,
and the two modules never import each other.
Spawning emits no event: the snapshot's o list (below) is what a new order
shows up in.
The summary. order_summary(state) is the compact ticket rail the phase-06
snapshot encoder projects (PLAN §4's o), one mapping per live order in rail
order: id, r (the recipe id), l (the fraction of the deadline still
unspent, two decimals, clamped to [0, 1]) and pts (the value).
tests/sim/test_orders.py pins the first-order delay (nothing before tick 30),
the spawn's two-draw thread and the interval scheduling, the full-rail retry
(its own test: the retry cadence, and the rng_state untouched across the
blocked due ticks), the zero-tick-span clamp, deadline expiry on the exact tick,
the ORDER_MISSED payload, 200 simulated seconds of interval bounds and cap
under idle and busy rails, same-seed/different-seed sequences, and the summary;
tests/sim/test_orders_fuzz.py runs twenty randomized specs (fixed seed) for
300 s each, re-checking no negative times, no duplicate ids, no unbounded
order count and no exceptions on every tick.
Serve scoring — the window and the miss
sim/scoring.py is the single owner of the round's score and streak
(the two numbers the verdict turns on), from both sides: the SERVE press
(routed by stations.apply_station_verb with the kernel's book) and the
rail's miss (injected into step_orders as on_miss, so orders.py and
scoring.py never import each other). Nothing here reads the clock or
anything outside the state it is handed.
A serve, in check order — the first failure is the answer, each a named
DENIED with the dish left in the hand and the state otherwise untouched:
| Check | Reason | When it fires |
|---|---|---|
| burnt | burnt |
quality <= 0.0 or form BURNT — a burnt dish is rejected, never scored |
| no target | no_target |
the dish is not PLATED (not a dish at all) |
| no orders | no_orders |
the ticket rail is empty |
| wrong recipe | wrong_recipe |
a declared dish_id the book does not know; an ingredient no book recipe requires; or no live order naming the resolved recipe |
| incomplete | incomplete |
the dish's single ingredient is not one the declared recipe requires (a missing or wrong ingredient — the sim plates one item per dish) |
A dish without a declared dish_id composes every book recipe that
requires its ingredient kind — the match then decides among them. On a
match, the dish fills the oldest compatible live order (the smallest
id — ids are monotonic at spawn, so first onto the rail is first served,
FIFO) and the state spends on it: the order is removed from the rail, the
hand empties, score += points, streak += 1, the serving cook's score
and dishes_served advance (per-player contribution — display only, never
competitive, PLAN §5), and counters["served"] += 1. The dirty props are
not the serve's: a prop is dirty from its use (the work's start — the
plate's PLATE_DISH, the pot's set-down; the station pipeline's stock
bookkeeping, below), so the serve itself moves no counter. SERVED (the
payload carries order_id, points, quality, streak and
player_id) is followed by SCORE, both at the cook.
The math (tests/sim/test_scoring.py pins it against five hand-computed
serves, the expectations written as literals):
points = round(base × quality × streak_mult × time_bonus)
base = the order's value (the recipe's base_score, frozen at spawn)
quality = the dish's quality (1.0 → 0.0; a burnt dish never reaches it)
streak_mult = 1 + 0.1 × min(streak, 5) # the streak *after* the increment;
# capped at MAX_STREAK_MULT = 1.5
time_bonus = 1 + 0.5 × fraction # fraction = time_left over the level
# deadline in seconds (the state's
# order_deadline × 0.05 — the span every
# order is spawned with), clamped [0, 1];
# 1.5 with the full deadline unspent,
# 1.0 at the edge; a non-positive span
# buys no bonus (no division by zero)
The floor is MIN_SCORE_FLOOR = 0: the score is never negative. round is
Python's (banker's rounding at an exact .5; the pinned cases never land
on one).
A miss. apply_miss(state, order) — the scoring half of the expiry
step_orders records: score = max(0, score − order_penalty) and
streak = 0, with SCORE carrying the applied delta (negative or zero,
with the order_id) at the rail. A miss that changes nothing — no penalty
configured and no streak to reset — returns the state untouched and fires no
SCORE (the ORDER_MISSED already names it).
tests/sim/test_scoring.py pins the five hand-computed serves (the
literals, no formula re-derived in the test), every rejection path (the dish
and the order intact, the state the same object back), FIFO matching with
two compatible orders, the streak cap, the score floor, the per-player
contribution summing to the round's score, the bookless-press ValueError,
and the wiring through the whole step() (a press at the window scoring,
a press rejected, an expiry missing); tests/sim/test_scoring_property.py
runs 500 seeded random serves and re-checks points ≥ 0, the served
counter equal to the orders removed, the streak multiplier never past its
cap, and no order served twice.
Round end — the verdict, the settle, the stars
sim/round_state.py runs as substep stage 7, and it owns the round's clock
and its verdict — PLAN §5 says the verdict turns on score against
target_score and nothing else.
The verdict, one check per substep. The score stage (2b, through the
action gate's SERVE) and the verdict stage run in the same substep, so
a serve that reaches the target wins on that press:
| Check | When | Outcome |
|---|---|---|
| the clock | time_left runs to zero (a residue under TIME_EPS is float noise, not "0.0 s left") |
WON if score >= target_score, LOST otherwise; time_left 0.0 |
| the early win | score >= target_score with the clock still live |
WON; time_left held at this substep's countdown — the clock stops on the win, at the value it had when the press scored |
Exactly one ROUND_END event fires, on the substep the verdict lands (the
payload carries result, score, target and stars, at the rail — the
event's x/y are 0: the end of a round is HUD, not floor). An
ABORTED round fires none: the room tells the players with its own ev.
The settle — the freeze after the verdict. A state that carries a
result is finished gameplay. From the substep the verdict lands, the
kernel freezes every gameplay stage — the action gate, movement, stations,
freshness, orders and hazards are all skipped — and only the frozen clock
clamp and tick += 1 keep running:
| What | During the settle |
|---|---|
| player inputs | ignored (no movement, no verbs) |
| stations / freshness / orders / hazards | frozen (no progress, no burn, no deadline spent) |
time_left |
held at the verdict value — the verdict is the last tick that changes the clock; never negative |
rng_state |
untouched (a draw-less Rng returns its seed) |
| events | none — the ROUND_END and the verdict substep's own events (the winning SERVED, the SCORE) are the only ones that land |
The room supervisor (phase 05) owns the window: it keeps the room ticking
for SETTLE_S (1.0 s) after the verdict so the winning substep's events
land in the players' buffers. Because the sim is a frozen
stage-7-and-tick machine for the whole window, stars and the result
payload are a function of the state at the verdict alone — the round
message sent at the buzzer and the one sent at the window's end agree on
everything but the tick count.
abort(state, reason) is the crash-isolation seam (phase 12): the room's
tick loop caught an exception and the round ends ABORTED, without a
ROUND_END of its own (reason is the room's ev text — the state stores
no free-form strings, so a replay cannot drift on them). The verdict is
final: a round that already won or lost keeps it — a crash after the
buzzer cannot un-win a round.
The stars. stars(state) grades a finished state, from the frozen
time_left and time_limit (the share of the clock the win left on the
table):
| Stars | When |
|---|---|
| 0 | not WON — LOST, ABORTED, or a round not ended yet |
| 1 | won, with less than 25 % of the clock left (or a no-clock state: time_limit <= 0 — there is no share of a missing clock to grade) |
| 2 | won, with time_left >= 0.25 × time_limit |
| 3 | won, with time_left >= 0.5 × time_limit |
The thresholds are inclusive at the boundary (exactly 25 % / 50 % buys the
star), and 0.25 × time_limit is an exact binary half (a power-of-two
scale), so the comparison is stable to the tick —
tests/sim/test_round_state.py pins both sides of each boundary at tick
granularity. A buzzer win is one star (zero clock), and a win in a state
with no clock (a hand-built round) is the win star only.
The result payload. result_payload(state) is the body of the phase-06
round message (PLAN §7), plain JSON by construction (no Vec2, no enum,
no dataclass, no NaN — tests/sim/test_result_payload.py walks it with a
recursive assert_json_safe and names the first leak by path). Its keys,
exactly:
| Key | Value |
|---|---|
result |
"won" / "lost" / "aborted" — or null before the verdict |
score, target |
the round's score and the level's target_score |
stars |
stars(state) |
time_left |
the frozen verdict value |
counters |
the projection onto exactly served / missed / wasted (a never-written counter reads 0; the state's other counters do not leak in) |
longest_streak |
the round's high-water mark of consecutive serves — sim/scoring.py is the single owner (a serve raises the mark, a miss resets the current streak and never the mark); sim/round_state.py reads it and nothing else writes it |
players |
one mapping per seat, in seat order: id, name (the seat's display_name, the sanitized non-authoritative label), score, dishes_served, missed_contrib |
level_id, seed, ticks |
the round's provenance and length |
missed_contrib is the one number in the payload that is an estimate, and
it is documented as such: the sim does not attribute an expired order to a
cook (it expired on the rail, not on a person), so the payload spreads the
round's missed count evenly over its seats (missed // players each,
floor) — display only, never a competitive gate (PLAN §5).
build_state copies level.time_limit onto the state beside time_left
(the stars need the limit, and a replay must re-derive the grade without
the Level object) and each seat's display_name onto its cook.
tests/sim/test_round_state.py pins the verdict (the early win on the
substep the score crosses, the buzzer on both sides of TIME_EPS, the one
ROUND_END), the settle (inputs ignored, no new SERVED, no order expiry,
the station and order objects untouched, the tick monotonic, rng_state
untouched, time_left never negative), the ABORTED seam (the verdict
final, the kitchen otherwise the same object), the star boundaries (one
tick below and exactly at 25 % and 50 %), the star stability across the
settle, longest_streak (the owner raises it, the miss never resets it,
the payload reads it) and build_state's copies; tests/sim/test_result_payload.py
walks the payload for JSON-safety (zero leaks), pins the exact key sets (top
level, counters, per-player), the value types, the seat order, the
missed_contrib split (floor, no-cooks, one-cook) and the payload's
byte-stability across two identical builds and across the settle window.
Purity and determinism
A5 makes sim/ import-free of the outside world; sim/purity.py parses the package and
fails on asyncio, fastapi, socket, time or uvicorn, and
tests/sim/test_step_kernel.py runs that guard. On top of it:
- No wall clock. Time enters only as the
dtargument.time/datetimedo not appear insim/— not even in the replay harness (replay.py): it measures wall time only when a caller injects aclockcallable, and reports0.0when none is, so the module imports nothing the purity guard would refuse and the kernel stays clock-free by construction. - No
random.sim/rng.pyis splitmix64 — 20 lines, no stdlib dependency — becauserandom.Random's stream is explicitly not portable across Python versions and a replay must survive an interpreter upgrade. The round's integer lives inKitchenState.rng_state; stage 5 (orders) rebuilds anRngfrom it, draws its spawns, and writes the counter back to the state it returns, while stage 6's (hazards)Rngis built from the post-orders counter — so one substep's draws are a function of the pre-steprng_statealone, and the state stays frozen and hashable while subsystems still get a stream.Rng.fork(salt)derives a child stream without disturbing its parent: the option for a subsystem that needs independence. - Nothing is mutated.
KitchenStateand every entity are frozen dataclasses; a substep returnsdataclasses.replace(...)copies. Hand a state tostep()and keep it: it is still exactly what it was, hash included. - Same seed, same input script, same state. Two states built by
build_state(level, seats, seed, book)and fed the same(dt, inputs)script stay bit-identical — which means the samestate_hash()— for as long as you like. Different seeds diverge.
uv run pytest tests/sim -q
uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state; print('kernel ok')"
Building a round
build_state(level, seats, seed, book) is the only way a round begins, so a round is a
function of exactly four things: level content, seats, seed, inputs. Seats are placed at
level.spawns[seat % len(level.spawns)] — a four-cook round on a two-spawn kitchen seats
everyone — at the tile centre, with cooks sharing a spawn offset a quarter tile along
x then y so nobody starts inside anybody else. The level's orders spec is copied onto
the state as scalars in tick units (sim/orders.to_ticks; next_order_at at the 1.5 s
first-order delay, next_order_id at 1), which is how a replay is self-contained without
the Level object; book is not read here — it is what the caller's step /
run_script passes to stage 5, which values its spawns from it. StateBuildError
covers the two cases with no deterministic answer: a level with zero spawns, and a
negative seat index.
Replaying and re-blessing goldens
A round is a function of exactly four things — level, seats, seed, inputs — so a
committed script plus a golden state hash is a reproducible test of the whole kernel,
and sim/replay.py is the seam that makes it a one-command contract (A21, PLAN §9
layer 3). Three goldens are committed as of task 05: carry_two_player (task 04's
12-tick pass and contested pick, checked by tests/sim/test_replay_carry.py),
walk_solo (600 ticks of movement-only walking around the level-1 kitchen, every
tick recorded) and pick_drop_pass (two cooks on the fixture kitchen in
tests/fixtures/levels/_replay/: a pick from the shelf, a set-down on the prep,
a HANDED_OFF pass, and a long-press trash), each checked by
tests/sim/test_replay.py.
Golden history: phase 03 → phase 04 (pipeline added) — the three
committed goldens were re-blessed for the phase's deliberate sim-contract
changes: KitchenState carries the level's orders spec as scalars in
tick units plus next_order_at / next_order_id (task 02), and the
level's time_limit beside time_left, the seat's display_name, and
the decided round's settle freeze (task 05) — re-blessed one last time at
the end of task 06, with the pipeline live. No rule of an already-real
stage changed; the event sequences the goldens pin are unchanged — the
full record is in "Goldens and the CLI" below.
The input script
InputScript is one JSON file — {seed, level_id, tick_hz, frames} plus the
optional name, record_every and items — and InputScript.load(path) /
.dump(path) / .validate() are its whole surface. validate() refuses anything
the kernel cannot replay, naming every problem at once: a non-u64 seed, a
tick_hz that is not 1 / STEP_DT (the script is replayed in STEP_DT
substeps, so any other rate is unrunnable), a non-finite move, an unknown
face, an unknown ItemKind/ItemForm; load() is strict about the shape,
so a typo cannot quietly become a different script.
- A frame is
{tick, player_id, move, action, face}; it is fed to the substep that advancesstate.tickfrom the frame'stick(stampedreceived_at_tick = tick,seq= the frame's order for its player), exactly the stamping a live room does. A long press is eight consecutiveTrueframes. - The seats are derived from the script alone: the unique
player_ids in order of first appearance take seats 0, 1, 2, … at the level's spawns (withbuild_state's stacking offset). A replay needs no room object and no token list — the four things, nothing else. itemspre-placeItem(kind, form)on named stations before tick 0. Phase 04's pipelines do not exist yet, so an item can enter the world only this way or in a hand; thepick_drop_passshelf stock is exactly such an entry.record_everyis the golden's cadence:1(the default) records every tick,0records nothing (what a timed run uses to skip the hash cost). The built state (tick 0) and the final tick are always recorded, whatever the cadence.- The run length is the last frame's tick + 1, or
max_tickswhen the caller pins it — thecheckCLI pins it to the golden's tick, so a script edited to be shorter or longer is still compared on the golden's terms.
run_script(level, book, script, max_ticks=None, clock=None) is the pure driver:
build_state, the pre-placed items, then step(state, STEP_DT, frames) per
tick, recording (tick, state_hash()) at the cadence. The states at the recorded
ticks ride along in the ReplayResult so compare(result, golden) can diff a
mismatch without re-running anything: compare() checks every golden tick in
order, reports the first divergent tick first, and the first hash mismatch
carries the field-level diff (which player, station or order moved since the
last matching tick — players[0].pos.y, stations[2].contents[0], …), so a
broken golden is diagnosable in one read. missing/extra entries flag a
record_every cadence mismatch on either side.
Goldens and the CLI
uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json
uv run python -m undercooked.sim.replay record tests/fixtures/replays/walk_solo.json
check runs the script — its level must be in the content directory the CLI
loads (UNDERCOOKED_LEVELS_DIR, default levels/; the pick_drop_pass kitchen
is a test fixture, so its golden is checked through the harness from
test_replay.py) — and compares every recorded hash to
tests/goldens/<name>.golden.json (the script's provenance, the final tick and
hash, and every recorded hash). Exit 0 when they all match, 1 when any
diverge (the first bad tick and its changed fields are printed), 2 when the
script, the level or the golden is missing. record does the same run and writes
the golden: it is the manual re-bless, and it says so on screen.
A golden is re-blessed only after a deliberate sim-contract change — a
constant moved, a subsystem reordered, a rule intentionally changed — and the
change is recorded in the phase task that makes it. The hash diff then appears
in the commit, which is the point: a golden that moved without that record is
the failure the harness exists to catch, and record run to silence a failure
is how the guard gets defeated. As of phase 04 task 02 the three committed
goldens were re-blessed for one such change: KitchenState carries the
level's orders spec as scalars in tick units plus the two bookkeeping fields
(next_order_at, next_order_id), so the state's serialization moved — the
replayed kitchens' pools are live, so the rounds now spawn the level's orders
mid-run, and no rule of an already-real stage changed (the event sequences the
goldens pin are unchanged). As of phase 04 task 05 the three committed
goldens were re-blessed for one more such change: KitchenState carries the
level's time_limit beside time_left (the stars grade the win by the share
of the clock left, and a replay must re-derive the grade without the Level
object), a Player carries the seat's display_name, and a decided round
freezes the gameplay stages for the settle — so walk_solo's final 360 ticks
(past level 1's 12 s buzzer) are the frozen settle rather than more walking.
Again no rule of an already-real stage changed; the event sequences the
goldens pin are unchanged. The numbers under a golden that has not moved
are locked two levels down, where they cannot drift: tests/sim/test_rng.py
pins published splitmix64 vectors (the generator itself can never change
quietly) and tests/sim/test_step_kernel.py pins the tick count, the clamping,
the accumulation and cross-run hash equality.
The perf smoke
tests/sim/test_perf_smoke.py (marked slow, so the quick loop skips it but
the gate's slow stage runs it): four cooks, 2 000 ticks on level 1, one
direction per cook per tick from the sim's own splitmix64. The budget is
asserted and the measured numbers printed in the report: mean step < 0.2 ms
and total < 400 ms — 5 000+ ticks/s, two and a half orders of magnitude
more than the 20 Hz the rooms run at.
What proves it
tests/sim/test_replay.py pins the whole seam: both committed scripts match
their goldens (the pick_drop_pass event sequence asserted in code, so a drift
shows which rule moved), the one-coordinate mutation (first bad tick named,
changed field named), the record-cadence stability and its record_every = 0
escape, the in-process double replay, the clock seam, the CLI's three exit
paths (match, mutation, missing level/golden), the record-into-a-temp-dir
re-bless, and the golden files' never being rewritten by a test run. The
carry_two_player golden stays with tests/sim/test_replay_carry.py, where
task 04 pinned it.
Not real yet
A hook returning the state unchanged is not a bug, it is this phase's scaffolding — but it
is worth naming out loud so nobody reads a green suite as "the game works". As of task 01
(the station pipelines) the cooks walk, slide, bump, get stunned and stop, the hands do
something (a free hand picks, a full hand sets down, the set-down is marked, a long press
over the trash can destroys the carried item, two cooks who press the same item in the
same substep are arbitrated to a winner and a CONTESTED_LOST loser), and the work verbs
are spent: a set-down on a work surface starts the work, the prep plates, the stove cooks
(chop 0.9 s, the stove 6 s chopped / 8 s raw — 5 s for patty/fish, the pan's; 8 s for
rice, the pot's), the sink and the fixtures' own in-place wash wash in 1.2 s, a released
worker's work stays frozen and claimable, and a dirty fixture is washed — its prop's
stock paid back. Freshness is real too: a dish on a ready stove burns
exactly 8.0 s after the ready flip (the station goes DIRTY with it), a
raw or chopped item left on a board rots linearly over 20 s, a carried
prepared item decays at half the board's rate, a burnt board item yields
to a new raw set-down (a named WASTED, counters["wasted"]) while a
usable one never is, and the dirty loop's stock — use dirties, a wash
cleans, …_clean + …_dirty is the level's limit every tick — has its
no-dead-end guarantee proven on a 2 000-tick worst-case run. Every refusal is a named DENIED event. The ticket rail is
real too: the level's orders spawn from its pool 1.5 s in (the recipe a
seeded draw, the value the recipe's base_score), the concurrency cap holds,
a full rail retries after 1 s without spending the draw, and an order expires
on exactly the tick its clock reaches 0 — missed, counted, and named. Serve
scoring is real too: a plated dish is validated (burnt, not-a-dish, no orders,
wrong recipe, incomplete — each a named DENIED with the dish kept in the
hand), a matched dish fills the oldest compatible order and scores
round(base × quality × streak × time_bonus) with the streak capped and the
score floored, and the expiry's miss spends its penalty and resets the streak.
Still identity: no hazard burns or spills (phase 10), the
shelf's stock is not yet seeded (phase 05's room, from level.station_items),
and a pot is a stuck prop (its wash is the stove's in-place wash — it is
not a carried item). What is real is the shape they all plug into: the
clock, the accumulator, the ordering, the input contract, the body that cannot enter a
wall, the reach zone that cannot see through a wall, the pair of hands that never hold two
items at once, the single worker slot a fixture has, and the round's end conditions: the early
win the moment the score reaches the target, the buzzer verdict, the one
ROUND_END, the post-verdict settle that holds the clock and the kitchen
for SETTLE_S, the stars from the frozen clock, and the plain-JSON result
payload the round message carries — all of it computed by the server, never
by the client.