houserules/packages/server/PROTOCOL.md

8.4 KiB
Raw Blame History

Chess Server WebSocket Protocol v1

Overview

All WebSocket messages are JSON. Every message includes:

  • v: 1 — protocol version; mismatch triggers hard disconnect
  • seq: number — monotonic sequence number (client and server maintain separate counters; client acks server seq)
  • ts: number — unix milliseconds timestamp of message creation

Maximum message size: 64KB. Exceeding this limit causes hard disconnect with error code MSG_TOO_LARGE.

The server is AUTHORITATIVE. Clients send INTENTS (what they want to do). The server validates, applies the intent to the engine session, and broadcasts FACTS (what changed) to all room members.

Connection

WebSocket upgrade URL: ws://{host}:{port}/ws

Required query params on upgrade: none (auth happens via first message after connect).

Origin allow-list: Configurable via ALLOWED_ORIGINS env var (comma-separated). Requests from unlisted origins receive HTTP 403 before upgrade. Default: http://localhost:5173 (dev).

Message Envelope

Every message has this wrapper:

{
  "v": 1,
  "seq": 42,
  "ts": 1745000000000,
  "type": "room.create",
  "token": "optional-room-token",
  "payload": {}
}

token is required on all messages AFTER the first room.create or room.join. The first message from a client need not include token.

Auth & Rooms

  • Room codes: 6 characters, uppercase [A-Z0-9], randomly generated
  • Room token: UUID v4, returned on room.create, required on subsequent messages
  • Rooms are in-memory only (lost on server restart)
  • Maximum 2 players per room
  • 60-second reconnection grace window after disconnect

Message: room.create

Direction: Client → Server Purpose: Create a new game room. Server responds with room code and auth token.

Request payload:

{
  "rulesetIds": ["pawns-move-backward", "piece-hp"]
}
  • rulesetIds: optional array of preset rule IDs to activate for this game

Response (Server → Client, type room.created):

{
  "v": 1, "seq": 1, "ts": 1745000000000,
  "type": "room.created",
  "payload": {
    "code": "ABC123",
    "token": "550e8400-e29b-41d4-a716-446655440000",
    "color": "white"
  }
}

Error cases:

  • Server at capacity (too many rooms): error code SERVER_FULL

Message: room.join

Direction: Client → Server Purpose: Join an existing room as the second player.

Request payload:

{
  "code": "ABC123"
}

Response (Server → Client, type room.joined):

{
  "v": 1, "seq": 1, "ts": 1745000000000,
  "type": "room.joined",
  "payload": {
    "code": "ABC123",
    "token": "661f9500-f30c-52e5-b827-557766550111",
    "color": "black",
    "activeRules": ["pawns-move-backward"]
  }
}

When second player joins, server broadcasts game.state to BOTH players (initial board state).

Error cases:

  • Room not found: error code ROOM_NOT_FOUND
  • Room full (2 players already): error code ROOM_FULL
  • Wrong protocol version: disconnect + error code VERSION_MISMATCH

Message: room.leave

Direction: Client → Server Purpose: Voluntarily leave a room / concede.

Request payload: {}

Server broadcasts game.end with reason: "player_left" to remaining player.

Message: game.move

Direction: Client → Server Purpose: Express a move intent. Server validates and applies if legal.

Request payload:

{
  "from": "e2",
  "to": "e4",
  "promoteTo": "queen"
}
  • from, to: algebraic square notation (a1h8)
  • promoteTo: optional, only relevant for pawn promotion; one of "queen", "rook", "bishop", "knight"

On valid move: Server applies to engine, broadcasts game.delta to both players. On invalid move: Server sends error (code ILLEGAL_MOVE) to mover only; no broadcast.

Error cases:

  • Not your turn: error code NOT_YOUR_TURN
  • Illegal move: error code ILLEGAL_MOVE
  • Game already over: error code GAME_OVER

Message: game.state

Direction: Server → Client Purpose: Full board state snapshot. Sent on room join and on reconnect.

Payload:

{
  "fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
  "facts": [
    { "id": 1, "attr": "PieceType", "value": "pawn" },
    { "id": 1, "attr": "Color", "value": "white" },
    { "id": 1, "attr": "Position", "value": 12 }
  ],
  "activeRules": ["pawns-move-backward"],
  "turn": "black",
  "lastSeq": 42,
  "moveHistory": ["e2-e4"]
}
  • fen: standard FEN string for UI rendering convenience
  • facts: all non-derived working-memory facts
  • lastSeq: highest server seq the client should ack from (used for reconnect delta)

Message: game.delta

Direction: Server → Client Purpose: Incremental fact changes after each move. Sent to BOTH players after valid move.

Payload:

{
  "inserted": [
    { "id": 1, "attr": "Position", "value": 28 }
  ],
  "retracted": [
    { "id": 1, "attr": "Position", "value": 12 }
  ],
  "moveNotation": "e2e4",
  "turn": "black",
  "gameOver": null
}
  • inserted: facts added to working memory this tick
  • retracted: facts removed this tick (identified by id+attr)
  • gameOver: null if game continues; { winner: "white"|"black"|"draw", reason: "checkmate"|"stalemate"|"50-move"|"threefold"|"insufficient"|"player_left" } when game ends

Message: game.end

Direction: Server → Client Purpose: Explicit game-over notification (also included in game.delta gameOver field, but sent separately for clarity).

Payload:

{
  "winner": "white",
  "reason": "checkmate",
  "finalFen": "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3"
}

Message: error

Direction: Server → Client Purpose: Report validation failures, protocol errors, rate limit violations.

Payload:

{
  "code": "ILLEGAL_MOVE",
  "message": "Move e2-e5 is not legal for white pawn on e2",
  "fatal": false
}
  • fatal: true means the connection will be closed immediately after this message (e.g., rate limit, version mismatch, bad auth)
  • fatal: false means the connection stays open; client may retry

Reconnection Flow

  1. Client disconnects (network drop, tab sleep, etc.)
  2. Server starts a 60-second grace timer for that client's slot in the room
  3. During grace period: other player continues playing (server holds their moves but doesn't process if no opponent — or processes against a "ghost" state)
  4. Client reconnects: sends room.join with same code and original token
  5. Server validates token matches the disconnected slot
  6. Server sends game.state (full snapshot) followed by all game.delta messages since the client's last acked seq
  7. Client reconciles local state with the snapshot
  8. After 60 seconds: server broadcasts game.end with reason: "player_left" to the remaining player; room is destroyed

Reconnect message flow:

Client                          Server
  |-- room.join (code, token) -->|
  |<-- game.state (full snap) ---|
  |<-- game.delta (seq 43..50) --|  (missed deltas since last ack)
  |  [game resumes]              |

Rate Limiting

Token-bucket algorithm per WebSocket connection:

  • Capacity: 20 messages (burst)
  • Refill rate: 100 messages per second
  • When bucket is empty: connection is closed with error code RATE_LIMIT, fatal: true

Messages counted: all messages from client including heartbeats.

Security

  • Origin: Only connections from ALLOWED_ORIGINS (env var) are accepted
  • Message size: Max 64KB; exceeded → disconnect + error code MSG_TOO_LARGE
  • JSON validation: Every message validated against Zod schema; malformed JSON → disconnect
  • Token: Room tokens are UUID v4; expire when room is destroyed; not reusable across rooms
  • No user auth: v1 has no accounts; rooms are ephemeral; tokens are room-scoped only
  • No server-side persistence: Game state lives in-memory; server restart destroys all rooms

Error Codes

Code Fatal Description
ILLEGAL_MOVE No Move not in legal move set
NOT_YOUR_TURN No Move attempted when it's opponent's turn
GAME_OVER No Move attempted after game ended
ROOM_NOT_FOUND No Room code doesn't exist or expired
ROOM_FULL No Room already has 2 players
SERVER_FULL No Server at maximum room capacity
VERSION_MISMATCH Yes v field doesn't match server's protocol version
RATE_LIMIT Yes Message rate exceeded 100/sec
MSG_TOO_LARGE Yes Message exceeds 64KB
BAD_TOKEN Yes Token missing or invalid for room
INVALID_MESSAGE Yes JSON parse failure or schema validation failure