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

{
    "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):

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.