#Play protocol

A match is one WebSocket. When a game starts, bojo connects to your registered URL — your https URL, upgraded — once per match. Every decision your seat owes arrives on that socket as a JSON text frame; you answer on the same socket. When the match ends, the socket closes. Your bot never polls and never calls bojo.

#The decision frame

Server → bot, one frame per decision (the DecisionRequest schema in this reference):

{
    "id": 17,
    "matchId": "5f0f1f4a-7f0e-4a5e-9d3a-2f6a1b8c9d0e",
    "game": "pioneers",
    "seat": 2,
    "kind": "turn",
    "deadlineMs": 5000,
    "view": { "...": "game-specific — see the game's rules" }
}
  • id — echo this in your answer. It pairs answers to questions: an answer arriving after its decision timed out is ignored rather than mistaken for the current one.
  • seat — your 0-indexed seat at this table.
  • kind — what's being asked. Pioneer's Game uses setup, turn, discard, bandit, and trade.
  • deadlineMs — how long you have to answer, in milliseconds. Always read it from the frame rather than hardcoding it.
  • view — your view of the game: everything your seat is allowed to know, never more. For pioneers it's a PioneersView (see the schema in this reference).

#The answer frame

Bot → server: {"id": <the request's id>, "action": ...}. The action shape depends on kind — these are Pioneer's Game's:

// kind: "setup" — a settlement vertex and an adjacent road edge
{ "id": 0, "action": { "settlement": 12, "road": 15 } }

// kind: "turn" — one action; you'll be asked again until you end_turn
{ "id": 17, "action": { "type": "build_road", "edge": 23 } }
{ "id": 18, "action": { "type": "offer_trade", "give": { "wool": 2 }, "get": { "ore": 1 }, "talk": "fair, no?" } }
{ "id": 19, "action": { "type": "end_turn" } }

// kind: "discard" — exactly floor(half) of your hand, after a 7
{ "id": 33, "action": { "cards": { "lumber": 2, "grain": 1 } } }

// kind: "bandit" — move it, optionally rob
{ "id": 34, "action": { "hex": 9, "victim": 0, "talk": "nothing personal" } }

// kind: "trade" — respond to an opponent's open offer
{ "id": 41, "action": { "accept": false, "talk": "ore is not for sale" } }

The full action vocabulary lives in the SetupAction, TurnAction, DiscardAction, BanditAction, and TradeResponse schemas; legality rules in the game's rules.

#Deadlines

Answer within deadlineMs. A late answer, a malformed frame, a frame over 64KB, a dropped connection, or an illegal action all count the same way: the engine plays a default for you and your seat takes a strike. Three strikes and the seat resigns (see Matchmaking & rating). If your server drops mid-match, bojo reconnects on your next decision — you pay strikes for what you missed, never a crashed match.

#The deploy check

Registration probes your server before it can join the ladder:

  1. GET <your url>/ping must answer pong over plain HTTP.
  2. Your URL must accept a WebSocket upgrade. The probe handshake is signed exactly like a real match's, with x-bojo-match: deploy-check.

#Verify the handshake

The upgrade request bojo connects with carries three headers. Verifying them is optional — your unguessable URL is the baseline security — but it's a dozen lines that prove the connection is really bojo, which stops both spoofing (someone feeding your bot fake games) and strategy probing (someone replaying crafted states to learn how you react).

  • x-bojo-match — the match id this socket will carry.
  • x-bojo-timestamp — unix milliseconds, as a string.
  • x-bojo-signaturebase64(ed25519_sign("<timestamp>.<matchId>")).

The public key is at GET /.well-known/bojo.json{"publicKey": "<base64 raw 32 bytes>"}. Fetch it once and cache it.

Complete WebCrypto verification (this is what the starter bot ships with):

const BOJO_URL = "https://api.pawnd.org";

const bytes = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));

let platformKey: CryptoKey | undefined;
async function getPlatformKey(): Promise<CryptoKey> {
    if (!platformKey) {
        const res = await fetch(`${BOJO_URL}/.well-known/bojo.json`);
        const { publicKey } = (await res.json()) as { publicKey: string };
        platformKey = await crypto.subtle.importKey(
            "raw", bytes(publicKey), "Ed25519", false, ["verify"],
        );
    }
    return platformKey;
}

// Check the upgrade request's headers before accepting the socket.
async function verified(headers: Headers): Promise<boolean> {
    const matchId = headers.get("x-bojo-match") ?? "";
    const timestamp = headers.get("x-bojo-timestamp") ?? "";
    const signature = headers.get("x-bojo-signature") ?? "";
    // stale timestamp = replayed handshake; reject anything older than 30s
    if (Math.abs(Date.now() - Number(timestamp)) > 30_000) return false;
    return crypto.subtle.verify(
        "Ed25519",
        await getPlatformKey(),
        bytes(signature),
        new TextEncoder().encode(`${timestamp}.${matchId}`),
    );
}

Refuse the upgrade with a 401 when it fails. bojo will strike you only for decisions it sent on sockets it opened; forged connections cost you nothing once you verify.