# SIMULATION.md — the tick contract **Status:** current as of phase 03 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 — and the replay harness: the input-script format, the committed golden hashes, the `record`/`check` CLI, and the headless perf smoke). **Filled in by phase 04** for the station and order pipelines, and by phase 10 for hazards — each of those replaces an 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 ```text 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". | | `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. | `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` | identity → phase 04 | | 5 | Orders | `sim/orders.py` | identity → phase 04 | | 6 | Hazards | `sim/hazards.py` | identity → phase 10 | | 7 | Round timer and end conditions | `sim/round_state.py` | countdown + time-up verdict real; early win and stars → phase 04 | | 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. ```text 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`, never `vel`. Being bumped does not fling anybody, and a bumped cook keeps walking in the direction they pressed. * **Iterated.** `PUSH_ITERATIONS` passes 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_inputs` uses (earliest `received_at_tick` wins, tie-break lowest `seq`), 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: 1. the tile directly in front (the `REACH` tier — always in reach from any point of the tile, worst case √(0.5² + 1.5²) ≈ 1.581); 2. the two diagonal-front tiles (the `FORGIVE` tier 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); 3. 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: 1. 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); 2. the serve window, as a `ServeTarget`, so the score path is distinguishable from merely standing at a fixture; 3. the fixture itself; 4. `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, whatever is held — the player needs "this board is filthy", not "you are holding the wrong thing". Dirt outranks every other state. | | `STATION_BUSY` | `station_busy` | A fixture another cook is working, a `BUSY` fixture, or one on cooldown. 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 `action` clears both counters (the button is up; the next `True` is 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 `TRASH` spends ("The hand"); the `HOLD_WORK` threshold 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 — is `START_WORK`'s, not `PICK_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: 1. an explicit `face` in the frame (d-pad, touch stick) wins; 2. otherwise the last non-zero intent, dominant axis, ties resolve to horizontal; 3. standing still keeps the facing, so an `action` press with no direction does not spin the cook around (PLAN §5 "one action button"); 4. 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. ## 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 `dt` argument. `time`/`datetime` do not appear in `sim/` — not even in the replay harness (`replay.py`): it measures wall time only when a caller *injects* a `clock` callable, and reports `0.0` when none is, so the module imports nothing the purity guard would refuse and the kernel stays clock-free by construction. * **No `random`.** `sim/rng.py` is splitmix64 — 20 lines, no stdlib dependency — because `random.Random`'s stream is explicitly not portable across Python versions and a replay must survive an interpreter upgrade. The round's integer lives in `KitchenState.rng_state`; each substep rebuilds an `Rng` from it and writes the counter back, so the state stays frozen and hashable while subsystems still get a stream. `Rng.fork(salt)` derives a child stream without disturbing its parent, which is how orders and hazards get independent streams that both reproduce from the seed. * **Nothing is mutated.** `KitchenState` and every entity are frozen dataclasses; a substep returns `dataclasses.replace(...)` copies. Hand a state to `step()` 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 same `state_hash()` — for as long as you like. Different seeds diverge. ```bash 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. `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`. ### 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 advances `state.tick` from the frame's `tick` (stamped `received_at_tick = tick`, `seq` = the frame's order for its player), exactly the stamping a live room does. A long press is eight consecutive `True` frames. * The seats are derived from the script alone: the unique `player_id`s in order of first appearance take seats 0, 1, 2, … at the level's spawns (with `build_state`'s stacking offset). A replay needs no room object and no token list — the four things, nothing else. * `items` pre-place `Item(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; the `pick_drop_pass` shelf stock is exactly such an entry. * `record_every` is the golden's cadence: `1` (the default) records every tick, `0` records 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_ticks` when the caller pins it — the `check` CLI 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/.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. 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 04 the cooks walk, slide, bump, get stunned and stop, and the hands do something: a free hand picks an item out of a ready fixture or off a counter a teammate set down (a `HANDED_OFF` inside the window, a `PICKED` outside it), a full hand sets one back down on a fixture — shelf, work surface or prep counter — and the set-down is marked for exactly that, a long press over the trash can destroys the carried item on its 8th substep (`TRASHED`, the raw score delta in the payload, `counters["trashed"]` up by one), and two cooks who press the same item in the same substep are arbitrated to a winner and a `CONTESTED_LOST` loser, the item in exactly one place. Every refusal is a named `DENIED` event. Still identity: no station progresses (phase 04), no order spawns (phase 04), no hazard burns or spills (phase 10), and the work verbs — chop, cook, plate, serve, wash — are named by the ladder but unspent no-ops until phase 04's pipelines exist to spend them (`HOLD_WORK`'s threshold with them; the long-press `TRASH`, the hand-off and the `HANDLING_S` floor settled at `0.0` arrived with task 04's carry rules). 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, and the round timer that ends a round 12 seconds after it started, whether or not anybody cooked.