Skip to content

Wire protocol

Everything — phones and the board's own screen — speaks the same JSON protocol over a WebSocket at ws://<board>:8720/ws. The board renderer is just a privileged client of its embedded server; that symmetry is what will make a future cloud relay a transport swap rather than a rewrite. You rarely need this page to build a game (the SDK hides it), but it's the contract for custom clients, bots, and debugging.

Every frame carries v: 1 (the protocol version). Schemas live in @boardr/protocol as zod definitions — import them for a typed client.

Session lifecycle

phone                    server                     board
  │  hello {phone}         │      hello {board}       │
  │────────────────────────▶◀──────────────────────────│
  │  welcome {token}        │                          │
  ◀────────────────────────│──────────────────────────▶│
  │                         │   createSession {gameId} │
  │                         ◀──────────────────────────│
  │  lobby {seats…}         │                          │
  ◀────────────────────────│──────────────────────────▶│
  │  claimSeat {seat,name}  │                          │
  │────────────────────────▶│                          │
  │  seated {playerId}      │      startSession        │
  ◀────────────────────────│◀──────────────────────────│
  │  state {view…}          │  (per-client filtered)   │
  ◀────────────────────────│──────────────────────────▶│
  │  action {move,args}     │                          │
  │────────────────────────▶│                          │
  │  actionAck / error      │                          │

The welcome token is the reconnect credential: present it in a later hello and the server rebinds your seat and replays your current state. Phones survive lock-screens and dropped Wi-Fi this way.

Client → server

MessageWhoPayloadPurpose
helloallclientKind, token?, name?identify / reconnect
ping / resyncallt / —RTT + request a fresh state push
claimSeatphoneseat, namesit down in an open lobby
actionseated / boardactionId, move, args[], actAs?dispatch a move. actAs is board-only (hotseat)
leavephonegive up the seat
createSessionboardgameIdopen a lobby
startSessionboardstart with the seated players
startMatchboardgameId, playerNames[]quick-start hotseat (no phones)
stopMatchboardtear down the lobby/match

Server → client

MessagePayloadPurpose
welcometoken, clientId, playerId, sessionIdsession identity
lobbygame (public manifest), seats[], phase, joinUrllobby state; joinUrl feeds the QR
stateversion, mode: 'snapshot', viewyour filtered view + meta (players, currentPlayer, legalMoves, gameover)
actionAckactionId, versionyour move committed
errorcode, message, refActionId?rejection — codes like INVALID_MOVE, NOT_YOUR_TURN, SEAT_TAKEN
seatedseat, playerId, sessionIdyour claim succeeded
presenceclients[]board-only: who's connected
sessionEndedreasonlobby/match torn down
devReloadgameId, noncedev hot-reload: re-import UI bundles with ?v=nonce

Privacy on the wire

state.view is filtered server-side per recipient before serialization: the board gets { public }, player p gets { public, secret: secret[p] }, and internal never leaves the engine. There is no "full state" frame to intercept — a phone on the network can't see another player's rack no matter what it sends.

HTTP endpoints

The same server also serves: the phone app at /, game bundles at /games/<id>/…, GET /api/info and /api/games (library + problems), GET /api/registry (community index, annotated), POST /api/install + DELETE /api/install/:id (loopback-only — phones can never install code), and POST /dev/reload/:gameId for the dev loop.

boardr — games on the table, hands on your phone.