# 5. Protocol reference All messages are JSON objects with a `type` field, sent as websocket text frames. There are two protocols: - **Client protocol** — browser ↔ server on `/ws` (public URL `wss://game.marvinkrausser.com/wizard/api/ws`). Schema: `wizard_server/app/protocol.py` (Python) and `wizard_website/src/api/types.ts` (TypeScript). Keep both in sync. - **AI protocol** — AI service ↔ server on `/internal/ai`. Implemented in `wizard_server/app/ai_bridge.py` and `wizard_ai/wizard_ai/service/client.py`. Game objects inside messages (cards, actions, observations, events) use the engine's JSON format described in [the engine chapter](03-game-engine.md#json-format). --- ## Client protocol ### Browser → server | `type` | Fields | Allowed when | Effect | |--------|--------|--------------|--------| | `hello` | `token: string \| null` | always; must be the first message | starts or resumes a session | | `create_lobby` | `name` | after hello | creates a lobby and joins it as host | | `join_lobby` | `code`, `name` | after hello | joins an existing lobby | | `leave_lobby` | – | in a lobby | leaves (during a game an AI takes the seat) | | `add_ai` | – | host, lobby waiting, < 6 seats | adds an AI seat | | `remove_player` | `player_id` | host, lobby waiting | removes a seat (human or AI) | | `set_options` | `hidden_bids: bool` | host, lobby waiting | changes game options | | `start_game` | – | host, lobby waiting, 3–6 seats | starts the game | | `action` | `action: {"type":"bid","value":n}` or `{"type":"play","card":id}` | your turn in a running game | performs a move | | `return_to_lobby` | – | host, game finished | back to the waiting lobby | Validation (`protocol.py`): - `name`: whitespace stripped, 1–20 characters. - `code`: whitespace stripped, upper-cased, 4–12 characters. - Unknown `type` or wrong field types → `error` message, connection stays open. ### Server → browser #### `welcome` Sent after every `hello`. ```json {"type": "welcome", "player_id": "3f9c0a1b2d4e", "token": "Zq…", "name": "Marvin"} ``` The browser stores `token` (in `sessionStorage`) and sends it in the next `hello` to resume the same session. `player_id` identifies you in `lobby.players` and `lobby.host_id`. #### `state` Sent after `hello` and whenever anything in your lobby changes. It always contains the **complete** current state. ```json { "type": "state", "lobby": { "code": "7C7TAJ", "status": "playing", "host_id": "3f9c0a1b2d4e", "options": {"hidden_bids": false}, "players": [ {"id": "3f9c0a1b2d4e", "name": "Marvin", "is_ai": false, "connected": true}, {"id": "ai-1a2b3c4d", "name": "Merlin", "is_ai": true, "connected": true}, {"id": "ai-5e6f7a8b", "name": "Morgana", "is_ai": true, "connected": true} ], "min_players": 3, "max_players": 6, "ai_available": true }, "game": { "you": 0, "observation": { "…": "the personal Observation of seat 0" } }, "events": [ {"type": "trick_completed", "trick": {"…": "…"}} ] } ``` - `lobby: null, game: null` → you are not in a lobby (start page). - `game: null` → lobby is waiting (no game running). - `players[i]` is seat `i` in the game (`observation` uses the same indices). - `events` lists what the last action caused: `trick_completed`, `round_completed`, `round_started`, `game_finished`. Usually empty. - `ai_available` is `false` when no AI service is connected; AI seats then play random moves. #### `server_status` Sent right after `welcome` and to every connected client whenever the server's load level or capacity changes. ```json {"type": "server_status", "load": "overloaded", "accepting_games": false} ``` - `load`: `normal`, `busy` (informational) or `overloaded`. - `accepting_games`: `false` while the server refuses `create_lobby` and `start_game` (overloaded, or `MAX_ACTIVE_GAMES` games running). Everything else, including running games, keeps working. #### `error` ```json {"type": "error", "message": "Only the host can do that"} {"type": "error", "message": "The server is busy right now, …", "code": "busy"} ``` `code` is present for errors the client may react to: | `code` | Meaning | |--------|---------| | `busy` | no capacity: connection limit reached (followed by close 1013), or `create_lobby` / `start_game` refused because the server does not accept new games | | `rate_limited` | too many messages on this connection, or too many lobbies created from this IP | Sent only to the client that caused it. Typical messages: `Send 'hello' first`, `Invalid message: …` (followed by the pydantic reason), `Lobby ABC123 does not exist`, `The game in this lobby has already started`, `This lobby is full`, `Only the host can do that`, `Wizard needs 3 to 6 players`, `Illegal action Bid(value=2) for player 0`, `It is not player 1's turn`, `You were removed from the lobby`, `This session was opened in another window`, `Too many messages, slow down.`, `You are creating lobbies too quickly, please wait a moment.` ### Close codes | Code | Meaning | Frontend reaction | |------|---------|-------------------| | (handshake rejected) | origin not in `ALLOWED_ORIGINS`; the server closes with 1008 before accepting, so the browser sees a failed connection | reconnects with backoff | | 1008 | message flood (too many dropped messages) | reconnects with backoff | | 1009 | message larger than 64 KB (uvicorn `--ws-max-size`) | reconnects with backoff | | 1013 | "Try Again Later": connection limit reached (preceded by an `error` with code `busy`) | banner "The server is full", retries slowly (15 s, 30 s, 60 s …) | | 4000 | the session was opened in another tab | stops reconnecting, shows a banner | | other | network problem, server restart | reconnects (1 s, 2 s, 4 s … max 10 s) | ### Example conversation ```mermaid sequenceDiagram participant H as Host browser participant S as Server participant G as Guest browser H->>S: hello {token:null} S-->>H: welcome, state {lobby:null} H->>S: create_lobby {name:"Ann"} S-->>H: state {lobby: code "K7M2QX", players:[Ann]} G->>S: hello {token:null} S-->>G: welcome, state {lobby:null} G->>S: join_lobby {code:"k7m2qx", name:"Ben"} S-->>H: state {players:[Ann, Ben]} S-->>G: state {players:[Ann, Ben]} H->>S: add_ai S-->>H: state {players:[Ann, Ben, Merlin]} S-->>G: state {…} H->>S: start_game S-->>H: state {game:{you:0, observation{phase:"bidding", current_player:1}}} S-->>G: state {game:{you:1, …}} G->>S: action {type:"bid", value:0} S-->>H: state {…} S-->>G: state {…} ``` --- ## AI protocol The AI service connects to `ws://wizard_server:8083/internal/ai` with the header `Authorization: Bearer ` and without forwarding headers (see "Routes" in [the server chapter](04-server.md)). | Direction | Message | Meaning | |-----------|---------|---------| | AI → server | `{"type":"hello","agent":"heuristic"}` | informational, logged | | server → AI | `{"type":"decide","request_id":"","timeout":5.0,"observation":{…}}` | choose an action for `observation.player`; the server waits at most `timeout` seconds | | AI → server | `{"type":"action","request_id":"","action":{"type":"play","card":17},"agent":"heuristic"}` | the chosen action; `agent` names the agent that decided (logged) | | AI → server | `{"type":"error","request_id":"","message":"…"}` | the AI failed or refused (`overloaded`, `expired`); the server plays a random move | Rules: - Requests are independent; the AI service may answer them in any order (matched by `request_id`) and may handle several concurrently. - The answer must be one of `observation.legal_actions`; otherwise the server plays a random legal move. - No answer within `AI_DECISION_TIMEOUT` (5 s) → random move. The AI service drops requests whose `timeout` passed before it could start them (`expired`). - The server stops asking an AI connection that already has `AI_MAX_INFLIGHT` unanswered requests, and pauses the AI after repeated real failures (circuit breaker). `overloaded` / `expired` answers are load shedding, not failures, and do not trip the breaker. See "Layer 4" in [chapter 9](09-limits-and-performance.md). - Several AI service instances may connect; the server distributes requests round robin.