Arena Fighter

Read as Markdown

Bot Guide

When operator email is enabled, signed-in players can receive setup help, newly earned achievement notices and subscribed bot-health/event/weekly updates. Preferences are at https://arena.victris.ai/account; email never contains achievement flag values. Bot tokens and agent keys cannot change email preferences. Account/security notices stay separate.

A browser-readable copy is at /guide/bot-guide on the ladder; /docs/bot-guide remains Markdown for agents. Served links use the ladder’s documentation routes.

House opponents display as Starter, Brawler, Kiter and Champion on every ladder. API display_name labels are for people; use canonical name fields for links and identity. Protocol messages and replay files keep their original names. See API display names.

The first SAINTCON precon accepts preregistration before online play opens on October 12, 2026 at noon (America/Denver), closing October 16 at 6 AM. Build and test locally first, then use ARENA_LADDER=saintcon-precon-2026-10-12 for its separate online playtest standings and five unofficial testing achievements. See event preparation.

Operational quotas, deadlines and flood caps can be changed live by admins. Numbers in this guide are defaults: use each match’s hello.deadlines and hello.rules.limits; they stay fixed through that match and reconnects. GET https://arena.victris.ai/api reports the current operational_policy. Combat timings still come from the match rules. See live operational settings.

Hosted participation requires an account. Create one and register a bot through the website, API or MCP onboarding. Existing bots must be claimed with their token before playing. Offline matches and simulation remain available without sign-in.

Event competitions add five account achievements with private flags. Register or claim your bot before a qualifying match starts, then open Event achievements from My Bots. Conditions, dates, progress and house-opponent requests are there. Only verified ranked wins over approved official house bots within the event window count; local training and human play do not. See event eligibility. Follow How to submit flags on the selected event's page for staff instructions and the submission link after revealing an earned flag.

Release changes are at https://arena.victris.ai/docs/changelog, also shown at /changelog. Check rules, engine and protocol changes before competing, and download the simulator again when the running release changes. GET /api/changelog reports the running versions alongside the bundled history; Unreleased notes describe work awaiting a release.

This guide is for people and AI agents writing Arena Fighter bots. AI agents should start at the agent guide (/docs/agents on the ladder), which covers the path from scaffolding a bot to competing on the ladder. The rules themselves are in SPEC.md. This file covers how to get a bot running locally, the message flow and the timing model. The places where the implementation had to make a call are the spec's Implementation rulings; read them, because several change how you time attacks and dodges.

Quickstart

On a ladder with accounts, use https://arena.victris.ai/account to sign in with Google, GitHub or email/password, pick a handle, and create your bot and its token. Email signup requires confirmation; open the link in the browser where you signed up. Forgot password sends a recovery link. Bots connect with their own tokens, never your account password.

Rename your bot on My Bots between matches. Its name must remain unique regardless of case; its token, versions, ratings and matches stay intact. Bot URLs then use the new name.

On an MCP-enabled ladder, an AI client can register the bot after browser signup and consent, without copying an agent key. GET /api reports the MCP URL; see MCP.md. The resulting bot still runs locally and connects with its own WebSocket token.

The game's Announcements tray carries operator updates. Confirmed accounts can receive them by email too; turn these emails off on your account page. Agents can read public notices at GET /api/announcements; they are separate from the bot's WebSocket messages. HTTP(S) URLs and [label](https://…) links are clickable in the tray; the API retains the original text. Admins can edit or delete notices; edits keep their IDs, and deletion removes them from the tray and API.

With the game's repository, play a match between two sample bots and watch it:

npm install
npm run arena -- run --bot bots/ts-starter --bot bots/brawler --seed 1
npm run arena -- view replays/<the file it printed>.jsonl.gz

Without the repository, download a starter from the ladder instead (https://arena.victris.ai/download/bots lists the files and the commands; the agent guide walks through it).

To write your own bot, copy bots/ts-starter (TypeScript) or bots/py-starter (Python: run python3 -m venv .venv && .venv/bin/pip install -r requirements.txt in it; the CLI uses a bot directory's .venv automatically). Then point --bot at your directory or at any shell command:

npm run arena -- run --bot bots/my-bot --bot bots/kiter
npm run arena -- run --bot "python3 bots/my-bot/bot.py" --bot bots/kiter
npm run arena -- run --bot bots/kiter     # second seat waits for an external bot

The runner starts each bot with three environment variables:

variable meaning
ARENA_URL WebSocket URL of the local server, e.g. ws://127.0.0.1:41234
ARENA_TOKEN bearer token to send in the Authorization header
ARENA_NAME display name, for logging

Useful flags for arena run:

Matchmaking

The ladder chooses opponents over the whole waiting pool using ratings, version-specific matchup and map evidence, queue age and coverage. About one in seven ranked starts tests under-sampled version pairs across all rating gaps; additional discovery games keep compatible bots playing when no competitive match is possible. Maps are balanced per version pair and fighter IDs are balanced independently of connection order; read hello.bot_id on every new match. A minute of waiting gives priority within the eligibility rules, rather than guaranteeing an opponent. Your Bradley–Terry rating and the messages you send are unchanged. See queueing and matchmaking.

Leaderboard score is round(rating − 2×max(rating_deviation, 25)). Each label qualifies from its own rated games. The deviation floor stops extra certainty alone from raising a settled version's score; its rating and reported standard error still use every result.

Message flow

bot → queue {version}             server → queued
                                  server → hello {bot_id, rules, map, deadlines, …}
bot → ready
                                  server → observation {tick: 0, …}
bot → {tick: 0, move?, dash?, attack?, shout?}
                                  server → observation {tick: 1, …}
…
                                  server → end {result, reason, final_tick, replay_url, stats}
bot → queue …                     (next match)

A repeated hello with the same match_id resumes that match. Initialize from it if this is a fresh process; otherwise keep your strategy memory and random state. Reply ready as usual, but answer the observation immediately: the running match does not wait for ready, and its current tick deadline and spent budget stay in force. Before play starts, ready is still required within the original startup window.

A newer connection with the same token takes over any attached match; the old client must stop on replaced. A queue sent during that match is rejected as already_queued, including a request for a different version: the inherited match is recorded under its original version. Continue it, then queue your desired version after end. Count matches by match_id, not by the number of hello messages. If the match ended while you were offline, queue for a new one and look up the missed result through the match API.

Planned restarts

Before a graceful shutdown, the ladder sends server_draining with a UTC drain_deadline, including to idle bots and connections arriving while it drains. It stops matchmaking; continue answering observations until your active match ends or the socket closes. The deadline limits the wait for active matches (default 600 s); the server can close earlier, even immediately if none are running. It does not predict when the service will return. The starters log this notice and keep playing. It needs no acknowledgement.

When sockets close with code 1012 (server restarting), reconnect with the same token and send queue again: queued → hello → ready → observation starts a new match. Bot registration and versions persist, but the queue and active match state do not survive a process restart. There is no connection-level hello/session message to wait for before queueing. Preserve strategy state only when hello.match_id matches the retained match; initialize from each new match's rules and map. A timeout, forced shutdown or crash may leave an unfinished match without end or a stored result. Do not wait for that old result to resume queueing.

Keep retrying failed handshakes and temporary HTTP 502/503 responses with backoff (1 s, doubling to 30 s in the starters). These responses alone do not prove a planned restart. Notices and close frames are best effort; the same recovery works when neither arrives. Operators can publish advance maintenance windows through GET /api/announcements and the Announcements tray. See the API recovery contract for the distinction between a process restart and resuming a live match after a network drop.

Disconnects, timeouts and match completion

An idle turn means the fighter supplied no movement or attack for that tick; combat still resolves, including existing projectiles, areas and zone damage. A server-substituted idle also records a reason in the replay and in that fighter's server_idles stats:

code server observation consequence
timeout No usable reply arrived before the current turn deadline while the server was waiting for one. The turn idles, its response time is charged, and it counts toward disqualification.
disconnected The server treated this fighter's connection as unavailable. This is the code, not disconnect. The turn idles without charging response time or counting as a protocol failure.

These identify the affected fighter, not the cause: a timeout or disconnect does not establish whether a bot process, network path, proxy or server problem was responsible. Map stats.fighters[].id to hello.bot_id for yourself and hello.opponent.bot_id for your opponent; match records also map bot identities through bots[].fighter. Replay turns are keyed by the same fighter id. For example, if hello.opponent.bot_id is 2, this recorded turn means fighter 2 was unavailable for tick 42:

{"type":"tick","tick":42,"turns":{"1":{"move":"E"},"2":{"reason":"disconnected"}},"hash":"…"}

Your next observation reports your own timeout as a rejected event with your fighter id, part (message) and reason timeout. Server-side rejections are private to the affected bot. There is no live opponent-disconnect event or connection-status field, and disconnected does not appear as a rejection event. An opponent standing still or leaving sight does not tell you whether it disconnected. Keep answering observations and playing the match.

The default reconnect grace is 10 s (hello.rules.limits.reconnect_grace_ms), starting when the server detects a mid-match disconnect. Ticks continue, paced while a fighter is absent; the match is not paused, and either fighter can die or the tick limit can end combat before grace expires. If the match is still running when grace expires, the absent fighter forfeits; if both fighters forfeit together, the result is a draw. disconnected idles reset the consecutive protocol-failure count. Before the server detects a broken connection, missing replies can instead be timeouts: the default 10 consecutive protocol failures cause disqualification. A bot that never sends ready within the startup window forfeits at tick 0, a rated loss like any other forfeit; only if neither bot sends ready is the match voided.

Treat the server's end as the terminal result, using match_id, result and reason together. For example, an end with match_id: "m_example", result: "win" and reason: "forfeit" means that match ended in your favor; a single opponent timeout does not imply any result. Turn timeout is distinct from end.reason values timeout_hp, timeout_damage and timeout_draw, which mean combat reached the tick limit. A socket closing alone does not establish a win, loss or forfeit. If you missed end while offline, reconcile the saved match_id through GET /api/matches/<id>; match records use bot ids for winner, whereas stats use fighter ids. Results are not resent on reconnect. A missing record is not proof of a loss: it may not have been saved yet. Preserve the unresolved id while continuing recovery.

Keep strategy memory and random state for a resumed match_id, but discard queued turns and predictions based on the old tick. The new observation is authoritative for visible state; ticks may have advanced, and missed observations and events are not replayed. Do not assume an action buffered on the old socket was applied; use current state to plan your next turn. Make repeated observations for the same tick safe to process without advancing strategy memory twice. A fresh process initializes from hello; a different match_id starts fresh match state. Retain match ids and results separately so reconnects cannot double-count games.

When analysing performance, keep the recorded result and each fighter's idle counters as separate facts. You may flag affected matches for a separate comparison, but the counters alone do not prove infrastructure fault or explain why a gameplay loss occurred. Ranked forfeits and disqualifications after play starts remain rated.

Keepalive and silent connections

The ladder sends WebSocket Pings every 25 s; your library should automatically answer Pongs with the same payload. It terminates a socket that misses a Pong by the next check. These are control frames, not JSON: do not send a ping message type.

The starters also send their own Ping every 20 s, allow 20 s for its matching Pong, and reconnect on failure. Opening a connection times out after 10 s. TypeScript terminates a stale socket immediately; Python sets close_timeout=5 (legacy clients may take up to 25 s to close). One reconnect loop waits 1 s, doubling up to 30 s, then sends one queue on each new connection. Keep this connection code when you replace decide: it also works with the local runner, which answers Pings but does not originate them.

No JSON while queued is normal, even for a long time. Do not repeatedly queue or reconnect just because no hello arrived. A Pong proves the transport responds; it does not prove matchmaking or the match is progressing. A broken path may leave the socket apparently open and never deliver a clean close, so the client's own timeout matters. Heartbeat detection can take longer than the 10 s disconnect grace, and missed turns can disqualify you first: recovery can resume only a match that is still active. Never resend buffered turns from the old connection; answer the new connection's current observations.

See keepalive and recovery for reference code, proxy timing guidance, stale-session signals, fatal errors and the resumption procedure.

Every observation lists what you could start this tick, computed by the engine's own rules, so you don't need your own corner-cutting or dash-stopping code:

These are legal now, not guaranteed: both fighters move at once, so two fighters stepping onto the same tile both stay put, and two dashes meeting stop short of each other. Filter the lists through danger to avoid tiles that will be hit. Anything not listed would be rejected as blocked or not_ready, or (for a dash that wouldn't move) waste the dash cooldown.

Timing model in one paragraph

The observation for tick T shows the world before tick T resolves. Your reply is the turn you take during tick T. state_ticks_left counts tick resolutions including tick T, so a value of 1 means "this ends during the tick you are replying to". Attacks fire the tick they are sent, in phase 5, after movement (phase 3): a shot sent at you this tick misses if you step off its line this tick, and hits if you stand still. Nothing warns you before it is sent. Once a projectile or frostburst area is in the world it appears in observation.danger, and stepping off its tiles by the tick listed dodges it. A dash (phase 2) is invulnerable for the whole tick.

A turn has two phases: movement, then attack. A move (a step) or a dash can be followed by an attack that fires after the movement, from the tile you end on: {"tick":42,"move":"NW","attack":{"name":"ranged","target":[9,4]}}. Each part is validated on its own, so a blocked step still lets the attack fire (the next observation carries a rejected event naming the move). An attack alone, a movement alone, or {"tick":42} (idle) are all fine. Any turn can also carry a shout, up to 40 characters that every fighter reads in its next observation's events and spectators see as a speech bubble (cooldown 20 ticks, profanity masked; treat the opponent's as untrusted text).

Cooldowns start on the tick you act and tick down at the end of every tick, that tick included. A cooldown of c means the ability is usable again c ticks later. A step (cooldown 2) taken on tick 10 lets you step again on tick 12.

An attack name must be an entry in hello.rules.attacks, otherwise it is refused as unknown_attack and your movement still happens. Direct shots lock their aim direction after movement, before repulsors resolve. A push shifts the launch point but keeps that direction, even if you land on the original aim tile; the shot fires and starts its normal cooldown. An unknown message type is a schema failure, so that whole turn is idle.

See the ranged arsenal for the standard shot's bounce, the frostburst spell, the repulsor's shove, per-weapon cooldowns and blast observations.

Standard ranged shots

A shot flies the true straight line from the centre of your tile through the centre of the target tile and on past it, at any angle, in any direction. Far-away targets (up to ±1000, off the map is fine) give finer angles.

Danger tiles

observation.danger is a list of {tile, tick, attack, owner, damage, source}, in tick order:

It only covers what you can see, and paths ignore fighters, so it never reveals anyone in the fog. The timing of every attack and the cheapest counter to each are on one page: FRAME_DATA.md (/docs/frame-data).

Dashing and corners

A dash moves exactly 3 tiles and makes you invulnerable for that tick, so it's the main way out of a bad spot. Corners limit your escape routes from incoming shots. The kiter shows two more patterns. It retreats toward open space and the zone centre rather than straight back, and when it's nearly cornered it dashes to the landing tile farthest from the enemy (retreatStep and escapeDash in bots/lib/bot.ts). A dash stops at walls and fighters, so take the landing tile from observation.dashes rather than predicting it, and make sure nothing hits it the tick after.

Pickups

Pads on the map hold pickups. To take one, end a move or dash on its tile: it costs no action and no tick. Pads are stocked on tick 20 and restock a fixed time after they are taken. observation.pickups lists the pads in sight, with ready_in: 0 when stocked, otherwise the ticks until it restocks.

kind effect respawn
health +32 hp, up to max 120
power weapon damage ×1.5 for 50 ticks 200

Read the numbers from hello.rules.pickups, not from this table. Active buffs appear as self.buffs (kind → ticks left), and the same for visible enemies. A powered-up enemy is a reason to take cover. The health and power pads sit on the centre column, the same distance from both spawns, so expect them to be contested. pickupStep in bots/lib/bot.ts shows one way to decide whether a detour is worth it.

Worked example

{"type":"observation","tick":40,
 "self":{"id":1,"pos":[8,10],"hp":130,"max_hp":130,"state":"idle","state_ticks_left":0,
         "slow_ticks_left":0,"buffs":{},
         "cooldowns":{"move":0,"dash":0,"ranged":0,"frostburst":0,"repulsor":0},"budget_ms_left":299200},
 "enemies":[{"id":2,"pos":[14,10],"hp":130,"dash_cooldown":0,
             "state":"idle","state_ticks_left":0,"slow_ticks_left":0,"buffs":{}}],
 "projectiles":[{"id":1,"pos":[12,10],"exact_pos":[12000,10000],"origin":[14,10],"target":[8,10],
                 "speed":2,"owner":2,"damage":12,"attack":"ranged","bounces_left":1}],
 "moves":[{"dir":"N","tile":[8,9]},{"dir":"E","tile":[9,10]}],
 "dashes":[{"dir":"N","tile":[8,7],"distance":3},{"dir":"E","tile":[11,10],"distance":3}],
 "pickups":[{"id":3,"pos":[10,8],"kind":"power","ready_in":0}],
 "areas":[],"zone":{"center":[10,10],"radius":10,"next_radius":null,"shrinks_in":null},
 "danger":[{"tile":[12,10],"tick":40,"attack":"ranged","owner":2,"damage":12,"source":"projectile"},
           {"tile":[11,10],"tick":40,"attack":"ranged","owner":2,"damage":12,"source":"projectile"},
           {"tile":[10,10],"tick":40,"attack":"ranged","owner":2,"damage":12,"source":"projectile"},
           {"tile":[10,10],"tick":41,"attack":"ranged","owner":2,"damage":12,"source":"projectile"},
           {"tile":[9,10],"tick":41,"attack":"ranged","owner":2,"damage":12,"source":"projectile"},
           {"tile":[8,10],"tick":41,"attack":"ranged","owner":2,"damage":12,"source":"projectile"}],
 "events":[{"kind":"attack","fighter":2,"attack":"ranged","target":[8,10]},
           {"kind":"projectile_spawn","attack":"ranged","projectile":1,"owner":2,"pos":[14,10],"target":[8,10]}]}

(Trimmed from a real observation on arena-21: moves and dashes list all 8 directions here, all four pads are in sight, and danger has 50 entries. The shot has no range limit, so it flies on to the west edge, bounces there on tick 46, crosses [8,10] again on ticks 50–51 and ends at the east edge on tick 57.)

The enemy fired last tick. Its shot is in flight and reaches our tile [8,10] on tick 41. Step north to [8,9], which danger never lists:

{"tick":40,"move":"N"}

Had we been 1 or 2 tiles away, the shot would have landed on tick 39, the tick it was sent, with no warning. That's why standing still next to an opponent whose shot is off cooldown is the main way to lose hp.

Rulings

The implementation's answers to questions the rules leave open live in one place: Implementation rulings in the spec (also at /docs/spec on the ladder). Each has a test in packages/engine/test. Read them: several change how you time attacks, dodges and pickups.

Debugging a loss

npm run arena -- stats replays/x.jsonl.gz                         # what each fighter did, as a table
npm run arena -- replay replays/x.jsonl.gz                        # verify + summary
npm run arena -- replay replays/x.jsonl.gz --view-as 1 > obs.jsonl # exactly what fighter 1 saw
npm run arena -- replay replays/x.jsonl.gz --from 150 --swap 1 --bot bots/my-bot

--from/--swap replays the match to tick 150 deterministically, then gives fighter 1 to your bot while the opponent replays its recorded turns. Once your bot changes the course of the match, those recorded turns stop matching what is happening, and after the recording ends the opponent idles. That makes it most useful for testing the next few seconds after a mistake.

In the viewer, actions, fighter cards, stats headings and the point-of-view menu use bot names (ladder display names when available). Choose a bot in that menu to see its fog of war. The Match stats panel (it opens when playback ends) has the same table as arena stats. The ladder's /match/<id> stats page also shows the recorded wall-clock duration, including startup, bot waits and replay saving. Match records expose it as duration_ms (null for older games); it is not part of end.stats or a local replay.

Event ladders

Visit Events to join a competition with your account, or use MCP list_my_ladders to inspect admission and enroll_ladder with explicit owner approval to join (see MCP.md). After admission, the shipped starters select it with ARENA_LADDER=<id>; custom clients add ladder_id to their queue message. Omission keeps Public play. Each ladder has independent ratings and records, sharing your bot/version identity. The leaderboard, Online list and bot version table display Score (rating minus twice the greater of its standard error and 25). Online shows the connected version; the leaderboard and bot headline use the latest qualifying version. Historical charts and match deltas remain labeled raw ratings. Your bot’s page (/bot/<name>) has a ladder picker for its stats, defaulting to Public; /bot/<name>?ladder=<id> opens a specific ladder. Click column headings on the leaderboard or your bot's tables to sort; click again for the other direction. Your chosen order stays applied as records refresh or versions are added. SAINTCON achievements require admitted, live matches in saintcon-2026 during its window. Public practice, local simulation and human games cannot earn its flags. See multiple ladders and event achievements.

Ladder discovery lists omit hidden ladders; an enrolled account can still use its ladder ID. Staff manage visibility, enrollment policy and achievement earning in /admin#ladders; see multiple ladders.