# Rooms — the lobby-to-result state machine ## Purpose This is the contract for the `Room` (`src/undercooked/server/room.py`) and the `RoomSupervisor` (`src/undercooked/server/rooms.py`): the explicit finite state machine that turns a level plus a set of seats into a running simulation and a result, the room's data, its one 20 Hz deadline-scheduled tick task, its crash isolation, the supervisor's bookkeeping, and what a restart loses. Read it before touching either module; the wire messages that cross this boundary are phase 06's (`docs/PROTOCOL.md`). The unit that turns a level plus a set of seats into a running simulation and back is the `Room` (`src/undercooked/server/room.py`), watched over by the `RoomSupervisor` (`src/undercooked/server/rooms.py`). This file states what each state accepts from the client in words; phase 06 pins the per-state message whitelist on top of it. ## The state machine Five states, nine events, a table of exactly fourteen legal pairs. The table is data (`ROOM_TRANSITIONS`), not `if`-ladders: `Room._fire(event)` is a dict lookup — anything not in the table raises `IllegalTransition`, and every state change in the room goes through that one door (a test greps the room module for ad-hoc state writes and fails on a bypass). ``` host_start +--------+ (>=1 ready non-host, +-----------+ | LOBBY | or solo host, and a | COUNTDOWN | +--------+ selected level) +-----------+ ^ ^ (3.0 s) | | to_lobby / retry / countdown_done | | next_level (builds the round) | +------------------------->| | +---------+| | | PLAYING | | +---------+ | round_over | +-----------+ +---------------- | ROUND_END | +-----------+ (any state) close | +--------+ | CLOSED | (absorbing) +--------+ ``` | From \ Event | `host_start` | `countdown_done` | `round_over` | `to_lobby` | `retry` | `next_level` | `close` | `all_seats_empty` | `idle_expired` | |---|---|---|---|---|---|---|---|---|---| | `LOBBY` | `COUNTDOWN` | | | | | | `CLOSED` | `CLOSED` | `CLOSED` | | `COUNTDOWN` | | `PLAYING` | | | | | `CLOSED` | | | | `PLAYING` | | | `ROUND_END` | | | | `CLOSED` | | | | `ROUND_END` | | | | `LOBBY` | `COUNTDOWN` | `COUNTDOWN` | `CLOSED` | `CLOSED` | `CLOSED` | | `CLOSED` | | | | | | | (none) | (none) | (none) | The public verbs are thin wrappers over the table: `request_start` checks the lobby preconditions (a selected level, and either ≥ 1 ready non-host or a solo host — `StartRefused` names which one is missing), resets the round via `_begin_round`, and fires `host_start`; `retry` and `next_level` fire first (retry re-rolls the level's seed, `next_level` resolves the level in the campaign first) and share the same round reset; `close` fires `close` (a no-op already `CLOSED`) and stops the tick task. ## The room's data | Field | Meaning | |---|---| | `code` | the Crockford code the supervisor minted (5 chars, unique, never reused) | | `seats` / `is_full` / `add_seat` / `remove_seat` | the seated players: `token`, `display_name` (sanitized at the boundary), `host`, `player_id` (the sim identity), `color` (the first-free palette colour, remembered per token — "The lobby"), ready-up flags; `add_seat` takes the lowest free slot, `ValueError` when full | | `level` / `level_id` | the campaign level a round is built from; `select_level` resolves an id in the room's content (a data change, not a transition) | | `state` / `state_since` | the FSM state and when it was entered (the injected `Clock`) | | `countdown_left` | the 3.0 s of `COUNTDOWN`, decremented by wall time | | `sim_state` | the round's `KitchenState` — the single source of truth while playing; the server's copy only, it never leaves the process | | `last_result` | the `sim.round_state.result_payload` of the last finished round (`verdict`, `round_no`, `score`, `time_left`, `deadline_share`) | | `round_no` / `round_seed` | the round counter (1, 2, …) and its seed: round 1 plays the base seed, every later round steps the LCG cursor — a fresh, and from one base pinned, seed | | `events_since_snapshot` / `take_events` | the sim events (scored, wasted, burnt, …) since the last client snapshot; drained, never accumulated | | `_queued_inputs` / `queue_input` | the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`; see "One 20 Hz tick task", the `Inputs` paragraph) | | `stats()` / `note_input_stat` | the room's diagnostics census: the lobby/round counters plus the input counters (`inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`, `large_seq_gaps`) | | `snapshot_seq` | the room's snapshot counter (the phase 06 transport stamps it on frames) | ## One 20 Hz tick task, deadline-scheduled Every live room owns exactly **one** asyncio task — the `RoomSupervisor.start()` contract is "the GC loop and every room's tick task". `Room.start()` creates it (and refuses a double `start`); `Room.close()` cancels it and awaits its unwind (an idempotent, ordered teardown: a second `close()` is a no-op, a `CLOSED` tick returns early, and `CancelledError` passes through the crash isolation untouched). The loop never `sleep`s a fixed period. It schedules on the injected `Clock`'s `now()`: each iteration waits `deadline - now`, where `deadline` starts at the first tick and advances by exactly `1/tick_hz`; after the `Clock` has jumped (a lid, a test advancing a `FakeClock`) the deadline resynchronises to `now + period`, so at most one catch-up tick can happen and it is clamped to `MAX_DT` by the sim — no catch-up burst. One `Room.tick_once(now)` per deadline; with a `FakeClock` the whole cadence is a deterministic test (100 ticks over exactly 5.0 s, no drift). `tick_once`'s semantics per state (the body the loop crash-isolates): - **`COUNTDOWN`** — decrement `countdown_left` by the wall `dt` (floored at 0); at zero, fire `countdown_done` and build the round. - **`PLAYING`** — `sim.step(sim_state, dt, queued, recipe_book)`: the kernel's own `STEP_DT` substeps, `dt` clamped to `[0, MAX_DT]` by the sim (a jump costs at most two substeps). The per-tick input queue is drained into the step (the frames carry their `received_at_tick` stamp from `queue_input`) — **unless the tick advances no substep** (a wall-clock `dt` too short to fill a `STEP_DT` once the accumulator is counted): then the queue is *kept* and the step is not called, so a frame is never dropped without being applied (a 20 Hz wall-clock loop jitters 48-51 ms; without the guard a queued frame would be lost on a good fraction of ticks). A settled state (`result` set) still drains: the kernel ignores its inputs there by design. The sim's events are appended to `events_since_snapshot`. When the sim's `result` lands, the room opens the **settle** — `sim.round_state.SETTLE_S` (1.0 s) of wall time, the sim in its frozen stage-7 machine in between — and only then captures `last_result = result_payload(sim_state)` and fires `round_over`. - **`LOBBY` / `ROUND_END`** — the room's own idle checks (the same rules and numbers the supervisor's GC applies externally, so a room closes itself even if the GC is a cycle late): `all_seats_empty` once the room has been empty past `SEAT_GRACE_S` (30 s), `idle_expired` once `now - last_activity` passed `lobby_idle_timeout_s`. `remove_seat`/`set_ready`/`queue_input` stamp `last_activity` (an empty room stamps `empty_since`). - the first tick of a room's life carries `dt = 0` (the prime); `dt` is floored at 0, so a backwards `Clock` never un-runs the sim. Inputs: `queue_input` accepts a `sim.model.PlayerInput` only while `PLAYING` and for a seated, connected token: an unknown token is an `err` event with `reason=not_seated` (the player is not a seat at all) or `reason=unknown_player` (the token is a seat but the sim id is not its) — the room's only in-band error channel — while a `DISCONNECTED` seat's frame is a **silent no-op** (the client is not at fault, so no error is queued) and `last_seq` on the connection is left untouched. The queue is per-tick with a capacity of `INPUT_QUEUE_PER_SEAT x len(seats)` (4 x the seated players): at capacity, the *oldest* frame of the arriving player is dropped (the newest intent wins; `inputs_dropped` bumped) — the queue never grows unbounded. The queue is drained by the next tick that advances a substep (a zero-substep tick keeps it — see the `PLAYING` bullet) and cleared then; `stats()` counts the input traffic (`inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves` — the clamp itself is the transport's, `ws.to_player_input` — and `large_seq_gaps`, the tolerated-loss sibling). ## Crash isolation The whole `tick_once` body is wrapped: any exception (a sim bug, a bad level payload) is **logged with the room code and round — never a player token** — and the room aborts: `sim.round_state.abort` marks the sim (`abort_reason`), an `err` event is buffered, `close` fires, and the exception **never propagates** out of the loop. One crashing room closes only that room: the supervisor's other rooms keep ticking, the GC loop keeps running, the server stays up. A `CLOSED` room refuses new seats and inputs (the phase 06 whitelist enforces the same on the wire). ## The supervisor `RoomSupervisor` (one per process, built by `create_app`) owns: - the **code minting** — `server/codes.py`'s Crockford alphabet, 5 chars, no ambiguous set, unique against the live registry (a collision re-rolls); - **capacity** — at most `max_rooms` (default 200) rooms at once; `create_room` past the cap is refused, and a room that is `full` refuses new seats; - the **one-token-one-room** index — a token sits in at most one room, ever; `create_room` rejects a token that already has a room, and a room's `remove_seat` clears it (joining another room while seated elsewhere is impossible by construction, not by a check); - the **5 s GC** — a `FakeClock`-driven loop that closes empty rooms past `SEAT_GRACE_S` and idle rooms past `lobby_idle_timeout_s`, and prunes the room index of `CLOSED` rooms; - `start()` (the GC loop + every room's tick task) and `shutdown()` (cancel the GC, close every room); - the **session store** — a bounded LRU `SessionStore` (task 04): every token that created or joined a room is remembered there, and the progression (`unlocked_level`, the best scores, the counters) is the store's to own; `session_for` reads through it (task 03's `set_level` unlock check and `lobby_payload`'s `unlocked` read the session's `unlocked_level` through this), the rooms get it as their `store`, and `POST /api/rooms` / `GET /api/session` run on it. In-memory only: a process restart clears all progression (A6); ## Failure and expiry The three ways a room leaves the registry, and the windows that govern each (all timestamps from the injected `Clock`; the tests drive them with a `FakeClock` — `tests/integration/test_lifecycle_end_to_end.py`): - **The seat grace (`SEAT_GRACE_S`, 30 s).** A room whose *last seat left* more than `SEAT_GRACE_S` ago is closed, in any state (an orphaned room is an orphaned room). `SEAT_GRACE_S` is a module constant in `server/room.py` — the room's own tick check and the supervisor's GC read the one number. Exactly at 30 s the room is still up (the rule is strictly past the grace); one `gc_pass()` after it closes and drops the room, its code no longer resolves (a 404) and its tokens are purged from the index. - **The lobby idle (`UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S`, 900 s).** An *occupied* `LOBBY` room (the `ROUND_END` state runs the same check) whose `last_activity` is past the timeout is closed; exactly at the timeout it is still up. `last_activity` is stamped by any meaningful event (a join, a leave, a ready-up, an emote), so a lobby that is being used never expires. - **The GC period (`GC_INTERVAL_S`, 5 s).** One `gc_pass()` every 5 s (the loop is cancellable; the sleep is injectable, so a test advances the `FakeClock` and never waits). One pass, in order: drop rooms already `CLOSED` (their teardown is done), close rooms past the seat grace, close idle lobbies past the timeout, and purge token-index entries whose room is gone or whose seat is empty. A closed room is *dropped*, not just closed: the code stops resolving immediately, and a room a player `join`s while `CLOSED` (the window before the drop) is a named 410 `room_closed`. **Disconnected seats (task 05).** A dropped connection marks the seat `connected=false`; the seat is *kept* for the rejoin path. The park comes first: after `PARK_AFTER_S` (3 s) the cook's carried item is released (onto the station on the tile, or `counters["lost_items"]`), and the sim's cook sits in `disconnected`. The grace that drops the seat is `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s), enforced by the room's own tick in every state (`SeatAbandoned`): the seat is emptied (one `ghost_off` event, a host transfer, `empty_since` for the close rules) and any still-carried item is released at once. A rejoin inside the grace reclaims the seat (`welcome.rejoined`; the snapshot stream resumes within one tick); after it, the spectate/join rules apply. The emptied room closes through the 30 s seat grace above (the room's own check in `LOBBY`/`ROUND_END`; the GC in any state), and a closing room closes its sockets with `err room_gone` + close `4401` — the client's "the room died" signal (the contract in `docs/PROTOCOL.md`, "Heartbeats, drops, and seat grace"). **The crash policy** is the crash-isolation section above, restated as lifecycle: a crashed room fires `close` from inside its own tick (it becomes `CLOSED` with its sim `ABORTED` and one `tick_crash` err event), the *next* `gc_pass()` drops it (a `CLOSED` room is rule 1 — the pass does not close it again, it reaps it), the other rooms keep ticking, and the server keeps serving. One crashing room is one `CLOSED` room; the supervisor's stats, the other rooms and `/healthz` are untouched. ## The lifecycle walkthrough (HTTP before the transport) The phase-05 contract is walked end to end over HTTP plus direct `Room` calls — `tests/integration/test_lifecycle_end_to_end.py` is this section, step for step (a `FakeClock` and manual `tick_once`; no test sleeps): 1. **Create.** `POST /api/rooms` (no body — the bare `curl -X POST` criterion) takes (or mints) the 24-byte session token (` X-Undercooked-Token` header or body `token`; the header wins), mints the 5-character Crockford code, seats the creator at seat 0 as host with the campaign's first level, and returns the token **once** in `you`. 503 `room_full` when the process is at `max_rooms` (US-01 criterion 7; the existing rooms are untouched). 2. **Join (×3).** `POST /api/rooms/{code}/join` seats the next player in the lowest free slot; a typed code is normalized (confusables mapped, a malformed code 404s exactly like an unknown one — US-02 criterion 4, no stack trace in the body). A 5th join on a four-seat room is a 503 `room_full` and the code is retained. A `join` after `LOBBY` would become a spectator ("The lobby"). 3. **Ready-up.** The ready flags (the phase-06 `ready` message is the transport copy of `Room.set_ready`). The start precondition: a solo host, or ≥ 1 ready non-host — `can_start` renders it for the client's button. 4. **Start.** `Room.start(token)` — `LOBBY` only, host only (the preconditions named: `NotInLobby`, `NotHost`, `NothingToStart`). Fires `host_start` → `COUNTDOWN`: `round_no` 1, a fresh seed, the 3.0 s countdown. 5. **Countdown → the round.** 60 frames of 0.05 s (no drift — the tick cadence section); at zero `countdown_done` builds the round (level + seats + seed + the room's shelf/prop seeding) and the room is `PLAYING`. 6. **`PLAYING` → the verdict.** One `tick_once` per 0.05 s frame: the kernel's `step` with the per-tick input queue. A scripted full-pipeline round (the phase-04 policy) reaches the verdict; the room then settles for `SETTLE_S` (1.0 s, the sim's frozen stage-7 machine) before `round_over` fires and `last_result = result_payload(state)` lands. 7. **`ROUND_END`.** The payload is the phase-04 contract's exact key set (`result`, `score`, `target`, `stars`, `time_left`, `counters`, `longest_streak`, `players` — one per seat, `level_id`, `seed`, `ticks`). The result is recorded into **every seated player's session** through the store ("Progression"): a `WON` round raises `unlocked_level` to `order + 1` (the solo path's "unlock advanced"), stores the `best` run, counts the round and the served dishes. 8. **`next_level`.** The host's next campaign pick (the value the store's `next_level_id` hands back — after a win, `order + 1`'s level; after a loss, the level just played): `select_level` + `round_no` 2 + a fresh seed + `COUNTDOWN`. `retry` is the same level with a fresh seed; `to_lobby` returns without a new round. 9. **Back to `LOBBY`.** The ready flags reset (ready up again), the same seats keep the same tokens and colours (a token's colour is remembered for the life of the room), the crown is where the host rules left it, and waiting spectators are promoted into the free seats. The room answers `GET /api/rooms/{code}` from the lobby with all its seats. **The gap (closed by phase 06).** HTTP has *create, join, inspect and session* — it has no `start`, no `ready`, no `set_level`, no `next_level`/`to_lobby` and no inputs: those verbs exist on `Room` (this walkthrough drives them by direct call) and arrive as wire messages with the phase-06 per-state whitelist ("Per-state client acceptance" above; `docs/PROTOCOL.md`). That is why the `curl` walk-through stops at `POST /api/rooms` → `POST .../join` → `GET /api/rooms/{code}` → `GET /api/session`, and the ready-up/start/round legs are the integration suite's direct calls. ## The lobby (task 03's verbs) `Room` (`server/room.py`) is the FSM; the lobby's *data* — `Seat`, `Spectator`, `sanitize_name`, the palette, the emote whitelist, the named refusals — lives in `server/seats.py`. ### Seats and ready-up `Seat` is one occupied seat (an empty seat is the *absence* of an entry): the token, a display name sanitized at the boundary, the seat index (the lowest free slot), the ready-up flag, the host flag, the connection flag. `toggle_ready` / `mark_connected` / `mark_disconnected` are the seat's own verbs. `ready` is what `request_start` / `start` read: a **solo host**, or ≥ 1 ready non-host, and a selected level. `can_start` is the same precondition plus a crown holder — the client's Start button renders off it. **Colours.** The four `SEAT_PALETTE` colours are handed out first-free, in seat order (seats by seat index, then spectators in join order). A token's colour is remembered for the life of the room: a reconnect (a seat reclaimed, a spectator re-added) keeps the same colour when it is still free; a fresh token takes the first free colour. When all four colours are taken, a late entry has none. ### Names `sanitize_name(raw, seat_index)` is the one place a player-typed name becomes a stored name: strip Unicode control and format characters (zero-width marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs of whitespace, trim, cap at 16; an empty result falls back to `Cook #{seat_index + 1}`. Names are non-unique by design; the client renders them via `textContent` (never HTML), and the server never stores an unsanitized name (the fallback is always available). ### The host rules The creator's seat 0 is the host. When the crown holder **leaves or their connection drops** (a dropped host connection is a host leave): | State | What happens | |---|---| | `LOBBY` / `ROUND_END` | the crown moves to the lowest occupied, **connected** seat; when no connected seat remains, the host slot is empty | | `COUNTDOWN` / `PLAYING` | the host slot is emptied and the round continues — **no mid-round election** (the crown stays empty until a later host-leave in `LOBBY`/`ROUND_END`) | A host that **returns does not automatically regain the crown** — that is explicit, and it avoids flip-flopping. A `LOBBY` with no host cannot start (`can_start` is `false`); the idle rules (the 30 s grace, the lobby timeout) reap it. `start(token)` is `LOBBY` only (`NotInLobby`), host only (`NotHost`), and raises `NothingToStart` — a `StartRefused`, the reason (`"no_level"` / `"no_ready_players"`) rides along — when the precondition is not met. ### Level picking and unlocks `set_level(token, level_id)` is the host's pick (PLAN §6 "limited to unlocked"): `LOBBY` only (`NotInLobby`), host only (`NotHost`), the id in the campaign (`UnknownLevel`) and its `order` within the requesting session's `unlocked_level` (`LevelLocked`; a token without a session record counts as unlocked 1 — the campaign's floor). The supervisor keeps the token → `SessionRecord` map and hands the room a `session_for` lookup; task 04's `SessionStore` owns the progression. ### `lobby_payload` The body of the phase-06 `lobby` message, per viewer: `code`, `state`, `level`, `host_seat` (the crown holder's seat index, or `null`), `can_start`, `seats[{index, name, color, ready, host, connected}]` (in seat order), `spectators[{name, color}]` (join order), `unlocked` (the viewer's `unlocked_level`, or `null` for an anonymous viewer), `round_no`. Tokens never leave the room in this payload. ### Spectators A `join` while the room is `COUNTDOWN` / `PLAYING` / `ROUND_END` adds a spectator (PLAN §6: "late joins wait in `SPECTATING`" until `LOBBY`): no seat, no sim player, `role="spectator"`, a colour from the same first-free palette (seats take first; a full room's late watcher has none). A full room never stops accepting spectators. A spectator's `input` is ignored as a named `err not_seated` (distinct from `unknown_player`); a `leave` removes the entry, a dropped connection keeps it; `to_lobby` promotes them into the free seats (if the room is full, the surplus stays watching). ### Emotes `emote(token, id)` is the `LOBBY`/`PLAYING` emote (PLAN §7: no chat in v1; elsewhere it is dropped). A seated player or a spectator may emote; the id must be in the `EMOTES` whitelist — `{wave, cheer, thumbs_up, thumbs_down, angry, star}` (an unknown id is an `UnknownEmote`, never a silent no-op). The throttle is **2 per 1.0 s per connection** (a third emote in the window is dropped, not queued). An accepted emote re-broadcasts as an `ev` event (kind `"emote"`; the id and the token ride in the payload). ## Progression (the session store, task 04) The `SessionStore` (`server/sessions.py`) is the process-wide, LRU-capped holder of every session's campaign progression (PLAN §6: "in memory, bounded; LRU-capped at 10 000 entries; wiped on process restart — acceptable per A6"). The supervisor owns exactly one (the app's); a `Room` holds a reference to it (a test-built room may hold none — such a room records nothing, and its `set_level` falls back to its `sessions` lookup). **The record.** One `SessionRecord` per token (the 24-byte `secrets.token_urlsafe(24)` identity, A16): `display_name` (sanitized, non-unique), `unlocked_level` (the highest campaign `order` the session may play — starts at 1, the campaign's floor), `best[level_id]` (a `BestRun`: `score`, `stars`, `completed_at` — the injected clock's seconds; a monotonic-relative number, never a wall timestamp, never an IP / user-agent / header — `tests/unit/test_session_privacy.py` locks the surface), `rounds_played` / `dishes_served` (counters, across rooms), `created_at` / `last_seen` (the LRU's stamps) and `endless_best` (phase 13's; `None` until it lands). **The verbs.** `get_or_create(token)` — an unknown token auto-creates at `unlocked_level=1` (a `get` never creates: a lookup is not an identity). `put(record)` — a caller-minted record; a token the store already knows is left alone (a re-creator keeps their unlocks). `record_result(token, level, result_payload)` — the room's `round_over` calls it for **every seated player** (spectators are not players): `rounds_played += 1` and `dishes_served += counters.served` on *every* result; only a `WON` result touches the progression — `unlocked_level` rises to `level.order + 1` (never decreases) and `best[level_id]` updates **when the score beats the stored one** (a tie keeps the older run and its `completed_at`; the stars are the winning run's). `is_unlocked(token, level_id)` — `order <= unlocked_level` (an id outside the campaign the store was built with is `False` — the room checks `UnknownLevel` first). `next_level_id(token, content)` — the lowest campaign `order` that is unlocked and has no best run yet: after a win, `order + 1`'s level; after a loss, the level just played (retry); `None` when every unlocked level has been won (the campaign is done for that session) — the value the room's `next_level` resolves through (`Room.next_level_id`). `to_public(token)` — the `GET /api/session` body: exactly `display_name`, `unlocked`, `best`, `rounds_played`, `dishes_served`, `endless_best` — nothing beyond that shape (no token, no internal stamps). `stats()` — the diagnostics census (`sessions`, `max_entries`, `evictions`). **The LRU.** Eviction runs on a *new* insert only (a touch never evicts): the oldest `last_seen` goes — **never the token that just touched the store** (never the current session). Ties break on the token (deterministic). The cap is `max_entries` (10 000 by default, PLAN §6); 10 001 sessions stay bounded at the cap. **The wiring.** The supervisor owns the store (the campaign mapping — `level_id -> order` — comes from the content bundle at construction); `POST /api/rooms` takes the session from the `X-Undercooked-Token` header or the body's `token` (the header wins; both absent → the server mints one, returned once in `you`) — a *known* token's progression is never reset; `GET /api/session` returns `to_public` (200 for a fresh token — `unlocked: 1`, empty `best`; 400 `no_token` without the header); `Room.set_level` asks the store's `is_unlocked` (a test-built room without a store falls back to its `sessions` lookup: a token without a record counts as unlocked 1); `Room.next_level_id(token)` reads the store's `next_level_id`; `round_over` records the result into every seated player's session. ## What is lost on restart Everything in a room — the FSM state, the seats, the `sim_state`, the `last_result`, the event queue, the snapshot counter — is in-process memory. A restart closes every room and the supervisor's index with it; there is no persistence and nothing to recover (PLAN §7: results/scoreboards die with the process by design). The session store is part of that: **a process restart clears all progression** — every unlock, best score and counter dies, and the next process starts every session at the campaign floor (A6; the module and the README both say so). What *is* rebuilt is content: levels and recipes are re-read from `levels/*.yaml` through the validated Pydantic models at startup (the same startup gate `create_app` runs — a bad file refuses to boot, it never serves a half-loaded campaign). ## Per-state client acceptance (words; the whitelist is phase 06) - **`LOBBY`** — join (a seat), ready-up (`toggle_ready` / `set_ready`), start (`start(token)`, the host), level pick (`set_level`, the host), emotes. No movement, no input, no snapshot of a sim (there is none yet). - **`COUNTDOWN`** — the client observes (the 3.0 s); a `join` becomes a spectator, start/level pick are refused (`NotInLobby`), emotes are dropped; the room no longer accepts start (the table says so). - **`PLAYING`** — inputs (`queue_input` — a spectator's input is an `err not_seated`), snapshots (`take_events` + the phase 06 frame), emotes; ready is irrelevant (the round is running). - **`ROUND_END`** — observe the result; `retry` / `next_level` (the host) / `to_lobby` (spectators are promoted into the free seats); the ready flags reset at `to_lobby`; emotes are dropped. - **`CLOSED`** — nothing. The room refuses seats, inputs and transitions; the client is told the room closed (phase 06's close frame) and the transport drops. Phase 06 turns these into the per-state message whitelist on the wire (`docs/PROTOCOL.md`); the room's own guards above are what the whitelist sits on.