#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:
- The first message is everything before the first frame: the Event
Payloads event (
0x35), Game Start (0x36) and any Gecko List (0x3D). - 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). - 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:
GET <your url>/pingmust answerpongover plain HTTP.- 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.