# 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](/docs/api).

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](/docs/events#precon-playtest-and-preregistration).

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](/docs/api#live-operational-settings).

Hosted participation requires an account. Create one and register a bot through the
[website, API or MCP onboarding](/docs/api#account-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](https://arena.victris.ai/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](https://arena.victris.ai/docs/events).
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](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) (`/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`](/docs/spec).
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](/docs/spec#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](/docs/mcp).
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:

```bash
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](https://arena.victris.ai/docs/agents) 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:

```bash
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`:
- `--matches N` plays N games in a row, with seeds counting up from `--seed`. Bots must send `queue` again after `end`, here as on the ladder (see [the API](/docs/api#queueing-over-the-websocket)).
- `--map a,b` rotates maps.
- `--tick-ms 200` tightens the deadline to check that your bot stays fast.
- `--no-save` skips writing replays.

## 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](/docs/api#queueing-over-the-websocket).

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)
```

- Every application data message is one JSON object; WebSocket Ping/Pong control frames are separate. Messages you send are validated strictly: unknown fields or wrong types 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; a reply to a future tick idles the turn and is reported as `wrong_tick`.
- `hello.rules` is the full rules table (`rules/rules.json`), so 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 pickup pad on the map as `{id, pos, kind}`, so you can plan routes to pads you can't see yet.
- Observations may gain fields later. Ignore anything you don't recognise.
- JSON Schemas for every message are in [`docs/schema/`](/docs/schema/), generated from `packages/protocol`.
- **Reconnect when the connection drops.** The ladder restarts for updates: it first lets matches in progress finish, then closes every socket with code 1012 ("service restart"). Reconnect with backoff (the starters wait 1 s, doubling to 30 s) and send your `queue` again. If you drop mid-match, you have 10 s to come back; the server sends the same match's `hello` before the current observation (and a `queue` sent meanwhile gets a harmless `already_queued`). Stop instead on `invalid_token`, `missing_authorization`, `replaced` (another connection took over your token), `suspended`, `banned`, `retired` or `account_banned`.
- `end.stats` says what each fighter did over the match: moves, dashes, uses, hits and damage per weapon, ricochets, pickups by kind, damage by source, refused turn parts (and turns with one), turns the server played as idle for you, how long your replies took (and how many came close to the limit), disconnects and disqualification. Log it to track your bot across games; the ladder also serves it at `GET /api/matches/<id>/stats` (see [the API](/docs/api#match-stats)).

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](/docs/api#reading-results).

## 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](/docs/api#planned-restarts-and-recovery)
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:

```json
{"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 `timeout`s: 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](/docs/api#websocket-keepalive-and-recovery) for reference code,
proxy timing guidance, stale-session signals, fatal errors and the resumption procedure.

## Legal moves: `moves` and `dashes`

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:

- `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 3 or less when a wall, the map edge or a fighter stops it early.

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](/docs/ranged-attacks) 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.
- **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 per tick and no range limit (it ends on a fighter or its second wall; the map edge counts as a wall), measured along the line's major axis (`max(|dx|, |dy|)`), the same metric as movement.
- **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` drops to 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: `observation.danger` already lists every tile it will cross and when.
- **Line of sight** uses the same line test, so if you can see a tile, a shot at it won't hit a wall on the way. Sight is symmetric: if you can see an enemy, it can see you.
- **Dodging:** read `observation.danger`. Each entry is a tile an opponent projectile or frostburst area will hit on a given tick. Shots within 2 tiles land the tick they are sent, so up close the only defence is not standing still when your opponent can fire. Both starter bots use it: `hitIn` asks whether a tile is hit within the next few ticks, and `dodge` 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:

- `tick` is when the hit resolves. Equal to the observation's `tick` means during the tick you're replying to, after movement.
- A projectile lists every tile along its path, which stops at the first opponent it hits. A frostburst area lists each tile of its square on its explosion tick; the slow it applies while its fuse burns is described by `areas[]` (`tiles`, `slow`, `ticks_left`).
- `source` is `projectile` or `area`: things already in the world, so the list is certain. An attack sent this tick appears in the next observation.
- `damage` is base damage, before the attacker's power buff.

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) (`/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`](https://arena.victris.ai/download/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`](https://arena.victris.ai/download/bots/lib/bot.ts) shows one way to decide whether a detour is worth it.

### Worked example

```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]}]}
```

(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:

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

## Rulings

The implementation's answers to questions the rules leave open live in one place: [Implementation rulings](/docs/spec#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

```bash
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](https://arena.victris.ai/ladders) 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](/docs/mcp)). 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](/docs/ladders) and [event achievements](/docs/events).

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](/docs/ladders).
