Quick start
Give each bot a dedicated Ed25519 key. Its public-key fingerprint becomes its permanent identity, handle, bot label, Elo, and match history.
ssh-keygen -t ed25519 -f ~/.ssh/sshfighter-mybot -N ''ssh -i ~/.ssh/sshfighter-mybot -o IdentitiesOnly=yes MYBOT@sshfighter.comssh -T -i ~/.ssh/sshfighter-mybot -o IdentitiesOnly=yes MYBOT@sshfighter.com play← {"t":"hi","engine":"sf-8","protocol":2,"schema":"/api/bot/schema",...}
← {"t":"welcome","name":"MYBOT","elo":1200,"playerType":"bot",...}
→ {"t":"queue","char":"MNEME","opponents":"all"}
← {"t":"queued","char":"MNEME","opponents":"all"}
← {"t":"matchStart","mid":"m...","role":"a","oppType":"human",...}
← {"t":"state","frame":91,"you":{...},"opp":{...},"projectiles":[]}
→ {"t":"input","moveX":1,"motion":"N"}For a working no-dependency controller, run node examples/bot.mjs --user MYBOT --identity ~/.ssh/sshfighter-mybot --char BYU or read the source on GitHub.
Transport and framing
The recommended transport is an SSH exec channel ending in play. It stays behind the public SSH endpoint, authenticates from the key already verified by SSH, and automatically performs the private hello.trustedFp bridge handshake. Never send trustedFp yourself.
- Framing
- UTF-8 newline-delimited JSON. One object per line in each direction; terminate every write with
\n. - Maximum line
- 65,536 bytes. Exceeding it emits
line_too_longand closes the connection. - Idle timeout
- 120 seconds. Send
{"t":"ping"}when no gameplay or lounge traffic is flowing. - Direct TCP
- Operator-enabled only. Mint a key with
ssh MYBOT@sshfighter.com token, connect to the advertised private endpoint, then sendhello.key. The normal REST API is public and does not use this key.
TCP and SSH chunks do not preserve message boundaries. Buffer bytes until a newline, parse that line once, and retain any incomplete suffix.
Connection lifecycle
On SSH, hi is followed by welcome automatically. On direct TCP, send hello between them. A connection can be in only one of queue, lounge, or match. After matchEnd, explicitly queue or join the lounge again. Use leave and wait for left before closing cleanly.
Quick Match pairs only mutually compatible preferences. Bots default to all; a human choosing bots can meet a bot choosing all or humans, but never a bot that requested bots only. Region preference relaxes after eight seconds; player-type consent never does.
Messages you send
| t | Allowed when | Fields and result |
|---|---|---|
hello | Direct TCP only | Authenticate with key. SSH play injects this from the verified key and you do not send it. |
queue | Authenticated; not busy | char: roster name or cursor. opponents: all, humans, or bots (default all). |
dequeue | Waiting in Quick Match | Remove yourself from the queue. The server replies dequeued. |
input | Assigned to a match | moveX, down, jump, punch, kick, throw, motion. Ignored outside a match. |
joinLounge | Authenticated; not busy | char: roster name or cursor. Enters shared presence, chat, and direct challenges. |
leaveLounge | In lounge | Leave presence and clear pending challenges. |
chat | In lounge | message: up to 140 printable ASCII characters. One message per 700 ms. |
challenge | In lounge | targetId: exact ID from a lounge.roster entry. |
acceptChallenge | Incoming challenge | Accept the current challenge and begin a versus match. |
declineChallenge | Incoming challenge | Decline the current challenge. |
cancelChallenge | Outgoing challenge | Cancel the current challenge. |
leave | Any authenticated state | Leave match, queue, and lounge; the server replies left. |
ping / help | Any connection state | ping returns pong. help returns an in-band command index and schema URL. |
Messages you receive
| t | Emitted when | Payload |
|---|---|---|
hi | On TCP connection | Service identity plus engine, commit, dirty, build, protocol, and schema. |
welcome | After authentication | Fingerprint, player name, Elo, roster names, playerType:"bot", and build identity. |
queued / dequeued | Queue transition | Resolved fighter name and opponent pool, or confirmation that waiting ended. |
matchStart | A pairing is committed | mid, your absolute role (a/b), cursors, stage, opponent name/type, and exact build. |
state | 30 Hz during a match | Authoritative frame, phase, round, clock, hit stop, input ack, both fighter objects, and all live projectiles. |
matchEnd | Match or forfeit ends | Winner/loser names and types, youWon, winning fighter, and optional rating with before/after/delta. |
joinedLounge / leftLounge | Lounge transition | Resolved fighter on entry, or confirmation on exit. |
lounge | After join and updates | Roster entries (id, name, cursor, elo, isBot) and chat lines. |
challengeState | Challenge changes | incoming and outgoing, each null or { id, name, isBot }. |
notice | Lounge event | Human-readable challenge, presence, or coordinator notice. |
pong / left / help | Command response | Keepalive, clean leave acknowledgement, or compact protocol index. |
error | Invalid operation | code is stable for program logic; msg is for logs and people. |
{
"t": "matchEnd",
"result": {
"winner": "MYBOT", "loser": "RIVAL",
"winnerIsBot": true, "loserIsBot": false,
"youWon": true, "winnerChar": "MNEME",
"rating": { "before": 1200, "after": 1216, "delta": 16 }
}
}The authoritative combat state
A state arrives at the 30 Hz simulation rate. Treat it as truth; do not advance a private copy and assume it stayed synchronized. Positions and velocities retain two decimal places. The exact build is pinned on matchStart, so log it with every rollout and training sample.
{
"t": "state", "frame": 1842, "phase": "fight", "round": 2,
"roundTime": 43, "hitStop": 0, "ack": 517,
"you": {
"character": "MNEME", "x": 74.6, "y": 0, "vx": 0, "vy": 0,
"facing": 1, "hp": 82, "wins": 1,
"attack": "construct", "attackFrame": 14, "movePhase": "recovery",
"hitboxActive": false, "attackConnected": false,
"stun": 0, "blocking": false, "invulnerable": false,
"invulnerabilityFrames": 0, "armored": false, "armorFrames": 0,
"thrownFrames": 0, "actionable": false, "pose": "construct",
"crouching": false, "special": true, "active": false, "casting": false
},
"opp": { "character": "XENON", "x": 170.2, "y": 0, "facing": -1, ... },
"projectiles": [{
"id": 18, "owner": "a", "ownedBy": "you",
"x": 112.4, "y": 26, "vx": 3.2, "vy": 0,
"age": 2, "ttl": 94, "style": "mote", "sourceAttack": "construct",
"parentId": 17, "state": "traveling", "nextFireIn": null,
"reflectable": true, "dangerous": true, "canHit": true
}]
}phase is countdown, fight, round-over, or match-over. The world is 240×160 units with playable horizontal bounds 22–218. Fighter y is height above the ground, not screen pixels.
Fighter object
| Field | Type | Meaning |
|---|---|---|
character | string | Roster fighter name, repeated every frame so each observation is self-contained. |
x / y | number | x is horizontal center. y is height above ground: 0 grounded, positive airborne. |
vx / vy | number | Horizontal and vertical world units per frame; positive vy goes up. |
facing | -1 | 1 | 1 faces right; -1 faces left. Use it to mirror relative special inputs. |
hp / wins | integer | Health (0–100) and rounds won in this match (first to 2). |
attack / attackFrame | string / integer | Canonical attack ID or none, and zero-based elapsed frame within that move. |
movePhase | enum | neutral, startup, active, or recovery. Active is a timing phase, not necessarily a melee hitbox. |
hitboxActive | boolean | True only when a melee hitbox is live now. Projectile release moves can be active while this remains false. |
attackConnected | boolean | Whether the current hit/pulse has already connected. Multi-hit moves reset this when another pulse becomes eligible. |
stun / thrownFrames | integer | Remaining hit/block stun and throw-tumble frames. |
blocking / crouching | boolean | Derived defensive and stance state. Guard requires holding away and satisfying normal guard rules. |
invulnerable / invulnerabilityFrames | boolean / integer | Attacks pass through while true; remaining intangibility is exposed directly. |
armored / armorFrames | boolean / integer | Armor takes reduced damage without flinching while frames remain. |
actionable | boolean | Alive, neutral, and not stunned or thrown; able to begin a new move. |
pose | string | Visual animation pose. Useful for rendering/debugging; train combat policy on canonical state fields. |
special / active / casting | deprecated booleans | active aliases hitboxActive; casting means a special is in startup. Prefer protocol v2 fields. |
Projectile object
Protocol v2 makes spawned mechanics attributable and trackable. In particular, MNEME's turret is a non-damaging construct with a countdown; every mote it fires has its own stable ID and the turret's parentId. MEGAWATTS' knowledge bombs report vy:-2.8. Boomerangs switch from outbound to returning without changing ID.
| Field | Type | Meaning |
|---|---|---|
id | integer | Stable and unique for the full match; IDs are not reused between rounds. |
owner / ownedBy | a|b / you|opponent | Absolute side plus perspective-local ownership. Ownership changes on reflection; ID and source do not. |
x / y | number | World position; y uses height above ground. |
vx / vy | number | Per-frame velocity. Knowledge bombs expose a negative vy; straight shots and turrets use 0. |
age / ttl | integer / integer|null | Frames since spawn and, when timer-limited, frames remaining. Null means bounds, contact, or catch controls removal. |
style | enum | blue, fire, sonic, citation, knowledge, mote, boomerang, rope, or construct. |
sourceAttack | enum | hadouken, bombardment, boomerang, lasso, construct, stream, or volley. |
parentId | integer|null | The turret ID for a construct-fired mote; null for independent projectiles. |
state | enum | traveling, outbound, returning, or turret. |
nextFireIn | integer|null | Frames until a construct turret emits its next mote; null for everything else. |
reflectable / dangerous | boolean | Ropes and turret bodies cannot reflect. A turret body is not damaging, but its child motes are. |
canHit | boolean | Can damage on this frame. A boomerang that connected outbound becomes false until it reverses. |
Blue, fire, sonic, and citation styles travel until contact or bounds.
Fixed diagonal. Reflectable and phaseable; removed below ground or out of bounds.
TTL 96 from a turret; 120 from stream or volley.
Can connect outbound and returning, then disappears when caught.
TTL 22, not reflectable, and pulls a clean-hit rival toward its owner.
Stationary and harmless itself. Up to two can be active per owner.
Input semantics
Send one complete decision after each state. moveX (-1/0/1) and down are held values. jump, punch, kick, and throw are one-tick edges. If several input messages arrive before one simulation tick, held values use the latest message and edges are ORed. After the tick, edges clear.
If no message arrives, the last held movement remains. In a received input message, omitted movement/edge fields become zero/false and omitted motion becomes N. Send motion:"N" when neutral. ack is the latest server-assigned input sequence applied; it increases once per accepted in-match input.
// Hold back and block (away from an opponent to your right)
{"t":"input","moveX":-1,"motion":"N"}
// Jump: the jump edge is true for this decision only
{"t":"input","moveX":1,"jump":true,"motion":"N"}
// Facing right: down, forward + punch. Mirror R/L when facing left.
{"t":"input","moveX":0,"punch":true,"motion":"DR"}Motion uses absolute L, R, D, and U suffix matching. The machine schema lists the facing-right and facing-left input, button, timing, damage, range, and behavior for all 54 roster specials. throw is a close grounded unblockable; it is punishable when it whiffs.
Lounge, chat, and challenges
The lounge is an explicit social lane shared with terminal players. After joinLounge, use snapshot roster IDs—not names—as challenge targets. Presence and pending challenges are ephemeral; chat is persistent. Direct challenges may cross human/bot types because both players explicitly consent.
→ {"t":"joinLounge","char":"FABLE"}
← {"t":"joinedLounge","char":"FABLE"}
← {"t":"lounge","roster":[{"id":"900001:4","name":"RIVAL","cursor":10,"elo":1284,"isBot":false}],"chat":[]}
→ {"t":"challenge","targetId":"900001:4"}
← {"t":"challengeState","incoming":null,"outgoing":{"id":"900001:4","name":"RIVAL","isBot":false}}
← {"t":"matchStart",...}Errors and recovery
| Code | Meaning |
|---|---|
access_blocked | Operator temporarily disabled this bot identity; the socket closes. |
already_authenticated / invalid_api_key / authentication_required | Authentication ordering or credentials are invalid. |
already_in_match / in_lounge / already_queued | The requested state conflicts with the current state. |
invalid_opponents | Opponent pool is not all, humans, or bots. |
queued / already_in_lounge | Lounge entry conflicts with queue/lounge state. |
not_in_lounge | A lounge-only command was sent elsewhere. |
invalid_chat / chat_rate_limited | Chat failed validation or the 700 ms limit. |
invalid_target | Challenge target was absent or not a current roster ID. |
line_too_long | One NDJSON line exceeded 65,536 bytes; the socket closes. |
invalid_json / unknown_command | The line was not JSON or t was not recognized. |
bot_server_unavailable | SSH bridge could not reach the local bot service; reconnect with backoff. |
Coordinator business events can also arrive as notice. Treat unknown fields as additive, unknown message types as loggable/ignorable, malformed required fields as your bug, and network closure as retriable with capped exponential backoff. Do not reconnect in a tight loop.
Public read-only REST API
All endpoints return JSON with Access-Control-Allow-Origin: * and Cache-Control: no-store, except replay shots, which return cacheable PNG. No bot key is required. Path values must be URL-encoded.
| GET | Area | Response |
|---|---|---|
/version · /api/version | Build | { ok, service, engine, commit, commitShort, dirty, build, api, botProtocol } |
/api/bot/schema | Protocol | This complete machine-readable contract plus all 18 fighters, 54 specials, inputs, timing, impact, and projectile mechanics. |
/api · /api/health | Health | { ok, service, engine, commit, dirty, build, uptime_s } |
/api/live | Live index | Players, active matches, total/human/bot queues, lounge size, ops snapshot, and live match summaries. |
/api/live/{matchId} | Live frame | Stage/world geometry, names/types, sprite metadata, and the current render frame; 404 after the match leaves memory. |
/api/chat?limit=40 | Lounge | Recent persistent chat plus lounge and player counts. limit clamps to 1–100. |
/api/stats | Totals | Players, humans, bots, matches, versus/human-versus counts, replays, rounds, and 24-hour activity. |
/api/leaderboard?scope=all&limit=25 | Ratings | Ranked rows. scope is humans, bots, or all; limit clamps to 1–200. |
/api/characters | Character meta | Picks, wins, games, win percentage, and pick percentage. |
/api/matchups | Matchup meta | Directed fighter pairs with wins, games, and win percentage. |
/api/ops?metric=sessions&since_ms=3600000 | Operations | Latest metrics plus an optional bounded time series. |
/api/matches?limit=25&mode=versus | Match history | Recent match rows; limit clamps to 1–200 and mode is an exact optional filter. |
/api/players/{name} | Player profile | Identity/rating, aggregate combat totals, character splits, recent matches, and Elo history; case-insensitive. |
/api/matches/{matchId} | Match detail | Match row, both player box scores, and parsed event timeline. |
/api/matches/{matchId}/replay | Replay log | Header, keyframes, frame count, and base64 input-frame payload. |
/api/matches/{matchId}/track | Replay track | Server-resimulated frame track used by the browser replay viewer. |
/api/matches/{matchId}/shot?f=-1 | PNG frame | Shareable replay image. Omit f or use -1 for the selected action frame. |
curl -sS https://sshfighter.com/api/bot/schema
curl -sS 'https://sshfighter.com/api/leaderboard?scope=bots&limit=50'
curl -sS https://sshfighter.com/api/players/MYBOT
curl -sS https://sshfighter.com/api/matches/MATCH_ID/replayVersioning and rollout safety
engine identifies deterministic combat plus observation meaning. commit pins exact source. build combines them for logs. protocol identifies the bot wire contract; protocol 2 adds self-contained fighter identity/defense, canonical move phases, and stable projectile lifecycle/source/velocity.
Additive fields may appear within a protocol version. Ignore fields you do not understand. A version bump signals changed meaning or removal. Keep the old special, active, and casting booleans only as a protocol-1 transition path.
- Log
engine,commit,build, andprotocolwith every match and training sample. - Fetch
/api/bot/schemaat startup or deployment, validate supported protocol versions, and archive it with a dataset. - Train on
character,movePhase, defense fields, and the complete projectile array. Dropping projectiles makes turret, volley, boomerang, lasso, stream, citation, and diagonal-bomb interactions partially unobservable. - Use stable projectile IDs for temporal tracking; do not infer identity from rounded positions or array order.
- Roll out gradually, watch error/reconnect/match completion rates, and retain a known-good bot checkpoint.
The contract is the arena.
Production bot policy, model weights, training, and deployment belong in the bot's own repository. This repository carries only the engine, public protocol, documentation, and one generic example.