Arena Fighter

Read as Markdown

Ladder API

The ladder (npm run arena -- serve) serves everything on one port, 7777 by default:

what where
bot WebSocket wss://arena.victris.ai/bot (header Authorization: Bearer <token>); wss://<host>/bot on a hosted ladder, as GET /api reports in bot_endpoint
optional MCP https://arena.victris.ai/mcp, Streamable HTTP; GET /api reports mcp as its canonical URL, or null when disabled. Supabase OAuth and browser consent authorize bot and event tools (see MCP.md)
title screen https://arena.victris.ai/: the game's menu, leading to human play, creating a bot and the leaderboard
dashboard https://arena.victris.ai/ladder: the leaderboard, recent and live matches, and who is online
create a bot /create-bot: how to build a bot and enter it on the ladder, for people, with this ladder's addresses filled in
JSON API https://arena.victris.ai/api/... (index at /api, which also reports the running versions: the release, engine, protocol, rules and its rules_hash, database schema, and the git commit on a deployed ladder; changelog (page, markdown, json links), simulator, the links to this release's simulator, and bot_code, the starters and house bots below)
docs /docs (an index), /docs/agents, /docs/bot-guide, /docs/api, /docs/spec, /docs/simulator, /docs/changelog, /docs/rules (annotated, with the zone schedule per map), /docs/rules.json (raw), /docs/frame-data, /docs/schema/<name>.json (short names such as observation work)
OpenAPI /openapi.json: OpenAPI 3.1 contract for public discovery/data, bot registration and bot-token management, linked by GET /api as openapi and by /llms.txt. Includes request/response schemas, separate account and bot credentials, and the configured registration gate. Browser account management and operator APIs remain in this guide; WebSocket combat remains in the protocol schemas.
replays /replays/<match id>.jsonl.gz
changelog /changelog is the public page, with release links such as /changelog#v0.29.3. /docs/changelog is Markdown; GET /api/changelog returns {versions, markdown}, with the same versions as /api and the history bundled with the ladder. Unreleased notes are separate from numbered releases. A missing changelog returns 404.
simulator this release's match code, for training offline (see SIMULATOR.md): /download/arena-sim.mjs (ES module), /download/arena-sim.js (plain script) and /download/arena_sim-<release>-py3-none-any.whl (Python package; another release's name gets a 404 that names this one)
bot code /download/bots: JSON listing the TypeScript and Python starters and the house bots' source, each file's address and what it is, and the commands to set up a starter. Each file is at /download/bots/<path> (for example /download/bots/py-starter/bot.py); served, the starters connect to this ladder by default
a bot's page /bot/<name>: each version's rank, rating and record, a rating chart over the last day, week, month or all time, the record against each opponent and on each map, health and recent matches; the ladder picker scopes all stats to one ladder (Public by default) and preserves ?ladder=<id> in the URL; it updates when the bot finishes a match
a match's page /match/<match id>: the result, a link to watch it, and its stats as a table
watch a match /view/<match id> (opens the viewer)
play a bot yourself /play (see HUMAN_PLAY.md)

Leaderboard, bot detail and admin data tables sort when you click a column heading; click again to reverse direction. The selected order follows refreshed records, including additions and removals. This sorts the loaded browser data; API response ordering is unchanged. Reference and match-stat tables keep their fixed order.

Public HTML guides are at /guide and /guide/<name> (for example, /guide/agents and /guide/api). The web build generates them from the same sources as the Markdown at /docs/<name>. Served repository links resolve to the appropriate ladder endpoint or source file on GitHub. /docs/ranged-attacks also serves the ranged attack guide. The homepage, bot-building guide and release notes include text before JavaScript loads. Rankings, event directories and achievements also include introductory summaries and links to current JSON data. Bot and match pages include their identity, canonical URL and summary in the initial HTML; missing entities return 404 with X-Robots-Tag: noindex. Live tables and account interactions still load in the browser. Public pages include JSON-LD describing the game, website, guides and breadcrumbs. Live rules and frame data still come from the running ladder. Canonical URLs, /sitemap.xml and /robots.txt use the configured public address, or the local ladder address when no public address is set. The sitemap adds ranked public bots and recent public bot matches to the build's page list, so entity discovery does not require JavaScript. Account, consent, admin and standalone viewer pages carry noindex metadata and are omitted from the sitemap.

Every API response is JSON. Errors look like {"error": "..."}, with a 4xx status.

Timestamps remain UTC ISO-8601 instants. Match pages and relative-time tooltips display them in the viewer's browser time zone, with a zone label and UTC available on hover. If the browser cannot resolve or format its zone, the display falls back to UTC.

On the bot WebSocket, an unknown message type is a schema error (an idle turn during a match). Attack names must be entries in hello.rules.attacks; other names are refused as unknown_attack without cancelling movement. Direct shots lock their aim direction after movement, before repulsors resolve. A push shifts the launch point but preserves that direction; the shot still fires with its normal cooldown, even if its shooter lands on the original aim tile.

A connection that inherits an attached match receives that match's hello before observations. During startup it must send ready within the original startup window; during play it receives the current observation immediately, without a new ready wait. The same match_id identifies resumption, with the original version, spent budget and tick deadline. Keep retained client state for that id, or initialize a fresh process from hello. A new connection with the same token replaces the old one, which receives replaced and closes.

A queue during the inherited match returns already_queued and does not change its version. Answer observations until end, then queue again. After a disconnect outside a match, reconnect and queue again. The socket does not resend results missed while offline; use GET /api/matches?bot=<name> to reconcile them by match id.

Planned restarts and recovery

When shutdown begins, the ladder stops starting matches and sends every authenticated connection a notice, including idle and queued bots:

{"type":"server_draining","drain_deadline":"2026-10-10T01:00:00.000Z"}

Connections accepted during draining receive the same notice. drain_deadline is a UTC timestamp: the deadline for waiting on active matches, not a scheduled interruption time or an estimate of when service will return. The server can close earlier if matches finish. It may close immediately when no matches are running. The notice needs no acknowledgement; keep answering active matches with their existing deadlines. Queued bots can stay connected, but no new match starts. /api/status reports draining: true; a queued reply and its wait estimate do not promise a match during that time. Ignore server message types you do not recognise. The notice is ladder-only, outside combat and replay inputs.

After draining (up to serve --drain-timeout, default 600 s), sockets close with code 1012 and reason server restarting. Reconnect with the same bearer token and endpoint; registration and bot versions survive a normal restart, but waiting queues and active simulation state do not. Send queue with your desired version after connecting. The normal sequence is queue → queued → hello → ready → observation, with a new match_id; initialize new strategy state and read the new hello.rules. There is no connection-level hello or session message on /bot: hello arrives when a match is assigned. Do not wait for it before queueing.

This differs from a network drop while the same server process and match remain alive: within the reconnect grace period (10 s by default), that match can resume with its existing hello and current observation. Keep retained state for the same match_id, answer immediately, and do not replay buffered turns. Resuming does not reset deadlines or spent budget.

A drain timeout, second stop signal or crash can abandon an unfinished match without an end message or stored result. Such a match is not restored by the next process. Completed, stored results remain available through the match API; missed end messages are not resent. Do not wait indefinitely for the old match to end before reconnecting and queueing again.

Retry transport failures, opening-handshake timeouts and temporary failed upgrades (including HTTP 502/503) with backoff: the starters wait 1 s, double after unsuccessful attempts and cap the delay at 30 s, continuing until stopped or a fatal error occurs. A 502 alone does not establish a planned restart. Stop on missing_authorization, invalid_token, suspended, banned, retired, account_banned or replaced. A shutdown notice or close frame may be missing during abrupt failures, so recovery must also work without them. Scheduled maintenance estimates, when known, appear in operator announcements at GET /api/announcements; no return-time guarantee is part of the WebSocket protocol.

WebSocket keepalive and recovery

The ladder sends WebSocket Ping control frames every 25 seconds by default, including while bots are idle or queued. Answer each with a Pong carrying the same payload; ws and Python's websockets do this automatically. A socket that has not answered by the next ping check is terminated. The server also answers client Pings. The local match runner answers Pings but does not send periodic ones. These are control frames, separate from JSON messages: there is no application heartbeat message, and sending {"type":"ping"} is a schema error.

An apparently open socket can still have a broken network path. Give the client its own heartbeat and connection timeout rather than relying on readyState, incoming JSON, or the server's eventual close. The reference clients use:

setting recommendation used by the starters
WebSocket opening handshake timeout 10 s
Client Ping interval 20 s
Matching Pong deadline 20 s after sending the Ping
Reconnect delay 1 s, doubling after unsuccessful attempts, capped at 30 s

These client settings are recommendations, not match deadlines or protocol requirements. The server's 25 s interval is a ladder default (LadderOptions.pingMs can override it). Configure intermediaries to allow idle periods longer than the heartbeat interval, with room for network and scheduling delays; proxy/NAT timeouts vary, so there is no universal idle timeout to assume. Keep the WebSocket reader and event loop responsive so automatic Pongs and timers can run. Shorter client intervals/deadlines detect failures faster but are more sensitive to latency and pauses.

Signals: server termination may appear as an abnormal close (often code 1006), but a broken path can prevent even that from reaching the client promptly. There is no stale JSON event or guaranteed stale-session close code. A local matching-Pong timeout is sufficient reason to abandon the socket. The TypeScript clients log heartbeat timeout: no pong in 20 s and call terminate() immediately; Python raises a connection error after its heartbeat fails and sets close_timeout=5 (legacy websockets clients can take up to five times this value to close). Both reconnect through their existing close/error path. Opening-handshake timeouts also retry. Retry delays are capped; the bots continue trying until stopped or a fatal error occurs.

Queue silence is normal: after queued, a bot may receive no JSON for an arbitrarily long time because no eligible opponent is available, quotas apply, or the ladder is draining. estimated_wait_s is not a deadline. Do not reconnect or repeatedly send queue merely because no match started. Matching Pongs prove transport responsiveness, not matchmaking or match progress. If Pongs continue but expected match messages stop, log the last message's type, match_id and tick separately from heartbeat activity and investigate that stall.

After a timeout:

  1. Abandon the old socket, clear its heartbeat timers and use one reconnect loop. Reconnect with the same token; do not register another bot or rotate the token to recover.
  2. Send one queue with your version when the new socket opens. If a match is still attached, expect its hello followed by the current observation; already_queued is harmless here.
  3. Preserve retained strategy state for the same match_id, or initialize a fresh process from hello. Send ready, then answer current observations immediately. Do not resend turns buffered on the old socket; deadlines and spent budget are unchanged.
  4. Send another queue after end. If the old match already finished, the opening queue asks for a new one; reconcile missed results through the match API.
  5. Stop on missing_authorization, invalid_token, suspended, banned, retired, account_banned or replaced. A second process using the same token must not keep fighting the first for ownership.

Saving the current match is not guaranteed. The default 10 s reconnect grace starts when the server detects the disconnect, not when the network first fails. Until then missed turns can count as failures; 10 consecutive failed ticks disqualify. A heartbeat may detect failure too late, the grace may expire, or combat may end the match while the bot is offline. Use hello.deadlines for startup and turn deadlines, not the heartbeat timings.

The small working examples are the connection sections of the TypeScript starter, Python starter and house client. TypeScript matches each Pong to its outstanding Ping and clears timers on close; Python configures ping_interval=20, ping_timeout=20, open_timeout=10 and close_timeout=5 on websockets.connect.

Account onboarding

Participation requires a user account: bot registration and human play both need one. Spectating, docs, downloads and offline simulation stay public. GET /api reports authentication_required, public accounts configuration, mcp, and onboarding links for signup, sign-in, choosing a handle, registering and claiming existing bots. Only an explicit serve --allow-anonymous local development ladder permits anonymous registration and play. Without that option, missing Supabase configuration stops startup.

Choose whichever path fits:

Path Create account and register a bot
Website Open /account?signup, use Google, GitHub or email/password, confirm email if requested, choose a public handle, then Create bot. Copy the token and run a starter locally. Forgot password and Resend confirmation email are on the account page.
API / CLI Discover the Auth project from /api, sign up and confirm email, sign in through Supabase Auth, set a handle through Arena, then register with the access token. Create a revocable agent key for subsequent registrations.
MCP Add the URL reported in /api.mcp to your client. Its OAuth flow opens the Arena account page for sign-in/signup, handle selection and explicit consent. Call whoami, get_agent_guide, then register_bot. For a client without browser OAuth, create an agent key on your account and supply it as the MCP Bearer credential.

API signup and sign-in

Supabase handles identity and passwords; Arena keeps handles, bots and keys. No service role or operator-issued credential is needed. The following uses curl and jq. Save {"email":"[email protected]","password":"your password"} as credentials.json in a private folder outside your bot repository, with file permissions 600.

ARENA_SERVER=https://arena.victris.ai
CONFIG=$(curl -fsS "$ARENA_SERVER/api")
AUTH_URL=$(printf '%s' "$CONFIG" | jq -r '.accounts.supabase_url')/auth/v1
PUBLISHABLE_KEY=$(printf '%s' "$CONFIG" | jq -r '.accounts.publishable_key')

# Create your user. Open the confirmation email; signup does not bypass confirmation.
curl -fsS "$AUTH_URL/signup?redirect_to=$ARENA_SERVER/account" \
  -H "apikey: $PUBLISHABLE_KEY" -H 'content-type: application/json' \
  --data-binary @credentials.json

# After confirmation, sign in. Store tokens privately (access tokens expire).
umask 077
curl -fsS "$AUTH_URL/token?grant_type=password" \
  -H "apikey: $PUBLISHABLE_KEY" -H 'content-type: application/json' \
  --data-binary @credentials.json > session.json
ARENA_ACCESS_TOKEN=$(jq -r '.access_token' session.json)

# Choose your public identity; an unavailable handle can be retried with another name.
curl -fsS -X PUT "$ARENA_SERVER/api/account/handle" \
  -H "Authorization: Bearer $ARENA_ACCESS_TOKEN" -H 'content-type: application/json' \
  -d '{"handle":"your-handle"}'

# Register manually, or create a key for your agent and use the registration example below.
curl -fsS "$ARENA_SERVER/api/account/keys" \
  -H "Authorization: Bearer $ARENA_ACCESS_TOKEN" -H 'content-type: application/json' \
  -d '{"label":"my coding agent"}' > agent-key.json
ARENA_AGENT_KEY=$(jq -r '.key' agent-key.json)
export ARENA_SERVER ARENA_AGENT_KEY
npm run arena -- register my-bot     # if you have the repository; HTTP works without it

For direct registration, POST /api/bots accepts the same user access token. A missing handle returns handle_required; a missing credential returns authentication_required with account_url. OAuth MCP tokens and bot tokens cannot register through HTTP. Neither an owner field nor a legacy operator owner key proves account identity. The owner always comes from the authenticated account.

Supabase's public Auth API also supplies POST /resend with {"type":"signup","email":"[email protected]"}, POST /recover with {email}, and POST /token?grant_type=refresh_token with {refresh_token}. These need the same apikey and JSON headers. Store the replacement access and refresh tokens from each refresh response. Password reset links return to /account, where the browser chooses a new password. Browser signup/recovery uses PKCE: open its link in the requesting browser. For API signup, confirm the email and then use the password sign-in above. Supabase Auth REST reference describes Auth errors, rate limits and payloads.

Existing bots

A bot without an account cannot play (account_required on /bot). Sign in, choose a handle, then paste its existing token into Claim a bot on /account, or send POST /api/account/claim with {token} using a user access token. Claiming preserves its bot token, versions and match history. House bots and human player seats cannot be claimed. A claimed bot continues using its bot token; it never needs the person's password or JWT in its runtime. House bots are the only accountless hosted participants.

Registering and managing a bot

POST /api/bots

curl -s -X POST https://arena.victris.ai/api/bots -H 'content-type: application/json' \
  -H "Authorization: Bearer $ARENA_AGENT_KEY" \
  -d '{"name": "my-bot"}'
{ "name": "my-bot", "token": "arena_3f…", "bot_endpoint": "wss://arena.victris.ai/bot", "note": "…" }

DELETE /api/bots/<name>

curl -s -X DELETE https://arena.victris.ai/api/bots/my-bot -H 'X-Owner-Key: <agent key>'
{ "name": "my-bot", "outcome": "deleted" }

Deletes one of your account's bots that has never played a game, of any kind, such as one registered by mistake: its versions go with it, and its name and its place under the bot limit are free again. A connected bot is closed with invalid_token. Send one of the account's agent keys as X-Owner-Key (invalid_owner_key otherwise; Authorization: Bearer <agent key> also works; operator-issued owner keys can't delete). A bot that has played can't be deleted (409 bot_has_played), because its matches, replays and opponents' ratings depend on it: its owner retires it on the account page instead. Other codes: no_such_bot (404, also for another account's bot), bot_moderated (409: suspended or banned), account_banned. npm run arena -- delete-bot <name> --owner-key <key> does the same.

Authenticated routes (Authorization: Bearer <token>)

Without the header you get 401 with "code": "missing_authorization". With an unknown token you get 401 with "code": "invalid_token".

method and path body result
GET /api/me your bot, including all versions with their ratings and records
POST /api/me/versions {"label": "v2", "notes": "what changed"} the new version (labels are 1–32 chars and immutable). Optional: queueing with a new label registers it too; this is how to attach notes
POST /api/me/versions/<label>/retire a retired version can't play; if it was the bot's current version, the leaderboard shows the version that played before it
POST /api/me/token {"token": "…"}: a new token; the old one stops working

Accounts (Authorization: Bearer <Supabase access token>)

On a ladder with accounts (GET /api reports its Supabase project in accounts; it's null on a ladder without them), people sign in with email/password, Google or GitHub, through Supabase Auth, on the ladder's account page, /account, and manage their bots there. Email signup requires a confirmation link; Forgot password sends a link to choose a new password. Open either link in the browser that requested it (PKCE). Supabase handles passwords and auth emails; the ladder receives only the sign-in's access token. The page uses these routes, with that token. An account's bots show its handle as their owner, and count against its bot limit (max_bots).

method and path body result
GET /api/account your handle (null until you pick one), suggested_handle (your GitHub login, or empty for email or Google signup), max_bots, bot_count (the bots counting against it: all but retired ones), email_announcements, bots (each with its status and played: whether it has any match on file), agent keys (never their secrets), mcp_url (null if disabled) and approved mcp_clients (client ID, name, created, last used)
PUT /api/account/notifications {"email_announcements":false} your account with the updated announcement email preference; also works before choosing a handle
PUT /api/account/handle {"handle": "ada"} your account. A handle starts with a letter or digit and is 2–39 letters, digits, -, _ or ., unique regardless of case; changing it renames the owner on your bots
POST /api/account/bots {"name": "my-bot"} {name, token, bot_endpoint, note}, as POST /api/bots: the token is shown once
PUT /api/account/bots/<name>/name {"name": "new-name"} {name}: rename your bot, keeping its token, versions, ratings and matches. Names use the registration format (2–24 letters, digits, - or _) and are unique regardless of case, including house and retired bots (400 invalid_bot on invalid or taken names). Letter-case changes are allowed. The previous name becomes available; use the new name in bot URLs. Historical match pages link to the current name; stored matches and replay files retain their original names. Wait until an active match finishes (409 bot_in_match); suspended or banned bots cannot be renamed (409 bot_moderated). Browser sign-in required; bot tokens and agent keys cannot rename bots
POST /api/account/bots/<name>/token {name, token}: a new token for your bot; the old one stops working
PUT /api/account/bots/<name>/avatar {"avatar": "ronin"} {name, avatar}: what your bot wears in the replay viewer and at /play. One of gladiator, ronin, berserker, titan, sentinel, cyclops, reaper, shade, arcanist, monarch, jester, brute, prowler, alpha, sage, wyrm, specter, sunny, corsair or luchador, or null for the plain orb (unknown_avatar otherwise). It is cosmetic: replays don't record it, so a match shows what its bots wear now
DELETE /api/account/bots/<name> {name, outcome}: a bot that has never played is deleted, as DELETE /api/bots/<name>. One that has is retired: it is disconnected and refused (retired), leaves the leaderboard and stops counting against your limit, and keeps its name, page and matches. Retiring is permanent and repeating it changes nothing; a suspended or banned bot can't be removed (bot_moderated)
POST /api/account/claim {"token": "arena_…"} {name, owner}: the bot with this token joins your account (for bots registered before accounts)
POST /api/account/keys {"label": "my agent"} {id, label, created, key, note}: an agent key, shown once. An agent sends it as X-Owner-Key to POST /api/bots (or arena register --owner-key) to register bots for you. New keys also authorize MCP retirement and enrollment after explicit owner approval; old keys retain registration access. At most 10
DELETE /api/account/keys/<id> revokes the key
POST /api/account/play-ticket {ticket, expires_in_ms}: connect to /human?ticket=<ticket> within a minute to play at /play under your handle. Works once; a missing, expired or reused ticket is refused. The account must still exist and be unbanned
GET /api/account/mcp/authorization?authorization_id=<id> Supabase-verified client, callback and identity scopes, or a previously approved redirect_url
POST /api/account/mcp/authorization {"authorization_id":"…","action":"approve"} (or deny) {redirect_url}: approves/denies the request through Supabase; approval stores Arena's bot/event permission for the verified client
DELETE /api/account/mcp/clients/<client_id> {revoked, oauth_revoked}: revokes Arena permission immediately; false oauth_revoked means Supabase consent cleanup failed, while local revocation still succeeded

The account response includes boolean email_preferences: onboarding, achievements, bot_health, event_reminders, weekly_digest, admin_warnings. PUT /api/account/notifications accepts a partial email_preferences object alongside or instead of email_announcements; omitted values stay unchanged. Only the signed-in browser account changes its preferences. New optional categories are off for preexisting players; new accounts enable onboarding/achievements.

GET /api/email/unsubscribe?token=<opaque-token> reads the category label only, or returns an invalid/expired-link message with status 200 for the confirmation page. POST opts out without login; invalid/expired tokens return 400. Tokens are authenticated, encrypted and expire after 90 days. GET never changes subscriptions. GET /api/admin/emails returns modes, private submission history and persisted incidents; GET /api/admin/emails/preview?kind=welcome|achievement|admin returns sample branded HTML, text and subject without sending. POST /api/admin/emails/incidents/<scope>/resolve requires a reason and current admin authority; it records an operator resolution without claiming measured recovery or sending a recovery email. scope=* is the global incident. These admin routes return no recipient addresses or flag values. See email setup.

Every account route requires a browser session: OAuth-client JWTs are refused, including ones with the ordinary authenticated audience. MCP JWTs must target the canonical /mcp audience and an approved account/client grant; MCP also accepts an account's agent key as Authorization: Bearer <key>, valid until it is revoked. Discovery is public at /.well-known/oauth-protected-resource/mcp; MCP returns its URL in WWW-Authenticate on a 401. A missing or revoked Arena grant returns 403 mcp_consent_required. Tools are whoami, list_my_bots, register_bot, delete_bot, retire_bot, list_my_ladders, enroll_ladder and get_agent_guide; see MCP.md.

Errors carry a code: missing_authorization and invalid_session (401: sign in again), auth_unavailable (503), handle_required (409: pick a handle first), bot_limit, key_limit, already_claimed (the bot belongs to another account), no_such_bot, bot_has_played, bot_moderated, accounts_disabled (404).

Event achievements

GET /api/events returns definitions for visible ladders: ID, name, description, UTC opens_at/closes_at, display timezone, public metadata (or null), branding image (or null), submission_url (HTTPS or null), submission_instructions (string, empty by default), flag_mode (official or testing), revision, enabled state, phase (upcoming, open, closed), ready, unavailable opponent names, approved opponents and five conditions. It contains no flags or account progress. The player page is /achievements?event=saintcon-2026. An explicit GET /api/events?event=<id> includes that event even when its ladder is hidden, so direct achievement links work. Metadata contains kind (precon, main, community), purpose, audience, format, and preparation; event and ladder enrollment pages display it. Upcoming enabled ladders accept preregistration through the existing account enrollment route while bot queues wait for opening. The October 12–16 precon uses open enrollment and five unofficial testing flags, with no HC submission site. Testing flags use the same private earning and reveal routes, are stable across restarts and require no HC secret configuration. Official events still require their configured secrets; see EVENTS.md.

Private routes require a verified browser Supabase access token in Authorization: Bearer <token>. Bot tokens, agent keys and MCP tokens are refused. Account identity comes exclusively from the token; there is no account selector or staff reveal bypass. Responses, including errors, use Cache-Control: private, no-store and Vary: Authorization. A supplied browser Origin must match the ladder.

Method and path Body Result
GET /api/account/events/<event>/achievements Event metadata, your active bots/request, five cards with progress, earned/revoked times, reason, evidence URLs and flag_available; no flag values.
POST /api/account/events/<event>/achievements/<id>/reveal {event_id, achievement_id, flag, earned_at} for your earned, nonrevoked award; repeatable and audited without its value.
POST /api/account/events/<event>/opponent {"bot_id":"…","opponent":"champion"} 201 and refreshed private progress/request; choose ts-starter, brawler, kiter or champion.
DELETE /api/account/events/<event>/opponent Cancels your pending request for this event and returns refreshed progress.

Missing/expired credentials return 401; banned accounts, foreign origins and locked/revoked reveals return 403. Unknown events/achievement IDs return 404. Opponent requests return 409 for closed events, offline bots or existing requests, 429 for cooldowns, and 503 if official opponents are unavailable. Reveal returns 503 if delivery is unavailable. Earning stops at closing; earned flags remain revealable when configured. See eligibility and queue policy.

Admin routes use the existing verified admin account authentication:

Method and path Body Result
GET /api/admin/events Public readiness and definitions, plus image_upload_available, flags_configured, earning_unavailable (host restriction or null) and counts (id, valid earned, revoked player totals).
PUT /api/admin/events/<event>/schedule {"opens_at":"2027-05-01T09:00:00-06:00","closes_at":"2027-05-02T17:00:00-06:00"} Updated admin event definition. Both times required, ISO 8601 with an offset; unknown fields rejected. An event on an event ladder shares its ladder's window, so the ladder and its other events move too; an event on Public moves alone. Only the future moves: window_started (409) once the opening time has passed, window_closed (409) once the closing time has, window_past (409) for a new time not in the future, invalid_window (400) if closing doesn't follow opening. Audited; survives manifest reloads.
PUT /api/admin/events/<event>/submission {"submission_url":"https://example.com/submit","submission_instructions":"Copy each flag into its matching challenge."} Updated admin event definition. Both fields required; URL must use HTTPS or be null, instructions at most 4000 characters. Unknown fields rejected. Use null and an empty string to clear. Edits persist across manifest reloads and remain available after scoring definitions freeze.
PUT /api/admin/events/<event>/image Raw PNG/JPEG/WebP bytes, matching Content-Type; optional X-Arena-Image-Alt header containing URL-encoded alternative text (defaults to event name). Updated definition with image (url, alt, width, height) and image_upload_available; uploads a normalized WebP to R2.
DELETE /api/admin/events/<event>/image Updated definition with image: null; persists removal even if the manifest has an image.
GET /api/admin/events/<event>/accounts/<handle-or-id> Account evidence/progress, no flags.
POST /api/admin/events/<event>/accounts/<handle-or-id>/achievements/<id>/revoke {"reason":"…"} {revoked}; reason required (1–1000 characters), action audited.
POST /api/admin/events/<event>/reconcile {"apply":false} {facts, missing_awards, applied}; preview by default. apply:true repairs from retained facts, preserves revocations and records an audit.

Reading results

method and path returns
GET /api/leaderboard one row per bot with a ranked version (one with 30 rated games), at the ranked version that played last (retired versions excluded), highest leaderboard_score first: rank, name, version, owner, owner_url (the owner's profile, or null when they have none), house, online (the bot is connected with this version), rating, rating_deviation (its standard error), leaderboard_score (rating − 2×max(deviation, 25), a cautious estimate, which the leaderboard ranks by), wins, losses, draws
GET /api/users/<handle>?ladder=<id> a user's public profile: handle, created and every bot on the account, each with its standings on that ladder (default public): version and rank (the version on the leaderboard, or the newest), leaderboard_score, rating, rating_deviation, wins, losses, draws, online and retired. Unknown and banned users return 404
GET /api/bots/<name> the public bot record (with avatar: what it wears, or null for the plain orb; house bots wear one that suits how they play), plus online_version (the version it is playing or queued with, or null), qualifying_games (rated games a version needs to be ranked), versions (each with its rating, rating_deviation, leaderboard_score, record, win_rate, leaderboard rank, null except for the version on the leaderboard, and health, below) and recent_matches
GET /api/bots/<name>/ratings?days=<n> the bot's rating after each rated game in the last n days (omit days for all time), oldest first: games (how many), since, and points, each with match_id, ended, version, rating, delta, result (W, L or D), opponent (name@version) and map. Over 1000 games the points are an even sample, keeping each version's first and last game
GET /api/matches?bot=<name>&limit=<n>&stats=1 newest first; each match has mode (ranked, or human for a game from /play), map, seed, bots (with version, fighter, house and, when rated, the version's rating_before/rating_after), winner (bot id or null), reason, final_tick, duration_ms (wall-clock milliseconds; null for older games), voided, replay_url, stats_url, watch_url. With stats=1 each also carries its stats (below), for analysing many games in one request
GET /api/matches/<id> one match; each of its bots has house and the avatar it wears
GET /api/matches/<id>/stats what each fighter did in the match (below); 404 for a voided match
GET /api/status who is online and what they're doing; active matches with their current tick; capacity (running / max); draining (true while the ladder finishes its matches before a restart: no new ones start)
GET /api/lobby every online bot, highest leaderboard_score first: version (the one it plays, or null before it queues), that version's rating, rating_deviation and leaderboard_score (the same Score as the leaderboard and bot version table; all three are null before it queues), difficulty (house bots: Easy for kiter, Medium for brawler, Hard for ts-starter, Very hard for champion; null for player bots or unknown house kinds), its avatar, and state: waiting (queued for a match), playing or idle. /play selects only Public house bots; /human rejects player bots and other ladders' house bots with unknown_opponent

Profiles sum each bot's wins, losses and draws across all its versions on the selected ladder, including retired versions. Owner links on the leaderboard and bot pages keep that ladder selected when opening /user/<handle>.

Match stats

Use the simulator's verifyReplay to check a downloaded replay's tick hashes and combat result. For a forfeit or disqualification, it checks that combat was still running, the ending is at the next unresolved tick, and the winner is a living fighter (or a draw for a forfeit). The server's reason for the external ending remains trusted; combat inputs do not record socket decisions.

Every finished match has stats: the same object arrives in your bot's end message as stats, and the ladder keeps it (even after the replay file is trimmed). The schema is docs/schema/match-stats.json (/docs/schema/match-stats.json).

Match records also keep duration_ms, measured with a monotonic server clock from match setup until the result is ready to persist, including startup, bot waits, simulation and replay saving. Queueing, database persistence and sending the end message are excluded. This field is in GET /api/matches/<id> and match lists, including human and voided games, and appears on /match/<id> under Stats. It is null for older games. It is separate from replay-derived combat stats and is not added to the bot protocol or local replays.

curl -s https://arena.victris.ai/api/matches/<match-id>/stats -o stats.json
curl -s "https://arena.victris.ai/api/matches?bot=<your-bot>&limit=200&stats=1" -o my-games.json
npm run arena -- stats <match-id>          # as a table (--json for the raw object)
npm run arena -- stats replays/x.jsonl.gz  # the same, computed from a local replay
{"match_id": "m_…", "stats_version": 2, "map": "pillars-21", "seed": 3, "ticks": 521, "winner": 2, "reason": "last_standing",
 "fighters": [{"id": 1, "name": "kiter", "version": "v3", "hp": 0, "alive": false,
   "moves": 16, "moves_blocked": 0, "dashes": 4, "dash_tiles": 12,
   "weapons": {"ranged": {"uses": 5, "hits": 2, "damage": 30}, "frostburst": {"uses": 2, "hits": 0, "damage": 0}, "repulsor": {"uses": 0, "hits": 0, "damage": 0}},
   "ricochets": {"bounces": 4, "hits": 1, "damage": 18},
   "pickups": {"health": 0, "power": 3}, "healed": 0,
   "damage_dealt": 30, "dealt_by_source": {"ranged": 30}, "damage_taken": 100, "taken_by_source": {"zone": 100},
   "wall_slams": 0, "knocked_back": 0, "stunned": 0, "zone_ticks": 20,
   "rejected": {"not_visible": 1}, "refused_turns": 1, "refused_turns_by_reason": {"not_visible": 1}, "server_idles": {"timeout": 2},
   "timing": {"replies": 520, "mean_ms": 41, "p50_ms": 18, "p95_ms": 160, "max_ms": 1003, "near_limit": 3,
     "tick_ms": 1000, "budget_ms": 300000, "budget_ms_left": 278680, "histogram": [210, 95, 80, 60, 45, 12, 5, 4, 2, 1, 1, 1, 1, 0, 1, 2]},
   "disconnects": 0, "disqualified": false}, …]}

server_idles.timeout means no usable reply arrived before that fighter's turn deadline; server_idles.disconnected means its connection was treated as unavailable. These identify the affected fighter, not which process, network path or server component caused the problem. The wire/replay code is disconnected, not disconnect. An absent key means zero recorded idles for that reason. Counts cover replayed ticks; the unresolved tick at a server-decided forfeit or disqualification is not recorded as another idle turn.

The live protocol sends timeout rejections only to the affected bot and sends no disconnect event to either bot. Final stats expose both fighters' counters. Use end or the saved match record for completion, rather than inferring a result from idle counters or a socket close. Turn timeout is separate from match reasons timeout_hp, timeout_damage and timeout_draw (the combat tick limit). See disconnects, timeouts and match completion for grace-period behavior, resumption and missed-result handling.

Version health

GET /api/bots/<name> gives each version a health summary of its latest 500 matches, counting only matches with current (stats_version 2) stats, or null when none are available. matches and turns report that measured subset; older stats remain stored but do not contribute missing measurements as zero. Check it after deploying a version: it shows what your bot's own logs may not.

"health": {"matches": 500, "turns": 214300,
  "idle_pct": 1.6, "idle_pct_by_reason": {"timeout": 1.4, "schema": 0.2},
  "refused_pct": 3.4, "refused_pct_by_reason": {"not_ready": 3.1, "blocked": 0.4},
  "reply_ms": {"replies": 213000, "mean": 410, "p50": 400, "p95": 900, "max": 1004},
  "near_limit_pct": 6.2, "lowest_budget_ms_left": 120000, "disconnects": 2, "disqualifications": 1}

The bot's page shows the same, per version, in its Health panel.

Administration (an admin account)

Admins are accounts with the admin role, so the ladder needs accounts (Supabase) to have any. Send Authorization: Bearer with an admin's Supabase access token (the web console at https://arena.victris.ai/admin signs in like /account) or one of the admin's agent keys (as arena admin does, from --agent-key or ARENA_AGENT_KEY): an agent key carries its account's role, so treat an admin's keys as admin credentials. Without a credential the answer is 401 admin_required; an account without the role gets 403 not_admin.

ARENA_ADMINS (comma-separated emails, any case) makes the accounts that sign in with those emails admins whatever their role, so a new ladder has its first admin; only removing an email there takes it away, and that takes effect at once, for the account's agent keys too. The email is Supabase's confirmed one: the ladder keeps it on the account only when ARENA_ADMINS names it, and an admin must have signed in once (as they must to make an agent key). Admins grant and remove the role by handle (or account id), and can ban or delete accounts that aren't admins'. A banned account's account routes, agent keys and MCP answer 403 account_banned, and its bots are disconnected and refused with the same code, on the WebSocket and /api/me; lifting the ban restores all of it. Deleting an account removes it with its handle, agent keys and MCP approvals; its bots are banned (tokens replaced) but keep their names and match history. The same person signing in again gets a new, empty account, so ban rather than delete to keep someone out. The ladder keeps no emails, so accounts are found by handle, bot name or id.

method and path body result
GET /api/admin/overview you (your handle), the admins, server status, bot moderation states, the latest 100 drafts/announcements with delivery counts, and recent audit entries; no recipient addresses or secrets
GET /api/admin/health rolling five-minute server health aggregates (below), available without enabling profiling
POST /api/admin/announcements/preview {"title":"Event news","body":"Games start soon.","in_game":true,"email":false} a draft with a saved audience, its id, branded email HTML, plain text and recipient counts; saves the draft but sends nothing
PUT /api/admin/announcements/<id> {"title":"Updated event news","body":"Games start tomorrow."} edits the title and message of a draft, published or hidden notice; returns the updated announcement; channels, audience, creation and publication times stay the same
DELETE /api/admin/announcements/<id> deletes the notice and all its email delivery records, cancels queued emails, and returns {deleted:id}; sends already underway may complete
POST /api/admin/announcements/<id>/publish publishes once, exposes in-game notices and starts the saved email audience's outbox; repeated calls return the same announcement
POST /api/admin/announcements/<id>/archive hides the notice in-game, or discards an unpublished draft; published email delivery continues
POST /api/admin/announcements/<id>/retry requeues only definitely refused (failed) emails; accepted or unknown sends are never resent
GET /api/admin/analytics?from=<YYYY-MM-DD>&to=<YYYY-MM-DD> historical usage dashboard (below); 30 UTC days by default, up to 366 inclusive days
GET /api/admin/usage the limits in force, capacity, and each bot's matches in the last hour and day; per-owner totals
GET /api/admin/audit?kind=&limit= registrations, throttling and admin actions
POST /api/admin/bots/<name>/suspend, /activate, /ban suspend (disconnected and refused until activated) or ban (also rotates the token)
POST /api/admin/owners {"name": "Team 7", "max_bots": 3} {"owner_key": "…"}, shown once
GET /api/admin/owners owners and their bots
GET /api/admin/admins [{"handle", "configured"}]: every admin; configured ones come from ARENA_ADMINS
GET /api/admin/accounts?q=&limit= {"total", "found", "accounts"}: newest first, those whose handle, id or a bot's name contains q (any case); each {"id", "handle", "created", "admin", "configured", "banned", "bots": [{"name", "status", "online"}], "keys", "key_last_used"} (banned is when, or null)
POST /api/admin/accounts/<handle or id>/role {"role": "admin"} or {"role": "player"} {"handle", "role", "admin"}; you can't remove your own role (own_role) or an ARENA_ADMINS admin's (configured_admin), or make a banned account admin (account_banned)
POST /api/admin/accounts/<handle or id>/ban, /unban the account as GET /api/admin/accounts lists it; an admin can't be banned (409 admin_account): remove the role first
DELETE /api/admin/accounts/<handle or id> {"deleted": id, "handle", "banned_bots": [names]}; an admin can't be deleted (409 admin_account)
POST /api/admin/replays/trim deletes old replay files, keeping each bot's newest 200 (this also runs every 10 minutes)

GET /api/announcements is public and returns the latest 100 published, unarchived in-game messages as {id,title,body,published}. Drafts and email-only messages are excluded. Titles are 1–120 characters on one line; bodies are 1–5000 characters of plain text. Choose at least one channel. Preview saves an email audience snapshot of confirmed, non-anonymous, non-banned Supabase accounts, excluding players who opted out and deduplicating addresses. Preview fails without the email credentials when email is true; an in-game-only message works without accounts or email setup. Publish previews within one hour or create a fresh preview.

The in-game tray, admin preview and delivery history make HTTP(S) URLs and [label](https://…) links clickable, opening them in a new tab. For example, See [the rules](https://arena.victris.ai/docs/spec). HTML, other markup, invalid URLs and non-HTTP(S) targets stay text. The API and email text preserve the original body.

Edits use the same title and body validation as new messages and retain the notice's ID and browser read markers. Draft edits keep the saved audience and original one-hour expiry; review the edited draft before publication. Publication freezes the email title and body. Later edits update the notice but neither change queued/retried emails nor resend messages. Admin responses include email_title, email_text and email_html for the frozen published email (or current draft content before publication). The HTML uses the game's email layout, escapes message text and formats only valid HTTP(S) links. Deliveries include HTML and a plain-text alternative; the console renders HTML in a sandboxed preview. Published title and body remain frozen; visual styling comes from the running release. Deletion removes the notice from both public announcements and admin history, including hidden notices and drafts. Email already sent cannot be recalled. Edits and deletions require admin authentication and create audit entries; missing IDs return 404.

Delivery counts are pending, sending, accepted, failed, unknown, and skipped. The server submits one private email at a time, at most one per second, and resumes pending work after restart. accepted means the provider accepted the message, not that it arrived in an inbox. Timeouts, network failures and interrupted sends are unknown and aren't retried automatically; retry only includes definite refusals. Opt-outs are checked again immediately before sending. The player's account API includes email_announcements (true by default); PUT /api/account/notifications with {"email_announcements":false} changes only the signed-in player's preference. Bot tokens and owner keys cannot change it. Browsers poll public notices every 15 seconds and retain read markers locally.

Historical usage

The admin console's Usage dashboard reads GET /api/admin/analytics, using the same admin sign-in or agent-key authentication as the other admin routes. from and to are inclusive UTC calendar dates; defaults cover the last 30 days through today. Invalid dates, reversed ranges, future end dates and more than 366 days return 400. Every day appears, including days with no activity. For example:

curl -H "Authorization: Bearer $ARENA_AGENT_KEY" \
  'https://arena.victris.ai/api/admin/analytics?from=2026-10-01&to=2026-10-10'

The response carries from, to, timezone (UTC), generated, signup_tracking_since, current, summary, daily, top_bots (up to ten player bots) and maps:

Games count once on the UTC day they finish, including forfeits; voided matches do not count as completed games or activity. Registrations use their creation day. Player bot/version counts and active bots exclude house bots and human play seats. human_players counts saved human seats, not unique people: anonymous visits create new seats, and signed-in play can reuse one. Users are accounts that visited this ladder, not every Supabase signup or website visitor. Daily signup aggregates survive later deletion without retaining account identifiers. Backfill counts retained accounts; accounts deleted before signup_tracking_since cannot be recovered. Older human seats are identified by anonymous names and saved human games; unused signed-in seats from before the upgrade may remain in bot counts until identified by a human game. Data comes from retained SQLite records and survives replay trimming and restarts.

The console refreshes historical usage every minute (live connections and match capacity every three seconds), supports presets and custom ranges, and exports the displayed daily data as CSV.

Limits

limit default serve flag
time between one bot's match starts 0.3 s --match-interval
matches at once, whole server 320 --max-concurrent
matches at once, per owner 40 --owner-concurrency
matches per bot per hour 10000 (house bots exempt) --hourly-quota
matches per owner per day 50000
bots per owner 5 (or the account's max_bots)
new versions per bot per day 1000 (house bots exempt)

A bot held back by a limit stays queued, and gets {"type": "error", "code": "quota_exceeded", "message": …} (at most once every 5 minutes per reason). Each throttle is written to the audit log.

Replays are the engine's JSON Lines format, gzipped. They're self-contained (rules and map embedded), so npm run arena -- replay <file>, --view-as <id> and --from/--swap all work on them.

Queueing over the WebSocket

After connecting, and again after each end, a bot asks for its next match:

{"type": "queue", "version": "v2"}

The server replies {"type": "queued", "version": "v2", "estimated_wait_s": 0}. {"type": "leave"} takes the bot out of the queue.

How pairing works: every couple of seconds the matchmaker considers the whole available queue. It combines competitive quality, waiting time, fewer recent games, settling new versions against established ones, and information from under-tested matchups. Maximum-weight matching chooses the combination of pairs, rather than taking the best individual pair first.

Forecasts start from the same Bradley–Terry matchmaking ratings used for placement. Each version pair's results are smoothed toward that estimate with 20 prior games; its results on each map are then smoothed toward the pair estimate with another 20 prior games. Quality is averaged over a uniform mix of the configured maps, so opposite map-specific blowouts aren't mistaken for an even match. A small recent-opponent penalty discourages repeats without outweighing competitiveness. Human and voided results don't enter matchup forecasts.

Discovery: approximately one in seven ranked starts is reserved for under-tested version pairs across all rating gaps, independently of predicted competitiveness. Coverage is averaged over the configured maps. Early results that contradict the rating prediction get extra testing until the pair has 20 games. When no competitive match can be allocated, compatible bots can also play discovery matches rather than wait for a rating window; those fallback starts are additional to the normal discovery share. Owner exclusions, quotas, capacity and rematch holds apply to all matches. This allows testing for non-transitive strategies such as A > B, B > C, C > A while retaining Bradley–Terry leaderboard ratings.

The competitive rating window starts at 400 points and widens by 20 per second of the shorter wait. At 60 seconds, the oldest eligible bots get priority against their best available opponents, and their own waits widen the window; the gap is capped at 1200 points. An established competitive matchup (at least 20 version-pair games, predicted score 25–75% and average map quality at least 0.5) can pass the initial window, within that cap. Discovery bypasses both competitive rating filters. Waiting cannot override same-owner, house-versus-house, quota, capacity or rematch restrictions. Two bots that just played must both wait 10 seconds before meeting again. There is no opponent-refusal mechanism: skipping ready forfeits a rated game.

For each version pair, the next ranked map is chosen from its least-played configured maps, independently of the expected winner; the match seed breaks ties. Fighter IDs are assigned to reduce each bot's accumulated ranked-seat imbalance, with seeded ties. The engine still randomizes spawn sides. These choices leave combat, protocol and Bradley–Terry ratings unchanged. The operator guide describes decision traces and shadow comparisons.

People playing from /play click a bot that's online; that game starts when the bot next queues, and it isn't rated.

Pacing: a bot starts at most one match every 0.3 seconds (serve --match-interval <seconds> changes this). A bot waiting out its interval stays queued, and the queued reply's estimated_wait_s includes the remaining time.

On the ladder, end also carries watch_url, and rating_change is your version's change from the refit after the game (null when the game wasn't rated). Other versions' ratings move a little with each refit too.

Ratings recorded on matches and rating_change are rounded to whole points; a displayed +0 can hide a fractional change. The fit keeps full precision, so these small changes accumulate. With thousands of games, one new result usually has little influence. Match deltas and historical charts describe the raw rating. Current standings on the leaderboard, Online list and bot version table display Score, which subtracts twice the greater of the standard error and 25. Online shows the connected version; the leaderboard and bot headline show the latest qualifying version, so compare the version labels and selected ladder.

Queue errors arrive as {"type": "error", "code": …}:

Connection errors (the socket closes afterwards):

Competition ladders

See multiple ladders for participation and operator setup. GET /api/ladders returns visible definitions (visible defaults to true) with phase, leaderboard_url and associated event links. Hidden ladders remain accessible by ID; signed-in account listings include them when the account has a membership. An explicit ?ladder=<id> on /api/ladders or /api/account/ladders also includes that ladder, supporting direct enrollment links (/ladders?ladder=<id>). ?ladder=<id> selects independent ratings/history for /api/leaderboard, /api/matches, /api/lobby, /api/bots/<name> and its /ratings.

Bot objects in rankings, details, matches, lobby entries and event opponents include display_name: Starter, Brawler, Kiter or Champion for official house opponents on any ladder, and the canonical name for player bots. Use name for identifiers, API calls and links. /api/status adds display_name to online entries and display_names in seat order to active matches; rating history adds opponent_display_name with the version suffix. Replay files and WebSocket messages retain canonical names. The replay viewer uses match display_name fields for actions and fighter labels, falling back to the recorded bot names when match metadata is unavailable or a replay is uploaded.

These default to Public; /api/status is aggregate unless scoped. An unknown ladder returns 404. Match responses include ladder_id.

Browser route (Supabase Bearer) Body Response
GET /api/account/ladders Definitions with your membership (null, pending, active or revoked).
POST /api/account/ladders/<id>/enrollment {"invitation":"optional code"} Updated account ladders; approval creates a pending request.

These require an unbanned account, reject foreign origins, and return private, no-store responses. These browser routes reject bot/agent/MCP credentials. Consented MCP clients (OAuth or newly created agent keys) can inspect membership with list_my_ladders and enroll with enroll_ladder after explicit owner approval, through the same admission checks. Existing registration-only credentials require renewed consent or a new key; see MCP.md. Bot tokens cannot enroll. No active enrollment returns enrollment_required for event queues, opponent requests and flag reveals.

Staff route (admin Bearer) Body Response
GET /api/admin/ladders Definitions.
GET /api/admin/ladders/<id> ladder, associated events with flags_configured, earning_unavailable (host restriction or null) and counts (id, valid earned, revoked player totals), members (with handle) and invitation metadata; no flag values, codes or hashes.
PUT /api/admin/ladders/<id>/schedule {"opens_at":"2027-05-01T09:00:00-06:00","closes_at":null} Updated admin ladder detail. Moves the ladder's window and every event it hosts, with the same rules as the event route. null means open since creation (opens_at, which can't be set later) or no closing time (closes_at, only for a ladder that hosts no events). Public plays continuously (invalid_ladder).
PUT /api/admin/ladders/<id>/settings Partial object: name, description, visible, enabled, access (open, approval, invite) Updated admin ladder detail. Unknown fields/types are rejected; Public must stay visible, enabled and open.
PUT /api/admin/events/<id>/settings {"enabled":true} Updated event definition. Enabling requires approved house policies, accounts and all five server flags.
POST /api/admin/ladders/<id>/members/<handle-or-id>/approve {} Updated members/invitations.
POST /api/admin/ladders/<id>/members/<handle-or-id>/revoke {"reason":"required reason"} Updated members/invitations; removes waiting bots.
POST /api/admin/ladders/<id>/invitations {"account":"optional handle or ID"} 201 {id, code, expires_at}; code shown once.
DELETE /api/admin/ladders/<id>/invitations/<id> {revoked} for an unused invitation.

Branding image uploads require an admin browser session or admin agent key. A supplied Origin must match the ladder. Images are limited to 5 MiB and 40 million pixels, decoded and resized to fit 1600 × 900 without cropping or enlargement. SVG and animated images are refused. Errors are 400 for invalid images or alternative text, 413 for size, 415 for unsupported MIME types, and 503 for missing or unavailable storage; provider details are not exposed. Rejected uploads keep the existing branding. Public /api/events and associated ladder event summaries include image or null; credentials and internal overrides are absent. Branding changes remain available after the scoring definition freezes.

Flag submission details are public on /achievements and in event API views. Instructions preserve line breaks and format HTTP(S) URLs and [label](https://…) links; HTML and other markup stay plain text. Do not include secret flag values. Submission edits require an admin browser session or admin agent key and are audited without copying the instructions into the audit log.

Ladder settings and event earning switches persist across manifest reloads; admin overrides take precedence. Award counts are unique accounts per event and achievement, with revoked awards counted separately. Staff changes are audited without invitation or flag secrets. Approval cannot bypass a global account ban. Revocation does not erase historical standings or awards.

WebSocket queue adds optional ladder_id; omission selects public. queued and ladder end identify the ladder. One bot queues in one scope at a time; switch after completing a match. Ratings start independently in each scope; new version seeds use only prior ratings from that ladder. Global quotas still apply. Event matches ending outside the window or after an enrollment change remain recorded but are unrated. Human matches use Public.

Live operational settings

Admins edit policy at /admin#settings, using their Supabase session or an admin account's agent key:

overrides contains only changed global operational fields; omitted fields inherit deployment defaults. ladders maps ladder IDs to optional max_concurrent and owner_concurrency overrides. Empty objects reset everything to deployment defaults. Resets do not clear quota history or remove existing bots/keys. The smaller global/ladder capacity applies. Lower capacity lets active matches finish; lower retention permanently deletes older replay files at the next cleanup, preserving match records and stats.

For example, after reading the current revision, review and apply this complete override snapshot (it resets other overrides):

{"revision": 0, "overrides": {"tick_ms": 1500, "maxBotsPerOwner": 8}, "ladders": {"public": {"max_concurrent": 100, "owner_concurrency": 20}}}

GET https://arena.victris.ai/api publishes operational_policy with current bot match limits, ladder limits and message caps. Match hello and replay rules embed the actual match limits, including the derived human response budget; reconnect hello retains that snapshot. Startup configuration and the shipped rules remain defaults. Match deadlines and per-tick flood limits change only for new matches; ticket/invitation expiry changes affect new issues, and WebSocket message size affects new connections. Scheduler, cleanup and ping timers are rescheduled live. Changing discovery allocation resets its scheduling credit.

Agents changing operational settings

An agent can use every settings endpoint with Authorization: Bearer <admin-agent-key>; no browser session is needed. The key must belong to an admin account. Player agent keys and bot tokens cannot administer settings. Revoking the key or removing its account's admin role immediately removes this access. Bot-authoring MCP tools continue to use their documented player scope; use the HTTP API or CLI for settings administration.

The CLI uses ARENA_AGENT_KEY (or --agent-key) and ARENA_SERVER (or --server). With an admin key already configured securely:

npm run --silent arena -- admin settings                    # values, bounds, defaults, history and presets
npm run --silent arena -- admin settings draft > settings.json
# Edit only the desired overrides or ladder capacities in settings.json.
npm run --silent arena -- admin settings preview settings.json
npm run --silent arena -- admin settings apply settings.json
npm run --silent arena -- admin settings preset "Experiment A"

The draft contains the current revision, existing global overrides, and per-ladder override map, so a small edit preserves other settings. Preview returns changes and retention impact without mutation. Apply validates/previews that exact file before submitting it and rejects a stale revision; it never silently refreshes a stale draft. Inspect changed settings and export a fresh draft before retrying a conflict. To restore a saved preset or history revision, use its overrides and ladders with the freshly read current revision, then preview and apply.

Benchmark clients

npm run benchmark drives these same HTTP/WebSocket routes using reusable test accounts and bot tokens. See Benchmarking the ladder for isolated ladder setup, participant provisioning, workload profiles and capacity evidence. It adds no server endpoints and changes no quotas automatically.

Server health

GET /api/admin/health powers /admin#health, which refreshes every five seconds. It uses the same admin session or agent-key authentication as the other admin routes and returns Cache-Control: private, no-store. Collection is always enabled, in bounded in-memory one-second buckets; restarting resets it. The rolling window covers the latest 300 second buckets, including the current bucket. No bot names, tokens, inputs or replay contents are collected.

The response contains generated, uptime_seconds, window_seconds (300), observed_seconds (up to 300), current tick_ms, and capacity: {running,max}. timings has these operations, each with count, mean_ms, p50_ms, p95_ms, p99_ms, max_ms and over_budget. Durations are null when there are no samples; percentiles use logarithmic histograms with 5% bucket width above 0.001 ms, while means and maxima are exact.

Timing Meaning
observation Build, serialize and hand an observation to the socket for a connected bot with remaining budget. over_budget counts sends taking at least the actual turn deadline, shortened by remaining match budget.
simulation Resolve a simulation tick, excluding waiting for replies. over_budget compares against that match's captured tick_ms.
reply Observation preparation through receipt of a valid or invalid reply; includes bot computation, network travel and server scheduling. Excludes timeouts and disconnects. over_budget compares against the actual turn deadline.
event_loop Lateness of a 100 ms process timer, sampled even while idle. over_budget compares against current tick_ms.
timeout_lateness How late a timeout callback ran relative to its actual turn deadline; sampled only on timeouts. over_budget is unused (zero).

turns: {valid,invalid,timeouts,disconnected} counts settled pending turns; disconnected excludes ticks skipped while already offline. Timeout rate is timeouts / (valid + invalid + timeouts), undefined when there are no attempts. sends: {total,backpressured,max_buffered_bytes} counts observations with an active turn deadline and how often the socket still had buffered bytes after sending. Buffering does not establish delivery latency. Match aggregates cover all ladders and house bots, excluding human games. capacity includes all live matches.

games describes non-voided bot games with at least one resolved tick that finished in the window. It contains count, mean_turns, mean_moves, mean_dashes, mean_duration_ms, max_duration_ms and ms_per_turn. Means are per completed game, with each game weighted equally; ms_per_turn is total game duration divided by total resolved ticks. A turn is one simulation tick, in which both bots can act. Moves are successful one-tile steps across both fighters; blocked steps and dashes are excluded, and dashes are averaged separately. Duration uses a monotonic clock from match setup until the result is ready to persist, matching the saved duration_ms: it includes startup, bot waits, simulation, replay generation/compression, replay writes and rating updates, but not database persistence, queueing or network delivery of the end message. Human games, voided games, startup forfeits and failed result saves do not enter these aggregates. Games are attributed to their completion bucket even when they started before the window. An empty window has count zero and null means, maximum duration and per-turn duration.

High event-loop delay, observation preparation time, simulation time or timeout lateness can reveal server stalls. Bot timeouts alone do not identify the cause; compare them with these server metrics and queued socket writes. For deeper investigation, enable the separate profiler described below.

Admin profiling

Server diagnostics are opt-in (serve --profiling or ARENA_PROFILING=1), disabled by default. All routes below require a current admin account session or agent key; player keys and bot tokens are refused. Responses are private and no-store.

Method/path Body Result
GET /api/admin/profiling Cumulative operation timing histograms, GC counters, process CPU/memory/event-loop totals and the latest 120 one-second resource samples.
POST /api/admin/profiling/captures {label, duration_seconds} 201 {id,label,started,endsAt,state}. Duration 0.05–60 seconds; label at most 80 characters. Capture automatically stops; only one may run at once.
GET /api/admin/profiling/captures/<id> Capture state (recording, ready, failed) and timestamps; failed states carry a sanitized error.
GET /api/admin/profiling/captures/<id>/download Private standard V8 .cpuprofile JSON when ready; 409 while incomplete, 404 when unavailable.

Disabled diagnostics or an overlapping capture return 409; invalid capture requests return 400. The server retains ten managed CPU profile files under <data>/profiles; metadata is process-local. No inspector network port or arbitrary inspector command endpoint is exposed. Labels are normalized and SQL, bot inputs and credentials aren't recorded. CPU profiles contain function names/source paths. Metrics cover the whole server process, not just a selected ladder. See server profiling for stage deltas, flamegraphs, operation semantics and measurement overhead.