Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.
- Docker: CPU/memory/pids caps per container (overridable in .env);
uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
rate per connection (token bucket, close 1008 on floods), lobby
creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
throttling, with hysteresis (normal/busy/overloaded, severe lag
overloads immediately)
- Admission control: create_lobby and start_game refused while
overloaded or MAX_ACTIVE_GAMES are running; server_status message
broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
limits; docs updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.1 KiB
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 URLwss://game.marvinkrausser.com/wizard/api/ws). Schema:wizard_server/app/protocol.py(Python) andwizard_website/src/api/types.ts(TypeScript). Keep both in sync. - AI protocol — AI service ↔ server on
/internal/ai. Implemented inwizard_server/app/ai_bridge.pyandwizard_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.
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
typeor wrong field types →errormessage, connection stays open.
Server → browser
welcome
Sent after every hello.
{"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.
{
"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 seatiin the game (observationuses the same indices).eventslists what the last action caused:trick_completed,round_completed,round_started,game_finished. Usually empty.ai_availableisfalsewhen 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.
{"type": "server_status", "load": "overloaded", "accepting_games": false}
load:normal,busy(informational) oroverloaded.accepting_games:falsewhile the server refusescreate_lobbyandstart_game(overloaded, orMAX_ACTIVE_GAMESgames running). Everything else, including running games, keeps working.
error
{"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
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 <AI_SHARED_SECRET> and without forwarding
headers (see "Routes" in the server chapter).
| Direction | Message | Meaning |
|---|---|---|
| AI → server | {"type":"hello","agent":"heuristic"} |
informational, logged |
| server → AI | {"type":"decide","request_id":"<hex>","timeout":5.0,"observation":{…}} |
choose an action for observation.player; the server waits at most timeout seconds |
| AI → server | {"type":"action","request_id":"<same>","action":{"type":"play","card":17},"agent":"heuristic"} |
the chosen action; agent names the agent that decided (logged) |
| AI → server | {"type":"error","request_id":"<same>","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 whosetimeoutpassed before it could start them (expired). - The server stops asking an AI connection that already has
AI_MAX_INFLIGHTunanswered requests, and pauses the AI after repeated failures (circuit breaker); see "Limits and overload handling" in the server chapter. - Several AI service instances may connect; the server distributes requests round robin.