# 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):

```json
{
    "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:

```json
// 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-signature` — `base64(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):

```ts
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.
