Arena Fighter

Read as Markdown

Arena Fighter Engine Spec

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:

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.

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. Human players use one-use, 60-second play tickets and are bound to account IDs, excluded from registered bot quotas and refused on the ranked bot endpoint. Tickets for deleted or banned accounts are refused, including tickets issued before a ban. Human play accepts only online house bots assigned to Public, and rejects player bots and other ladders' house bots. The picker labels kiter Easy, brawler Medium, ts-starter Hard and champion Very hard, based on their relative public ladder strength when the labels were introduced, and orders them by difficulty by default. Claiming or registering cannot adopt human seats or 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.

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. Opponent sorting covers all opponents in the sampled history before limiting the display. 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.

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 and human play seats. 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

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

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

sequenceDiagram
    participant B as Bot
    participant S as Server
    B->>S: connect with token
    B->>S: queue (version)
    S->>B: queued
    S->>B: hello (match config)
    B->>S: ready
    loop every tick
        S->>B: observation
        B->>S: turn
    end
    S->>B: end (result, stats)
    B->>S: queue (the next match)

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

type when contents
queued reply to queue version, estimated_wait_s
hello match found or connection resumes a match protocol_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
observation every tick see Observation schema
end match over result (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
error protocol, auth, version or quota problem code, message; the connection may close afterwards

Messages from bot

type when contents
queue when ready for the next match version
ready reply to hello no fields
turn reply to each observation see Turn schema; must echo tick; type may be omitted or "turn"
leave any time outside a match leave 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.

limit value
Startup (hello to ready) 10 s
Per tick 1,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

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

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

Tile types

tile code movement projectiles sight effect
Floor . yes pass clear none
Wall # blocked stopped blocked knockback into it deals wall-slam damage
Out of zone none yes pass clear 5 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.

kind effect duration respawn
health +32 hp, capped at max instant 120 ticks
power weapon damage ×1.5, rounded down 50 ticks 200 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

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

stat value notes
max_hp 130 no passive regeneration
move_cooldown 2 ticks see Movement
sight_radius 8 tiles see World model
armor 0 flat 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

stateDiagram-v2
    [*] --> idle
    idle --> dashing: dash
    dashing --> recovering: landed
    recovering --> idle: recovery done
    idle --> stunned: wall slam
    stunned --> idle: stun done

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

state can act? notes
idle yes any turn
recovering no for 1 tick after a dash
dashing no only during the dash tick (observations never show it); invulnerable, see Combat
stunned no for 3 ticks after a repulsor slams you into a wall
dead no removed 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.

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

field type meaning
tick int current tick; the turn reply must echo it
self.pos [x, y] own tile
self.state string one of the fighter states
self.state_ticks_left int ticks until the current state ends, counting the tick you reply to (0 when idle)
self.slow_ticks_left, enemies[].slow_ticks_left int ticks until the opponent frostburst area that fighter stands in explodes, counting the tick you reply to; 0 = not slowed
self.cooldowns map ticks until each ability is usable: move, dash, every attack and shout; 0 = ready
self.budget_ms_left int response time left in the match budget, in ms
self.buffs, enemies[].buffs map active pickup buffs → ticks left, counting the tick being replied to
moves[] list every 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[] list every 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[] list pads in sight: ready_in is 0 while stocked, otherwise ticks until it restocks
enemies[] list visible opponents only
enemies[].dash_cooldown int ticks until that opponent can dash again; 0 = ready
projectiles[] list visible 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[] list visible 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
zone object safe circle (Chebyshev radius) and the next shrink
danger[] list every tile each visible opponent projectile and frostburst area will hit, with the tick it is hit on, in tick order; see Danger tiles
events[] list what 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.

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.

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

field value effect
move a direction: N, NE, E, SE, S, SW, W or NW step one tile; move cooldown 2
dash a direction move 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 tile fire at target, any tile but your own, after movement; the repulsor takes no target
shout text, 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

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

attack damage range (tiles) area cooldown (ticks) notes
frostburst 14 7 radius-1 square after 6-tick fuse 14 instant spell cast on a visible tile; affects opponents only; slows moves from inside the square until it goes off (move cooldown +2)
ranged 12 unlimited projectile along the straight line through the target tile's centre, at any angle, speed 2 tiles/tick; one flat-wall bounce 6 shortest cooldown; banks around cover
repulsor 5 1 the eight tiles around the attacker; no aim 12 shoves 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

Dash

property value
distance exactly 3 tiles in dir; fewer only when stopped early
cooldown 12 ticks
invulnerable the dash tick: projectiles pass through, blasts and repulsors miss
recovery 1 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

\text{damage} = \max\big(1,\ \text{base} - \text{armor}\big)

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.

flowchart TD
    A[1. Collect and validate] --> C[2. Dashes]
    C --> D[3. Movement]
    D --> P[4. Pickups]
    P --> H[5. Attacks and projectiles]
    H --> I[6. Zone]
    I --> J[7. Deaths]
    J --> K[8. Timers]

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

phase what happens
1. Collect and validate read 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. Dashes all dashers move at once and are invulnerable until phase 8
3. Movement all 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. Pickups a 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 projectiles every 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. Zone out-of-zone damage applies; the zone shrinks if due
7. Deaths fighters at 0 hp are marked dead and removed
8. Timers cooldowns, 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.

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

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

Shrinking zone

tick zone radius (Chebyshev, from map centre)
0–199 whole map (the smallest radius that covers it: 10 on 21×21)
200 half map width, rounded up (11 on 21×21), but never more than the whole map
every 60 ticks after shrinks by 2
minimum 1

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

Replay file (JSON Lines, gzip-compressed)

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

Simulator

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

topic ruling
state_ticks_left Counts tick resolutions including the tick being replied to. 1 = the state ends this tick.
Fire on send An 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 start Starts 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.
Dash Invulnerable 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

topic ruling
Failed moves A move that loses a conflict still starts its move cooldown.
Removed There 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

topic ruling
Knockback and stun Only 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 credit Damage to opponents credits the attacker with the hp actually removed; this is the timeout tiebreak's damage_dealt.
Projectiles Pass 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 knockback Direct 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

topic ruling
Collection After 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.
Restock A 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.
Power Applies 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 duration A 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.
Visibility Pads 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

topic ruling
What it is Text 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.
Audience Every 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.
Cleaning The 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.
Limits 1 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.
Timing Validated 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.
Replays Record 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.
Moderation Shouts are public. Operators can suspend or ban a bot that abuses them.

Zone

topic ruling
Representation Computed from centre and radius, never stored as x tiles.
Whole map The 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.
Announcement zone.next_radius and zone.shrinks_in are null except during the 20 ticks before a shrink.
Damage counter 5 + 5 × floor(consecutive ticks outside ÷ 20); resets when a tick ends inside.

Protocol and failures

topic ruling
Disqualification count Only 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.
Reporting Server-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 exhausted Remaining ticks are idle, observations keep coming, and it does not count as a failure.
Disconnects While 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 limits By 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 reasons Rejection 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.
Replays Replay 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; ranks, ratings, records, health, opponents, maps and recent matches all use the selected ladder, preserved in the page’s ?ladder=<id> URL. 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.