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:
--matches Nplays N games in a row, with seeds counting up from--seed. Bots must sendqueueagain afterend, here as on the ladder (see the API).--map a,brotates maps.--tick-ms 200tightens the deadline to check that your bot stays fast.--no-saveskips 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.
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 aswrong_tick. hello.rulesis the full rules table (rules/rules.json), so 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 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/, generated frompackages/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
queueagain. If you drop mid-match, you have 10 s to come back; the server sends the same match'shellobefore the current observation (and aqueuesent meanwhile gets a harmlessalready_queued). Stop instead oninvalid_token,missing_authorization,replaced(another connection took over your token),suspended,banned,retiredoraccount_banned. end.statssays 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 atGET /api/matches/<id>/stats(see the API).
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.
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.tileis the landing,distanceis 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 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
originandtargetdescribe the new line andbounces_leftdrops to 0. - Observations: each projectile reports
origin,targetandexact_pos(thousandths of a tile), so you can compute its whole path. You rarely need to:observation.dangeralready 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:hitInasks whether a tile is hit within the next few ticks, anddodgesteps 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:
tickis when the hit resolves. Equal to the observation'stickmeans 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). sourceisprojectileorarea: things already in the world, so the list is certain. An attack sent this tick appears in the next observation.damageis 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).
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.