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`.
908 lines
46 KiB
Markdown
908 lines
46 KiB
Markdown
# PROTOCOL.md — the wire contract (v1)
|
||
|
||
## Purpose
|
||
|
||
This document is the wire contract of the Undercooked server, in prose: what
|
||
the client sends, what the server answers, when each is legal, and what
|
||
happens when a frame is malformed, oversized, or out of its state. It is the
|
||
specification both sides of the WebSocket are built against — the client
|
||
(phases 07/08) reads it table by table, and the server's `protocol.py` is the
|
||
code of record for it. Phase 06's tasks have landed their sections as the
|
||
transport pieces did (input rules, the `snap` encoder, heartbeats and seat
|
||
grace); where a section still awaits its task it says so explicitly. Read
|
||
`docs/ROOMS.md` for the pre-socket HTTP lifecycle this contract rides on.
|
||
|
||
The single source of the wire contract. The code of record is
|
||
`src/undercooked/server/protocol.py`; `tests/unit/test_protocol_doc_sync.py`
|
||
keeps every table in this document field-for-field equal to the models'
|
||
`model_fields` (and the error/close code tables equal to the code constants),
|
||
so neither can drift. **Change a wire field and this file moves in the same
|
||
task — and `PROTOCOL_VERSION` is bumped (see "Versioning policy").**
|
||
|
||
The transport is a native WebSocket (`/ws/{room_code}`, PLAN A3) carrying
|
||
versioned JSON frames (PLAN A14). The HTTP surface (`/api/rooms`,
|
||
`/api/session`, `/api/levels`) is the pre-socket lifecycle
|
||
(`docs/ROOMS.md`); the socket is the live channel.
|
||
|
||
## Framing
|
||
|
||
- One JSON object per frame, UTF-8, ≤ `max_message_bytes` (default 4096;
|
||
`UNDERCOOKED_MAX_MESSAGE_BYTES`). A bigger frame is `too_big`.
|
||
- Every frame has a `type` string — the discriminator. Client→server
|
||
discriminators: `hello`, `ready`, `set_level`, `start`, `input`, `emote`,
|
||
`to_lobby`, `next_level`, `ping`. Server→client: `welcome`, `lobby`,
|
||
`countdown`, `snap`, `round`, `ev`, `err`, `pong`.
|
||
- The protocol version `p` travels **in the client's `hello` only**. The
|
||
server checks it there (outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]`
|
||
→ `protocol_mismatch`, close `4400`) and the connection is then bound to
|
||
the negotiated version. Every server→client frame *also* carries `p` so a
|
||
client can see what it is talking to.
|
||
- Unknown fields are **rejected, not ignored** (`extra="forbid"`). This is
|
||
intentional: a client and a server that disagree on shape must fail loudly
|
||
(`bad_field`), not silently misinterpret. It is also what makes
|
||
"no position is ever client-sent" trivially true — an `input` with a
|
||
`pos` field is a `bad_field`, full stop.
|
||
- `hello` and `ping` are legal in every room state (the handshake — including
|
||
a mid-round rejoin — and the keep-alive); everything else is legal only in
|
||
the states the per-state whitelist (the "Accepted when" column below, and
|
||
`protocol.py` `MESSAGE_WHITELIST`, the single source) allows. A message
|
||
outside its state is `wrong_state`.
|
||
- Per-connection rate limit: `max_inputs_per_s` (default 40;
|
||
`UNDERCOOKED_MAX_INPUTS_PER_S`) via a token bucket — over it is
|
||
`rate_limited` (task 02). One socket per token: a second `hello` with the
|
||
same token evicts the first (`evicted`, close `4403`, task 02).
|
||
|
||
## Versioning policy
|
||
|
||
- `p` is a single integer. The server accepts
|
||
`[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` (today `1..1`); outside it the
|
||
handshake is refused with `protocol_mismatch` and close code `4400`.
|
||
- **Field changes bump `PROTOCOL_VERSION`.** Because unknown fields are a
|
||
rejection, *any* change to a message shape — added, removed, renamed,
|
||
re-typed — is a breaking change: bump `PROTOCOL_VERSION` (and
|
||
`MIN_SUPPORTED_VERSION` only if older clients must keep working), update
|
||
this file, and update the client in the same task (AGENTS.md "Protocol
|
||
changes need a version bump").
|
||
- **Patch-compatible** (no bump): new *optional, additive* server→client
|
||
fields that a client may ignore, and new error-code *usages* of existing
|
||
codes. The client ignores unknown server fields; the server rejects unknown
|
||
client fields.
|
||
- A `hello` carrying an unsupported `p` is refused before anything else
|
||
happens on the socket — no seat is taken, no `welcome` is sent.
|
||
|
||
## Sequence: handshake and a full round
|
||
|
||
```text
|
||
browser A (host) server (one room) browser B
|
||
│ POST /api/rooms {name, token?} │
|
||
│───────────────────────────────────────▶ │
|
||
│ ◀── 201 {room_code, you:{token, seat, host}} ── │
|
||
│ │
|
||
│ WS /ws/{room_code} │
|
||
│───────────────────────────────────────▶ accept │
|
||
│ {"type":"hello","p":1,"token":…,"name":…} │
|
||
│───────────────────────────────────────▶ seat A (0, host) │
|
||
│ ◀── {"type":"welcome","you",…,"seats":[A]} ── │
|
||
│ ◀── {"type":"lobby",…} ── │
|
||
│ │ WS /ws/{room_code}
|
||
│ ◀─────────────────── B connects
|
||
│ ◀── {"type":"lobby",…} (re-broadcast) ────────────────────────▶ B
|
||
│ │ {"type":"hello","p":1,"token":B}
|
||
│ │ ◀── welcome + lobby
|
||
│ {"type":"ready"} (B) │
|
||
│ ◀───────────────────
|
||
│ ◀── {"type":"lobby",… ready:true …} ─────────────────────────▶ B
|
||
│ {"type":"start"} (A, host) │
|
||
│───────────────────────────────────────▶ COUNTDOWN (3 s) │
|
||
│ ◀── {"type":"countdown","n":3} 2 1 ────────────────────────▶ B
|
||
│ PLAYING (20 Hz) │
|
||
│ {"type":"input","seq":1,"move","act","face"} │
|
||
│───────────────────────────────────────▶ tick, snapshot out │
|
||
│ ◀── {"type":"snap","raw":{…}} (SNAPSHOT_HZ) ────────────────▶ B
|
||
│ ◀── {"type":"ev","events":[…]} ───────────────────────────────▶ B
|
||
│ {"type":"ping","t":…} │
|
||
│───────────────────────────────────────▶ │
|
||
│ ◀── {"type":"pong","t":…,"now":…} ───────────────────────────▶ B
|
||
│ verdict + 1 s settle│
|
||
│ ◀── {"type":"round","result","score",…} ─────────────────────▶ B
|
||
│ {"type":"next_level"} / {"type":"to_lobby"} │
|
||
│───────────────────────────────────────▶ COUNTDOWN / LOBBY │
|
||
```
|
||
|
||
## Client → Server messages
|
||
|
||
| `type` | Model | Payload | Accepted when |
|
||
|---|---|---|---|
|
||
| `hello` | `HelloMsg` | `{p, token?, name?}` | always (handshake / rejoin) |
|
||
| `ready` | `ReadyMsg` | `{}` | `LOBBY` |
|
||
| `set_level` | `SetLevelMsg` | `{level}` | `LOBBY` (host only; level unlocked) |
|
||
| `start` | `StartMsg` | `{}` | `LOBBY` (host only) |
|
||
| `input` | `InputMsg` | `{seq, move, act, face}` | `PLAYING` |
|
||
| `emote` | `EmoteMsg` | `{id}` | `LOBBY`, `PLAYING` |
|
||
| `to_lobby` | `ToLobbyMsg` | `{}` | `ROUND_END` |
|
||
| `next_level` | `NextLevelMsg` | `{}` | `ROUND_END` |
|
||
| `ping` | `PingMsg` | `{t}` | always |
|
||
|
||
### HelloMsg
|
||
|
||
The handshake: identity (or anonymous → the server mints a token) and the
|
||
protocol version. `name` is sanitized server-side (control/format characters
|
||
stripped, whitespace collapsed, capped at 16) and is non-unique and
|
||
non-authoritative.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"hello"` | discriminator |
|
||
| `p` | int | protocol version; outside `[1, 1]` → `protocol_mismatch`, close `4400` |
|
||
| `token` | str \| null | 1–128 chars; the session token (PLAN A16), null = mint one |
|
||
| `name` | str \| null | 0–64 chars pre-sanitize; the display name |
|
||
|
||
### ReadyMsg
|
||
|
||
The ready-up (US-03): flips the caller's seat `ready` on (a toggle — sending
|
||
again flips it off; the `lobby` frame carries the flag).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"ready"` | discriminator |
|
||
|
||
### SetLevelMsg
|
||
|
||
The host's level pick (PLAN §6 "limited to unlocked"). The id must be in the
|
||
room's campaign (`bad_field` if not) and unlocked by the host's session
|
||
(`level_locked` if not).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"set_level"` | discriminator |
|
||
| `level` | str | 1–64 chars; a campaign level id (e.g. `01_first_kitchen`) |
|
||
|
||
### StartMsg
|
||
|
||
The host's *Start* (US-03). Preconditions (a selected level; a solo host, or ≥ 1
|
||
ready non-host) are the room's — a refusal is an `err` whose `msg` names the
|
||
reason (`no_level` / `no_ready_players`).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"start"` | discriminator |
|
||
|
||
### InputMsg
|
||
|
||
One frame of intent, sent on change plus a keep-alive at ≥ 5 Hz (PLAN §4).
|
||
The sim clamps the magnitude again (`movement.intent_direction()`); the wire
|
||
rejection is the early, explicit half of that rule. `seq` semantics (drop/resync, gaps) are the "Input rules" section
|
||
below.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"input"` | discriminator |
|
||
| `seq` | int | ≥ 0; the client's monotonically increasing counter |
|
||
| `move` | object | `Vec2Msg` — an intent direction, magnitude ≤ 1 |
|
||
| `act` | bool | the primary action (use/pick/drop) press this frame |
|
||
| `face` | `"up"` \| `"right"` \| `"down"` \| `"left"` \| null | explicit facing; null = derive from `move` |
|
||
|
||
### EmoteMsg
|
||
|
||
An emote (PLAN §7: no chat in v1, emote whitelist). `id` must be in
|
||
`seats.EMOTES` — `wave`, `cheer`, `thumbs_up`, `thumbs_down`, `angry`,
|
||
`star` — checked server-side (an unknown id is `bad_field` with the whitelist
|
||
in the message), throttled to 2 per 1.0 s per connection, and re-broadcast to
|
||
everyone as an `ev` event.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"emote"` | discriminator |
|
||
| `id` | str | 1–32 chars; one of the six whitelisted emotes |
|
||
|
||
### ToLobbyMsg
|
||
|
||
Return to the lobby after a round (PLAN §6): ready flags reset, waiting
|
||
spectators are promoted into free seats.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"to_lobby"` | discriminator |
|
||
|
||
### NextLevelMsg
|
||
|
||
Start the next campaign level after a round (PLAN §6 "next-level"). The level
|
||
id is resolved server-side from the viewer's session (`Room.next_level_id`):
|
||
after a win it is `order + 1`'s level, after a loss the level just played. A
|
||
"retry" is a `next_level` whose resolution is the level just played — the
|
||
message carries no payload by design. The host's *pick* in the lobby is
|
||
`set_level`.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"next_level"` | discriminator |
|
||
|
||
### PingMsg
|
||
|
||
Keep-alive / RTT probe; answered with `pong`.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"ping"` | discriminator |
|
||
| `t` | int | ≥ 0; the client's timestamp, echoed back in `pong` |
|
||
|
||
## Server → Client messages
|
||
|
||
| `type` | Model | When |
|
||
|---|---|---|
|
||
| `welcome` | `WelcomeMsg` | after `hello` |
|
||
| `lobby` | `LobbyMsg` | on every lobby-state change (and once in `welcome`'s wake) |
|
||
| `countdown` | `CountdownMsg` | `COUNTDOWN`, one per whole second (3, 2, 1) |
|
||
| `snap` | `SnapMsg` | `PLAYING`, at `SNAPSHOT_HZ` (players) / half (spectators) |
|
||
| `round` | `RoundMsg` | `ROUND_END` (after the 1 s settle) |
|
||
| `ev` | `EvMsg` | any — the FX/log batch for the boundary |
|
||
| `err` | `ErrMsg` | on rejection |
|
||
| `pong` | `PongMsg` | after `ping` |
|
||
|
||
### WelcomeMsg
|
||
|
||
The handshake answer: who you are in this room, which room, its state, the
|
||
selected level, the seat list.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"welcome"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `you` | object | `YouWire` — your own seat |
|
||
| `room` | str | the room code |
|
||
| `state` | str | the room state (`lobby`/`countdown`/`playing`/`round_end`/`closed`) |
|
||
| `level` | str \| null | the selected level id, or null for none |
|
||
| `seats` | object[] | `SeatWire` per occupied seat, in seat order |
|
||
| `rejoined` | bool | `true` when this `hello` reclaims a *disconnected* seat (the rejoin inside the grace, task 05); a fresh join and a duplicate `hello` on a live socket are `false` |
|
||
|
||
### LobbyMsg
|
||
|
||
The lobby state (the body `Room.lobby_payload` builds, phase 05): sent on every
|
||
change and once right after `welcome`, so a joiner has the full picture.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"lobby"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `code` | str | the room code |
|
||
| `state` | str | the room state |
|
||
| `level` | str \| null | the selected level id |
|
||
| `host_seat` | int \| null | the crown holder's seat index, or null |
|
||
| `can_start` | bool | whether a start pressed now would be accepted |
|
||
| `seats` | object[] | `SeatWire` per occupied seat, in seat order |
|
||
| `spectators` | object[] | `SpectatorWire` per watching token, join order |
|
||
| `unlocked` | int \| null | the *viewer's* `unlocked_level` (null for an anonymous viewer) |
|
||
| `round_no` | int | how many rounds this room has started |
|
||
|
||
### CountdownMsg
|
||
|
||
One per whole second of the 3 s countdown: `n` = 3, then 2, then 1.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"countdown"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `n` | int | whole seconds left (3 → 2 → 1) |
|
||
|
||
### SnapMsg
|
||
|
||
A snapshot frame (PLAN §4): encoder-owned — the model wraps the
|
||
compacted, quantized snapshot in `raw`. Note the two `p`s: **this**
|
||
message's `p` is the protocol version; `raw`'s `p` field is its *players*
|
||
array, nested one level down. The shape, the delta mode, the pacing and
|
||
the size budgets are the "The `snap` frame" section below.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"snap"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `raw` | object | the snapshot body — see "The `snap` frame" below |
|
||
|
||
### RoundMsg
|
||
|
||
The verdict after the 1 s settle: `sim/round_state.py` `result_payload`'s exact
|
||
keys, plus `unlocked`. `players` is per-seat contribution, display only
|
||
(PLAN §5 — never competitive gatekeeping).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"round"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `result` | `"won"` \| `"lost"` \| `"aborted"` \| null | the verdict (null before the verdict) |
|
||
| `score` | int | the round's final score |
|
||
| `target` | int | the level's `target_score` |
|
||
| `stars` | int | 0–3, from the remaining clock share (PLAN §5) |
|
||
| `time_left` | float | seconds, frozen at the verdict |
|
||
| `counters` | object | `served` / `missed` / `wasted` counts (0 where never written) |
|
||
| `longest_streak` | int | the round's high-water mark of consecutive serves |
|
||
| `players` | object[] | `PlayerScoreWire` per seat, in seat order |
|
||
| `level_id` | str | the level that was played |
|
||
| `seed` | int | the round's seed (replayable) |
|
||
| `ticks` | int | how many sim ticks the round ran |
|
||
| `unlocked` | str \| null | the viewer session's next level id (null when none/unknown) |
|
||
|
||
### EvMsg
|
||
|
||
A batch of transient events for the boundary — `chopped`, `burnt`,
|
||
`served`, an emote, a `denied` nudge, an `err` note, … FX/log only,
|
||
never game truth. The batch caps at 24 events per frame
|
||
(`MAX_EVENTS_PER_FRAME`); the overflow counts in
|
||
`Room.stats()["events_dropped"]`. The events' short-keyed payload map is
|
||
the "Events onto frames" subsection of "The `snap` frame" below.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"ev"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `events` | object[] | `EventWire` per event, in occurrence order |
|
||
|
||
### ErrMsg
|
||
|
||
A rejection. `code` is a stable `ErrorCode` value (see "Error codes"); `msg` is
|
||
human-readable; `seq` — when the rejection refers to an `input` frame — is
|
||
that frame's `seq`, which is what the client resyncs off (task 03).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"err"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `code` | str | one of the `ErrorCode` values |
|
||
| `msg` | str | human-readable explanation |
|
||
| `seq` | int \| null | the offending `input` frame's `seq`, when applicable |
|
||
|
||
### PongMsg
|
||
|
||
The answer to a `ping`: `t` echoed, `now` is the server's current time in
|
||
seconds (the room's injected `Clock`) — the RTT is `now - t` (plus the
|
||
one-way path, which a client estimates from the delta).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `type` | `"pong"` | discriminator |
|
||
| `p` | int | the server's protocol version |
|
||
| `t` | int | the client's `ping` timestamp, echoed |
|
||
| `now` | float | the server's current time, seconds |
|
||
|
||
## Wire objects
|
||
|
||
### Vec2Msg
|
||
|
||
A movement intent (the `input` `move`). Both coordinates finite, magnitude
|
||
`hypot(x, y)` ≤ 1 — an intent direction, not a speed: the sim clamps the same
|
||
way (`movement.intent_direction()`), so a client sending `(50, 0)` is a
|
||
`bad_field` on the wire and walks at `MAX_SPEED` at worst in the sim.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `x` | float | finite, with `y` magnitude ≤ 1 |
|
||
| `y` | float | finite, with `x` magnitude ≤ 1 |
|
||
|
||
### YouWire
|
||
|
||
The viewer's own seat (as `welcome`'s `you`): the token is the viewer's own,
|
||
echoed; it never appears anywhere else.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `token` | str | the viewer's session token (echoed once) |
|
||
| `seat` | int | the viewer's seat index |
|
||
| `host` | bool | whether the viewer's seat holds the crown |
|
||
|
||
### SeatWire
|
||
|
||
One seat as the lobby shows it — no token, ever (PLAN §11).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `index` | int | 0-based seat index |
|
||
| `name` | str | the sanitized display name |
|
||
| `color` | str | the palette colour (one of the four) |
|
||
| `ready` | bool | the ready-up flag |
|
||
| `host` | bool | the crown flag |
|
||
| `connected` | bool | a dropped seat stays, `connected: false` |
|
||
|
||
### SpectatorWire
|
||
|
||
A watching token (PLAN §6 "SPECTATING"): no seat, not in the sim.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `name` | str | the sanitized display name |
|
||
| `color` | str | the palette colour |
|
||
|
||
### EventWire
|
||
|
||
One transient event (`sim/model.py` `Event`, projected): the `EventKind` value
|
||
by its short name, its position in tile units, and the payload (short keys —
|
||
e.g. a `served` event carries the order id as `i`).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `kind` | str | the `EventKind` value (e.g. `served`, `burnt`, `emote`, `err`) |
|
||
| `x` | float | tile units (0 for HUD-level events) |
|
||
| `y` | float | tile units (0 for HUD-level events) |
|
||
| `payload` | object | short-keyed extras (e.g. `{"i": 17}`) |
|
||
|
||
### PlayerScoreWire
|
||
|
||
One seat's contribution for the result screen (display only).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | str | the seat's sim player id |
|
||
| `name` | str | the seat's display name |
|
||
| `score` | int | this seat's contribution to `score` |
|
||
| `dishes_served` | int | how many of the served dishes this seat made |
|
||
| `missed_contrib` | int | how many missed orders count against this seat |
|
||
|
||
## The `snap` frame (encoder-owned)
|
||
|
||
The `raw` body of a `snap` message, built by `server/encoding.py` (an
|
||
explicit field map per type — no `pickle`, no `dataclasses.asdict`). The
|
||
projection is quantized: positions to 2 decimals (`Vec2.quantized(2)`),
|
||
progress/quality/life to 2 decimals, enums to short strings. A frame is a
|
||
projection of one `KitchenState` — the same state always encodes to the
|
||
same frame (a replay's frames are replayable too), and it is the single
|
||
source of truth for the client's interpolation.
|
||
|
||
**Nothing internal leaves the server.** A frame carries no `token` field,
|
||
no `display_name`, no round `seed`, no `rng_state`, and no station
|
||
internal `id` — a station's wire identity is its kind plus coordinates
|
||
(`k` + `x`/`y`), and an event's payload reaches the wire only through the
|
||
explicit short-key map below (a key that is not listed there never
|
||
leaves). `tests/unit/test_encoding_no_leak.py` checks this recursively on
|
||
full and delta frames alike; `snapshot_size(frame)` (the UTF-8 length of
|
||
the frame's JSON) is the budget hook.
|
||
|
||
#### The shape (full frame)
|
||
|
||
| `raw` key | Type | Meaning |
|
||
|---|---|---|
|
||
| `t` | int | the sim tick number |
|
||
| `s` | int | the score |
|
||
| `tgt` | int | the target score |
|
||
| `tl` | float | time left, seconds (2 decimals) |
|
||
| `full` | bool | `true` in a full frame, `false` in a delta (below) |
|
||
| `p` | object[] | players: `i` (the sim player id), `x`/`y` (tile units, 2 decimals), `f` (facing 0–3: up/right/down/left), `h` (the carried item's short form, e.g. `tomato_chopped`, or null), `s` (state: `idle`/`moving`/`stunned`/`working`), `pi` (seat index) |
|
||
| `st` | object[] | stations: `k` (kind, a short string: `spawn_shelf`/`chop_board`/`stove`/`prep`/`serve`/`sink`), `x`/`y` (tile units), `g` (the first content's short form, e.g. `onion_raw`, or null), `q` (the first content's quality 0–1, 2 decimals; 1.0 for an empty station), `p` (progress 0–1, 2 decimals), `s` (state: `idle`/`working`/`ready`/`burning`/`dirty`/`blocked`) |
|
||
| `o` | object[] | orders: `id` (int), `r` (recipe id), `l` (deadline share 0–1, 2 decimals — the order's `time_left` over its deadline window), `pts` (the order value) |
|
||
| `hz` | object[] | hazards: `t` (kind: `fire`/`spill`/`moving_obstacle`/`rush`), `x`/`y` (tile units), `l` (life — seconds remaining, 2 decimals; 0.0 = permanent until removed) |
|
||
| `e` | object[] | the events that rode this boundary (the compact event shape, below) |
|
||
|
||
A real captured frame (level 1, the first `PLAYING` boundary, two seats —
|
||
`tests/integration/test_encoding.py` produces the same shape and asserts
|
||
its keysets): a new connection's first frame is always a full one.
|
||
|
||
```json
|
||
{
|
||
"t": 0, "s": 0, "tgt": 45, "tl": 150.0, "full": true,
|
||
"p": [
|
||
{"i": "tok-a", "x": 2.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 0},
|
||
{"i": "tok-b", "x": 9.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 1}
|
||
],
|
||
"st": [
|
||
{"k": "spawn_shelf", "x": 1.0, "y": 1.0, "g": "tomato_raw", "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "chop_board", "x": 4.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "stove", "x": 8.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "prep", "x": 2.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "serve", "x": 9.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "sink", "x": 3.0, "y": 5.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"},
|
||
{"k": "spawn_shelf", "x": 8.0, "y": 5.0, "g": "onion_raw", "q": 1.0, "p": 0.0, "s": "idle"}
|
||
],
|
||
"o": [], "hz": [], "e": []
|
||
}
|
||
```
|
||
|
||
#### Delta frames
|
||
|
||
The stream runs in delta mode: a frame encoded against the *previous*
|
||
frame (which may itself be a delta) is `"full": false` and carries its
|
||
entities under `d` — `d.p`, `d.st`, `d.o`, `d.hz` — **only the changed
|
||
entities** (new, moved, or state-changed since `prev`; an entity that
|
||
moved back to its previous values is in, one unchanged is out; the
|
||
diff is a whole entry, so a changed entry carries all its values),
|
||
plus a `{"gone": true}` **tombstone** for each entity that vanished
|
||
(an order served or missed, a hazard extinguished — a tombstone carries
|
||
the entity's identity keys so the client can drop it). The scalars
|
||
(`t`, `s`, `tgt`, `tl`) and the tick's `e` ride every frame, full or
|
||
delta. A delta of an idle kitchen shrinks a typical frame by ≥ 40%;
|
||
the client merges by **upserting on the identity key and dropping the
|
||
tombstones** (the identity keys: `p` by `i`, `st` by `k`+`x`+`y`, `o`
|
||
by `id`, `hz` by `t`+`x`+`y`) — applying a delta chain to the last full
|
||
lands on the server's own re-encode (the decode-equivalence the test
|
||
suite checks).
|
||
|
||
The stream keeps the client resyncable: a fresh connection's first frame
|
||
is a full one, a **dropped snapshot** (the send queue's drop policy)
|
||
marks the connection `needs_full` so its next frame is a full one again,
|
||
and the **full cadence** — a full frame every 20 encoded frames
|
||
(`FULL_SNAPSHOT_EVERY_TICKS = 20`; the fulls land at passes 1, 21, 41, …
|
||
per audience) — means a client can always resync cheaply.
|
||
|
||
#### Pacing (encode-once, fan-out-many)
|
||
|
||
The room paces an encode pass every `tick_hz / snapshot_hz` ticks for
|
||
the player audience and every `2 ×` that for spectators (the default
|
||
20 Hz tick / 20 Hz snapshot: a player pass on every tick, a spectator
|
||
pass on every second one). One pass encodes **once** and reuses the
|
||
frame across every connection of that audience (encode-once,
|
||
fan-out-many); a pass with no audience encodes nothing. `snap` frames
|
||
arrive at `SNAPSHOT_HZ` for players and half that for spectators; the
|
||
`countdown` and `round` state frames reach *every* connection in the
|
||
room. `UNDERCOOKED_SIM_LATENCY_MS` (default 0) applies a server-side
|
||
send delay — the pump sleeps that long per batch — so netcode tests
|
||
simulate RTT without a proxy; with 150, an input→frame round trip is
|
||
lower-bounded by 150 ms (the integration suite measures it).
|
||
|
||
#### Events onto frames (`e`) and the `ev` batch
|
||
|
||
An event rides the frame of the boundary it occurs in. The compact `e[]`
|
||
entry (the snapshot's shape) is `{"k": <the stable `EventKind` name>,
|
||
"x"/"y" (tile units, 0 for HUD-level events), …the payload's short
|
||
keys flattened…}`; the standalone `ev` message (a non-`PLAYING`
|
||
boundary — a lobby emote, a `resync` nudge, the round-end verdict's
|
||
`round_end` event — plus every state change outside `PLAYING`) carries
|
||
the same events in the `EventWire` shape (`kind`, `x`, `y`, `payload`
|
||
with the short keys nested). Events are **capped at 24 per frame**
|
||
(`MAX_EVENTS_PER_FRAME`); the overflow is not spread over the next
|
||
frame — it is counted in `Room.stats()["events_dropped"]`.
|
||
|
||
The payload keys reach the wire only through this explicit map
|
||
(`EVENT_PAYLOAD_SHORT` in `encoding.py`) — anything unlisted (a station's
|
||
internal id, the per-event `tick` — the frame's `t` is the tick) never
|
||
leaves the server:
|
||
|
||
| payload key | short | | payload key | short |
|
||
|---|---|---|---|---|
|
||
| `player_id` | `i` | | `item` | `it` |
|
||
| `to` | `to` | | `form` | `fm` |
|
||
| `order_id` | `oi` | | `score_delta` | `sd` |
|
||
| `id` | `oi` | | `stun` | `stn` |
|
||
| `points` | `pts` | | `emote` | `em` |
|
||
| `quality` | `q` | | `result` | `res` |
|
||
| `streak` | `stk` | | `score` | `sc` |
|
||
| `delta` | `d` | | `target` | `tgt` |
|
||
| `reason` | `r` | | `stars` | `stars` |
|
||
| `verb` | `v` | | `seq` | `seq` |
|
||
|
||
So a `served` event is `{"k": "served", "oi": 17, "pts": 40, …}` on the
|
||
snapshot and `{"kind": "served", "payload": {"oi": 17, "pts": 40, …}}`
|
||
in an `ev` batch; the `resync` nudge of the "Input rules" section is
|
||
`{"k": "resync", "i": <player_id>, "seq": <n>}` attached (or the
|
||
`EventWire` shape in an `ev` flush).
|
||
|
||
#### Size budgets
|
||
|
||
A 4-player / 4-order / 3-hazard frame is **≤ 1.5 KB**; a 64×64 level's
|
||
frame is **≤ 4 KB** (the grid, the station ids and the spawn coordinates
|
||
never leave the server — a large level encodes to the same handful of
|
||
stations as a small one). The 100-tick downlink stays **≤ 60 KB/s** per
|
||
player. The integration suite measures all three over live sockets.
|
||
|
||
## Error codes
|
||
|
||
`err.code` values (the `ErrorCode` constants in `protocol.py`); the
|
||
`tests/unit/test_protocol_doc_sync.py` guard keeps this table equal to the
|
||
code.
|
||
|
||
| Code | Meaning |
|
||
|---|---|
|
||
| `unknown_type` | the `type` discriminator is not a known client message type |
|
||
| `bad_json` | the frame is not valid JSON |
|
||
| `bad_field` | shape/field validation failed (wrong type, missing/extra field, non-finite or magnitude > 1 move, negative/non-int `seq`, non-bool `act`, unknown `face`, unknown emote id, out-of-range token/name/level length) |
|
||
| `too_big` | the frame exceeded `max_message_bytes` (task 02) |
|
||
| `rate_limited` | the per-connection token bucket is empty (task 02) |
|
||
| `not_seated` | a verb from a token that holds no seat in this room (a spectator, or none) |
|
||
| `not_host` | `start` / `set_level` from a non-host |
|
||
| `level_locked` | `set_level` on a level not unlocked by the session |
|
||
| `wrong_state` | a message not in `MESSAGE_WHITELIST` for the room's current state |
|
||
| `room_full` | joining a full room (or the process is at `max_rooms`) — close `4402` |
|
||
| `room_gone` | unknown code, or the room is closed/gone — close `4401` |
|
||
| `protocol_mismatch` | `hello` `p` outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` — close `4400` |
|
||
| `evicted` | a second socket for the same token replaced this one — close `4403` |
|
||
| `internal` | a server-side failure (a tick crash aborts the room; the players are told, the rest of the server is unaffected) |
|
||
|
||
## Close codes
|
||
|
||
WebSocket close codes (the `CloseCode` constants in `protocol.py`):
|
||
|
||
| Code | Name | Meaning |
|
||
|---|---|---|
|
||
| `1000` | `normal` | clean close |
|
||
| `4400` | `protocol_mismatch` | the `hello` `p` is outside the supported range (also: no `hello` within the 5 s handshake window — task 02) |
|
||
| `4401` | `room_gone` | the code is unknown, or the room is closed/gone |
|
||
| `4402` | `room_full` | the room (or the process) has no free seat |
|
||
| `4403` | `evicted` | a second socket for the same token replaced this one |
|
||
| `4404` | `abuse` | repeated oversize / rate violations (task 02) |
|
||
|
||
## Limits
|
||
|
||
| Limit | Default | Env var | What it caps |
|
||
|---|---|---|---|
|
||
| `max_message_bytes` | 4096 | `UNDERCOOKED_MAX_MESSAGE_BYTES` | one inbound frame (→ `too_big`) |
|
||
| `max_inputs_per_s` | 40 | `UNDERCOOKED_MAX_INPUTS_PER_S` | the per-connection inbound rate (→ `rate_limited`) |
|
||
| `tick_hz` | 20 | `UNDERCOOKED_TICK_HZ` | the sim rate (5–60) |
|
||
| `snapshot_hz` | 20 | `UNDERCOOKED_SNAPSHOT_HZ` | the `snap` rate to players (≤ `tick_hz`; spectators get half) |
|
||
| `sim_latency_ms` | 0 | `UNDERCOOKED_SIM_LATENCY_MS` | the injected server-side send delay per pump batch (netcode RTT simulation; 0 = never sleeps) |
|
||
| `max_players` | 4 | `UNDERCOOKED_MAX_PLAYERS` | seats per room (→ `room_full`) |
|
||
| name length | 16 | — | after `sanitize_name` (raw `hello` `name` ≤ 64 pre-sanitize) |
|
||
| token length | 128 | — | `hello` `token` |
|
||
| emote throttle | 2 / 1.0 s | — | per connection |
|
||
| handshake window | 5 s | — | no `hello` within it → close `4400` (task 02) |
|
||
| heartbeat dead window | 12 s | — | no inbound traffic → the server closes `1000` and runs the disconnect teardown (task 05) |
|
||
| seat grace (guest idle) | 45 s | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | a disconnected seat is kept, then `SeatAbandoned` (task 05) |
|
||
|
||
## Manual probe (websocat equivalent)
|
||
|
||
The handshake, the happy path and the refusal closes can be reproduced
|
||
without a browser — and without `websocat` (an external tool the project
|
||
does not ship) — with `curl` (the pre-socket lifecycle) plus any
|
||
WebSocket client; the project's own `websockets` dependency is the
|
||
docstring's probe. Against a running `uv run undercooked`
|
||
(`http://127.0.0.1:8000`):
|
||
|
||
```bash
|
||
uv run undercooked
|
||
# in a second shell: mint a room, then read its census
|
||
# (the `sockets` counter is the live `/ws/{code}` census)
|
||
curl -s -X POST http://127.0.0.1:8000/api/rooms -H 'content-type: application/json' -d '{}'
|
||
curl -s http://127.0.0.1:8000/api/stats
|
||
```
|
||
|
||
Then the socket half (replace `CODE` with the `room_code` curl printed;
|
||
`token` may be omitted — the server mints one and echoes it in the
|
||
`welcome`):
|
||
|
||
```python
|
||
import json
|
||
import websockets
|
||
|
||
async def main() -> None:
|
||
ws = await websockets.connect("ws://127.0.0.1:8000/ws/CODE")
|
||
await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-probe"}))
|
||
print(json.loads(await ws.recv())) # the `welcome`
|
||
print(json.loads(await ws.recv())) # the current `lobby`
|
||
await ws.send(json.dumps({"type": "ready"}))
|
||
print(json.loads(await ws.recv())) # the `lobby` re-broadcast, `ready: true`
|
||
await ws.send(json.dumps({"type": "ping", "t": 1}))
|
||
print(json.loads(await ws.recv())) # the `pong`
|
||
await ws.close()
|
||
|
||
import asyncio
|
||
|
||
asyncio.run(main())
|
||
```
|
||
|
||
The refusal closes, observed from the client's close code:
|
||
|
||
| What you do | The `err` you get | The close code |
|
||
|---|---|---|
|
||
| connect `/ws/ZZZZZ` (no such room) | `room_gone` | `4401` |
|
||
| `hello` with `p: 99` | `protocol_mismatch` | `4400` |
|
||
| connect, then send nothing for `hello_timeout_s` | — | `4400` |
|
||
| join a full room with a fresh token | `room_full` | `4402` |
|
||
| a second socket, same token | `evicted` (the first socket) | `4403` |
|
||
| 3 consecutive oversized / rate-limited frames | `too_big` / `rate_limited` | `4404` |
|
||
| an `Origin` outside `allowed_origins` (non-empty) | — (refused before the accept) | HTTP `403` |
|
||
|
||
An *abrupt* drop (the client's transport vanishes without a close
|
||
handshake — e.g. `ws.transport.close()` in the probe client) is not an
|
||
error: the room sees exactly one disconnect, keeps the seat for the
|
||
rejoin path, and the `sockets` census in `/api/stats` drops by one.
|
||
|
||
## Input rules
|
||
|
||
`input` is the only client message that reaches the simulation. A frame
|
||
passes four layers before its intent is in the per-tick queue — the
|
||
per-state whitelist (`input` is `PLAYING`-only), the seat check, the
|
||
`seq` discipline, and the clamp — and the room's input counters
|
||
(`stats()`) record what each layer did. The wire's `Vec2Msg` rejection
|
||
(magnitude `> 1` → `bad_field`) is the early, explicit half of the
|
||
clamp; the layers below are the ones a frame that survives it still
|
||
traverses.
|
||
|
||
**The `seq` discipline** (per connection, against `last_seq`, which
|
||
starts at `0` — the client's first frame must therefore be `seq ≥ 1`):
|
||
|
||
- `seq == last_seq` — an **idempotent resend**: silently dropped (no
|
||
error, no stat; the frame's effect already landed, and a client that
|
||
retransmits over a flaky connection is not at fault).
|
||
- `seq < last_seq` — a **replay**: `err bad_field` *carrying the frame's
|
||
`seq`*, plus a `resync` event (below). Counted in `stale_seqs`;
|
||
`last_seq` is never lowered — a late duplicate of an earlier frame
|
||
cannot un-accept what came after.
|
||
- `seq > last_seq` — **accepted**, and `last_seq` advances to it. A
|
||
jump of **≤ 3** ahead is tolerated silently (UDP-ish loss: a snapshot
|
||
boundary can swallow up to three ticks' worth of inputs without the
|
||
round noticing); a larger jump is accepted **and counted** in
|
||
`input_gaps` (a reconnect that re-sent from a stale counter is a
|
||
condition worth seeing in the room's stats, not a refusal — the
|
||
`resync` path is what corrects a client that goes backwards).
|
||
|
||
**The `resync` contract.** The `resync` event — `{"k": "resync",
|
||
"i": <player_id>, "seq": <n>}` when it rides a `snap`'s `e` (a `PLAYING`
|
||
boundary), the `EventWire` shape in an `ev` flush otherwise — is the
|
||
server's way of saying "your counter has left mine": on it, the client
|
||
**hard-snapshots** — it discards its prediction buffer, adopts the latest
|
||
`snap` as ground truth, and resumes `seq` from the `err`'s `seq` + 1.
|
||
The `err` frame's `seq` field is what the client resyncs *off*; the
|
||
event is what tells it *to*.
|
||
|
||
**Clamping and quantization** (`to_player_input`, the wire layer's
|
||
second half of the magnitude rule). An accepted frame's `move` is
|
||
clamped to magnitude `≤ 1` (a `(1000, 1000)` intent becomes a unit
|
||
diagonal — the sim's `movement.intent_direction()` is the backstop
|
||
behind this one) and quantized to 3 decimals (a replay must reproduce
|
||
a frame's effect; past 3 decimals a float is client noise, not
|
||
intent). `face` must be a known value (`"up"/"right"/"down"/"left"` —
|
||
an unknown one is `bad_field` at the wire); when it is `null` it is
|
||
**derived from `move`** — the dominant axis wins (a perfect 45° press
|
||
resolves to the horizontal axis, the sim's `update_facing` rule); a
|
||
zero `move` derives `null` ("hold"). `act` is coerced to `bool`.
|
||
`received_at_tick` is the **server's own tick** — the contested
|
||
arbitration's ordering (arrival, then `seq`, then seat) never depends
|
||
on asyncio scheduling or a client's clock. A frame whose `move`
|
||
magnitude exceeded 1.0 is counted in `clamped_moves`.
|
||
|
||
**No position is ever client-sent.** There is no position field in the
|
||
protocol, and `extra="forbid"` makes an `input` carrying one a
|
||
`bad_field`, full stop (teleport is structurally impossible — the sim
|
||
integrates velocity from intents, so even an accepted `move` is capped
|
||
at `MAX_SPEED` by `movement.desired_velocity`, whatever it is).
|
||
|
||
**The per-tick queue and its stats.** The room's input queue is
|
||
per-tick (a tick consumes it, then it is empty) with a capacity of
|
||
`4 × seats` (16 at a full room). At capacity, a new frame drops the
|
||
*oldest* entry **for that player** (the newest intent wins) and is
|
||
counted in `inputs_dropped`; a full queue that holds none of the
|
||
player's entries drops the *new* frame instead (one spammer cannot
|
||
clear another player's tick). The queue never grows unbounded. A
|
||
`DISCONNECTED` seat's actions are ignored (the frame's socket is not
|
||
the one that dropped, and the sim parks the cook anyway); a spectator's
|
||
are `err not_seated`. `stats()` — `inputs_received` (frames queued),
|
||
`inputs_dropped`, `stale_seqs`, `clamped_moves`, `input_gaps` — is the
|
||
room's fixed-shape census of this discipline.
|
||
|
||
## Heartbeats, drops, and seat grace (task 05)
|
||
|
||
#### Heartbeats
|
||
|
||
The client keeps the socket honest with a `ping` at ≥ 5 Hz (any inbound
|
||
frame refreshes the server's `last_seen`; the `ping` is the documented
|
||
keep-alive, answered `pong{t, now}`). The server watches the silence: no
|
||
inbound traffic for **`HEARTBEAT_DEAD_S` = 12 s** (a constant, not a
|
||
setting — 12 s of silence is dead on any honest client) and the server
|
||
closes the socket with **`1000`** and runs the standard disconnect
|
||
teardown. The room sees exactly the same drop as an abrupt close (one
|
||
`mark_disconnected`, one `GHOST_ON`) — this is what catches a
|
||
*half-open TCP*, where the read side would wait forever.
|
||
|
||
#### A drop: `on_disconnect`
|
||
|
||
A closed or dead socket (an abrupt close, the heartbeat timeout, or the
|
||
test seam) marks the seat `connected = false` — **kept** for the rejoin
|
||
path — emits one `ghost_on` event (at the cook's position, payload `i`
|
||
= the player id), switches the sim's cook (`connected = false`, state
|
||
`disconnected`), and applies the host-crown rule (a dropped host is a
|
||
host *leave*).
|
||
|
||
**The park (after 3 s).** After **`PARK_AFTER_S` = 3.0 s** of
|
||
disconnection the cook's carried item is released — once, idempotently
|
||
(a double call cannot double-release): onto the station on the cook's
|
||
tile (a marked set-down, grabbable, `dropped` event; the hand-off
|
||
window applies, so a released item can be passed), or into
|
||
`counters["lost_items"]` (a `ghost_park` event) when no station sits
|
||
under the tile. An item is **never deleted silently**: the station or
|
||
the counter is its tombstone. A cook that carried nothing parks with no
|
||
release at all.
|
||
|
||
**`SeatAbandoned` (after the grace).** A seat disconnected for longer
|
||
than `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s) is emptied by the
|
||
room's own tick in every state: one `ghost_off`, the seat out (a host
|
||
transfer, `empty_since` for the close rules), and a *forced* release of
|
||
the cook's carried item (the seat is going away; the release must not
|
||
wait for the park deadline when the grace is shorter). The cook's sim
|
||
player stays (parked). When the room empties, it closes through the
|
||
existing rules (the 30 s seat grace in `LOBBY`/`ROUND_END`; the
|
||
supervisor's GC in any state, a 5 s pass) — its sockets get
|
||
`err room_gone` + close `4401`.
|
||
|
||
#### The rejoin
|
||
|
||
- **Inside the grace** — a `hello` with the same token reclaims the
|
||
seat: `welcome.rejoined = true`, `connected = true`, one `ghost_off`,
|
||
the sim's cook restored (`connected = true`, state `idle` — a
|
||
still-carried item is kept; an already-parked cook comes back
|
||
empty-handed), and the snapshot stream resumes on the next player pass
|
||
(within one tick of `SNAPSHOT_HZ`; a rejoining socket's first frame
|
||
is a full one, its `needs_full` resync).
|
||
- **After the grace** — the seat is gone: the spectate/join rules apply
|
||
(`PLAYING` → a spectator; `LOBBY` → a free seat). A room that closed
|
||
in the meantime answers the `hello` with `err room_gone` + close
|
||
`4401` (a stale token) before any seat is taken.
|
||
|
||
#### Ghost events
|
||
|
||
| `k` | When | Payload (short keys) |
|
||
|---|---|---|
|
||
| `ghost_on` | a socket dropped (once per drop) | `i`; at the cook's position |
|
||
| `ghost_off` | a rejoin inside the grace, or a `SeatAbandoned` (once each) | `i`; at the cook's position |
|
||
| `ghost_park` | a park release with no station under the tile | `i`, `it`, `fm`; `counters["lost_items"]` + 1 |
|
||
|
||
A park release onto a station is the plain `dropped` event, with the
|
||
set-down mark. The client's ghost label renders off the `disconnected`
|
||
player state / the seat's `connected` flag plus these events.
|
||
|
||
#### The close-code decision (what the client can tell apart)
|
||
|
||
| Situation | Code | The client does |
|
||
|---|---|---|
|
||
| a clean close — or the server closing a dead client (the heartbeat) | `1000` | treat as a *drop*: the seat is kept for the grace; reconnect with backoff (any inbound frame resets the 12 s clock) |
|
||
| a `hello` `p` unsupported, or no `hello` in time | `4400` | fix the client; do not auto-retry |
|
||
| an unknown code, or the room closed/gone (including a stale-token rejoin after the grace) | `4401` (+ `err room_gone`) | **the room died**: no auto-retry into that code |
|
||
| a full room (a new token) | `4402` | tell the player; try another room |
|
||
| evicted by a second socket for the token | `4403` | the token's seat is live on the other socket; stop this one |
|
||
| repeated oversize/rate/shape violations | `4404` | the connection was abusive; a fresh socket resets the bucket |
|
||
|
||
#### The test-only seam
|
||
|
||
`POST /api/test/kill_socket/{code}/{token}` — registered **only when
|
||
`UNDERCOOKED_TEST_MODE=1`** (never set in production): force-closes the
|
||
token's connection server-side (a hard drop for the client; the room
|
||
sees the standard `on_disconnect`), so e2e tests can simulate a dropped
|
||
socket. `404 {"code": "no_connection"}` when the room or the token
|
||
holds no live socket.
|
||
|
||
## Testing the protocol (task 06)
|
||
|
||
The contract above is driven by real sockets against a real
|
||
`uvicorn.Server` (an ephemeral `127.0.0.1` port per test — never a
|
||
routable interface) in three suites under `tests/integration/`:
|
||
|
||
| Suite | What it drives |
|
||
|---|---|
|
||
| `test_ws_round_two_clients.py` | two scripted clients play a complete round (the lobby → the ready-ups → the start → the `countdown` → the `snap` stream → inputs → the shared `served` event and the byte-identical `round` frames → the mid-round spectator → the `to_lobby` return) |
|
||
| `test_ws_abuse.py` | the guardrails: 2000 malformed frames (the `4404` close, the other connections untouched, `/healthz` still `200`), the 500-message burst (`rate_limited` → `4404`, the send-queue high-water mark within its bound), the size cap (64 KB refused `too_big` — text and binary — 4 KB boundary admitted), the `start`/`set_level`/`emote` flood (the host check and the emote throttle hold), 200 concurrent handshakes |
|
||
| `test_ws_budgets.py` (`slow`) | the performance budgets, on a live 4-player room: the tick cost over 400 real ticks, the `snap` size, the downlink rate, the input→snapshot echo |
|
||
|
||
**The test seams.** The suites reach the server's own state without a
|
||
second protocol: `app.state.supervisor` (the room's `stats()`, the seat
|
||
flags, the `mark_disconnected` verb the endpoint's `finally` runs on a
|
||
genuine drop) and `app.state.ws_registry.stats()` (the connection
|
||
census: `sockets`, the send-queue high-water mark `send_queue_max`,
|
||
the process's peak RSS `memory_mb`). The scripted client
|
||
(`tests/integration/conftest.py` `ws_client`) is a connected socket
|
||
with a background reader (a merged snapshot view: full frames reset,
|
||
delta frames patch, `e` events accumulate) and the verbs
|
||
`send_model` / `expect` / `collect`. One rule the suites follow (and
|
||
the reason a drain here is bounded by a window, not by a silence): a
|
||
live room's `snap` stream never goes quiet while the round is
|
||
`PLAYING` — a healthy server produces frames for the whole 150 s
|
||
time limit, so "wait for silence" is a wait that only a *broken*
|
||
server ends (the pre-fix tick crashed the room on its first sim
|
||
event, and the unbounded drains "passed" on that abort; the fix made
|
||
them hang, and the windows made them real).
|
||
|
||
**The budget table** (the bounds the budget suite asserts; the
|
||
*measured* column is this suite's run — the bound is the PLAN §10
|
||
number, the measurement is what a regression moves):
|
||
|
||
| Budget | Bound | Measured (this run) |
|
||
|---|---|---|
|
||
| mean tick cost, 4-player room, 400 real ticks | ≤ 2 ms | 0.53 ms (p99 1.13 ms) |
|
||
| `snap` frame size (the 4p wire shape) | ≤ 1.5 KB | 1111 B max (101 frames / 5 s window) |
|
||
| downlink rate (one client, 5 s window, steady state) | ≤ 60 KB/s | 12.3 KB/s |
|
||
| input→snapshot echo (send → the first `snap` after it) | ≤ 120 ms | 50 ms (one tick, at 20 Hz) |
|
||
| 200 concurrent handshakes — p95 | < 1 s | 164 ms (wall 0.18 s for all 200) |
|
||
| 200 concurrent handshakes — RSS growth | ≤ 128 MB | 23.4 MB |
|
||
| send-queue high-water mark (the 500-burst) | ≤ 4 × `MAX_SEND_QUEUE` | 2 |
|
||
|
||
Two measurement notes. The downlink window runs *after* a 2 s drain:
|
||
the tick-measurement phase leaves the kernel's TCP buffer holding the
|
||
window's share (the socket is unread while the ticks are measured),
|
||
and without the drain the window measures the backlog's catch-up —
|
||
a one-time burst — not the steady state a continuously-reading
|
||
client sees. And the echo's `0 ms` samples are the frame already
|
||
in the socket's buffer (sent by the tick between the send and the
|
||
read); the bound applies to the slowest of the three samples.
|