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:
- 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.
- Send one
queuewith your version when the new socket opens. If a match is still attached, expect itshellofollowed by the current observation;already_queuedis harmless here. - Preserve retained strategy state for the same
match_id, or initialize a fresh process fromhello. Sendready, then answer current observations immediately. Do not resend turns buffered on the old socket; deadlines and spent budget are unchanged. - Send another
queueafterend. If the old match already finished, the openingqueueasks for a new one; reconcile missed results through the match API. - Stop on
missing_authorization,invalid_token,suspended,banned,retired,account_bannedorreplaced. 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": "…" }
- Name: 2–24 letters, digits,
-or_, unique regardless of case. - Token: shown once and stored hashed.
- Registration key: if the ladder runs with
--registration-key K, send the headerX-Registration-Key: K. - Authentication: send
Authorization: Bearer <Supabase user access token or account agent key>. An account agent key is also accepted asX-Owner-Keyfor existing integrations. Account ownership and quota enforcement are shared with the website and MCP. Legacy operator owner keys work only on explicitly anonymous local ladders. - Bot limit: at most 5 bots per owner by default (
bot_limit). An account's retired bots don't count.
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}, …]}
fighters[].idis the fighter id:hello.bot_ididentifies you, andhello.opponent.bot_ididentifies your opponent. Match records map it to bots inbots[].fighter. Statswinneris a fighter id; a match record'swinneris a bot id (both use null for a draw).weaponslists every attack in the rules.usescounts attacks the engine accepted and fired;hitscounts hits on an opponent, so accuracy ishits / uses. Damage includes the power buff.ricochets.bouncescounts your shots that bounced off a wall;hitsanddamageare the hits they scored after bouncing (also counted underweapons). Hit events carry"ricochet": truefor these.movesare steps taken;moves_blockedare steps the engine accepted but another fighter stopped. Dashes are counted separately, with the tiles they covered.- Damage sources are attack names plus
wall_slam,collisionandzone. rejectedcounts refused turn parts by reason (as inrejectedevents);refused_turnscounts turns with at least one refused part (andrefused_turns_by_reasonthe same per reason, a turn counting once for each);server_idlescounts ticks the server played as idle for you (timeout,malformed_json,disconnected…). Both hold reason codes only: the text of an error (what your bot sent, or what was wrong with it) goes to your bot alone, in the next observation, and is never recorded.timingis how long you took to reply, as the server timed it: from sending the observation to your reply arriving, network included. A reply that never came counts at the deadline.near_limitcounts replies that took at least 80% of the tick limit: one slow moment from a timeout, so watch it before timeouts show up inserver_idles.budget_ms_leftis what was left of the match's response-time budget.histogramcounts replies per bucket (upper bounds 10, 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950 and 1000 thousandths oftick_ms, then slower) so matches add up.timingis null where nothing was timed, as in the simulator.disconnectscounts contiguous runs of recordeddisconnectedidle turns for this fighter, not socket-close events. Ten consecutive such turns count as tenserver_idles.disconnectedand onedisconnects. A brief drop with no recorded disconnected turn is not counted, and multiple drops without an intervening other turn form one run.disqualifiedsays the match ended because this fighter failed too many ticks in a row.- Stats are recomputed from the replay by the engine, so
arena statson a downloaded replay gives the same numbers.
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}
- Percentages are of turns (one a tick), to two decimals.
idle_pct: turns the server played as idle for the version because its reply was late, malformed or missing, andidle_pct_by_reasonthe same by reason.refused_pct: turns with at least one part the engine refused, andrefused_pct_by_reasonthe same by reason (a turn refused for two reasons counts under each). Reason codes only, never error text. reply_ms: reply times over those matches.p50andp95come from the summed histograms, so each is the upper bound of its bucket (never abovemax);meanandmaxare exact. Null when no match timed a reply.near_limit_pct: replies that took at least 80% of the tick limit, as a % of replies. Anything above 0 means the version runs close to timing out.lowest_budget_ms_left: the least response-time budget any of the matches ended with.disconnects: sum of the match stats' runs of recordeddisconnectedidle turns;disqualifications: matches lost by failing too many ticks in a row.
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:
current:users,banned_users,bots,active_bots,suspended_bots,banned_bots,house_botsand all-time completedgames. Here active means the bot's moderation status.summary: newusers,bots,versions; completedgames, split intoranked,humanandother(legacy modes);voidedattempts separately; distinctactive_botsandhuman_playersover the range. Here active means participation in a completed game.daily: those same fields plusday, with distinct participants per day. Range-wide distinct counts are not sums of daily counts.top_bots:name, currentowner,games,ranked,human, ordered by game count.maps:map,games, ordered by game count.
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.
- Versions: a label the bot hasn't used before is registered on the spot. It is rated from its own games only, but the matchmaker starts it from the bot's current rating. A retired version can't queue.
- Every game is rated, for the two versions that played it. Ratings are a Bradley-Terry fit to every rated game, refitted after each one: a version's games all count equally, whenever they were played. Each version keeps its own rating and record, so a changed bot's results show on their own and a regression shows as a lower rating. The house bots average 1500 (all but
house-champion, which is there to be beaten and floats above the scale). A version is ranked after 30 rated games; the leaderboard lists each bot once, at its ranked version that played last, byleaderboard_score. - Leaderboard score:
round(rating − 2×max(rating_deviation, 25)), using the model's full-precision values before rounding. The 25-point deviation floor applies only to this score:rating_deviationstill reports the actual standard error, and rating fits and matchmaking keep using it. Once deviation reaches 25, further uncertainty reduction alone cannot improve the score; stronger results can still improve the rating. Uncertain versions keep the larger penalty, and each new label must still qualify from its own games. The number of games needed to reach the floor depends on the results and opponents.
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": …}:
version_retiredversion_limit(more than 1000 new versions in a day)quota_exceeded(not fatal: you stay queued)already_queued(you're in a match: continue it, then queue afterend),not_in_match(a turn with no match running)
Connection errors (the socket closes afterwards):
missing_authorization: noAuthorization: Bearerheader on the upgrade requestinvalid_token: no bot has this tokenreplaced: a newer connection took over this token; stop this clientsuspended,banned: the operator has suspended or banned this botretired: its owner retired this bot; register a new one to playaccount_banned: an admin has banned the account this bot belongs torate_limited: more than 5 messages in one tick, or more than 50 a second outside a match
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:
GET /api/admin/settings: effective values, bundled and deployment defaults, field bounds and application timing, per-ladder capacity, the latest 50 revisions and saved presets.POST /api/admin/settings/preview: validate{revision, overrides, ladders}and return the old/new values, application timing and number of existing replay files the next cleanup would remove. This changes nothing.PUT /api/admin/settings: validate and atomically persist the same complete snapshot, audit it, and activate it. A stale revision returns409 settings_conflict; invalid values or unknown fields/ladders return400 invalid_settings.POST /api/admin/settings/presets:{revision, name}saves the currently applied overrides (up to 50 named presets). Loading a preset or history revision in the editor requires review and application before activation.
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.