Arcade SDK docs
Curated server integration

External authoritative multiplayer

Keep your existing multiplayer server as the single authority while the Arcade SDK handles Bounty Board admission, short-lived room tickets, connection, and reconnect. This contract is game- and hosting-vendor neutral.

Overview

Use this path only when a mature server must remain responsible for admission, simulation, state, reconnects, and results.

Your server owns

Admission, game rules, input validation, simulation, viewer-safe state, and real terminal results.

The SDK owns

Host ticket requests, the WebSocket lifecycle, bounded join data, reconnect, and a small transport envelope.

Registration

External authorities are reviewed and enabled per Arcade game. Registration is not self-service.

  • Exact Arcade game slug
  • Dedicated staging and production wss: endpoints
  • Room-code format and creation behavior
  • Allowed joinData fields and validation rules
  • Finite-match or endless-session result model
  • Operational contact for key rotation and incidents

Bounty Board provisions one dedicated server-side ticket secret per game and environment. Never ship it in a browser bundle or reuse another game's room service credentials. Registered endpoints must use wss: outside loopback development and must not contain userinfo, query parameters, or a fragment.

Verify every ticket

The SDK receives a 60-second HMAC ticket from the trusted host. Your server verifies it before admitting the socket.

Ticket encoding
payloadB64 = base64url(UTF8(JSON(payload)))
signatureB64 = base64url(HMAC-SHA256(payloadB64, ticketSecret))
ticket = payloadB64 + "." + signatureB64
Ticket claims
{
  "v": 1,
  "sub": "player:<opaque-game-scoped-value>",
  "name": "Display Name",
  "avatarUrl": null,
  "slug": "your-game-slug",
  "roomId": "ROOM-CODE",
  "guest": false,
  "iat": 1700000000000,
  "exp": 1700000060000
}

Compare the HMAC in constant time, require protocol version 1, reject expired tickets, and match both slug and roomId exactly. Treat sub as an opaque game-scoped identity. The display name and optional avatar are never authorization data.

Connect from the SDK

The public game API stays the same whether Bounty Board hosts the room module or connects to a registered external authority.

Join a room
import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';

const room = await joinRoom({
  code: 'ROOM-CODE', // or create: true
  joinData: {
    schemaVersion: 1,
    loadout: 'starter',
    mode: 'friends',
  },
});

room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
room.on('connection', ({ connected }) => setReconnecting(!connected));

if (!room.trySend({ type: 'move', x: 1, y: 0 })) {
  setReconnecting(true);
}

The SDK opens your registered endpoint with an encoded ticket and optional encoded join query. joinData is untrusted JSON capped at 1 KiB. Keep the authenticated route separate from anonymous or legacy sockets, and redact request URIs from access logs because the ticket appears in the query string.

Room messages

All frames are JSON text. Send welcome only after ticket and admission validation, and keep every snapshot viewer-safe.

Required welcome frame
{
  "t": "welcome",
  "playerId": "room-scoped-player-id",
  "players": [
    { "id": "room-scoped-player-id", "name": "Display Name", "avatarUrl": null }
  ],
  "state": { "phase": "lobby" }
}
Snapshots, events, and inputs
// Server -> client
{ "t": "snapshot", "tick": 42, "state": { "phase": "playing" } }
{ "t": "event", "data": { "type": "round_started", "round": 2 } }
{ "t": "player_leave", "playerId": "p2" }

// Client -> server: input only, never authoritative state
{ "t": "input", "seq": 7, "data": { "type": "move", "x": 1, "y": 0 } }

Send welcome within 10 seconds. The server may also send player_join, player_leave, event, error, and—only for finite games—match_end. Reject bad tickets with unauthenticated and invalid admission data with rejected, then close before welcome.

Reconnect safely

The SDK requests a fresh ticket for the same room and reuses the original joinData during its bounded reconnect attempts.

Rebind the seat from the verified (ticket.sub, ticket.roomId) pair, never from a client-supplied player id or reconnect token. Clear held controls while disconnected and return the latest complete viewer-safe state in the new welcome. Prediction-based clients should receive the last processed input sequence so acknowledged inputs can be discarded.

Finite results only

A match-ending message is a server-authored fact, not a client request. Endless rooms must not invent one.

Finite match result
{
  "t": "match_end",
  "results": [
    {
      "playerId": "p1",
      "name": "Display Name",
      "placement": 1,
      "score": 1200,
      "outcome": "win"
    }
  ]
}

Persistent Bounty Board results, when enabled, use a separately provisioned server-to-server reporting credential. Never expose that credential to the game or accept client-authored standings. Death, respawn, a round transition, or one player leaving does not automatically end a match.

Production checklist

Prove the trust boundary and failure paths before Bounty Board enables the authority in production.

  • Use separate endpoints and ticket secrets for staging and production.
  • Reject tampered, expired, wrong-slug, and wrong-room tickets before welcome.
  • Treat sub as opaque and name/avatarUrl as display-only data.
  • Reject malformed, unknown, or oversized joinData; the limit is 1 KiB.
  • Validate every input and clear held controls on disconnect.
  • Send only viewer-safe snapshots and bound every frame size.
  • Rebind reconnects from the fresh ticket, never a client identity claim.
  • Redact tickets and join payloads from logs and error telemetry.
  • Emit match_end only from a real server-authoritative terminal condition.
  • Keep the game usable when multiplayer is unsupported or temporarily down.