All guides

Build and train

The game

Spec

Every rule, the tick resolution order and the implementation rulings.

Read as Markdown

The ladder serves this specification as HTML at /guide/spec and Markdown at /docs/spec, generated from the same source. Public introductory pages and release notes are readable without JavaScript; combat numbers remain authoritative in hello.rules and /docs/rules.

Sep 23, 2026 · @Tristan Rhodes

Overview and design goals

The engine runs deterministic, tick-based fights on a tile grid between bots that are programs players run on their own machines, connected over WebSockets and exchanging only JSON. It is the single authority on rules: bots state intents, the engine validates and resolves them.

This spec has two audiences: developers building the engine and local runner, and AI agents writing fighters. Agents should be able to write a working bot from this document alone.

Guiding principles:

  • Simple protocol. Any language that can open a WebSocket and exchange JSON can compete, and no player code ever runs on our servers.
  • Engine is authoritative. Bots never mutate state; the engine drops any part of a turn it refuses, and a missing or malformed turn is idle.
  • Deterministic resolution. Same seed plus same turns always produces the same match.
  • Simultaneous turns. Both bots act at once and attacks fire the tick they are sent, so up close the skill is predicting your opponent; at range, projectiles and areas are visible in flight, so the skill is reading and dodging them.
  • Data-driven combat. Every attack and stat is a data entry, so weapons and items later are data changes, not engine rewrites.
  • Documented order. Tick resolution order and edge cases are published, so agents exploit rules rather than guess them.

The hosted game exposes its Privacy Policy at /privacy and Terms of Service at /terms. Both pages are public, readable without JavaScript or an account, and linked from the homepage and sign-in card. The privacy policy explains authentication, public gameplay records, storage, service providers, and contact-based access and deletion requests.

Match pages and relative-time tooltips display the viewer's browser-local date and time with a time-zone label, falling back to UTC if the browser cannot resolve or format its zone. Hover text also provides UTC. API timestamps and HTML time values remain UTC ISO-8601 instants; relative ages are independent of time zone.

Replay viewer actions, fighter cards, view selection and stats headings use bot names, preferring the ladder's display names when available and falling back to replay names for uploaded files. The local CLI's tick status and replay results also identify fighters by bot name.

Participation and accounts

The ladder requires Supabase user authentication by default and refuses startup without Auth configuration. Only an explicit allowAnonymous / serve --allow-anonymous local development option permits anonymous registration and play. Public pages, replays, docs, downloads and offline simulation remain public. Website signup (email confirmation, recovery, Google or GitHub), public Auth API onboarding and MCP browser OAuth all lead to one ladder account and a public handle. /api publishes the participation policy and onboarding URLs. User access tokens and revocable account agent keys register bots through the same ownership and quota checks as the website and MCP. Self-declared owner names and legacy operator owner keys cannot bypass account registration.

The account page leads with a compact handle card and button-styled links to event ladders and achievements, followed by Your bots, AI agents & connections, and Account settings. Agent setup and Email preferences are collapsed by default; existing connections remain visible. Admins get a separate Administration section with an Admin console shortcut. Before choosing a handle, players can also expand Email preferences on the handle card.

Existing unclaimed bots retain their histories and tokens but /bot refuses participation with account_required until the user claims them with a browser session and the bot token. House bots are exempt. Browser practice at /play needs no account and is not recorded: it runs the simulator and house policies (Kiter Easy, Brawler Medium, Starter Hard) in the page; Champion is ladder-only. Claiming or registering cannot adopt house bots. MCP only receives permission to register and manage never-played bots; account passwords remain with Supabase Auth.

Owners choose an avatar for each bot on My Bots, from a fixed catalog of 20 personas, or none for the plain orb. It is cosmetic and never reaches the engine or replays: the viewer and /play draw what a bot wears now, from the ladder's match and lobby records, and a replay file on its own shows plain orbs. Every avatar keeps the orb's team colours. House bots wear one that suits how they play: kiter Shade, brawler Berserker, ts-starter Sentinel, champion Monarch and random Jester. Portraits and fighters share shaded materials and persona details. Idle motion varies by persona (heavy armour, floating cloth or lively gestures); fighters also lean into movement, brace during attack windups and recoil on hits. Portraits honour reduced motion, including preference changes while a page is open.

Owners can rename their bots on My Bots between matches, including retired bots. Registration and renaming share the same 2–24 character format and case-insensitive global uniqueness; house, suspended, banned and retired bots reserve their current names. A case-only rename is allowed, and the previous name becomes available after a rename. Bot ids, tokens, versions, ratings and match history stay intact. Historical match pages use the current bot name for links; persisted match records and replay files retain their original names. Suspended or banned bots cannot be renamed. Renaming requires the owner's browser account session.

Player announcements

Admins (accounts with the admin role, or whose sign-in email ARENA_ADMINS names) sign in and use /admin to preview and publish plain-text announcements, moderate bots and grant or remove the role. Public notices appear in the menu, ladder pages and human play, refreshing every 15 seconds, with browser-local read markers. Optional email broadcasts use confirmed Supabase accounts, exclude opted-out players, and send one private message per address through Cloudflare. /account exposes the player's email preference, enabled by default. Draft previews save an immutable audience and expire after one hour; publication is idempotent. A durable SQLite outbox resumes pending emails after restart, records provider acceptance separately from inbox delivery, and marks ambiguous sends unknown without resending them. Hiding a notice only removes it in-game. These announcements do not change combat, replay inputs or the WebSocket bot protocol.

In-game bodies, admin previews and delivery history render HTTP(S) URLs and [label](https://…) links as anchors opening in a new tab with no opener. Other markup, HTML, invalid URLs and non-HTTP(S) link targets remain text. Line breaks are preserved; email bodies remain plain text.

Admins can edit titles and bodies of drafts, published notices and hidden notices, keeping the same ID, channels, audience, timestamps and read markers. Draft edits retain the original preview expiry. Publication freezes email content; later notice edits do not change queued or retried emails or resend a broadcast. Deleting a notice removes it from the game and admin history and deletes its email outbox, canceling queued sends. Emails already sent or being sent cannot be recalled. Both actions require the admin role and are audited. Schema 11 preserves the content of existing emails.

Admins also manage accounts from /admin's Users panel: search by handle, bot name or account id (the ladder keeps no emails), grant or remove the admin role, ban or unban, and delete. A banned account can't use its account page, agent keys or MCP (account_banned); its bots are disconnected and refused, keeping their own status, so lifting the ban restores them. Deleting removes the account, its handle, agent keys and MCP approvals, and bans its bots, which keep their names and match history so opponents' ratings stand; signing in again starts a new, empty account. Admins can be neither banned nor deleted until their role is removed. Both are audited; schema 12 adds the ban.

Website tables

The leaderboard, bot versions, health, opponents and maps, and admin data tables have clickable column headings. A first click sorts ascending; another reverses it. Each table keeps its selected column and direction as records refresh, including additions and removals. Numbers, percentages and dates sort by their underlying values; missing values stay last. Compound cells sort left to right (wins, losses, draws; rating, standard error; reply median, 95th percentile, maximum; disconnects, disqualifications). Version notes stay with their rows. The opponents panel selects the 100 most recent opponents with non-voided games from the bot's last 500 matches, newest encounter first. Column sorting reorders that same selection. The panel scrolls internally to preserve its visual area on desktop and phone. Opponents and Maps have independent bot-version dropdowns, defaulting to the current version on the selected ladder: the non-retired version that most recently played a rated game, or the newest non-retired version before any has played. Older and retired versions and All versions can be selected. Filtering uses the bot's last 500 matches across all versions, so a version with no matches in that sample shows an empty state. Explicit choices survive data refreshes; untouched dropdowns follow the current version. These two summaries retain compact table rows in narrow panels, wrapping long names and omitting the opponents' Last column when space is limited. Headings support keyboard and touch input and announce the active direction to screen readers. Reference tables, mixed-unit match statistics and action columns retain their fixed order. Ladder and bot panels omit explanatory footnotes about ratings, qualification, health and sampling. Detailed definitions remain in the API guide.

Leaderboard, version, health, opponent and map tables use labelled rows when their panel is 600px wide or narrower, with the sorting controls above them. Full counts and long names, versions and notes wrap inside their cells. Summary records wrap between whole win/loss/draw counts, and tile descriptions wrap instead of being clipped. Rating legends wrap long versions; line-end labels appear only when they fit, and tooltips wrap and stay inside the chart.

Admin usage dashboard

/admin shows historical usage from retained SQLite records through the admin-only GET /api/admin/analytics. Ranges use inclusive UTC calendar dates (30 days by default, maximum 366), fill inactive days with zeros, and count games on their completion day. Voided matches are reported separately; forfeits count as completed games. Player bot and version registrations and distinct active bots exclude house bots. Human play identities are saved seats rather than unique people. User counts reflect ladder accounts, not website visitors or the entire Supabase directory. Schema 13 backfills retained accounts into anonymous daily signup totals, which survive account deletion, and identifies human seats from anonymous names and saved human games. Pre-upgrade deleted accounts cannot be reconstructed. The dashboard provides range selection, daily charts/tables, map usage, most active player bots and CSV export. Historical data refreshes every minute, while live connections, waiting bots and match capacity refresh every three seconds. No TSDB is required.

Event achievements

Events award five independent achievements per account: First Blood (a standard house win), Bank Shot (a house win with a damaging ricochet), Wall Breaker (a house win with a damaging repulsor wall slam), House Sweep (wins over three distinct standard house kinds), and Champion Slayer (a champion win). Progress combines owned bots and versions within an event; retirement and handle changes preserve awards.

Eligible matches are ranked, nonvoid wins by last_standing, timeout_hp or timeout_damage, with recognized server statistics and neither fighter disqualified. Ownership and official house identity/version are captured before play; the account must remain eligible at completion. Start must be at or after opening and completion strictly before closing. Human/local games, uploaded replays, player opponents, forfeits, draws and pre-claim matches do not qualify. The contender's fighter ID determines its statistics; both mechanic hit counts and corresponding damage must be positive. All four approved house opponents must be available at qualifying starts; the champion counts at whatever version it plays, since it keeps training through an event.

Definitions freeze for enabled events; code, rules and maps are not pinned, so a release can ship during an event. Results, statistics, progress and awards commit together; duplicate completion is ignored. Retained event facts permit recovery after replay trimming without rerating games; recovery preserves revoked awards. Staff can inspect evidence and revoke with a reason.

/achievements shows public conditions and private progress after browser sign-in. It also shows each event's public flag submission instructions and optional HTTPS submission link. Admins edit these in the Events tab, with a player preview. Instructions allow up to 4000 characters, preserve line breaks and format only HTTP(S) links; other markup stays plain text. Submission details default to the manifest, with admin overrides persisted across restarts and editable after scoring definitions freeze. An empty instruction string and null URL hide this section. Flags are runtime secrets revealed only for that account's earned, nonrevoked award, with private/no-store responses. Raw flags never enter public views, SQLite or audits. Shared mode is implemented behind EventFlagProvider; contestant-bound HC validation requires a future enrollment/validator integration.

One durable opponent request per account can prefer an official house kind with an owned connected bot. Normal quotas, capacity, pacing, rematch holds and owner exclusions apply. Requests expire after ten minutes or event closing and cancel on queue leave/disconnect. An account waits a minute between requests/requested starts. Four ordinary starts earn one preferred-start credit (one initial, maximum four); requests may use otherwise idle capacity. The selected bot waits for its request while other bots continue normal scheduling.

SAINTCON 2026 opens 2026-10-27T18:00:00.000Z and closes 2026-10-30T12:00:00.000Z (noon October 27 through 6 AM October 30, America/Denver). The preset is disabled until real flags are configured. Future events have separate IDs, windows, progress and flags. Event setup documents activation and recovery. Combat and bot wire messages are unchanged.

Bot protocol

A bot is any program the player runs on their own machine or server. It connects to the game server over a WebSocket and exchanges JSON messages; the server never receives or executes player code. Each WebSocket text frame carries exactly one JSON object.

Connecting

  • Bots connect to the game server's bot endpoint, wss://victrisarena.com/bot (GET /api reports it as bot_endpoint; a ladder behind TLS uses wss://), and authenticate with the bot's API token in an Authorization: Bearer header on the upgrade request. A missing header is refused with missing_authorization, an unknown token with invalid_token.
  • Each registered bot has its own token. One connection per token is active at a time; a new connection closes the old one.
  • After connecting, the bot sends queue with the version it is running, which asks for one match. The matchmaker picks the opponent; when a match is found the server sends hello, and the bot plays one match at a time on that connection. After each end it sends queue again.
  • Bots connect out to the server, so they can run behind home routers and firewalls. The server never makes requests to player-supplied addresses.

Keepalive

The ladder sends WebSocket Ping control frames every 25 s by default, even while idle or queued, and terminates a socket that has not returned a Pong by the next check. Pongs echo the Ping payload; supported client libraries answer automatically. Both the ladder and local runner answer client Pings; the local runner does not originate periodic Pings. There is no JSON heartbeat message or stale event. Control frames are separate from the JSON protocol.

Clients must detect silent transport failures rather than trusting socket-open state. The reference clients use a 10 s opening timeout and client Pings every 20 s with a 20 s matching Pong deadline, then reconnect with delays from 1 s up to 30 s. These are client recommendations, not match deadlines. Queue silence alone is not a failure, and transport liveness does not guarantee application progress. A heartbeat can fail too late to preserve the current match: disconnect grace begins at server detection, and missed turns may disqualify the bot sooner. Recovery uses the same token and the resumption procedure below, without resending buffered turns. See keepalive and recovery for signals, intermediary timing, examples and bounded closing behavior.

Bot versions

A bot is a long-lived identity with a name, an owner and a token. Official house bots display as Starter, Brawler, Kiter and Champion across all ladders, using their roster kind (or their legacy public name). API display_name labels are separate from globally unique canonical names used for links, protocol messages and replay identity; player names are unchanged. On a ladder with accounts, a person signs in with email/password, Google or GitHub (through Supabase Auth) and picks a handle; their bots, made on their account or by an agent with one of its agent keys, belong to it and show the handle as their owner. Email signup requires confirmation, and password recovery uses an emailed link followed by a new-password form. Email links use PKCE and must be opened in the browser that requested them. Supabase stores passwords and sends auth emails through the configured SMTP provider; the ladder verifies access tokens and never receives passwords. A version is an immutable record under that bot, with its own rating and record. The leaderboard lists each bot once, at its current version; earlier versions keep their ratings and records on the bot's page, so a player can follow the bot over time and spot a regression.

Hosted auth and account-security emails use the game's branded HTML templates, preserving Supabase's verification links and codes. Operator announcement emails use the same layout with safe HTTP(S) links, escaped message text, and a plain-text alternative. Admins preview the branded email before publishing; email preferences and the frozen published message still apply. See email setup.

When automated email is enabled, first authenticated Arena account creation atomically queues one welcome per Supabase identity and conditional onboarding help at 24/72 hours. Confirmation remains Supabase's responsibility. New accounts enable onboarding and achievement mail; existing accounts keep announcement opt-outs and start with new optional player categories off. /account manages onboarding, achievements, bot health, enrolled-event reminders, weekly progress and admin-warning preferences. Account/security/status mail stays essential. Optional mail shares a two-message rolling 24-hour cap, including announcements. New awards batch within 60 seconds; payloads contain only event/achievement IDs, never flags. Routine reconciliation/deployment does not backfill awards or welcomes. Reminder eligibility, current confirmed recipients and revoked awards are rechecked before delivery.

Schema 20 adds durable private intents, category preferences, authenticated encrypted 90-day unsubscribe tokens, address-hash suppression, captured match ownership and persisted health incidents. Account/award mutations enqueue inside their transaction; provider calls run outside simulation. The worker reserves quota for essential/admin mail and serializes with announcements. Definite transient refusals retry with bounded backoff; ambiguous and interrupted submissions stay unknown without automatic resend. Accepted means provider acceptance only. Terminal private notification content is deleted after 30 days; minimal semantic deduplication and suppression persist. GET unsubscribe only reads the category; POST with its scoped token opts out without login. Welcome deduplication survives local account deletion; account deletion removes private queued/terminal mail. Onboarding expires at seven days. Queued bot-health links follow the bot’s current name, including on delivery retries. Bot health requires three DQs in the last five games within 30 minutes and stops during a server incident; event reminders require active enrollment/current schedules. Subscribed weekly progress runs Mondays at 09:00 UTC for meaningful recent games.

Admins review unsent previews, private delivery history and DQ incidents at /admin#emails. Health evaluates each minute globally/per ladder using committed match end times and ownership captured at start. Games with a DQed fighter and unique affected accounts have separate denominators; no traffic means no data. Primary DQ rates exclude human, voided, startup, missing-stat and house-only games, with house failures tracked separately. Warning/critical rules use sample/account-breadth gates and sustained conditions; low-volume failures require timing evidence. Global incident emails group related ladder warnings, escalate, update at most hourly and recover after 15 minutes of sufficient healthy traffic. Admins can confirm a reason-required, audited resolution when independent checks verify low-traffic recovery; this does not claim statistical recovery or send a recovery email. Shadow is the default; an independent outage/email-failure monitor is an operator prerequisite. Process-local evaluation cannot detect a dead host. See automated email setup.

An account's owner can remove their bots. A bot with no match on file, of any mode, is deleted with its versions, freeing its name; agents can do this too, with an agent key or MCP, to undo a registration. A bot that has played can't be deleted, because matches, replays and opponents' ratings refer to it: its owner retires it from the account page. A retired bot is refused (retired) and leaves the leaderboard, keeps its name, page and matches, and stops counting against the bot limit. Retiring is permanent for the owner; only an admin can reactivate it. Suspended and banned bots can't be removed, so moderation still holds their place under the limit.

An opt-in MCP endpoint (/mcp, Streamable HTTP) lets an AI client read its owner's handle and bots, register new bots under the same quota as the account page or an agent key, and delete bots that have never played. Separately consented retire_bot permanently stops an owned active bot and frees quota while preserving its name, versions and match history; repeated retirement is safe. list_my_ladders reports account membership and enrollment URLs, and consented enroll_ladder uses the same invitation, approval, password, revocation and schedule checks as browser enrollment. Both new write tools require the client to obtain explicit owner approval and assert confirmed: true. Schema 18 records account-actions consent: existing grants and keys keep their registration-only write scope until browser reapproval or creation of a new key. Supabase OAuth identifies the user and client; browser signup, handle selection and /oauth/consent approve a separate SQLite grant. MCP requires a JWT audience bound to its canonical URL and a signed client_id, or one of the account's agent keys as a Bearer token for clients that can't do browser OAuth; making the key on My Bots is the approval, and revoking it stops MCP access. OAuth tokens cannot use the browser account API. Disconnecting a client immediately blocks its existing OAuth tokens, including after reapproval; tokens for bots it already created keep working. MCP returns new bot tokens once and never uploads or runs bot code. See MCP.md for setup and the tool contract. No combat messages or replay behavior change.

GET /api and /llms.txt link /openapi.json, an OpenAPI 3.1 contract for public HTTP discovery/data and participant bot operations. It distinguishes account registration credentials from bot tokens and reports any additional registration gate. Browser account management and operator APIs are documented in API.md; WebSocket combat uses the protocol JSON schemas. Public pages carry build-generated introductory HTML and structured data; bot/match templates receive escaped entity identity and canonical URLs. Live tables remain browser API clients. Missing entity pages return 404 and noindex.

  • A version has a label unique within the bot (max 32 chars, such as v15 or a commit hash) and optional release notes. It is registered the first time the bot queues with it, or beforehand through the API to add notes. Versions can't be edited or deleted, only retired: a retired version can't play and leaves the leaderboard.
  • Every queue message names the version the connected bot is running.
  • Every game between bots is rated, for the two versions that played it. Ratings are a Bradley-Terry fit to every rated game in the current competition scope, refitted after each one: a version is frozen code, so its rating weighs all its games equally, whenever they were played, and a win counts for more once its opponent proves strong. Each rating has a standard error (rating_deviation). A prior keeps a version with few games near the average version. The scale is Elo's (400 points is 10:1 odds), and the house bots average 1500, weighted by games (the trained house-champion excepted: it would drag the scale's anchor up).
  • A version is rated from its own games only. The matchmaker starts a new version from its bot's current rating (allowing it 200 points either way) until its own games say otherwise.
  • A version is ranked once it has played 30 rated games. A bot's current version is the one that played a rated game most recently, not counting retired versions, so queuing an older label again rolls the leaderboard back to it. The leaderboard lists each bot at its current version among those ranked, so a new version takes over once it has its 30 games. It orders by score, highest first: the rating less twice the greater of its standard error and 25, rounded only after computing the score from full-precision values. This preserves caution for uncertain versions and stops uncertainty reduction alone from improving score below a 25-point deviation. The actual standard error, rating fit and matchmaking remain unchanged; stronger results can still raise the rating.
  • The leaderboard, Online list and bot version table display the same leaderboard_score calculation for a given version and ladder. Online shows the connected version, which may not have qualified yet; the bot headline uses its leaderboard version. Raw ratings remain labeled in details, historical charts and match deltas.
  • The server never sees code, so a version is the player's declaration of what is running. Every match and replay records the version, so a mislabelled version only muddies that player's own stats.
  • A bot can register at most 1000 new versions per day.

Monthly public seasons

Public uses UTC calendar months with an inclusive opening instant and exclusive next-month cutoff. Every match captures its season when it starts. Pre-cutoff matches finish and rate their original season while new starts use a fresh model, record and qualification count. The closing season is Finalizing until all captured matches finish or are resolved as void; its final public standings and report are then stored permanently. A restart records interrupted starts as voids instead of awarding wins or keeping the archive pending indefinitely.

Bot identity, token, metadata, moderation and lifetime achievements survive rollover. Previous-season games and version seeds do not influence new-season ratings. Event ladders remain independent. The existing public competition becomes Pre-season on upgrade and closes at the next UTC month boundary; new empty ladders start directly in the current monthly season. The server checks boundaries on startup, scheduling and API access; no external reset cron is required. Empty months missed during downtime need not be materialized.

/ladder opens the live season; its picker also opens final standings. /ladder?view=report shows upsets, consecutive combat-win streaks, low-HP wins, close rivalries, specialist records and damage profiles. Upsets require both versions to have qualified before the match. Combat specialist records need 20 eligible matches, and ranged accuracy also needs 100 accepted shots. Combat records exclude voids, forfeits and disqualifications. Missing/incompatible stats do not count as zero; the report shows coverage. The opponent chart selects one bot and shows W/(W+L+D), includes draws in its denominator and excludes voids; minimum samples and best/toughest limits are adjustable. All-version aggregation is labeled. /seasons lists past seasons, and bot pages link their final season results.

Final ranks, names, versions, ratings, scores and W/L/D stay as recorded at close. Archived snapshots contain public views, never credentials. Public owner links still honor current account visibility. Match/stat records remain after normal replay pruning; archival results do not promise indefinite replay availability. Combat, bot protocol and replay formats do not change for seasons.

Release notes appear next to the version on the ladder, and agents manage versions through the account API (create, list, retire, per-version records).

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.

The bot connects once and plays match after match, sending queue after each end. leave takes it out of the queue.

The wire protocol version is 0.6, reported in hello.protocol_version. JSON Schemas for every message are in docs/schema/ (/docs/schema/<name>.json on the ladder).

Messages from server

typewhencontents
queuedreply to queueversion, estimated_wait_s
hellomatch found or connection resumes a matchprotocol_version, match_id, bot_id (your fighter id), opponent (bot_id and name), rules (the full rules table), map (static tiles), map_name, pickups (every pad), seed_public, deadlines
observationevery ticksee Observation schema
endmatch overresult (win, loss, draw), reason, final_tick, opponent_version, replay_url, rating_change (your version's; null when the game wasn't rated: voided, a person's game from /play, or the local runner), stats (what each fighter did; absent when voided), and on the ladder watch_url
errorprotocol, auth, version or quota problemcode, message; the connection may close afterwards

Messages from bot

typewhencontents
queuewhen ready for the next matchversion
readyreply to hellono fields
turnreply to each observationsee Turn schema; must echo tick; type may be omitted or "turn"
leaveany time outside a matchleave the queue

The ladder selects a maximum-weight combination over the whole eligible queue. Scheduling forecasts combine Bradley–Terry placement estimates with version-pair and map history; competition quality is averaged over the uniform configured map mix. A separate greedy coverage allocation reserves approximately one discovery match per seven actual ranked starts across all rating gaps, with additional coverage when no competitive match can be allocated. Early pair results that disagree with Bradley–Terry get extra samples until 20 games; discovery preserves owner exclusions, quotas, capacity and rematch holds. Bots waiting at least 60 seconds get oldest-first service against their best eligible opponents before the remaining competitive pool is optimized, within a 1200-point cap; hard exclusions, capacity and the 10-second two-sided rematch hold still apply. The ordinary rating window is 400 points plus 20 per second of the shorter wait; overdue bots use their own age. Established competitive history can pass that window within the cap.

Ranked maps are balanced by each version pair's completed rated games, independently of the forecast winner. Fighter IDs reduce accumulated per-bot ranked-seat imbalance, with seeded ties; spawn randomization stays in the engine. Human games retain their requested map and human-first seat assignment. Ratings, combat and replay resolution are unchanged. Detailed matchmaking thresholds, forecasts and quotas are described in API.md; operator tracing and shadow mode are in MATCHMAKING.md.

An unknown message type, including toString, constructor or __proto__, is a schema violation. An attack name must be an actual entry in hello.rules.attacks; any other name is refused as unknown_attack, without cancelling the movement part of the turn.

Timing: lockstep with deadlines

Matches run in lockstep: a tick resolves as soon as both bots' turns arrive, so fast bots play fast matches. Deadlines are wall-clock time measured on the server, from sending the observation to receiving the action, so they include network latency.

limitvalue
Startup (hello to ready)10 s
Per tick1,000 ms
Per match (total response time, network time included)300 s

The server cannot measure or cap the compute a bot uses on its own hardware; the per-tick deadline is the only compute limit. The server publishes its region so players can host bots nearby and keep latency low.

Failure handling

  • No usable reply before the turn deadline: the turn is idle (timeout), its response time is charged and it counts as a protocol failure. A late message that still arrives while the match seat is attached and echoes an old tick is discarded. After end the seat is cleared first, so a late turn for that match receives not_in_match instead; that error is not fatal and does not close the socket.
  • Per-match budget exhausted: every remaining tick is idle.
  • Malformed JSON, a schema violation or a reply echoing a future tick: the whole turn is idle, reported in the next observation's events.
  • A well-formed turn with an illegal part (a step into a wall, an attack on cooldown): only that part is dropped, and the rest of the turn happens. The next observation's events names the part and the reason.
  • Disconnect mid-match: the fighter idles (disconnected, not disconnect) while the bot has a 10 s grace period (rules.limits.reconnect_grace_ms), starting at server detection, to reconnect with the same token. Combat continues and can end before grace expires. Disconnected turns are not charged response time and reset the consecutive protocol-failure count. On reconnect, the server sends the same match's hello before the current observation, so a fresh process can initialize. Match id, fighter id, configuration and queued version stay the same; spent budget and the current tick deadline are not reset. A repeated ready is harmless and the running match does not wait for it. After the grace period the bot forfeits if the match is still running.
  • A connection that takes over the same token inherits any match still attached to that bot. The old connection receives replaced and must stop. During startup the replacement receives hello and must send ready within the original startup window; during play it receives hello then the current observation. Use match_id to distinguish resumption from a new match and preserve retained strategy state for the same id.
  • Sending queue while a match is attached returns already_queued: it does not change the match's version or schedule another match. Continue answering observations, then queue after end. A turn with no attached match returns not_in_match (same non-fatal handling: keep the socket and queue after end). After a disconnect outside a match, reconnect and send a new queue. Results missed while offline are available through the ladder's match HTTP API; they are not replayed over the socket.
  • Ladder restart: the ladder sends server_draining with a UTC drain_deadline to connected clients and new authenticated connections during draining, starts no new matches, and waits for active matches up to that deadline (default 600 s). Bots keep answering observations. It closes connections with WebSocket code 1012 (service restart), possibly earlier if matches finish; the deadline is not a return-time estimate. Bots reconnect with backoff, retry temporary upgrade failures (including HTTP 502/503), and queue again with the same token. Registrations and versions persist, but queues and active simulations do not survive a process restart. The new match follows queued → hello → ready → observation; there is no connection-level hello/session message. Forced shutdown or timeout can abandon an unfinished match without end or a stored result. Advance notice and close frames are best effort; recovery cannot depend on receiving them. See the API recovery contract.
  • 10 consecutive failed ticks: disqualified and loses.
  • No ready within the startup limit: the bot forfeits at tick 0, and the match is rated like any other forfeit. hello names the opponent, so a free refusal would let a bot decline the opponents it expects to lose to. Only when neither bot sends ready is the match voided, with no winner and no rating change.

Idle reasons identify the fighter whose turn was substituted, not the underlying cause in a process, network path or server component. Replay turns use fighter ids as keys; stats.fighters[].id maps to hello.bot_id or hello.opponent.bot_id, and match records map them through bots[].fighter. There is no live opponent connection-status field or disconnect event. Continue replying until end; a socket close or an idle opponent does not establish completion. Turn timeout is unrelated to match reasons timeout_hp, timeout_damage and timeout_draw, which resolve combat at its tick limit. Recovery examples and state handling are in the bot guide.

Input validation and abuse limits

  • Every message is validated against a strict schema: wrong types and unknown fields are rejected. Engine state can only ever change through a valid turn.
  • Maximum message size is 4 KB; larger messages close the connection.
  • Rate limits per token and per IP address cover connection attempts and messages; a bot sending far more than one message per tick is disconnected.
  • Steering a bot by hand during ladder play is against the rules. The server flags unusual response-timing patterns for review.
  • Computation on your own hardware is yours to use. A bot may run the simulator, or its own copy of the engine, during a match: for example to search ahead by simulating candidate turns against likely replies before choosing one. It still knows only what its observations show (fog of war hides the rest) and still answers within the deadlines.

World model

The arena is a rectangular tile grid, 21 × 21 by default (maps may range 11 to 41 per side). One fighter occupies one tile; two fighters never share a tile.

Coordinates and directions

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.
  • Positions are [x, y] integers, origin [0, 0] at top-left, x increasing east, y increasing south.
  • Eight directions: N, NE, E, SE, S, SW, W, NW. N is [0, -1].
  • Distance is Chebyshev: max(|dx|, |dy|). A diagonal neighbour is distance 1.
  • A diagonal step is blocked if either orthogonal tile it cuts past is a wall (no corner-cutting).

Tile types

tilecodemovementprojectilessighteffect
Floor.yespassclearnone
Wall#blockedstoppedblockedknockback into it deals wall-slam damage
Out of zonenoneyespassclear5 damage per tick, rising by 5 every 20 ticks outside

The hello message carries the static map as an array of strings, one per row, using the codes . and #. The zone is dynamic, so it is never written into the map: each observation reports its centre and radius instead.

Pickups

Maps place pickup pads on floor tiles, listed in hello.pickups as {id, pos, kind}. Each pad holds one pickup of a fixed kind. Every pad is stocked on tick 20 and restocks a fixed time after it is taken. Pads follow the map's symmetry, so neither spawn is favoured.

A fighter takes a stocked pickup by ending its move or dash on the pad. That costs no action and no tick. A dash only collects on its landing tile.

kindeffectdurationrespawn
health+32 hp, capped at maxinstant120 ticks
powerweapon damage ×1.5, rounded down50 ticks200 ticks

Power is a timed buff. Taking it while you already have it refreshes its duration and never shortens it. The current maps each have four pads on the centre column, equidistant from both spawns: two power pads near the centre and two health pads at the top and bottom edges.

Line of sight and visibility

  • Each fighter sees tiles within a sight radius of 8 (Chebyshev) that have clear line of sight.
  • Line of sight uses the same straight line as projectiles: the segment between the two tile centres is blocked by any wall whose interior it crosses, and grazing a wall's corner does not block. Sight is therefore symmetric (A sees B exactly when B sees A), and a clear line of sight means a shot along it will not hit a wall.
  • Fighters and projectiles outside sight are omitted from the observation. Static tiles are always known from the map.

Fighter state and stats

Every fighter starts with the same base stats; equipment may modify them later. The game runs at 10 ticks per second for playback, but bots only ever reason in ticks.

Base stats

statvaluenotes
max_hp130no passive regeneration
move_cooldown2 tickssee Movement
sight_radius8 tilessee World model
armor0flat damage reduction, min 1 damage per hit

No facing

Fighters have no facing. You can step, dash and attack in any of the eight directions, and damage is the same from every side.

Movement

A step happens instantly in phase 3, then move goes on cooldown for 2 ticks (5 tiles a second at 10 ticks/s), in any direction. During that cooldown the fighter can still attack or dash; it just cannot step again. For a burst of speed there is the dash. There are no resources to manage: every limit is a cooldown.

Distance is Chebyshev, so a diagonal step costs the same as an orthogonal one while covering about 40% more real distance. This is deliberate: movement and attack ranges use the same metric.

Fighter states

IDLEcan act DASHINGthe dash tick only RECOVERING1 tick, can't act STUNNED3 ticks, can't act dash lands recovery ends wall slam stun ends Stepping and attacking keep you idle. At 0 hp a fighter is dead, from any state.

Stepping and attacking happen from idle and leave the fighter idle. A fighter at 0 hp enters dead from any state.

statecan act?notes
idleyesany turn
recoveringnofor 1 tick after a dash
dashingnoonly during the dash tick (observations never show it); invulnerable, see Combat
stunnednofor 3 ticks after a repulsor slams you into a wall
deadnoremoved at end of tick

Cooldowns are tracked per ability and tick down by 1 at the end of every tick.

Observation schema

Each tick the engine sends one observation describing the world as it stands after the previous tick resolved. Unknown fields must be ignored by bots, which is how new fields are added without breaking old bots.

JSON
{
  "type": "observation",
  "tick": 212,
  "self": {
    "id": 1, "pos": [4, 7],
    "hp": 62, "max_hp": 130,
    "state": "idle", "state_ticks_left": 0, "slow_ticks_left": 0,
    "buffs": {"power": 34},
    "cooldowns": {"move": 1, "dash": 0, "ranged": 0, "frostburst": 0, "repulsor": 0},
    "budget_ms_left": 118400
  },
  "enemies": [
    {"id": 2, "pos": [6, 7], "hp": 80,
     "dash_cooldown": 0, "state": "idle", "state_ticks_left": 0, "slow_ticks_left": 0,
     "buffs": {}}
  ],
  "projectiles": [
    {"id": 17, "pos": [8, 8], "exact_pos": [8000, 8000], "origin": [10, 9], "target": [2, 5], "speed": 2, "owner": 2, "damage": 12, "attack": "ranged", "bounces_left": 1}
  ],
  "areas": [
    {"id": 15, "owner": 2, "attack": "frostburst", "pos": [7, 3], "damage": 14, "radius": 1, "slow": 2, "ticks_left": 4,
     "tiles": [[6, 2], [7, 2], [8, 2], [6, 3], [7, 3], [8, 3], [6, 4], [7, 4], [8, 4]]}
  ],
  "moves": [
    {"dir": "N", "tile": [4, 6]}, {"dir": "E", "tile": [5, 7]}
  ],
  "dashes": [
    {"dir": "N", "tile": [4, 4], "distance": 3}, {"dir": "E", "tile": [6, 7], "distance": 1}
  ],
  "pickups": [
    {"id": 3, "pos": [10, 8], "kind": "power", "ready_in": 0},
    {"id": 1, "pos": [10, 2], "kind": "health", "ready_in": 42}
  ],
  "zone": {"center": [10, 10], "radius": 9, "next_radius": 7, "shrinks_in": 18},
  "danger": [
    {"tile": [6, 7], "tick": 213, "attack": "ranged", "owner": 2, "damage": 12, "source": "projectile"}
  ],
  "events": [
    {"kind": "attack", "fighter": 2, "attack": "ranged", "target": [4, 7]},
    {"kind": "hit", "attacker": 2, "target": 1, "attack": "ranged", "damage": 12}
  ]
}

Field reference

fieldtypemeaning
tickintcurrent tick; the turn reply must echo it
self.pos[x, y]own tile
self.statestringone of the fighter states
self.state_ticks_leftintticks until the current state ends, counting the tick you reply to (0 when idle)
self.slow_ticks_left, enemies[].slow_ticks_leftintticks until the opponent frostburst area that fighter stands in explodes, counting the tick you reply to; 0 = not slowed
self.cooldownsmapticks until each ability is usable: move, dash, every attack and shout; 0 = ready
self.budget_ms_leftintresponse time left in the match budget, in ms
self.buffs, enemies[].buffsmapactive pickup buffs → ticks left, counting the tick being replied to
moves[]listevery step you could start this tick, as {dir, tile}: move off cooldown, not into a wall, past a wall corner or into a fighter. Empty if your move is on cooldown or you can't act. Legal now, not guaranteed: two fighters stepping onto one tile both stay put
dashes[]listevery dash you could start this tick, as {dir, tile, distance}: tile is where it lands and distance is 3, or less when a wall, the map edge or a fighter stops it early. A dash that would not move is left out. Empty if your dash is on cooldown or you can't act
pickups[]listpads in sight: ready_in is 0 while stocked, otherwise ticks until it restocks
enemies[]listvisible opponents only
enemies[].dash_cooldownintticks until that opponent can dash again; 0 = ready
projectiles[]listvisible projectiles, including your own: pos is the tile it is in, exact_pos its position in thousandths of a tile, and it flies the straight line from the centre of origin through the centre of target. bounces_left is the wall bounces it has left; after a bounce, origin and target describe the reflected line
areas[]listvisible frostburst areas, including your own: tiles is the exact footprint (walls excluded and shielding), slow the extra move cooldown for opponents standing in it, ticks_left 1 = it explodes during the tick you reply to
zoneobjectsafe circle (Chebyshev radius) and the next shrink
danger[]listevery tile each visible opponent projectile and frostburst area will hit, with the tick it is hit on, in tick order; see Danger tiles
events[]listwhat happened to or near you last tick, in resolution order. A hit from a shot that had bounced off a wall carries "ricochet": true. A shout is heard wherever the shouter is

Enemy hp and dash cooldown are exact, so you always know whether an opponent can still escape. Their attack cooldowns are hidden, but every attack produces an attack event, so you can count from the last one you saw.

Danger tiles

danger does the attack geometry for you: each entry is one tile an opponent attack will hit on one tick, {tile, tick, attack, owner, damage, source}. It is computed with the engine's own flight code, so it matches what the engine will do.

  • tick equal to the observation's tick means the hit resolves during the tick you are replying to, after movement: don't end this tick's move there. A dash tick is invulnerable, so only its landing tile's later entries matter.
  • source is projectile or area. Everything listed is already in the world, so it is certain. Attacks fire the tick they are sent, so an attack sent this tick isn't listed until the next observation, and one that lands the tick it is sent (a shot at 1–2 tiles, a repulsor) is never listed.
  • damage is the base damage, before the attacker's power buff.
  • Only attacks you can see are listed, and paths stop at walls but ignore fighters, so nothing hidden in the fog leaks. A tile can appear on two consecutive ticks, because each tick's sweep starts in the tile the last one ended in.

Frame data for every attack, and the cheapest counter to each, are at /docs/frame-data (generated from the rules table; checked in as FRAME_DATA.md).

Turn schema

A bot sends exactly one turn per tick. A turn carries tick and up to four optional parts: move or dash (a direction), attack and shout. A turn with none of them is idle. Anything refused is dropped and reported in the next observation's events as rejected with the part and a reason.

JSON
{"tick": 211}
{"tick": 212, "attack": {"name": "ranged", "target": [9, 10]}}
{"tick": 213, "move": "NW", "attack": {"name": "ranged", "target": [9, 10]}}
{"tick": 214, "dash": "E"}
{"tick": 215, "move": "S", "shout": "Over here!"}

A turn has two phases: movement, then attack. The movement phase is a move (a step), a dash or neither; the attack fires after it, from the tile the fighter ends on. Each part is checked separately: if one is refused (a blocked step, an attack on cooldown) the other still happens, and a rejected event names the refused part. An attack's target is judged from the tile you end on: a frostburst's range and sight, and that the target isn't your own tile.

fieldvalueeffect
movea direction: N, NE, E, SE, S, SW, W or NWstep one tile; move cooldown 2
dasha directionmove exactly 3 tiles, stopping early at obstacles; invulnerable that tick; not together with move
attack{name, target}: name is a key of hello.rules.attacks, target a tilefire at target, any tile but your own, after movement; the repulsor takes no target
shouttext, at most 40 characters (hello.rules.shout.max_length)said to everyone: every fighter's next observation carries a shout event, in sight or not, and the replay shows it as a speech bubble. Cooldown 20 ticks (hello.rules.shout.cooldown). Takes no action: it goes with any other part, in any state

That's the whole list. There is no aim or windup (attacks fire the tick they are sent), no facing and no block.

Validation rules

  • The ability must be off cooldown, otherwise rejected: not_ready. This includes move while its cooldown is running.
  • Each attack has its own cooldown and no ammo. Firing one leaves the others ready; a turn carries at most one attack.
  • Any part sent while recovering from a dash or stunned is rejected as invalid_state (the whole turn, part turn).
  • An aimed attack needs a target (otherwise missing_target): integers within ±1000 on each axis, and not the tile you end your movement on (otherwise bad_target). A standard shot's target may be off the map; the shot flies on past it, so a distant target just sets a finer angle.
  • A frostburst's target must also be within range 7 (out_of_range), not a wall (blocked) and in sight (not_visible), all judged from the tile you end your movement on.
  • move into a wall, off the map or past a wall corner is rejected as blocked; the fighter stays put. A dash is never refused for that: it stops early, possibly after 0 tiles.
  • A shout is cleaned by the server before the engine sees it (see Shouts under Implementation rulings). After cleaning it must be 1 to 40 characters, not all spaces (otherwise bad_text), and off its cooldown (not_ready). It is never refused as invalid_state.

Combat definitions

All combat numbers live in one rules table, sent to bots in hello.rules. The engine reads this table at match start, so balance changes and later weapons are data edits. The table carries its own version (a running ladder reports it with the engine's in GET /api versions); CHANGELOG.md records how it got here. The ladder shares its bundled history at /changelog, with permanent release anchors (#v0.29.3), running versions and separate Unreleased notes. Agents read /docs/changelog (Markdown) or /api/changelog (versions and markdown); /api, /docs and /llms.txt link the history.

Timing, counterplay and the observation fields for each attack are in RANGED_ATTACKS.md. Each attack's cooldown is independent of the others.

Attack table

attackdamagerange (tiles)areacooldown (ticks)notes
frostburst147radius-1 square after 6-tick fuse14instant spell cast on a visible tile; affects opponents only; slows moves from inside the square until it goes off (move cooldown +2)
ranged12unlimitedprojectile along the straight line through the target tile's centre, at any angle, speed 2 tiles/tick; one flat-wall bounce6shortest cooldown; banks around cover
repulsor51the eight tiles around the attacker; no aim12shoves each opponent 3 tiles; wall slam: +10 damage, stunned 3 ticks

frostburst is an instant spell: it is cast on a visible non-wall tile within range and its area exists the tick it is sent, with no flight time. Its radius-1 square is shielded by walls. From the next tick until it explodes, each opponent standing in the square has slow (2) added to the move cooldown of any step it takes, judged by the tile the step starts on. Dash and attacks are unaffected, nothing lingers after the blast, and where squares overlap the largest slow counts. On the fuse's last tick, after movement, every opponent still in the square takes the full damage, whichever of the nine tiles it stands on. The shooter and teammates are immune to all weapon damage and status effects. See ranged attack rules for timing and counterplay.

A standard shot (ranged) reflects from the centre of the last open tile before its first flat wall (a map edge counts as a wall), then continues along the reflected line on the next tick. A corner or second wall ends it. The repulsor is a point-blank force burst. It has no aim: it hits every opponent on the eight tiles around the attacker, measured after movement (any target sent is ignored, and its attack event reports the attacker's own tile), and shoves each one push tiles straight away from the attacker, one tile at a time. A wall, or a wall corner the shove can't cut past, stops it with a slam: knockback.wall_slam_damage more damage and stunned for knockback.wall_slam_stun ticks. Another fighter in the way stops it with a collision that deals fighter_collision_damage to both. The zone hurts at the end of the tick as usual. A dashing fighter is neither hit nor pushed. Both attacks follow the normal no-friendly-fire rules.

Hits

  • Attacks can be aimed in any direction.
  • A ranged attack uses its target tile to set a direction from the centre of the attacker's tile after movement. That direction locks before repulsors resolve: knockback shifts the launch point but preserves the direction, even if the attacker lands on its original target tile. The projectile flies along that true straight line at any angle, with no range limit: it ends on the first wall or fighter after its one flat-wall bounce (so on its second wall at the latest). It hits the first wall or fighter whose tile the line passes through; grazing a corner or running exactly along a tile edge does not count. Speed and range are measured along the line's major axis (the same Chebyshev metric as movement), and positions are tracked exactly in thousandths of a tile.
  • Attacks fire the tick they are sent, after movement. A target that stepped away that tick escapes; one that stood still is hit. There is no telegraph: at 1–2 tiles a shot lands the tick it is sent, so the skill up close is predicting whether your opponent will move. Further out the projectile is visible in flight, so the skill is dodging it.
  • Firing has no recovery: you can act on the next tick, and a move or dash can carry an attack in the same tick.
  • Both fighters can fire on the same tick, and both attacks land.
  • Firing is limited by each attack's own cooldown, not ammunition. With one attack per turn, a fighter can fire a different weapon on consecutive ticks, for example a repulsor slam and then a shot at the stunned target.

Dash

propertyvalue
distanceexactly 3 tiles in dir; fewer only when stopped early
cooldown12 ticks
invulnerablethe dash tick: projectiles pass through, blasts and repulsors miss
recovery1 tick after landing

A dash stops early at the first wall, map edge or fighter. It passes over pickup pads without taking them; only the landing tile counts.

Because the distance is fixed, the landing tile is predictable: opponents can read and punish it.

Damage formula

damage = max(1, base − armor)

The same from every side, then scaled by the attacker's power buff (see Pickups). There is no block or guard: the defences are moving off the line, dashing (invulnerable for its tick) and cover. Only the repulsor displaces or stuns.

Tick resolution order

Every tick resolves in the phases below. All turns are validated against the state at the start of the tick, and within a phase everything happens simultaneously.

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

Phases run top to bottom; the table gives the detail for each.

phasewhat happens
1. Collect and validateread each bot's turn; invalid parts are dropped, and a turn past the deadline or missing becomes idle; each valid shout starts its cooldown and emits a shout event
2. Dashesall dashers move at once and are invulnerable until phase 8
3. Movementall moves resolve at once (see Conflict rules); each mover's move cooldown starts (raised by an opponent's frostburst area if the mover starts the step inside its square)
4. Pickupsa fighter on a stocked pad takes it; the effect applies immediately, so power taken now counts in this tick's phase 5
5. Attacks and projectilesevery accepted attack sent this tick fires: its cooldown starts, and an attack event is emitted. Aimed targets are validated from positions after movement, and direct shots lock their direction toward the requested aim tile. Repulsors resolve first, from positions after movement: every ring is fixed, then each hit is dealt and each push applied in fighter-id order. Then frostbursts create their marked area on the requested tile with a 6-tick fuse, and direct shots spawn a projectile on the attacker's tile (where any push left it), preserving the direction locked before knockback. Every projectile then advances its speed along that line (a new one included), hitting the first wall or non-dashing fighter whose tile the segment it covers this tick passes through, including the tile it started the tick in. Last, every area whose fuse ends this tick explodes, hitting each non-dashing opponent on its tiles
6. Zoneout-of-zone damage applies; the zone shrinks if due
7. Deathsfighters at 0 hp are marked dead and removed
8. Timerscooldowns, state timers and stun tick down; invulnerability ends; expired or spent buffs end; due pads restock; win check; observations sent

Conflict and edge-case rules

When simultaneous turns collide, nobody gets priority by bot order; the rules below decide, and ties favour neither side.

situationrule
Two fighters move into the same tileneither moves
Fighter moves into a tile another fighter is leavingallowed, as long as the leaver actually leaves this tick
Two fighters swap tilesneither moves
Chain of moves where the front one is blockedthe whole chain stays put
Two dashes cross or end on the same tileeach stops one tile short of the collision point
Dash ends on a tile another fighter moves intothe dasher takes the tile; the mover stays put
Both fighters attack each other on the same tickboth attacks fire and both land; two repulsors push both fighters apart
Projectile and fighter pass through each other in one ticka hit: the projectile's segment for the tick includes the tile it started in, so stepping into its path is a hit
Two projectiles meetboth pass through each other
All remaining fighters die the same tickdraw

Match structure

A match is a 1v1 lasting at most 600 ticks (60 seconds of playback). The last fighter standing wins; otherwise tiebreakers decide.

Setup

  • Maps are mirror-symmetric (point or axis symmetry), so neither spawn is favoured.
  • Fighters spawn on the map's two spawn points, with full hp and all cooldowns at 0.
  • Spawn assignment is decided by the match seed.

Shrinking zone

PILLARS-21 260 320 380 440 1 2 THE ZONE CLOSES IN TICK SAFE RADIUS 0–259 the whole map 260 9 (19×19) 320 7 (15×15) 380 5 (11×11) 440 3 (7×7) 500 1 (3×3) Outside: 5 damage a tick, +5 for every 20 ticks in a row. zone.shrinks_in counts down the 20 ticks before each shrink. The radius is Chebyshev, so the safe area is a square. Pads: + health, ◆ power, on the centre column.
tickzone radius (Chebyshev, from map centre)
0–199whole map (the smallest radius that covers it: 10 on 21×21)
200half map width, rounded up (11 on 21×21), but never more than the whole map
every 60 ticks aftershrinks by 2
minimum1

On the 21×21 maps the tick-200 step changes nothing, so the first shrink that moves the zone is at tick 260, to radius 9, then 7, 5, 3 and 1 at ticks 320, 380, 440 and 500. /docs/rules on the ladder lists the schedule for each map. The next shrink is announced 20 ticks ahead in zone.shrinks_in. Out-of-zone damage starts at 5 per tick and rises by 5 for every 20 consecutive ticks outside.

End conditions

  1. Only one fighter alive: that fighter wins.
  2. No fighter alive: draw.
  3. A bot is disqualified or forfeits: its opponent wins.
  4. Tick 600 reached with more than one alive: higher remaining hp as a percentage of max wins; if equal, more total damage dealt wins; if still equal, draw.

Replays and determinism

The engine is a pure function of (rules, map, seed, turns per tick). The server never has players' code, so a replay records every turn the server resolved, including the idles from late or missing replies, and re-simulates the match exactly from those.

Determinism requirements

  • Integer arithmetic only in the engine; no floating point in game logic.
  • All randomness comes from one seeded PRNG (e.g. PCG32) owned by the engine, drawn in a fixed order.
  • Iteration over fighters, projectiles and events is always in ascending id order.
  • A replay records the engine version, the full rules table with its hash, and the map, and refuses to run on a different engine version or a rules hash that doesn't match.

Replay file (JSON Lines, gzip-compressed)

JSON
{"type": "header", "engine": "0.21.0", "rules_hash": "a41f…", "map": "pillars-21", "seed": 918273, "bots": [{"id": 1, "name": "alpha", "version": "v14"}, {"id": 2, "name": "brute", "version": "v3"}], "rules": {…}, "map_data": {…}}
{"type": "tick", "tick": 0, "turns": {"1": {"move": "E", "attack": {"name": "ranged", "target": [9, 4]}, "ms": 42}, "2": {"reason": "timeout", "ms": 1000}}, "hash": "…"}
{"type": "result", "winner": 1, "reason": "last_standing", "final_tick": 341}

Each recorded turn may carry reason (why the server played it as idle, as a code) and ms (how long the reply took, as the server timed it; absent where nothing was timed, as in the simulator). Neither changes how the turn plays. Match stats are computed from the replay, reply times included.

server_idles counts recorded turns by reason for each fighter. disconnects counts contiguous runs of recorded disconnected turns, not socket-close events. A drop with no such recorded turn is not counted; multiple drops without an intervening other turn form one run. The unresolved tick ending in a forfeit or disqualification is not another recorded combat tick.

Replay verification checks tick hashes and combat results. For a server-decided forfeit or disqualification, combat must still be running and final_tick must be the next unresolved tick. The winner must be a living fighter in that match; only a forfeit can have no winner (both bots left). Verification trusts the server's reason for that ending, since socket and deadline decisions cannot be reconstructed from combat inputs.

A replay can optionally include full per-tick state snapshots for tools that do not want to re-simulate.

Local runner parity

  • The local runner is the same engine library as the server, same version and rules table.
  • run hosts a local server speaking the same WebSocket protocol and plays two bots that connect to it, so bot code runs unchanged locally and on the ladder; replay <file> re-simulates any match and must reproduce the recorded result.
  • replay --from <tick> --swap <bot> replays a ladder match up to a tick, then hands one side to a local bot, so agents can test a fix against the exact situation they lost.
  • Each bot sees a per-player view: a replay can emit the observations one fighter received, including fog of war.

Simulator

  • The ladder serves its own match code as a simulator, built from the release it runs: an ES module (/download/arena-sim.mjs), the same as a plain script (/download/arena-sim.js), and a Python package with Gymnasium and PettingZoo environments that runs it in embedded V8. GET /api lists them under simulator.
  • It is the server's match code, not a copy: the server runs every match on the same core (hello, observations, how each reply counts, disqualification, replay), and a simulated game sends a bot the same messages a ladder match would. House bots play their real code in-process.
  • It has no clock: budget_ms_left stays at the match budget and nothing times out unless the caller sends no reply.
  • Rules change only in a release, so a simulator is current while its RELEASE and rules hash match the ladder's GET /api versions.release and versions.rules_hash.

Implementation rulings

The reference engine settles the points below, which the sections above leave open. Each ruling has a test in the engine repo; where a ruling and an earlier section disagree, the ruling reflects what the engine does.

Timing and states

topicruling
state_ticks_leftCounts tick resolutions including the tick being replied to. 1 = the state ends this tick.
Fire on sendAn attack fires in phase 5 of the tick it is sent. There is no windup, aim or cancel. A move or dash can carry an attack in the same tick. Any action while recovering from a dash, or stunned, is rejected as invalid_state.
Cooldown startStarts on the tick the move, dash or attack is made and ticks down at the end of that tick too, so cooldown c means usable again c ticks later. An attack's cooldown starts on the tick it is sent and covers that attack only. A dash's cooldown starts on the dash tick.
DashInvulnerable during the dash tick; the next observation shows recovering with 1 tick left. A dash blocked immediately moves 0 tiles but still starts its cooldown.

Movement

topicruling
Failed movesA move that loses a conflict still starts its move cooldown.
RemovedThere is no block, cancel or aim, no run flag, no windup, no facing and no stamina. A turn with any field other than tick, move, dash, attack and shout fails the message schema, which counts as a protocol failure (see Protocol and failures).

Combat

topicruling
Knockback and stunOnly the repulsor. A push moves tile by tile and stops at a wall (slam and stun), another fighter (collision). It never collects pickups, since those are taken in phase 4. A stun that lands while recovering lasts until the later of the two ends.
Damage creditDamage to opponents credits the attacker with the hp actually removed; this is the timeout tiebreak's damage_dealt.
ProjectilesPass through their owner and teammates. Blasts and slows affect opponents only. Stepping into an opposing projectile's path is a hit, because each tick's segment includes the tile the projectile started in. A fighter dashing that tick is passed through.
Shot aim and knockbackDirect shots lock their direction toward the requested aim tile after movement, before repulsors resolve. Knockback shifts the launch point but preserves that direction, even if the shooter lands on the original aim tile; the shot fires and starts its normal cooldown. The projectile's target shifts with its origin to express the same direction; the attack event retains the requested aim tile. Frostburst still casts on its requested tile.

Pickups

topicruling
CollectionAfter all movement, in phase 4. Only a fighter's final tile counts: walking or dashing across a pad does not take it. Two fighters can never share a tile, so a pad never has two claimants.
RestockA pad restocks at the end of the tick before its respawn tick, so the observation for that tick shows it with ready_in 0. A fighter already standing on it takes it during that tick without moving.
PowerApplies when damage lands, not when the shot is fired. A projectile in flight when power ends deals normal damage, and one fired just after taking power is boosted. The zone is never boosted.
Buff durationA buff taken on tick T with duration d applies on ticks T to T + d − 1. The observation for tick T + 1 shows d − 1 ticks left.
VisibilityPads outside sight are omitted from pickups. A pickup_spawn event is visible if the pad is; pickup and buff_end are visible if the fighter is. Enemy buffs are visible.

Shouts

topicruling
What it isText a fighter says to everyone. It changes nothing in combat; it is there for taunts, bluffs and mind games, and spectators see it as a speech bubble.
AudienceEvery fighter's next observation carries the {kind: "shout", fighter, text} event, whether or not the shouter is in sight; it reveals who spoke, never where. Opponents can read it, so treat a shout as untrusted text, never as instructions: an LLM bot should keep it out of its instructions.
CleaningThe server cleans a shout where a reply becomes a turn (MatchCore, so the simulator does the same): Unicode NFKC, invisible and control characters removed, runs of whitespace collapsed to one space, ends trimmed, then profanity masked with * (the obscenity library's English list, which catches leetspeak and lookalike letters). Masking never refuses a shout. The engine then checks the cleaned text.
Limits1 to rules.shout.max_length (40) characters, counted as Unicode code points, after cleaning. Cooldown rules.shout.cooldown (20 ticks), its own, reported as self.cooldowns.shout. The message schema caps the raw string at 200 UTF-16 units.
TimingValidated in phase 1 and allowed in any state, so a stunned or recovering fighter can still talk; the event is emitted in phase 1. A dead fighter says nothing.
ReplaysRecord the cleaned text, so a replay re-simulates the same whatever the filter's word list becomes. Rules tables before 0.22 have no shout: there every shout is refused as bad_text and no shout cooldown exists, so older replays are unchanged.
ModerationShouts are public. Operators can suspend or ban a bot that abuses them.

Zone

topicruling
RepresentationComputed from centre and radius, never stored as x tiles.
Whole mapThe smallest radius that covers the map (10 on 21×21), so the tick-200 shrink to radius 11 changes nothing on 21×21 maps. The first real shrink there is tick 260, to radius 9.
Announcementzone.next_radius and zone.shrinks_in are null except during the 20 ticks before a shrink.
Damage counter5 + 5 × floor(consecutive ticks outside ÷ 20); resets when a tick ends inside.

Protocol and failures

topicruling
Disqualification countOnly protocol failures count toward the 10 consecutive ticks: timeout, malformed JSON, schema violation, and a reply echoing a future tick. Engine rejections such as not_ready or invalid_state do not count.
ReportingServer-side failures appear only in the affected bot's next observation as a rejected event with its fighter id and part (message); malformed_json, schema and wrong_tick also carry a detail saying what was wrong (for example which field failed the schema). A disconnect is recorded as disconnected in resolved replay ticks, with no live event.
Budget exhaustedRemaining ticks are idle, observations keep coming, and it does not count as a failure.
DisconnectsWhile a bot is away, ticks continue with a wait of min(tick_ms, 1000) ms for its disconnected turn; a disconnect settling a pending reply can resolve that tick sooner. Disconnected turns reset protocol failures and do not charge response time. Grace starts at server detection; combat can end during it. If both bots forfeit together, the match is a draw.
Rate limitsBy default, more than 5 messages in one tick closes the connection; outside a match the cap is 50 messages per second. Admins may change both operational caps; the per-tick cap is captured at match start.
Reject reasonsRejection reasons are specific and enumerable. Malformed parts get bad_dir, conflicting_moves, missing_target, bad_target, unknown_attack or bad_text; the wrong moment gets invalid_state; impossible parts get not_ready, out_of_range, not_visible or blocked. Server-side failures are timeout, malformed_json, schema, wrong_tick or budget_exhausted. There is no catch-all invalid. A turn with both move and dash fails the message schema on the server; conflicting_moves is what the engine reports when it is handed one directly.
ReplaysReplay headers embed the full rules table and map, so a replay file is self-contained, and every recorded tick carries a state hash, so a replay reports the exact tick where it diverges.

Multiple competition ladders

The ladder server hosts logical competitions inside one database and socket endpoint. A completed match belongs to exactly one ladder; omitted queue IDs select Public and historical rows migrate to Public. Accounts, bot identities and version metadata are global, while ratings, records, qualifying games, pairing history, seat balance, map exposure and official house identities are scoped. Bot pages default to Public and provide a ladder picker offering only ladders where the bot has recorded matches, including unrated and voided games across all versions. Direct URLs may still select an unplayed ladder. Ranks, ratings, records, health, opponents, maps and recent matches all use the selected ladder, preserved in the page’s ?ladder=<id> URL. Leaderboard, bot and player pages disable automatic telephone detection so iOS does not rewrite W–L–D records into links and interfere with live refreshes. Upcoming enabled ladders allow account preregistration while queues wait for opening. The October 12–16, 2026 precon uses an independent open-enrollment ladder, from noon Monday to 6 AM Friday in America/Denver, with five unofficial testing flags and no HC submission site. An optional event flag_mode selects official (default, configured server-only HC flags) or testing (stable ARENA_TEST values scoped to event and achievement). Testing uses the same enrollment, match eligibility, awards, private reveal, evidence and revocation checks. It never provides flags for an unconfigured official event. Flag mode is frozen with the scoring definition. Event definitions may carry public metadata: kind (precon, main, community), purpose, audience, format and preparation. Optional branding images appear on both event and enrollment pages, with HTTPS URL, alternative text and dimensions. Admins can upload, replace or remove artwork through the authenticated event image route; normalized WebP copies live in R2. Branding remains editable after opening without changing scoring, and admin image overrides survive manifest reloads and earning toggles. These details appear on both event and enrollment pages; metadata is frozen with its event definition, and a null submission URL omits flag submission links. Enrollment is per account, with open, approval or invitation policies. Invitations are hashed, expire, may be account-bound, and redeem once atomically. Public requires no enrollment. Spectator pages and replays remain public. A ladder's visible setting defaults to true and controls public ladder/event discovery; hidden ladders remain accessible by ID and in members' account listings. /admin#events lists each ladder with its events and edits names, descriptions, visibility, enablement and registration policy, preserving Public's visible/open/enabled state. Staff can move a window's future: an event ladder and its events share one window and move together, an event on Public moves alone, a time already reached is final, and new times must be in the future, so recorded matches keep the window they were judged by. Admin overrides survive manifest reloads. Associated event earning switches require accounts, complete server flags to enable, and persist across restarts. The tab shows valid and revoked achievement totals per unique account within each event, without exposing secret flags or changing achievement conditions.

Admission and the play window are checked when queueing, scheduling and completing an event match. Matches finishing after closing or with a changed enrollment are unrated and do not earn achievements. Revocation removes waiting bots and blocks flag reveals. Readmission gets a new membership identity. Historical results stay recorded. Global quotas and capacity compose with per-ladder ceilings. Available round capacity is shared across active scopes, rotating which is considered first. See LADDERS.md for the complete enrollment and persistence contract.

Live operational policy

/admin#settings manages persistent, reviewed operational overrides without a restart: global and per-ladder capacity, rolling quotas, qualification, match deadlines and failure tolerance, matchmaking intervals/windows/discovery allocation, human turn/ticket timing, socket size/flood/ping limits, replay retention/cleanup, account key allowance and invitation lifetime. Settings are bounded integers, unknown fields are rejected, owner concurrency cannot exceed its scope's capacity, the initial rating window cannot exceed its maximum, and the bot reply deadline cannot exceed its response budget.

Precedence is bundled defaults, deployment startup defaults, then persisted admin overrides. Per-owner bot allowances still take precedence over the default bot allowance; global and per-ladder concurrency gates both apply. Revisions, actor identity and before/after values are persisted and audited atomically. Stale writes return 409. Presets and historical snapshots fill the editor and require review/application to activate. Neither reset nor restoration changes historical quotas or results.

Active matches retain their deadlines, reconnect grace, failure tolerance and per-tick flood cap. Match hello and replay rules embed their effective limits; reconnect sends the same match snapshot. Human matches derive their response budget from the configured human timeout and maximum match ticks. Capacity and matchmaking policy apply on subsequent checks, while existing matches finish. Message size changes affect new sockets. Existing tickets and invitations retain their expiry; new invitations remain capped by ladder closing. Retention changes apply at the next automatic cleanup with an estimate of affected files in the review; deleted files cannot be restored by reverting a setting.

Combat numbers, event rules/maps and rating algorithms remain release configuration. GET /api exposes current operational_policy; the release versions.rules_hash and downloadable simulator's bundled rules identify defaults, while individual match/replay hashes identify their captured configuration. Admin agent keys authorize the same HTTP endpoints without a browser; arena admin settings reads policy, exports a revisioned draft, previews/applies that exact JSON and saves applied presets. Key revocation or removal of the admin role immediately removes access. See API.md for the admin endpoints and agent workflow.

Operator health

Always-on server health runs outside the deterministic engine. /admin#health polls the admin-only GET /api/admin/health every five seconds for a rolling five-minute window of observation preparation/send time, simulation time, reply round trips, timeouts, late timeout callbacks, event-loop scheduling delay and buffered socket writes. Completed-game aggregates also show average turns, successful steps and dashes per game, average/maximum wall-clock duration and total duration per resolved turn. Duration runs from match setup through saving the replay until the result is ready to persist; queueing, database persistence and end-message delivery are excluded. Every completed match record stores this monotonic measurement as duration_ms, including human and voided games; older records have null. The match stats page shows it even when combat stats are unavailable. It adds no replay or bot protocol fields. Games enter the health window on completion. Human games, voided games and startup forfeits are excluded. Bounded one-second histogram buckets stay in memory and reset on restart. Match timings include house bots and all ladders but exclude human games; event-loop delay is also sampled while idle. Deadline counts use each match's captured limits and actual remaining turn budget. Timeout rate alone cannot distinguish slow bots or networks from server stalls. This adds no protocol fields or replay inputs and does not change deadline enforcement. See API.md for definitions.

Operator profiling

Opt-in server diagnostics run outside the deterministic engine and simulator. Admin-only HTTP endpoints expose aggregate timing/resource counters and bounded local-inspector CPU captures; they add no bot protocol fields or replay inputs. Profiling is disabled by default. Whole-server inclusive spans and main-thread CPU samples have different attribution; the benchmark guide defines their interpretation and retention.