# Play protocol

**A match is one WebSocket.** When a game starts, stomped connects to your
registered URL — your https URL, upgraded — once per match. One JSON text
frame asks you to pick a character; after that the socket carries the game:
binary messages in (the `.slp` event stream, one message per frame), binary
pads out (your controller state, one per frame). When the game ends, the
socket closes. Your bot never polls and never calls stomped.

## 1. The pick

Server → bot, one text frame (the `PickRequest` schema in this reference):

```json
{
    "id": 0,
    "kind": "pick",
    "matchId": "Xk3p9QaB",
    "mode": "live",
    "port": 1,
    "opponent": { "botId": "b7Qm2ZkA", "name": "rusher" },
    "deadlineMs": 5000,
    "inputDelayFrames": 2
}
```

- `port` — 0 or 1: your player index in every slp event that follows.
- `deadlineMs` — how long you have to answer.
- `inputDelayFrames` — the mode's lead (see Pads below).

Bot → server, one text frame: `{"id": 0, "action": {"character": 9, "costume": 0}}`.
`character` is the slp external character id (`Character` in
`@slippi/slippi-js`: 0 Captain Falcon … 25 Ganondorf); `costume` 0–5. A late
or invalid answer gets a random character, costume 0. The stage is random
among the legal six.

## 2. The game, server → bot

Binary frames carrying the raw `.slp` event stream, byte for byte as the
replay file:

1. The first message is everything before the first frame: the Event
   Payloads event (`0x35`), Game Start (`0x36`) and any Gecko List (`0x3D`).
2. Then one message per game frame holding exactly that frame's events, in
   file order: Frame Start (`0x3A`), Pre-Frame Update (`0x37`) per port,
   Post-Frame Update (`0x38`) per port, Item Update (`0x3B`) if any, Frame
   Bookend (`0x3C`).
3. The last message is Game End (`0x39`).

Concatenating every binary message is byte-identical to the `raw` block of the
stored replay (`GET /matches/{id}/replay.slp`). Feed the messages to
slippi-js's `SlpStream` — or any streaming `.slp` parser — as they are.
Frame numbering is the `.slp` frame index: the first frame is −123, play
starts at 0.

If your socket drops mid-game, stomped reconnects and resends the first message
(the header above) before continuing with the current frame; frames you
missed are gone.

## 3. Pads, bot → server

Binary frames of exactly 12 bytes, little-endian:

| offset | type | field |
| --- | --- | --- |
| 0 | i32 | frame |
| 4 | u16 | buttons |
| 6 | u8 | stickX |
| 7 | u8 | stickY |
| 8 | u8 | cstickX |
| 9 | u8 | cstickY |
| 10 | u8 | L |
| 11 | u8 | R |

Buttons use the GameCube physical bit layout (the slp pre-frame
`physicalButtons`): DPAD_LEFT `0x0001`, DPAD_RIGHT `0x0002`, DPAD_DOWN
`0x0004`, DPAD_UP `0x0008`, Z `0x0010`, R `0x0020`, L `0x0040`, A `0x0100`, B
`0x0200`, X `0x0400`, Y `0x0800`, START `0x1000`. Sticks are raw 0–255 with
centre 128; triggers are raw 0–255 with 0 released. The game applies its own
dead zones and UCF; nothing is pre-processed. Neutral is
`buttons=0, sticks=128, triggers=0`.

The pad for frame F is what the game polls at frame F. You MAY send pads for
any future frame up to 600 frames past the last poll; the last one received
before the poll wins; a pad for a frame already polled, or beyond that window,
is ignored; a frame polled without a pad from you is neutral. Anything that is
not 12 bytes is ignored.

**The lead.** Having seen frame F's bookend, send the pad for
`F + 1 + inputDelayFrames`. In `live` it is 2: frames are polled every
16.7 ms regardless, so a pad for F + 1 would usually miss; F + 3 lands.
The first frame, −123, is polled before its events reach you: send the pads
for the first `1 + inputDelayFrames` frames right after answering the pick.

## 4. The end

After Game End the server closes the socket. There is no end frame; the
result is on the match record.

## Forfeits

A bot forfeits when **no pad of its lands for 300 consecutive polls** — 5
seconds of game time — whether its socket is down, it sends nothing, or its
pads keep arriving after the frame they were for was polled. The game is
stopped and the record says so. A bot that never connects forfeits before the
game is run. Forfeits are rated (score 0 against 4). Both bots missing 300
polls together is a double forfeit, a draw.

If the engine itself breaks (crashes, or in `live` falls more than a second
behind the clock for five seconds), the match is aborted unrated with
`endReason: "engine"`; in `live` the clock waits for a lagging engine rather
than blaming the bots.

## 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-stomped-match: deploy-check`.

## Verify the handshake

The upgrade request stomped 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 stomped, which stops both
spoofing (someone feeding your bot fake games) and strategy probing (someone
replaying crafted states to learn how you react).

- `x-stomped-match` — the match id this socket will carry.
- `x-stomped-timestamp` — unix milliseconds, as a string.
- `x-stomped-signature` — `base64(ed25519_sign("<timestamp>.<matchId>"))`.

The public key is at `GET /.well-known/stomped.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 STOMPED_URL = "https://api.stomped.dev";

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(`${STOMPED_URL}/.well-known/stomped.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-stomped-match") ?? "";
    const timestamp = headers.get("x-stomped-timestamp") ?? "";
    const signature = headers.get("x-stomped-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. Forged connections cost you
nothing once you verify.
