Start here
Bot guide
The message flow, the timing model and how to read an observation.
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:
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:
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:
| 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 |
arena run flag | effect |
|---|---|
--matches N | plays N games in a row, seeds counting up from --seed; bots send queue again after end, as on the ladder |
--map a,b | rotates maps |
--tick-ms 200 | tightens the deadline, to check that your bot stays fast |
--no-save | skips 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.
- Validation is strict. Unknown fields, wrong types or an unknown message
typeturn 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 aswrong_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, sendqueueas usual, and do not cancel a scheduled re-queue. Error frames have nomatch_idortick; afterend, treatnot_in_matchas belonging to the match that just finished. The starters already ignore it. hello.rulesis the full rules table (rules/rules.json). Read attack numbers from it instead of hard-coding them.hello.mapis the static map, one string per row,.floor or#wall. Index it asmap[y][x].hello.pickupslists 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 frompackages/protocol. end.statssays 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 atGET /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
readyas usual, but answer the observation at once: the match doesn't wait forready, and its tick deadline and spent budget stay in force. Before play starts,readyis still required within the original startup window. - The match keeps the version it started with, even if your new
queueasked for another. Queue the version you want afterend. - Count matches by
match_id, not byhellomessages. 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:
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_leftcounts resolutions including tickT, 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
cmeans usable againcticks 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:
{"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.
The grid and legal moves
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.tileis the landing;distanceis 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
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
originandtargetdescribe the new line andbounces_leftis 0. - Observations. Each projectile reports
origin,targetandexact_pos(thousandths of a tile), so you can compute its whole path. You rarely need to:dangerlists 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:
| field | meaning |
|---|---|
tick | when the hit resolves; equal to the observation's tick means during the tick you're replying to, after movement |
source | projectile or area: things already in the world, so the list is certain. An attack sent this tick appears in the next observation |
damage | base 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.
| kind | effect | respawn (ticks) |
|---|---|---|
health | +32 hp, up to max | 120 |
power | weapon damage ×1.5 for 50 ticks | 200 |
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:
{"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:
{"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.
- 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.
- Sockets close with code 1012 (
server restarting). Reconnect with the same token and sendqueue:queued → hello → ready → observationstarts a new match. There is no connection-level hello to wait for. - 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_idmatches the retained match, and don't wait for an old match'send: 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:
| code | what the server saw | consequence |
|---|---|---|
timeout | no usable reply arrived before the turn deadline | the turn idles, its response time is charged, and it counts toward disqualification |
disconnected | the 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:
{"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.
disconnectedidles reset the consecutive-failure count. Before the server detects a broken connection, missing replies can betimeouts, and 10 in a row disqualify.- A bot that never sends
readywithin the startup window forfeits at tick 0, a rated loss like any forfeit. Only if neither bot sendsreadyis the matchvoided.
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
pingmessage 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 onequeueper new connection. Keep this code when you replacedecide: 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
helloarrived. - 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
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_idevery 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 addladder_idtoqueue;/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.