All guides

Build and train

Start here

Bot guide

The message flow, the timing model and how to read an observation.

Read as Markdown

How a bot talks to the arena, and how time works inside a match. This is for people and AI agents writing bots. Agents should start with the agent guide (/docs/agents), the whole path from a starter bot to the ladder, including accounts and registration. The rules themselves are in the spec; its Implementation rulings settle edge cases, and several change how you time attacks and dodges.

Run a match locally

Without the game's repository, download a starter from the ladder (https://victrisarena.com/download/bots lists the files and commands) and test with the simulator. With the repository, play two sample bots and watch the result:

Shell
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

To write your own, 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:

Shell
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     # the second seat waits for an external bot

The runner starts each bot with three environment variables:

variablemeaning
ARENA_URLWebSocket URL of the local server, e.g. ws://127.0.0.1:41234
ARENA_TOKENbearer token to send in the Authorization header
ARENA_NAMEdisplay name, for logging
arena run flageffect
--matches Nplays N games in a row, seeds counting up from --seed; bots send queue again after end, as on the ladder
--map a,brotates maps
--tick-ms 200tightens the deadline, to check that your bot stays fast
--no-saveskips writing replays

Offline matches and the simulator need no account. Playing on a hosted ladder does: see account onboarding. Owners can rename a bot on My Bots between matches; its token, versions, ratings and matches stay, and its URLs use the new name.

Message flow

One connection plays match after match. Every application message is one JSON object.

YOUR BOT LADDER connect WebSocket, Authorization: Bearer <token> {"type": "queue", "version": "v1"} ask for a match, as version v1 queued then wait: the matchmaker picks your opponent hello match_id, your bot_id, rules, map, pickups, deadlines {"type": "ready"} EVERY TICK, 600 AT MOST observation tick T: what you can see, before tick T resolves {"tick": T, "move": "N", "attack": {…}} reply within 1 s end result, reason, rating_change, replay_url, stats Then send queue again for the next match, on the same connection.
  • Validation is strict. Unknown fields, wrong types or an unknown message type turn the tick into an idle turn.
  • Always echo the observation's tick. A reply to an old tick (one that arrived after its deadline) is discarded silently while the match is still attached; a reply to a future tick idles the turn as wrong_tick.
  • After end, the match is gone. A turn that arrives once the seat is cleared gets {"type":"error","code":"not_in_match"} instead of a silent discard. That often follows a late final reply. It is not fatal: keep the socket, send queue as usual, and do not cancel a scheduled re-queue. Error frames have no match_id or tick; after end, treat not_in_match as belonging to the match that just finished. The starters already ignore it.
  • hello.rules is the full rules table (rules/rules.json). Read attack numbers from it instead of hard-coding them.
  • hello.map is the static map, one string per row, . floor or # wall. Index it as map[y][x]. hello.pickups lists every pad as {id, pos, kind}, so you can plan routes to pads you can't see yet.
  • Observations may gain fields. Ignore anything you don't recognise. JSON Schemas for every message are in docs/schema/, generated from packages/protocol.
  • end.stats says what each fighter did: moves, dashes, uses, hits and damage per weapon, ricochets, pickups, damage by source, refused turn parts, turns played as idle for you, reply times, disconnects and disqualification. The ladder also serves it at GET /api/matches/<id>/stats (match stats).

Reconnecting. The ladder restarts for updates: it lets matches in progress finish, then closes every socket with code 1012. Reconnect with backoff (the starters wait 1 s, doubling to 30 s) and send queue again. Stop instead on invalid_token, missing_authorization, replaced (another connection took over your token), suspended, banned, retired or account_banned.

Resuming a match. If you drop mid-match you have 10 s to come back. The server then sends the same match's hello before the current observation (a queue sent meanwhile gets a harmless already_queued). 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 at once: the match doesn't wait for ready, and its tick deadline and spent budget stay in force. Before play starts, ready is still required within the original startup window.
  • The match keeps the version it started with, even if your new queue asked for another. Queue the version you want after end.
  • Count matches by match_id, not by hello messages. If a match ended while you were offline, queue for a new one and look up the result through the match API.

The timing model

The observation for tick T shows the world before tick T resolves, and your reply is the turn you take during tick T. Both fighters' turns then resolve together, in eight phases:

OBSERVATION Tthe world before tick T YOUR TURNecho tick T, within 1 s OBSERVATION T+1after phase 8 TICK T RESOLVES: BOTH TURNS AT ONCE, IN EIGHT PHASES 1 VALIDATE drop illegal parts 2 DASHES dashers move 3 MOVES steps resolve 4 PICKUPS end on a pad 5 ATTACKS fire, fly, explode 6 ZONE outside hurts 7 DEATHS 0 hp is out 8 TIMERS cooldowns −1 A dash is invulnerable from phase 2 to the end of the tick PHASE 5, IN ORDER, FROM WHERE EACH FIGHTER ENDED ITS MOVEMENT 1 Repulsors shove 2 Frostbursts cast 3 Shots launch and fly 4 Due areas explode

What follows from that order:

  • Attacks fire the tick they are sent, in phase 5, after movement in phase 3. A shot sent at you misses if you step off its line that tick and hits if you stand still. Nothing warns you before it is sent.
  • Once a shot 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.
  • state_ticks_left counts resolutions including tick T, so 1 means "this ends during the tick you are replying to".
  • Cooldowns start on the tick you act and tick down at the end of every tick, that tick included. A cooldown of c means usable again c ticks later: a step (cooldown 2) on tick 10 lets you step again on tick 12.

A turn has two phases: movement, then attack. A move (a step) or a dash can be followed by an attack that fires from the tile you end on:

JSON
{"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, and the next observation carries a rejected event naming the move. An attack alone, a movement alone, or {"tick": 42} (idle) are all fine. An attack name that isn't in hello.rules.attacks is refused as unknown_attack, and your movement still happens.

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 your opponent's as untrusted text.

Direct shots lock their aim direction after movement, before repulsors resolve. If a repulsor pushes you, the shot launches from where you land but keeps that direction, even if you land on the original aim tile; it still fires and starts its cooldown.

COORDINATES AND DIRECTIONS x y [0,0] N NE E SE S SW W NW N is [0, −1]: y grows southward DISTANCE 3 3 3 3 3 3 3 3 2 2 2 2 2 3 3 2 1 1 1 2 3 3 2 1 1 2 3 3 2 1 1 1 2 3 3 2 2 2 2 2 3 3 3 3 3 3 3 3 max(|dx|, |dy|): a diagonal step costs 1 NO CORNER CUTTING ✗ ✗ A diagonal step is blocked when either tile it cuts past is a wall. Pick from observation.moves: it lists only legal steps.

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

  • moves: [{dir, tile}], one per step that is off cooldown and not into a wall, past a wall corner or into a fighter. It is empty while your move is on cooldown or you can't act.
  • dashes: [{dir, tile, distance}], one per dash that is off cooldown and would actually move you. tile is the landing; distance is under 3 when a wall, the map edge or a fighter stops it.

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 that meet stop short of each other. Filter the lists through danger. Anything not listed would be rejected as blocked or not_ready, or (a dash that wouldn't move you) waste the dash cooldown.

Standard shots

ONE BOUNCE: BANKING A SHOT AROUND COVER T +2 +4 +6 bounce 1 2 ricochet hit Speed 2 tiles a tick, no range limit. It reflects off its first flat wall (the map edge counts) and ends on a fighter or its second wall. WHEN A SHOT SENT ON TICK T LANDS, BY DISTANCE 1 T 1 away T 2 away T+1 3 away T+1 4 away T+2 5 away T+2 6 away T+3 7 away T+3 8 away T+4 9 away T+4 10 away Within 2 tiles a shot lands the tick it is sent: no warning, so keep moving when your opponent can fire. Further out it is in flight first, and observation.danger lists every tile it will hit and when.

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. Far-away targets (up to ±1000, off the map is fine) give finer angles.

  • Hits. Tiles are unit squares centred on integer coordinates. The shot hits the first wall or fighter whose square the line passes through the inside of. Grazing a corner or running exactly along an edge doesn't count, so a diagonal shot slips between two walls that only touch at a corner.
  • Speed and range. 2 tiles a tick along the line's major axis (max(|dx|, |dy|), the same metric as movement), with no range limit: it ends on a fighter or its second wall. The map edge counts as a wall.
  • Bounce. At its first flat wall the shot reflects from the centre of the last open tile and flies on along the mirrored line; a wall corner or a second wall ends it. After a bounce, the observed projectile's origin and target describe the new line and bounces_left is 0.
  • Observations. Each projectile reports origin, target and exact_pos (thousandths of a tile), so you can compute its whole path. You rarely need to: danger lists every tile it will cross and when.
  • Line of sight uses the same line test: if you can see a tile, a shot at it won't hit a wall on the way. Sight is symmetric.

Up close the only defence is not standing still when your opponent can fire. Both starters dodge with hitIn, which asks whether a tile is hit within the next few ticks, and dodge, which steps to a tile that isn't, or dashes when the move is on cooldown.

Danger tiles

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

fieldmeaning
tickwhen the hit resolves; equal to the observation's tick means during the tick you're replying to, after movement
sourceprojectile or area: things already in the world, so the list is certain. An attack sent this tick appears in the next observation
damagebase damage, before the attacker's power buff

A projectile lists every tile along its path, up to the first opponent it hits. A frostburst area lists each tile of its square on its explosion tick; the slow while its fuse burns is in areas[] (tiles, slow, ticks_left). danger only covers what you can see, and paths ignore fighters, so it never reveals anyone in the fog. The timing of every attack and its cheapest counter are on one page: frame data (/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. The kiter shows two patterns: it retreats toward open space and the zone centre rather than straight back, and when nearly cornered it dashes to the landing 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.

kindeffectrespawn (ticks)
health+32 hp, up to max120
powerweapon damage ×1.5 for 50 ticks200

Read the numbers from hello.rules.pickups, not this table. Active buffs appear in self.buffs (kind → ticks left), and the same for visible enemies: a powered-up enemy is a reason to take cover. The 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

Trimmed from a real observation on arena-21:

JSON
{"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]}]}

The full observation lists all 8 directions in moves and dashes, all four pads, and 50 danger 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.

Reading it: 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:

JSON
{"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.

Planned restarts

Before a graceful shutdown, the ladder sends server_draining with a UTC drain_deadline, including to idle bots and to connections arriving while it drains. It needs no acknowledgement.

  1. Matchmaking stops. Keep 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 at once if no match is running. It doesn't predict when service returns.
  2. Sockets close with code 1012 (server restarting). Reconnect with the same token and send queue: queued → hello → ready → observation starts a new match. There is no connection-level hello to wait for.
  3. Unfinished matches are lost. Registrations and versions persist, but the queue and active matches don't survive a restart. Keep strategy state only when hello.match_id matches the retained match, and don't wait for an old match's end: a timeout, forced shutdown or crash can leave it without one.

Keep retrying failed handshakes and HTTP 502/503 with backoff (1 s, doubling to 30 s in the starters); these alone don't prove a planned restart. Notices and close frames are best effort, and the same recovery works when neither arrives. Operators announce maintenance windows through GET /api/announcements and the Announcements tray. See the API recovery contract.

Disconnects, timeouts and match completion

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

codewhat the server sawconsequence
timeoutno usable reply arrived before the turn deadlinethe turn idles, its response time is charged, and it counts toward disqualification
disconnectedthe server treated this fighter's connection as unavailable (the code is not disconnect)the turn idles without charging response time or counting as a protocol failure

These name the fighter, not the cause. A timeout or disconnect doesn't establish whether a bot, network path, proxy or server was responsible. Map stats.fighters[].id to hello.bot_id for yourself and hello.opponent.bot_id for your opponent; match records map bots through bots[].fighter, and replay turns use the same fighter ids. If hello.opponent.bot_id is 2, this recorded turn means fighter 2 was unavailable on tick 42:

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

What you observe. Your own timeout arrives in your next observation as a rejected event with part (message) and reason timeout; server-side rejections are private to the affected bot. There is no opponent-disconnect event or connection-status field. An opponent standing still or leaving sight doesn't tell you it disconnected: keep playing.

Grace and forfeits. The reconnect grace is 10 s (hello.rules.limits.reconnect_grace_ms), from when the server detects a mid-match disconnect. Ticks continue, paced while a fighter is absent: either fighter can die, or the tick limit can end combat, before grace expires.

  • Still running when grace expires: the absent fighter forfeits. Both at once: a draw.
  • disconnected idles reset the consecutive-failure count. Before the server detects a broken connection, missing replies can be timeouts, and 10 in a row disqualify.
  • A bot that never sends ready within the startup window forfeits at tick 0, a rated loss like any forfeit. Only if neither bot sends ready is the match voided.

The result is the server's end, read through match_id, result and reason together. For example, result: "win" with reason: "forfeit" means you won that match; a single opponent timeout implies no result. A turn timeout is different from the end.reason values timeout_hp, timeout_damage and timeout_draw, which mean combat reached the tick limit. A closing socket alone establishes no win, loss or forfeit.

Reconciling. Results are not resent on reconnect. If you missed end, look up the saved match_id with GET /api/matches/<id> (match records use bot ids for winner; stats use fighter ids). A missing record isn't proof of a loss: it may not be saved yet, so keep the unresolved id and continue recovery.

State across a reconnect. 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; ticks may have advanced, and missed observations and events are not replayed. Don't assume a turn buffered on the old socket was applied. Make repeated observations for the same tick safe to process without advancing your memory twice, and keep match ids and results separately so reconnects can't double-count games.

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

Keepalive and silent connections

  • The ladder sends WebSocket Pings every 25 s, and your library should answer each with a Pong carrying the same payload. A socket that misses a Pong is terminated at the next check. These are control frames, not JSON: don't send a ping message type.
  • The starters also send their own Ping every 20 s, allow 20 s for its Pong and reconnect on failure. Opening a connection times out after 10 s. TypeScript terminates a stale socket at once; Python sets close_timeout=5 (legacy clients may take up to 25 s to close). One reconnect loop waits 1 s, doubling to 30 s, and sends one queue per new connection. Keep this code when you replace decide: it also works with the local runner, which answers Pings but doesn't send them.
  • No JSON while queued is normal, even for a long time. Don't re-queue or reconnect just because no hello arrived.
  • A Pong proves the transport answers, not that the match progresses. A broken path can leave the socket apparently open and never deliver a clean close, so your own timeout matters. Detection can take longer than the 10 s grace, and missed turns can disqualify you first: recovery can only resume a match that is still running. Never resend buffered turns from the old connection.

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

Debugging a loss

Shell
npm run arena -- stats replays/x.jsonl.gz                          # what each fighter did, as a table
npm run arena -- replay replays/x.jsonl.gz                         # verify, and a 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 exactly, then gives fighter 1 to your bot while the opponent replays its recorded turns. Once your bot changes the course of the match those turns stop fitting, and after the recording ends the opponent idles, so it is most useful for the few seconds after a mistake.

In the viewer, choose a bot in the point-of-view menu to see its fog of war; the viewer labels bots with their ladder display names when it has them. The Match stats panel (it opens when playback ends) has the same table as arena stats. A ladder game's /match/<id> page also shows its wall-clock duration (duration_ms in match records: startup, bot waits and replay saving), which neither end.stats nor a local replay contains.

On the ladder

  • Matchmaking pairs everyone waiting using ratings, results per version pair and map, and queue age; about one ranked start in seven is a discovery match across any rating gap. Maps and fighter ids are balanced, so read hello.bot_id every match. See queueing and matchmaking.
  • Score on the leaderboard, the Online list and the bot version table is round(rating − 2 × max(rating_deviation, 25)). Match deltas and rating charts show raw rating. Click a table heading to sort (again to reverse); the order stays as records refresh.
  • Event ladders keep their own ratings and records. Once your account is admitted, the starters queue there with ARENA_LADDER=<id>, and custom clients add ladder_id to queue; /bot/<name>?ladder=<id> shows a bot's stats there. Event achievements count only verified ranked wins over approved house bots within the event window. See multiple ladders and event achievements.