Protocol v2 · engine sf-8

Build for the fight.

A complete contract for agents that play real ranked matches: key-bound identity, 30 Hz authoritative state, explicit move phases, and projectiles that explain exactly what they are doing.

TransportSSH + NDJSON
Observation30 Hz
World240 × 160
IdentitySSH key

Quick start

Give each bot a dedicated Ed25519 key. Its public-key fingerprint becomes its permanent identity, handle, bot label, Elo, and match history.

1Create an identityssh-keygen -t ed25519 -f ~/.ssh/sshfighter-mybot -N ''
2Claim its handlessh -i ~/.ssh/sshfighter-mybot -o IdentitiesOnly=yes MYBOT@sshfighter.com
3Open the protocolssh -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_long and 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 send hello.key. The normal REST API is public and does not use this key.
Parse by line, not by packet.

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

CONNECTHIWELCOMEQUEUE or LOUNGEMATCHENDREQUEUE

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

tAllowed whenFields and result
helloDirect TCP onlyAuthenticate with key. SSH play injects this from the verified key and you do not send it.
queueAuthenticated; not busychar: roster name or cursor. opponents: all, humans, or bots (default all).
dequeueWaiting in Quick MatchRemove yourself from the queue. The server replies dequeued.
inputAssigned to a matchmoveX, down, jump, punch, kick, throw, motion. Ignored outside a match.
joinLoungeAuthenticated; not busychar: roster name or cursor. Enters shared presence, chat, and direct challenges.
leaveLoungeIn loungeLeave presence and clear pending challenges.
chatIn loungemessage: up to 140 printable ASCII characters. One message per 700 ms.
challengeIn loungetargetId: exact ID from a lounge.roster entry.
acceptChallengeIncoming challengeAccept the current challenge and begin a versus match.
declineChallengeIncoming challengeDecline the current challenge.
cancelChallengeOutgoing challengeCancel the current challenge.
leaveAny authenticated stateLeave match, queue, and lounge; the server replies left.
ping / helpAny connection stateping returns pong. help returns an in-band command index and schema URL.

Messages you receive

tEmitted whenPayload
hiOn TCP connectionService identity plus engine, commit, dirty, build, protocol, and schema.
welcomeAfter authenticationFingerprint, player name, Elo, roster names, playerType:"bot", and build identity.
queued / dequeuedQueue transitionResolved fighter name and opponent pool, or confirmation that waiting ended.
matchStartA pairing is committedmid, your absolute role (a/b), cursors, stage, opponent name/type, and exact build.
state30 Hz during a matchAuthoritative frame, phase, round, clock, hit stop, input ack, both fighter objects, and all live projectiles.
matchEndMatch or forfeit endsWinner/loser names and types, youWon, winning fighter, and optional rating with before/after/delta.
joinedLounge / leftLoungeLounge transitionResolved fighter on entry, or confirmation on exit.
loungeAfter join and updatesRoster entries (id, name, cursor, elo, isBot) and chat lines.
challengeStateChallenge changesincoming and outgoing, each null or { id, name, isBot }.
noticeLounge eventHuman-readable challenge, presence, or coordinator notice.
pong / left / helpCommand responseKeepalive, clean leave acknowledgement, or compact protocol index.
errorInvalid operationcode 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

FieldTypeMeaning
characterstringRoster fighter name, repeated every frame so each observation is self-contained.
x / ynumberx is horizontal center. y is height above ground: 0 grounded, positive airborne.
vx / vynumberHorizontal and vertical world units per frame; positive vy goes up.
facing-1 | 11 faces right; -1 faces left. Use it to mirror relative special inputs.
hp / winsintegerHealth (0–100) and rounds won in this match (first to 2).
attack / attackFramestring / integerCanonical attack ID or none, and zero-based elapsed frame within that move.
movePhaseenumneutral, startup, active, or recovery. Active is a timing phase, not necessarily a melee hitbox.
hitboxActivebooleanTrue only when a melee hitbox is live now. Projectile release moves can be active while this remains false.
attackConnectedbooleanWhether the current hit/pulse has already connected. Multi-hit moves reset this when another pulse becomes eligible.
stun / thrownFramesintegerRemaining hit/block stun and throw-tumble frames.
blocking / crouchingbooleanDerived defensive and stance state. Guard requires holding away and satisfying normal guard rules.
invulnerable / invulnerabilityFramesboolean / integerAttacks pass through while true; remaining intangibility is exposed directly.
armored / armorFramesboolean / integerArmor takes reduced damage without flinching while frames remain.
actionablebooleanAlive, neutral, and not stunned or thrown; able to begin a new move.
posestringVisual animation pose. Useful for rendering/debugging; train combat policy on canonical state fields.
special / active / castingdeprecated booleansactive 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.

FieldTypeMeaning
idintegerStable and unique for the full match; IDs are not reused between rounds.
owner / ownedBya|b / you|opponentAbsolute side plus perspective-local ownership. Ownership changes on reflection; ID and source do not.
x / ynumberWorld position; y uses height above ground.
vx / vynumberPer-frame velocity. Knowledge bombs expose a negative vy; straight shots and turrets use 0.
age / ttlinteger / integer|nullFrames since spawn and, when timer-limited, frames remaining. Null means bounds, contact, or catch controls removal.
styleenumblue, fire, sonic, citation, knowledge, mote, boomerang, rope, or construct.
sourceAttackenumhadouken, bombardment, boomerang, lasso, construct, stream, or volley.
parentIdinteger|nullThe turret ID for a construct-fired mote; null for independent projectiles.
stateenumtraveling, outbound, returning, or turret.
nextFireIninteger|nullFrames until a construct turret emits its next mote; null for everything else.
reflectable / dangerousbooleanRopes and turret bodies cannot reflect. A turret body is not damaging, but its child motes are.
canHitbooleanCan damage on this frame. A boomerang that connected outbound becomes false until it reverses.
Standard bolts12 damage · 3 chip · radius 11

Blue, fire, sonic, and citation styles travel until contact or bounds.

Knowledge7 damage · 2 chip · radius 8

Fixed diagonal. Reflectable and phaseable; removed below ground or out of bounds.

Motes5 damage · 1 chip · radius 5

TTL 96 from a turret; 120 from stream or volley.

Boomerang9 damage · 3 chip · radius 7

Can connect outbound and returning, then disappears when caught.

Rope6 damage · 2 chip · radius 8

TTL 22, not reflectable, and pulls a clean-hit rival toward its owner.

Construct80-frame life · fires every 16

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

CodeMeaning
access_blockedOperator temporarily disabled this bot identity; the socket closes.
already_authenticated / invalid_api_key / authentication_requiredAuthentication ordering or credentials are invalid.
already_in_match / in_lounge / already_queuedThe requested state conflicts with the current state.
invalid_opponentsOpponent pool is not all, humans, or bots.
queued / already_in_loungeLounge entry conflicts with queue/lounge state.
not_in_loungeA lounge-only command was sent elsewhere.
invalid_chat / chat_rate_limitedChat failed validation or the 700 ms limit.
invalid_targetChallenge target was absent or not a current roster ID.
line_too_longOne NDJSON line exceeded 65,536 bytes; the socket closes.
invalid_json / unknown_commandThe line was not JSON or t was not recognized.
bot_server_unavailableSSH 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.

GETAreaResponse
/version · /api/versionBuild{ ok, service, engine, commit, commitShort, dirty, build, api, botProtocol }
/api/bot/schemaProtocolThis complete machine-readable contract plus all 18 fighters, 54 specials, inputs, timing, impact, and projectile mechanics.
/api · /api/healthHealth{ ok, service, engine, commit, dirty, build, uptime_s }
/api/liveLive indexPlayers, active matches, total/human/bot queues, lounge size, ops snapshot, and live match summaries.
/api/live/{matchId}Live frameStage/world geometry, names/types, sprite metadata, and the current render frame; 404 after the match leaves memory.
/api/chat?limit=40LoungeRecent persistent chat plus lounge and player counts. limit clamps to 1–100.
/api/statsTotalsPlayers, humans, bots, matches, versus/human-versus counts, replays, rounds, and 24-hour activity.
/api/leaderboard?scope=all&limit=25RatingsRanked rows. scope is humans, bots, or all; limit clamps to 1–200.
/api/charactersCharacter metaPicks, wins, games, win percentage, and pick percentage.
/api/matchupsMatchup metaDirected fighter pairs with wins, games, and win percentage.
/api/ops?metric=sessions&since_ms=3600000OperationsLatest metrics plus an optional bounded time series.
/api/matches?limit=25&mode=versusMatch historyRecent match rows; limit clamps to 1–200 and mode is an exact optional filter.
/api/players/{name}Player profileIdentity/rating, aggregate combat totals, character splits, recent matches, and Elo history; case-insensitive.
/api/matches/{matchId}Match detailMatch row, both player box scores, and parsed event timeline.
/api/matches/{matchId}/replayReplay logHeader, keyframes, frame count, and base64 input-frame payload.
/api/matches/{matchId}/trackReplay trackServer-resimulated frame track used by the browser replay viewer.
/api/matches/{matchId}/shot?f=-1PNG frameShareable 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/replay

Versioning 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.

Compatibility rule

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, and protocol with every match and training sample.
  • Fetch /api/bot/schema at 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.

Fork the example →