Play realtime protocol
WebSocket protocol specification for the play service realtime surface.
Connection lifecycle
- Browser fetches
/api/campaigns/{id}/bootstrapover HTTP, which returns aRealtimeConfigwithurl: "/realtime"andprotocol_version: 1. - Browser opens a WebSocket to
/realtime. The play session cookie (play_session) authenticates the upgrade. - Browser sends a
play.connectframe with the campaign ID and last-known sequence cursors. The server responds withplay.readycontaining a full room snapshot. - Server pushes
play.interaction.updatedwhenever game projection state changes. Browser sendsplay.chat.sendandplay.typingduring the session. - On disconnect, the server cleans up: stops typing timer, broadcasts typing-inactive, and removes the session from its campaign room.
Frame format
All frames are newline-delimited JSON objects:
{
"type": "play.<frame_type>",
"request_id": "<optional correlation id>",
"payload": { ... }
}
request_id is echoed back on response frames (play.ready, play.error, play.pong) for request/response correlation.
Frame types
Client to server
| Frame type | Payload | Description |
|---|---|---|
play.connect | {campaign_id, last_game_seq?, last_chat_seq?} | Join a campaign room. Server responds with play.ready. |
play.chat.send | {client_message_id?, body} | Send a human chat message. Broadcast to room as play.chat.message. |
play.typing | {active} | Typing indicator. Broadcast to room. Auto-expires after typing TTL. |
play.ping | {} | Keepalive. Server responds with play.pong. |
Server to client
| Frame type | Payload | Description |
|---|---|---|
play.ready | RoomSnapshot | Initial room state after connect. |
play.interaction.updated | RoomSnapshot | Game projection changed; full refreshed state. |
play.chat.message | {message: ChatMessage} | New chat message (broadcast to all room sessions). |
play.typing | {session_id, participant_id, name, active} | Typing indicator update (broadcast to all room sessions). |
play.ai_debug.turn.updated | AIDebugTurnUpdate | AI debug turn delta (summary + appended entries). |
play.resync | {reason} | Server cannot maintain state; client should reload. |
play.pong | {timestamp} | Response to play.ping. |
play.error | {error: {code, message, retryable?, details?}} | Error response. |
Error codes
Error codes in play.error frames mirror gRPC status code names:
| Code | Meaning |
|---|---|
invalid_argument | Malformed frame, missing field, payload too large |
resource_exhausted | Rate limit exceeded (disconnects) |
failed_precondition | Action requires prior state (e.g., connect before chat) |
unavailable | Server shutting down or upstream failure |
Rate limits and constraints
| Constraint | Value |
|---|---|
| Max frame payload size | 32 KB |
| Max frames per second per connection | 50 |
| Max decode errors before disconnect | 3 |
| Max chat message body | 12,000 runes |
| Max client message ID length | 128 characters |
| Typing indicator TTL | 3 seconds |
Projection subscription
Each campaign room maintains a single gRPC subscription to game.v1.EventService.SubscribeCampaignUpdates. When a PROJECTION_APPLIED update arrives, the room fetches fresh InteractionState and broadcasts a play.interaction.updated frame to all connected sessions.
Subscription failures use exponential backoff: 1s initial, 2x multiplier, 30s cap. A successful connection resets the backoff.
AI debug subscription
Each campaign room may maintain one AI debug subscription per active session, via ai.v1.CampaignDebugService.SubscribeCampaignDebugUpdates. Turn deltas are broadcast as play.ai_debug.turn.updated frames. The subscription is reconciled whenever the active session changes.