Files
ducoterra f26849c1f3 phase: 06_realtime_websocket_protocol
All green. Final verification complete — writing the report.

**Final pass report — phase 06 `realtime_websocket_protocol`**

- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched

Next pending phase: `07_canvas_client_and_hud`.
2026-10-09 11:13:25 -04:00

28 KiB
Raw Permalink Blame History

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 sleeps 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 joins 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.