{
  "info": {
    "title": "stomped",
    "version": "1",
    "description": "# Introduction\n\nstomped is a competitive arena for Super Smash Bros. Melee bots. You write a\nbot, host it as an HTTP server, and register its URL. stomped does the rest: it\nmatches your bot against others by rating, runs the game server-side on a\nheadless Slippi Dolphin, and opens a WebSocket to your bot for each match —\nthe game arrives as the raw `.slp` event stream, your controller state goes\nback, frame by frame. Win games, climb the ladder.\n\nThe game is Melee NTSC 1.02 under the default competitive ruleset: 1v1, 4\nstocks, 8 minutes, no items, six legal stages. Bots never run the game; they\nread frames and send pads. A bot registers into a **mode** — the same game\nunder a given clock. `live` runs a real 60 Hz clock, for bots hosted near the\narena.\n\nGetting on the ladder is two steps: **sign in to the dashboard with your\nemail, then enter your bot's URL.** That's it — the bot enters matchmaking on\nthe next tick. A one-file starter bot lives in the stomped repo under\n`examples/starter-bot`.\n\nThis reference is generated from the same contract the server implements —\nit can't drift. Machine-readable copies live at stable URLs:\n\n- `/openapi.json` — this entire reference as an OpenAPI 3.1 document,\n  including the rules and the play protocol.\n- `/llms.txt` — a map of every artifact, for AIs.\n\nstomped's Ed25519 platform public key — which signs every connection stomped makes\nto your bot — is published at `GET https://api.stomped.dev/.well-known/stomped.json`\nas `{\"publicKey\": \"<base64 raw 32 bytes>\"}`.\n\n## Limits\n\nNormal use — registering a few bots, iterating on one, polling your match\nhistory — never touches these; they exist to keep the arena responsive.\n\n- An account holds at most **20 API keys**. There is no cap on bots; bots of\n  one account are never matched against each other.\n- Requests are rate limited per account — per IP when anonymous: registering\n  a bot or changing its URL **10/min** (each one triggers the deploy check),\n  other writes **30/min**, match reads (`/matches`, records, replays)\n  **120/min**, everything else **600/min**.\n- Over a limit you get `429` with a `Retry-After` header (seconds). Back off\n  that long and the request succeeds.\n\n# Play protocol\n\n**A match is one WebSocket.** When a game starts, stomped connects to your\nregistered URL — your https URL, upgraded — once per match. One JSON text\nframe asks you to pick a character; after that the socket carries the game:\nbinary messages in (the `.slp` event stream, one message per frame), binary\npads out (your controller state, one per frame). When the game ends, the\nsocket closes. Your bot never polls and never calls stomped.\n\n## 1. The pick\n\nServer → bot, one text frame (the `PickRequest` schema in this reference):\n\n```json\n{\n    \"id\": 0,\n    \"kind\": \"pick\",\n    \"matchId\": \"Xk3p9QaB\",\n    \"mode\": \"live\",\n    \"port\": 1,\n    \"opponent\": { \"botId\": \"b7Qm2ZkA\", \"name\": \"rusher\" },\n    \"deadlineMs\": 5000,\n    \"inputDelayFrames\": 2\n}\n```\n\n- `port` — 0 or 1: your player index in every slp event that follows.\n- `deadlineMs` — how long you have to answer.\n- `inputDelayFrames` — the mode's lead (see Pads below).\n\nBot → server, one text frame: `{\"id\": 0, \"action\": {\"character\": 9, \"costume\": 0}}`.\n`character` is the slp external character id (`Character` in\n`@slippi/slippi-js`: 0 Captain Falcon … 25 Ganondorf); `costume` 0–5. A late\nor invalid answer gets a random character, costume 0. The stage is random\namong the legal six.\n\n## 2. The game, server → bot\n\nBinary frames carrying the raw `.slp` event stream, byte for byte as the\nreplay file:\n\n1. The first message is everything before the first frame: the Event\n   Payloads event (`0x35`), Game Start (`0x36`) and any Gecko List (`0x3D`).\n2. Then one message per game frame holding exactly that frame's events, in\n   file order: Frame Start (`0x3A`), Pre-Frame Update (`0x37`) per port,\n   Post-Frame Update (`0x38`) per port, Item Update (`0x3B`) if any, Frame\n   Bookend (`0x3C`).\n3. The last message is Game End (`0x39`).\n\nConcatenating every binary message is byte-identical to the `raw` block of the\nstored replay (`GET /matches/{id}/replay.slp`). Feed the messages to\nslippi-js's `SlpStream` — or any streaming `.slp` parser — as they are.\nFrame numbering is the `.slp` frame index: the first frame is −123, play\nstarts at 0.\n\nIf your socket drops mid-game, stomped reconnects and resends the first message\n(the header above) before continuing with the current frame; frames you\nmissed are gone.\n\n## 3. Pads, bot → server\n\nBinary frames of exactly 12 bytes, little-endian:\n\n| offset | type | field |\n| --- | --- | --- |\n| 0 | i32 | frame |\n| 4 | u16 | buttons |\n| 6 | u8 | stickX |\n| 7 | u8 | stickY |\n| 8 | u8 | cstickX |\n| 9 | u8 | cstickY |\n| 10 | u8 | L |\n| 11 | u8 | R |\n\nButtons use the GameCube physical bit layout (the slp pre-frame\n`physicalButtons`): DPAD_LEFT `0x0001`, DPAD_RIGHT `0x0002`, DPAD_DOWN\n`0x0004`, DPAD_UP `0x0008`, Z `0x0010`, R `0x0020`, L `0x0040`, A `0x0100`, B\n`0x0200`, X `0x0400`, Y `0x0800`, START `0x1000`. Sticks are raw 0–255 with\ncentre 128; triggers are raw 0–255 with 0 released. The game applies its own\ndead zones and UCF; nothing is pre-processed. Neutral is\n`buttons=0, sticks=128, triggers=0`.\n\nThe pad for frame F is what the game polls at frame F. You MAY send pads for\nany future frame up to 600 frames past the last poll; the last one received\nbefore the poll wins; a pad for a frame already polled, or beyond that window,\nis ignored; a frame polled without a pad from you is neutral. Anything that is\nnot 12 bytes is ignored.\n\n**The lead.** Having seen frame F's bookend, send the pad for\n`F + 1 + inputDelayFrames`. In `live` it is 2: frames are polled every\n16.7 ms regardless, so a pad for F + 1 would usually miss; F + 3 lands.\nThe first frame, −123, is polled before its events reach you: send the pads\nfor the first `1 + inputDelayFrames` frames right after answering the pick.\n\n## 4. The end\n\nAfter Game End the server closes the socket. There is no end frame; the\nresult is on the match record.\n\n## Forfeits\n\nA bot forfeits when **no pad of its lands for 300 consecutive polls** — 5\nseconds of game time — whether its socket is down, it sends nothing, or its\npads keep arriving after the frame they were for was polled. The game is\nstopped and the record says so. A bot that never connects forfeits before the\ngame is run. Forfeits are rated (score 0 against 4). Both bots missing 300\npolls together is a double forfeit, a draw.\n\nIf the engine itself breaks (crashes, or in `live` falls more than a second\nbehind the clock for five seconds), the match is aborted unrated with\n`endReason: \"engine\"`; in `live` the clock waits for a lagging engine rather\nthan blaming the bots.\n\n## The deploy check\n\nRegistration probes your server before it can join the ladder:\n\n1. `GET <your url>/ping` must answer `pong` over plain HTTP.\n2. Your URL must accept a WebSocket upgrade. The probe handshake is signed\n   exactly like a real match's, with `x-stomped-match: deploy-check`.\n\n## Verify the handshake\n\nThe upgrade request stomped connects with carries three headers. Verifying them\nis optional — your unguessable URL is the baseline security — but it's a\ndozen lines that prove the connection is really stomped, which stops both\nspoofing (someone feeding your bot fake games) and strategy probing (someone\nreplaying crafted states to learn how you react).\n\n- `x-stomped-match` — the match id this socket will carry.\n- `x-stomped-timestamp` — unix milliseconds, as a string.\n- `x-stomped-signature` — `base64(ed25519_sign(\"<timestamp>.<matchId>\"))`.\n\nThe public key is at `GET /.well-known/stomped.json` →\n`{\"publicKey\": \"<base64 raw 32 bytes>\"}`. Fetch it once and cache it.\n\nComplete WebCrypto verification (this is what the starter bot ships with):\n\n```ts\nconst STOMPED_URL = \"https://api.stomped.dev\";\n\nconst bytes = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));\n\nlet platformKey: CryptoKey | undefined;\nasync function getPlatformKey(): Promise<CryptoKey> {\n    if (!platformKey) {\n        const res = await fetch(`${STOMPED_URL}/.well-known/stomped.json`);\n        const { publicKey } = (await res.json()) as { publicKey: string };\n        platformKey = await crypto.subtle.importKey(\n            \"raw\", bytes(publicKey), \"Ed25519\", false, [\"verify\"],\n        );\n    }\n    return platformKey;\n}\n\n// Check the upgrade request's headers before accepting the socket.\nasync function verified(headers: Headers): Promise<boolean> {\n    const matchId = headers.get(\"x-stomped-match\") ?? \"\";\n    const timestamp = headers.get(\"x-stomped-timestamp\") ?? \"\";\n    const signature = headers.get(\"x-stomped-signature\") ?? \"\";\n    // stale timestamp = replayed handshake; reject anything older than 30s\n    if (Math.abs(Date.now() - Number(timestamp)) > 30_000) return false;\n    return crypto.subtle.verify(\n        \"Ed25519\",\n        await getPlatformKey(),\n        bytes(signature),\n        new TextEncoder().encode(`${timestamp}.${matchId}`),\n    );\n}\n```\n\nRefuse the upgrade with a `401` when it fails. Forged connections cost you\nnothing once you verify.\n\n# Matchmaking & rating\n\nThere is no queue to join. Register a bot, keep it `active`, and the\nscheduler seats it for you.\n\n## Modes\n\nA mode is the clock the game runs under. A bot registers into one mode for\nits lifetime and is rated only against that mode; each mode has its own\nleaderboard (`/leaderboard?mode=…`).\n\n| mode | per frame | lead | forfeit after | rest between matches | wall clock |\n| --- | --- | --- | --- | --- | --- |\n| `live` | 16.7 ms | 2 frames | 5 s of game time | 2 min | 12 min |\n\n- **`live`**: the engine runs on a real 60 Hz clock. A frame is polled every\n  16.7 ms whether or not your pad has arrived; a missing pad is neutral. The\n  lead is 2 frames. For bots hosted near the arena.\n\nThe rules, the stream and the pads are identical across modes; only when the\nengine advances differs.\n\n## One match per tick\n\nMatchmaking runs on a fixed tick (every 5 seconds). Each tick starts at most\none match: among the `active` bots of a mode that are not already playing and\nhave rested since their last match (the mode's *rest between matches*), it\nseats the two closest in rating. Two bots of one account never meet: the\nscheduler skips that pair and seats the next-closest. Ports are a coin flip.\nThe number of matches running at once is capped by the box (one engine process\nper match), so bots may wait a few ticks when the arena is busy. A bot plays at\nmost one match at a time.\n\n## Rating\n\nRatings are [openskill](https://openskill.me) (Weng-Lin): each bot carries\n`mu` (estimated skill) and `sigma` (uncertainty), starting at `mu = 25`,\n`sigma = 25/3`. The number shown everywhere is the conservative estimate\n`rating = mu − 3σ` — new bots start at 0 and climb as sigma shrinks.\n\nA win is placement 1, a loss placement 2; a draw is placement 1 for both and\nrates as a tie. Forfeits are rated like any other loss. The leaderboard ranks\nevery bot with at least one completed match; per-match rating deltas appear\non match records once the match completes.\n\n## Missed games\n\nA **miss** is a match your bot never joined: it was seated and either never\naccepted the socket or never sent a thing — no pick, no pad. Sending anything\nis not a miss. Misses count consecutively; any answered match resets the count.\n\nAfter **10 consecutive misses** the scheduler offers the bot a seat only\nevery **3 hours** instead of its mode's usual rest, so a dead server stops\nbleeding rating. If a miss streak grows a **week** old, the bot is set to\n`paused`. Changing the bot's `status` (pause or unpause) also resets the count\nimmediately — fix your server, flip it back to `active`, and it plays on the\nnext tick.\n\n## Wall clock\n\nA match still running past its mode's wall clock is **aborted**: no\nplacements, no rating changes. A game ends on stocks or the 8-minute timer\nlong before the clock does; aborts mean something broke.\n\n## Seed and replay\n\nEvery match has a random seed the engine is started with; it becomes public\nwhen the match ends. The replay is the `.slp` file the engine wrote —\nidentical to what both bots were streamed — at `GET /matches/{id}/replay.slp`\nonce the match completes. `GET /matches/{id}` carries the result: stage,\ncharacters, stocks and percent per port, frames played, how it ended, who won.\n`GET /matches` pages backwards with `before=<matchId>`, up to the newest\n10,000 matches. The arena MAY delete replays after a retention period; the\nrecord stays, and `replayBytes` is `null` once the replay is gone.\n\n# Rules\n\nSuper Smash Bros. Melee, NTSC 1.02, **1v1**. This page is the arena's public\nspec: if a bot and this page disagree, this page wins. The rules are the same\nin every mode (see Matchmaking & rating); only the clock differs.\n\nThe key words **MUST**, **MUST NOT**, **SHOULD** and **MAY** are to be\ninterpreted as described in RFC 2119 and RFC 8174 when, and only when, they\nappear in all capitals, as shown here. \"The engine\" is stomped's headless\nSlippi Dolphin; \"you\" is a bot holding a port.\n\n## The game\n\n- **4 stocks**, **8 minutes**, **no items**, damage ratio 1.0.\n- UCF and the Slippi gameplay codes are on; Pokémon Stadium is frozen.\n- One game per match. There are no sets, no stage striking and no counterpicks.\n\n## Stages\n\nOne of six, chosen at random per match (slp stage ids in parentheses):\nBattlefield (31), Final Destination (32), Dream Land (28), Yoshi's Story (8),\nFountain of Dreams (2), Pokémon Stadium (3).\n\n## Characters\n\nBoth bots pick blind, before the game, from the 26 playable characters by slp\nexternal id (0 Captain Falcon … 25 Ganondorf) with a costume index 0–5. A bot\nthat answers late or invalidly gets a random character, costume 0. A bot MUST\nNOT learn its opponent's pick before Game Start.\n\n## Ports\n\nPorts are 0 and 1 and equal the Slippi player index. Which bot gets which\nport is a coin flip per match.\n\n## Winning\n\n- **Stocks**: the last bot with a stock wins; the game ends on the frame the\n  other bot's last stock is lost.\n- **Time**: when the timer runs out, more stocks remaining wins; on equal\n  stocks, lower percent wins; equal stocks and percent is a **draw**.\n- **Forfeit**: a bot none of whose pads land for 300 consecutive polls (5\n  seconds of game time), or that never connects, loses by forfeit (see Play\n  protocol › Forfeits). Both at once is a draw.\n\nThere is no sudden death: a tie on time is a draw.\n\n## Scoring\n\nThe match record carries, per port, stocks and percent at the end; `score`\nis stocks remaining (0 against 4 on a forfeit). Placements are 1 for the\nwinner and 2 for the loser, 1 and 1 on a draw; they feed ratings (see\nMatchmaking & rating).\n\n## Inputs\n\nThe engine polls one controller state per port per frame. A pad you did not\nsupply in time is neutral; the engine never waits for you beyond what the\nmode allows. Pause is disabled; START does nothing. The engine applies the\ngame's own dead zones and UCF to the raw stick values you send — you MAY send\nany 0–255 and the game treats it as a controller would.\n\n## Information\n\nBoth bots receive the same stream: every event the replay will contain, as\nit is written. There is no hidden information in Melee beyond the engine's\nrandom seed, which stays private until the match ends.\n\n# Automation (optional)\n\n**You never need this to play.** The dashboard covers everything: sign in,\nregister your bot, pause it, watch matches. This section is for scripting the\nmanagement API — a CI pipeline that re-registers a bot on deploy, a cron job\nthat pauses it before maintenance.\n\nPublic endpoints — matches, live, leaderboard, health — need no auth at all.\nManagement endpoints — bots, account — accept two kinds of API key, created in\nthe dashboard under **API keys**. Both kinds share the same scopes and the same\nendpoints; pick per key:\n\n- **Signature key** (the default): you generate an Ed25519 keypair and register\n  the public half; every request is signed with the private key. No secret ever\n  crosses the wire — stomped stores nothing that could impersonate you — and a\n  captured request can't be reused for another endpoint or body, only replayed\n  as-is within a ±5 minute window (there is no nonce). It's also the shape that suits\n  agent-written bots: the agent mints its own keypair and only the harmless\n  public half ever appears in a chat or a log.\n- **Bearer key** (the basic one): stomped generates a secret token, shown once\n  at creation. Send it as a header on every request. Simplest to wire up, but\n  the secret has to travel to wherever it's used.\n\n## Signature keys\n\nGenerate a keypair (`ssh-keygen -t ed25519` works) and register the public key\nin the dashboard — OpenSSH `.pub` lines, PEM blocks, and raw base64 are all\naccepted. Registering shows the **key id** you send with each request.\n\nSend three headers:\n\n- `x-stomped-key` — your key id.\n- `x-stomped-timestamp` — unix milliseconds, as a string. Must be within\n  **5 minutes** of stomped's clock.\n- `x-stomped-signature` — `base64(ed25519_sign(message))` where the message is:\n\n```\n{timestamp}.{METHOD}.{path}.{body}\n```\n\n`METHOD` is uppercase, `path` includes the query string (e.g.\n`/bots?mode=live`), and `body` is the exact raw bytes sent — empty string\nfor bodyless requests. The signature is bound to method, path, body, and a\ntimestamp the api accepts within ±5 minutes; there is no nonce, so a captured\nrequest can be replayed as-is inside that window, but not altered or reused\nelsewhere.\n\nComplete client, using WebCrypto:\n\n```js\nasync function stomped(privateKey, keyId, method, path, body) {\n    const raw = body ? JSON.stringify(body) : \"\";\n    const timestamp = String(Date.now());\n    const message = `${timestamp}.${method}.${path}.${raw}`;\n    const signature = Buffer.from(\n        await crypto.subtle.sign(\"Ed25519\", privateKey, new TextEncoder().encode(message)),\n    ).toString(\"base64\");\n    return fetch(`https://api.stomped.dev${path}`, {\n        method,\n        headers: {\n            \"content-type\": \"application/json\",\n            \"x-stomped-key\": keyId,\n            \"x-stomped-timestamp\": timestamp,\n            \"x-stomped-signature\": signature,\n        },\n        body: raw || undefined,\n    });\n}\n\nawait stomped(privateKey, keyId, \"POST\", \"/bots\", {\n    name: \"my-first-bot\",\n    mode: \"live\",\n    url: \"https://your-bot.example.com\",\n});\n```\n\nNote this covers requests **to** stomped. Requests **from** stomped to your bot\n(the play protocol) are always signed with the platform key — bearer keys\nchange nothing on that side.\n\n## Bearer keys\n\nCreating one shows the token — `stp_` followed by random base64 — **exactly\nonce**; stomped keeps only a hash. Send it on every request:\n\n```\nAuthorization: Bearer stp_...\n```\n\n```js\nawait fetch(\"https://api.stomped.dev/bots\", {\n    method: \"POST\",\n    headers: {\n        \"content-type\": \"application/json\",\n        authorization: `Bearer ${process.env.STOMPED_KEY}`,\n    },\n    body: JSON.stringify({\n        name: \"my-first-bot\",\n        mode: \"live\",\n        url: \"https://your-bot.example.com\",\n    }),\n});\n```\n\nTreat the token like a password: env vars and secret stores, never the repo.\nThe `stp_` prefix exists so a leaked token is greppable. Lose it? Revoke the\nkey and create a new one — the token can't be shown again.\n\n## Scopes\n\nEvery key carries **scopes**, fixed when the key is created; each endpoint in\nthis reference lists the scope it requires. A dashboard session (browser\ncookie) carries every scope.\n\n| scope | allows |\n| --- | --- |\n| `bots.read` | list and inspect your bots |\n| `bots.write` | register, update, pause, remove bots |\n| `keys.read` | list your API keys |\n| `keys.write` | create and revoke API keys |\n| `account.read` | read your account |\n| `account.write` | change your handle |\n\nLeast privilege: a key for a bot-deploy script wants `bots.read` +\n`bots.write` (the default) and nothing more — it then can't mint keys or\ntouch the account even if it leaks.\n\n# Changelog\n\nBreaking changes to the public surface — the play protocol, the schemas, the\nendpoints, the rules — newest first. Everything else is in the reference.\n\n## 2026-10-13\n\n- **stomped.** The arena plays Super Smash Bros. Melee 1v1 in the `live`\n  mode; bots pick\n  a character over one JSON frame, then read the raw `.slp` event stream and\n  send 12-byte pads — see Play protocol.\n- Replays are `.slp` files at `GET /matches/{id}/replay.slp`; match records\n  carry a `summary` (stage, characters, stocks, percent, frames, end reason,\n  winner). `GET /matches/{id}/frames` is gone.\n- Headers are `x-stomped-*`, the platform key is at `/.well-known/stomped.json`,\n  bearer keys start with `stp_`."
  },
  "servers": [
    {
      "url": "https://api.stomped.dev"
    }
  ],
  "tags": [
    {
      "name": "bots",
      "description": "Register and manage your bots."
    },
    {
      "name": "account",
      "description": "Your account and its API keys."
    },
    {
      "name": "matches",
      "description": "Permanent match records and replays. Public."
    },
    {
      "name": "live",
      "description": "Running matches, frame by frame. Public."
    },
    {
      "name": "leaderboard",
      "description": "Per-mode rankings. Public."
    },
    {
      "name": "meta",
      "description": "Platform plumbing."
    },
    {
      "name": "your bot",
      "description": "Not stomped endpoints — what stomped does to YOUR server: the deploy check and the per-match WebSocket. See the Play protocol section."
    }
  ],
  "openapi": "3.1.1",
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "name",
          "email"
        ]
      },
      "ApiKeyMeta": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "kind": {
            "enum": [
              "bearer",
              "signature"
            ],
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "publicKey": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "The registered key for `signature` keys, else null."
          },
          "scopes": {
            "type": "array",
            "items": {
              "enum": [
                "bots.read",
                "bots.write",
                "keys.read",
                "keys.write",
                "account.read",
                "account.write"
              ],
              "type": "string"
            }
          },
          "createdAt": {
            "type": "string"
          },
          "lastUsedAt": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "revokedAt": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "kind",
          "name",
          "publicKey",
          "scopes",
          "createdAt",
          "lastUsedAt",
          "revokedAt"
        ]
      },
      "Bot": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "ownerId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "mode": {
            "enum": [
              "live"
            ],
            "type": "string",
            "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Where stomped connects the match WebSocket (and the `/ping` deploy check). In production it must be `https` — upgraded to `wss` for match sockets — and resolve to a public address (private/loopback ranges rejected), checked at registration and again at every connect. Registered URLs are never exposed publicly."
          },
          "status": {
            "enum": [
              "active",
              "paused"
            ],
            "type": "string",
            "description": "`active` bots are seated by matchmaking whenever idle; `paused` bots sit out. stomped pauses a bot itself after a week-long streak of missed games (see Matchmaking & rating)."
          },
          "createdAt": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "ownerId",
          "name",
          "mode",
          "url",
          "status",
          "createdAt"
        ]
      },
      "MatchSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "mode": {
            "enum": [
              "live"
            ],
            "type": "string",
            "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
          },
          "status": {
            "enum": [
              "running",
              "completed",
              "aborted"
            ],
            "type": "string",
            "description": "`aborted` = the mode's wall clock expired, neither bot connected, or the engine failed: no placements, no rating changes."
          },
          "stage": {
            "anyOf": [
              {
                "enum": [
                  31,
                  32,
                  28,
                  8,
                  2,
                  3
                ],
                "description": "A legal stage's slp id (`Stage` in @slippi/slippi-js)."
              },
              {
                "type": "null"
              }
            ],
            "description": "Null until the game starts."
          },
          "players": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MatchPlayer"
            }
          },
          "summary": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/GameSummary"
              },
              {
                "type": "null"
              }
            ],
            "description": "The result, once the match ends."
          },
          "replayBytes": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": -9007199254740991,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "Size of the .slp at `GET /matches/{id}/replay.slp`; null while running, and once the arena's retention period has deleted the replay."
          },
          "startedAt": {
            "type": "string"
          },
          "endedAt": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "mode",
          "status",
          "stage",
          "players",
          "summary",
          "replayBytes",
          "startedAt",
          "endedAt"
        ]
      },
      "MatchPlayer": {
        "type": "object",
        "properties": {
          "port": {
            "anyOf": [
              {
                "const": 0
              },
              {
                "const": 1
              }
            ],
            "description": "0 or 1 — the Slippi player index."
          },
          "botId": {
            "type": "string"
          },
          "botName": {
            "type": "string"
          },
          "character": {
            "anyOf": [
              {
                "enum": [
                  0,
                  1,
                  2,
                  3,
                  4,
                  5,
                  6,
                  7,
                  8,
                  9,
                  10,
                  11,
                  12,
                  13,
                  14,
                  15,
                  16,
                  17,
                  18,
                  19,
                  20,
                  21,
                  22,
                  23,
                  24,
                  25
                ],
                "description": "A playable character's slp external id (`Character` in @slippi/slippi-js): 0 Captain Falcon … 25 Ganondorf."
              },
              {
                "type": "null"
              }
            ],
            "description": "Null until the game starts."
          },
          "costume": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 5,
                "description": "Costume index, 0 = default."
              },
              {
                "type": "null"
              }
            ]
          },
          "placement": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": -9007199254740991,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "1 = won, 2 = lost; a draw is 1 for both. Null while running."
          },
          "score": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": -9007199254740991,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "Stocks left at the end (a forfeit scores 0 against 4). Null while running."
          },
          "ratingDelta": {
            "anyOf": [
              {
                "type": "number"
              },
              {
                "type": "null"
              }
            ],
            "description": "Rating after minus before, once the match completes."
          }
        },
        "required": [
          "port",
          "botId",
          "botName",
          "character",
          "costume",
          "placement",
          "score",
          "ratingDelta"
        ]
      },
      "Match": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "mode": {
            "enum": [
              "live"
            ],
            "type": "string",
            "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
          },
          "status": {
            "enum": [
              "running",
              "completed",
              "aborted"
            ],
            "type": "string",
            "description": "`aborted` = the mode's wall clock expired, neither bot connected, or the engine failed: no placements, no rating changes."
          },
          "stage": {
            "anyOf": [
              {
                "enum": [
                  31,
                  32,
                  28,
                  8,
                  2,
                  3
                ],
                "description": "A legal stage's slp id (`Stage` in @slippi/slippi-js)."
              },
              {
                "type": "null"
              }
            ],
            "description": "Null until the game starts."
          },
          "players": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MatchPlayer"
            }
          },
          "summary": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/GameSummary"
              },
              {
                "type": "null"
              }
            ],
            "description": "The result, once the match ends."
          },
          "replayBytes": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": -9007199254740991,
                "maximum": 9007199254740991
              },
              {
                "type": "null"
              }
            ],
            "description": "Size of the .slp at `GET /matches/{id}/replay.slp`; null while running, and once the arena's retention period has deleted the replay."
          },
          "startedAt": {
            "type": "string"
          },
          "endedAt": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "seed": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "The engine's random seed. Null while the match runs — public once it ends."
          }
        },
        "required": [
          "id",
          "mode",
          "status",
          "stage",
          "players",
          "summary",
          "replayBytes",
          "startedAt",
          "endedAt",
          "seed"
        ]
      },
      "GameSummary": {
        "type": "object",
        "properties": {
          "stage": {
            "enum": [
              31,
              32,
              28,
              8,
              2,
              3
            ],
            "description": "A legal stage's slp id (`Stage` in @slippi/slippi-js)."
          },
          "players": {
            "type": "array",
            "prefixItems": [
              {
                "type": "object",
                "properties": {
                  "character": {
                    "enum": [
                      0,
                      1,
                      2,
                      3,
                      4,
                      5,
                      6,
                      7,
                      8,
                      9,
                      10,
                      11,
                      12,
                      13,
                      14,
                      15,
                      16,
                      17,
                      18,
                      19,
                      20,
                      21,
                      22,
                      23,
                      24,
                      25
                    ],
                    "description": "A playable character's slp external id (`Character` in @slippi/slippi-js): 0 Captain Falcon … 25 Ganondorf."
                  },
                  "costume": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 5,
                    "description": "Costume index, 0 = default."
                  },
                  "stocks": {
                    "type": "integer",
                    "minimum": -9007199254740991,
                    "maximum": 9007199254740991,
                    "description": "Stocks left at the end."
                  },
                  "percent": {
                    "type": "number",
                    "description": "Damage at the end."
                  },
                  "missed": {
                    "type": "integer",
                    "minimum": -9007199254740991,
                    "maximum": 9007199254740991,
                    "default": 0,
                    "description": "Frames polled without a pad from this bot; each one was played neutral."
                  }
                },
                "required": [
                  "character",
                  "costume",
                  "stocks",
                  "percent"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "character": {
                    "enum": [
                      0,
                      1,
                      2,
                      3,
                      4,
                      5,
                      6,
                      7,
                      8,
                      9,
                      10,
                      11,
                      12,
                      13,
                      14,
                      15,
                      16,
                      17,
                      18,
                      19,
                      20,
                      21,
                      22,
                      23,
                      24,
                      25
                    ],
                    "description": "A playable character's slp external id (`Character` in @slippi/slippi-js): 0 Captain Falcon … 25 Ganondorf."
                  },
                  "costume": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 5,
                    "description": "Costume index, 0 = default."
                  },
                  "stocks": {
                    "type": "integer",
                    "minimum": -9007199254740991,
                    "maximum": 9007199254740991,
                    "description": "Stocks left at the end."
                  },
                  "percent": {
                    "type": "number",
                    "description": "Damage at the end."
                  },
                  "missed": {
                    "type": "integer",
                    "minimum": -9007199254740991,
                    "maximum": 9007199254740991,
                    "default": 0,
                    "description": "Frames polled without a pad from this bot; each one was played neutral."
                  }
                },
                "required": [
                  "character",
                  "costume",
                  "stocks",
                  "percent"
                ]
              }
            ],
            "description": "By port."
          },
          "frames": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991,
            "description": "Frames played, from frame 0 to the last."
          },
          "endReason": {
            "enum": [
              "stocks",
              "time",
              "forfeit",
              "engine"
            ],
            "type": "string",
            "description": "`stocks`: last stock standing. `time`: the timer ran out — more stocks wins, then lower percent; equal is a draw. `forfeit`: no pad of a bot's landed for 300 consecutive polls, or it never connected. `engine`: the engine broke; the match is `aborted` and unrated."
          },
          "winner": {
            "anyOf": [
              {
                "anyOf": [
                  {
                    "const": 0
                  },
                  {
                    "const": 1
                  }
                ],
                "description": "0 or 1 — the Slippi player index."
              },
              {
                "type": "null"
              }
            ],
            "description": "Null = draw (or no result on `engine`)."
          }
        },
        "required": [
          "stage",
          "players",
          "frames",
          "endReason",
          "winner"
        ],
        "description": "The result of a finished game, or where an aborted one stood."
      },
      "LiveMatch": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "mode": {
            "enum": [
              "live"
            ],
            "type": "string",
            "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
          },
          "stage": {
            "anyOf": [
              {
                "enum": [
                  31,
                  32,
                  28,
                  8,
                  2,
                  3
                ],
                "description": "A legal stage's slp id (`Stage` in @slippi/slippi-js)."
              },
              {
                "type": "null"
              }
            ],
            "description": "Null while the bots pick."
          },
          "frame": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991,
            "description": "The latest frame the engine reported."
          },
          "players": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "port": {
                  "anyOf": [
                    {
                      "const": 0
                    },
                    {
                      "const": 1
                    }
                  ],
                  "description": "0 or 1 — the Slippi player index."
                },
                "botId": {
                  "type": "string"
                },
                "botName": {
                  "type": "string"
                },
                "character": {
                  "anyOf": [
                    {
                      "enum": [
                        0,
                        1,
                        2,
                        3,
                        4,
                        5,
                        6,
                        7,
                        8,
                        9,
                        10,
                        11,
                        12,
                        13,
                        14,
                        15,
                        16,
                        17,
                        18,
                        19,
                        20,
                        21,
                        22,
                        23,
                        24,
                        25
                      ],
                      "description": "A playable character's slp external id (`Character` in @slippi/slippi-js): 0 Captain Falcon … 25 Ganondorf."
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "costume": {
                  "anyOf": [
                    {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 5,
                      "description": "Costume index, 0 = default."
                    },
                    {
                      "type": "null"
                    }
                  ]
                },
                "stocks": {
                  "type": "integer",
                  "minimum": -9007199254740991,
                  "maximum": 9007199254740991
                },
                "percent": {
                  "type": "number"
                },
                "x": {
                  "type": "number"
                },
                "y": {
                  "type": "number"
                }
              },
              "required": [
                "port",
                "botId",
                "botName",
                "character",
                "costume",
                "stocks",
                "percent",
                "x",
                "y"
              ]
            }
          },
          "startedAt": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "mode",
          "stage",
          "frame",
          "players",
          "startedAt"
        ]
      },
      "LeaderboardRow": {
        "type": "object",
        "properties": {
          "rank": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "botId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "owner": {
            "type": "string",
            "description": "The owner's public handle."
          },
          "rating": {
            "type": "number",
            "description": "Conservative openskill estimate `mu − 3σ`, rounded to 1dp. New bots start ~0."
          },
          "gamesPlayed": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "wins": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "losses": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "draws": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991,
            "description": "Ties on time and double forfeits."
          }
        },
        "required": [
          "rank",
          "botId",
          "name",
          "owner",
          "rating",
          "gamesPlayed",
          "wins",
          "losses",
          "draws"
        ]
      },
      "PickRequest": {
        "type": "object",
        "properties": {
          "id": {
            "const": 0,
            "description": "Always 0 — echo it in the answer."
          },
          "kind": {
            "const": "pick"
          },
          "matchId": {
            "type": "string"
          },
          "mode": {
            "enum": [
              "live"
            ],
            "type": "string",
            "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
          },
          "port": {
            "anyOf": [
              {
                "const": 0
              },
              {
                "const": 1
              }
            ],
            "description": "Your port, 0 or 1 — the Slippi player index in the event stream."
          },
          "opponent": {
            "type": "object",
            "properties": {
              "botId": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            },
            "required": [
              "botId",
              "name"
            ]
          },
          "deadlineMs": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991,
            "description": "How long you have to answer, in milliseconds. Late = random character."
          },
          "inputDelayFrames": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991,
            "description": "The mode's lead: having seen frame F, send the pad for F + 1 + inputDelayFrames."
          }
        },
        "required": [
          "id",
          "kind",
          "matchId",
          "mode",
          "port",
          "opponent",
          "deadlineMs",
          "inputDelayFrames"
        ],
        "description": "The one JSON text frame of a match, server → bot: pick a character."
      },
      "PickResponse": {
        "type": "object",
        "properties": {
          "id": {
            "const": 0
          },
          "action": {
            "type": "object",
            "properties": {
              "character": {
                "enum": [
                  0,
                  1,
                  2,
                  3,
                  4,
                  5,
                  6,
                  7,
                  8,
                  9,
                  10,
                  11,
                  12,
                  13,
                  14,
                  15,
                  16,
                  17,
                  18,
                  19,
                  20,
                  21,
                  22,
                  23,
                  24,
                  25
                ],
                "description": "A playable character's slp external id (`Character` in @slippi/slippi-js): 0 Captain Falcon … 25 Ganondorf."
              },
              "costume": {
                "type": "integer",
                "minimum": 0,
                "maximum": 5,
                "description": "Costume index, 0 = default."
              }
            },
            "required": [
              "character",
              "costume"
            ]
          }
        },
        "required": [
          "id",
          "action"
        ],
        "description": "The answer, bot → server, as a JSON text frame. Invalid or late: a random character, costume 0."
      },
      "Pad": {
        "type": "object",
        "properties": {
          "buttons": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535
          },
          "stickX": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          },
          "stickY": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          },
          "cstickX": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          },
          "cstickY": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          },
          "l": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          },
          "r": {
            "type": "integer",
            "minimum": 0,
            "maximum": 255
          }
        },
        "required": [
          "buttons",
          "stickX",
          "stickY",
          "cstickX",
          "cstickY",
          "l",
          "r"
        ],
        "description": "One controller state. On the wire it is 8 bytes after the i32 frame."
      }
    },
    "securitySchemes": {
      "signature": {
        "type": "apiKey",
        "in": "header",
        "name": "x-stomped-key",
        "description": "Ed25519 request signature (the default key kind): send `x-stomped-key` (key id), `x-stomped-timestamp`, and `x-stomped-signature` over `{timestamp}.{METHOD}.{path}.{body}`. No secret on the wire; a captured request stays valid for the same endpoint within a ±5 minute window (there is no nonce) — see the Automation (optional) section."
      },
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "A bearer API key: `Authorization: Bearer stp_…`, created in the dashboard (the secret is shown once). The basic option — see the Automation (optional) section."
      }
    }
  },
  "paths": {
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Liveness probe",
        "tags": [
          "meta"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "const": "ok"
                    }
                  },
                  "required": [
                    "status"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/me": {
      "get": {
        "operationId": "account.me",
        "summary": "Get your account",
        "description": "The calling account. `name` is the public handle (shown as `@name` on the leaderboard) — empty until one is picked.",
        "tags": [
          "account"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "account.read"
            ]
          },
          {
            "signature": [
              "account.read"
            ]
          }
        ]
      }
    },
    "/me/handle": {
      "post": {
        "operationId": "account.setHandle",
        "summary": "Set your handle",
        "description": "Sets the public handle, which doubles as the display name.",
        "tags": [
          "account"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "handle": {
                    "type": "string",
                    "pattern": "^[a-z0-9_]{2,32}$",
                    "description": "2–32 chars: lowercase letters, digits, underscores. Unique across stomped."
                  }
                },
                "required": [
                  "handle"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "account.write"
            ]
          },
          {
            "signature": [
              "account.write"
            ]
          }
        ]
      }
    },
    "/keys": {
      "post": {
        "operationId": "account.keys.create",
        "summary": "Create an API key",
        "description": "Creates an API key of either kind — see Automation (optional). A `signature` key (the default) registers the **public** half of an Ed25519 keypair — the private key never leaves you — and requests are authenticated by signing them with it. A `bearer` key returns a `stp_…` secret **once** (stomped stores only a hash); send it as `Authorization: Bearer …`. Scopes are fixed at creation; grant a key only what it needs (a bot-deploy script wants `bots.read` + `bots.write`, nothing more). An account holds at most 20 unrevoked keys (409 when full).",
        "tags": [
          "account"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "anyOf": [
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "const": "bearer"
                      },
                      "name": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 64
                      },
                      "scopes": {
                        "type": "array",
                        "minItems": 1,
                        "items": {
                          "enum": [
                            "bots.read",
                            "bots.write",
                            "keys.read",
                            "keys.write",
                            "account.read",
                            "account.write"
                          ],
                          "type": "string"
                        }
                      }
                    },
                    "required": [
                      "kind",
                      "name",
                      "scopes"
                    ]
                  },
                  {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "const": "signature"
                      },
                      "name": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 64
                      },
                      "publicKey": {
                        "type": "string",
                        "description": "An Ed25519 public key in any common keygen format: an OpenSSH `ssh-ed25519 …` line (the `.pub` from `ssh-keygen -t ed25519`), a PEM `PUBLIC KEY` block (`openssl pkey -pubout`), or the raw 32 bytes as base64. Stored and echoed as raw-32-byte base64."
                      },
                      "scopes": {
                        "type": "array",
                        "minItems": 1,
                        "items": {
                          "enum": [
                            "bots.read",
                            "bots.write",
                            "keys.read",
                            "keys.write",
                            "account.read",
                            "account.write"
                          ],
                          "type": "string"
                        }
                      }
                    },
                    "required": [
                      "kind",
                      "name",
                      "publicKey",
                      "scopes"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "kind": {
                      "enum": [
                        "bearer",
                        "signature"
                      ],
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "publicKey": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "The registered key for `signature` keys, else null."
                    },
                    "scopes": {
                      "type": "array",
                      "items": {
                        "enum": [
                          "bots.read",
                          "bots.write",
                          "keys.read",
                          "keys.write",
                          "account.read",
                          "account.write"
                        ],
                        "type": "string"
                      }
                    },
                    "createdAt": {
                      "type": "string"
                    },
                    "lastUsedAt": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "revokedAt": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "secret": {
                      "type": "string",
                      "description": "Bearer keys only: the `stp_…` token, shown exactly once — stomped stores a hash."
                    }
                  },
                  "required": [
                    "id",
                    "kind",
                    "name",
                    "publicKey",
                    "scopes",
                    "createdAt",
                    "lastUsedAt",
                    "revokedAt"
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "keys.write"
            ]
          },
          {
            "signature": [
              "keys.write"
            ]
          }
        ]
      },
      "get": {
        "operationId": "account.keys.list",
        "summary": "List your API keys",
        "description": "Every key on the account, including revoked ones (metadata only).",
        "tags": [
          "account"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ApiKeyMeta"
                      }
                    }
                  },
                  "required": [
                    "keys"
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "keys.read"
            ]
          },
          {
            "signature": [
              "keys.read"
            ]
          }
        ]
      }
    },
    "/keys/{id}/revoke": {
      "post": {
        "operationId": "account.keys.revoke",
        "summary": "Revoke an API key",
        "description": "Revocation is immediate: the bearer token or signatures from this key stop authenticating.",
        "tags": [
          "account"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyMeta"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "keys.write"
            ]
          },
          {
            "signature": [
              "keys.write"
            ]
          }
        ]
      }
    },
    "/bots": {
      "post": {
        "operationId": "bots.register",
        "summary": "Register a bot",
        "description": "Registers an HTTP server you host as a bot. Before accepting, stomped runs the **deploy check**: `GET {url}/ping` must answer with the body `pong`, and the URL must accept a signed WebSocket upgrade (`x-stomped-match: deploy-check`) — proof your server is live and ready to play; registration is refused otherwise. `name` must be unique among your own bots (duplicate → `409`). `mode` is fixed for the bot's lifetime — same server, second mode? Register a second bot pointing at it. New bots start `active` and enter matchmaking on the next tick. Register as many bots as you like; bots of one account are never matched against each other.",
        "tags": [
          "bots"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 64
                  },
                  "mode": {
                    "enum": [
                      "live"
                    ],
                    "type": "string",
                    "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
                  },
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Where stomped connects the match WebSocket (and the `/ping` deploy check). In production it must be `https` — upgraded to `wss` for match sockets — and resolve to a public address (private/loopback ranges rejected), checked at registration and again at every connect. Registered URLs are never exposed publicly."
                  }
                },
                "required": [
                  "name",
                  "mode",
                  "url"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Bot"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "bots.write"
            ]
          },
          {
            "signature": [
              "bots.write"
            ]
          }
        ]
      },
      "get": {
        "operationId": "bots.list",
        "summary": "List your bots",
        "description": "Lists your own bots, optionally filtered by mode or status.",
        "tags": [
          "bots"
        ],
        "parameters": [
          {
            "name": "mode",
            "in": "query",
            "schema": {
              "enum": [
                "live"
              ],
              "type": "string",
              "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "enum": [
                "active",
                "paused"
              ],
              "type": "string",
              "description": "`active` bots are seated by matchmaking whenever idle; `paused` bots sit out. stomped pauses a bot itself after a week-long streak of missed games (see Matchmaking & rating)."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "bots": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Bot"
                      }
                    }
                  },
                  "required": [
                    "bots"
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "bots.read"
            ]
          },
          {
            "signature": [
              "bots.read"
            ]
          }
        ]
      }
    },
    "/bots/{id}": {
      "get": {
        "operationId": "bots.get",
        "summary": "Get a bot",
        "description": "Owner-only: returns your own bot and `404`s for anyone else's, so registered URLs stay private. Public surfaces (leaderboard, matches) show only a bot's name and its owner's handle.",
        "tags": [
          "bots"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Bot"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "bots.read"
            ]
          },
          {
            "signature": [
              "bots.read"
            ]
          }
        ]
      },
      "patch": {
        "operationId": "bots.update",
        "summary": "Update a bot",
        "description": "Updates name, URL, or status — never `mode`. Pause (`status: \"paused\"`) before taking your server down: a bot that stops answering forfeits its matches. Pausing takes effect on the next matchmaking tick; a match already running finishes normally.",
        "tags": [
          "bots"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 64
                  },
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Where stomped connects the match WebSocket (and the `/ping` deploy check). In production it must be `https` — upgraded to `wss` for match sockets — and resolve to a public address (private/loopback ranges rejected), checked at registration and again at every connect. Registered URLs are never exposed publicly."
                  },
                  "status": {
                    "enum": [
                      "active",
                      "paused"
                    ],
                    "type": "string",
                    "description": "`active` bots are seated by matchmaking whenever idle; `paused` bots sit out. stomped pauses a bot itself after a week-long streak of missed games (see Matchmaking & rating)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Bot"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "bots.write"
            ]
          },
          {
            "signature": [
              "bots.write"
            ]
          }
        ]
      },
      "delete": {
        "operationId": "bots.remove",
        "summary": "Remove a bot",
        "description": "Removes the bot. Completed matches keep their record.",
        "tags": [
          "bots"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": [
              "bots.write"
            ]
          },
          {
            "signature": [
              "bots.write"
            ]
          }
        ]
      }
    },
    "/matches": {
      "get": {
        "operationId": "matches.list",
        "summary": "List matches",
        "description": "Matches, newest first. Page backwards with `before`; the newest 10,000 matches are reachable. Public — no auth needed.",
        "tags": [
          "matches"
        ],
        "parameters": [
          {
            "name": "mode",
            "in": "query",
            "schema": {
              "enum": [
                "live"
              ],
              "type": "string",
              "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "botId",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 20
            },
            "allowEmptyValue": true,
            "allowReserved": true
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "description": "A match id: only matches that started before it. Pass the last id of the previous page to get the next one."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matches": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/MatchSummary"
                      }
                    }
                  },
                  "required": [
                    "matches"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/matches/{id}": {
      "get": {
        "operationId": "matches.get",
        "summary": "Get a match",
        "description": "The permanent match record. The replay itself is a file: `GET /matches/{id}/replay.slp`, available once the match completes. Public — no auth needed.",
        "tags": [
          "matches"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Match"
                }
              }
            }
          }
        }
      }
    },
    "/live": {
      "get": {
        "operationId": "live.list",
        "summary": "List running matches",
        "description": "Every running match with each port's latest stocks, percent and position.",
        "tags": [
          "live"
        ],
        "parameters": [
          {
            "name": "mode",
            "in": "query",
            "schema": {
              "enum": [
                "live"
              ],
              "type": "string",
              "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matches": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LiveMatch"
                      }
                    }
                  },
                  "required": [
                    "matches"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/live/{id}": {
      "get": {
        "operationId": "live.get",
        "summary": "Spectate a running match",
        "description": "One running match; `404` once it has ended (read its record instead).",
        "tags": [
          "live"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LiveMatch"
                }
              }
            }
          }
        }
      }
    },
    "/leaderboard": {
      "get": {
        "operationId": "leaderboard.get",
        "summary": "Get the leaderboard",
        "description": "Every bot with at least one completed match, ranked by conservative openskill rating (see Matchmaking & rating). Public — no auth needed.",
        "tags": [
          "leaderboard"
        ],
        "parameters": [
          {
            "name": "mode",
            "in": "query",
            "required": true,
            "schema": {
              "enum": [
                "live"
              ],
              "type": "string",
              "description": "`live`: the game runs on a real 60 Hz clock; a pad that has not arrived when its frame is polled is neutral."
            },
            "allowEmptyValue": true,
            "allowReserved": true
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "rows": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LeaderboardRow"
                      }
                    }
                  },
                  "required": [
                    "rows"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/matches/{id}/replay.slp": {
      "get": {
        "operationId": "matches.replay",
        "tags": [
          "matches"
        ],
        "summary": "Download a replay",
        "description": "The match's .slp file, exactly what both bots were streamed. `404` while the match runs or when no game was played (a forfeit before the first frame). Public — no auth needed; counts against the match-reads limit.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The replay, as `attachment; filename=\"<id>.slp\"`.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/stomped.json": {
      "get": {
        "operationId": "wellKnown",
        "tags": [
          "meta"
        ],
        "summary": "Platform public key",
        "description": "stomped's Ed25519 platform public key — every request stomped sends to a bot is signed with its private half. Fetch once and cache.",
        "responses": {
          "200": {
            "description": "The key, as base64 of the raw 32 bytes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "publicKey": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "publicKey"
                  ]
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "ping": {
      "get": {
        "tags": [
          "your bot"
        ],
        "summary": "Deploy check (GET {your url}/ping)",
        "description": "Called when you register the bot. Your server must answer with the body `pong`, and the URL must also accept a signed WebSocket upgrade (the probe handshake carries `x-stomped-match: deploy-check`) — otherwise registration is refused.",
        "responses": {
          "200": {
            "description": "The literal body `pong`.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "pong"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "connect": {
      "get": {
        "tags": [
          "your bot"
        ],
        "summary": "Match socket (WebSocket upgrade of {your url})",
        "description": "A match is one WebSocket: stomped connects when the match starts and the socket closes when it ends. One JSON text frame asks for your character (PickRequest → PickResponse); then the game streams in as binary .slp events, one message per frame, and you answer with 12-byte pads (Pad), one per frame. A missing pad is neutral; 5 s of game time without one is a forfeit. stomped reconnects if your server drops. See the Play protocol section.",
        "parameters": [
          {
            "name": "x-stomped-match",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The match id this socket will carry (`deploy-check` for the probe)."
          },
          {
            "name": "x-stomped-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Unix milliseconds. Reject if more than 30s from your clock."
          },
          {
            "name": "x-stomped-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`base64(ed25519_sign(\"<timestamp>.<matchId>\"))` under stomped's platform key (published at `/.well-known/stomped.json`). Verifying is optional — the URL only you and stomped know is the baseline security."
          }
        ],
        "responses": {
          "101": {
            "description": "Switching Protocols — the match now flows over this socket: the pick as text, then .slp events in and pads out as binary."
          }
        }
      }
    }
  }
}