# 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](/docs/mcp)) |
| 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](/docs/simulator)): `/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](https://github.com/Victris-AI/saintcon-arena-fighter-game/blob/master/docs/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:

```json
{"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](#reading-results).
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](https://arena.victris.ai/download/bots/ts-starter/bot.ts),
[Python starter](https://arena.victris.ai/download/bots/py-starter/bot.py) and
[house client](https://arena.victris.ai/download/bots/lib/run.ts). 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":"you@example.com","password":"your password"}` as `credentials.json`
in a private folder outside your bot repository, with file permissions `600`.

```bash
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":"you@example.com"}`, `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](https://github.com/supabase/auth/blob/master/openapi.yaml)
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`

```bash
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"}'
```

```json
{ "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 header `X-Registration-Key: K`.
- **Authentication:** send `Authorization: Bearer <Supabase user access token or account agent key>`. An account agent key is also accepted as `X-Owner-Key` for 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>`

```bash
curl -s -X DELETE https://arena.victris.ai/api/bots/my-bot -H 'X-Owner-Key: <agent key>'
```

```json
{ "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](https://github.com/Victris-AI/saintcon-arena-fighter-game/blob/master/docs/DEPLOY.md#automated-player-emails-and-admin-health-alerts).

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](/docs/mcp).

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](/docs/events#precon-playtest-and-preregistration).

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](/docs/events).

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) (`/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.

```bash
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
```

```json
{"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[].id` is the fighter id: `hello.bot_id` identifies you, and `hello.opponent.bot_id` identifies your opponent. Match records map it to bots in `bots[].fighter`. Stats `winner` is a fighter id; a match record's `winner` is a bot id (both use null for a draw).
- `weapons` lists every attack in the rules. `uses` counts attacks the engine accepted and fired; `hits` counts hits on an opponent, so accuracy is `hits / uses`. Damage includes the power buff.
- `ricochets.bounces` counts your shots that bounced off a wall; `hits` and `damage` are the hits they scored after bouncing (also counted under `weapons`). Hit events carry `"ricochet": true` for these.
- `moves` are steps taken; `moves_blocked` are 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`, `collision` and `zone`.
- `rejected` counts refused turn parts by reason (as in `rejected` events); `refused_turns` counts turns with at least one refused part (and `refused_turns_by_reason` the same per reason, a turn counting once for each); `server_idles` counts 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.
- `timing` is 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_limit` counts replies that took at least 80% of the tick limit: one slow moment from a timeout, so watch it before timeouts show up in `server_idles`. `budget_ms_left` is what was left of the match's response-time budget. `histogram` counts replies per bucket (upper bounds 10, 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950 and 1000 thousandths of `tick_ms`, then slower) so matches add up. `timing` is null where nothing was timed, as in the simulator.
- `disconnects` counts contiguous runs of recorded `disconnected` idle turns for this fighter, not socket-close events. Ten consecutive such turns count as ten `server_idles.disconnected` and one `disconnects`. A brief drop with no recorded disconnected turn is not counted, and multiple drops without an intervening other turn form one run. `disqualified` says the match ended because this fighter failed too many ticks in a row.
- Stats are recomputed from the replay by the engine, so `arena stats` on 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](/docs/bot-guide#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.

```json
"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, and `idle_pct_by_reason` the same by reason. `refused_pct`: turns with at least one part the engine refused, and `refused_pct_by_reason` the 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. `p50` and `p95` come from the summed histograms, so each is the upper bound of its bucket (never above `max`); `mean` and `max` are 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 recorded `disconnected` idle 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:

```bash
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_bots` and all-time completed `games`. Here active means the bot's moderation status.
- `summary`: new `users`, `bots`, `versions`; completed `games`, split into `ranked`, `human`
  and `other` (legacy modes); `voided` attempts separately; distinct `active_bots` and
  `human_players` over the range. Here active means participation in a completed game.
- `daily`: those same fields plus `day`, with distinct participants per day. Range-wide
  distinct counts are not sums of daily counts.
- `top_bots`: `name`, current `owner`, `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:

```json
{"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, by `leaderboard_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_deviation` still 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](https://arena.victris.ai/docs/matchmaking) 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_retired`
- `version_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 after `end`), `not_in_match` (a turn with no match running)

Connection errors (the socket closes afterwards):
- `missing_authorization`: no `Authorization: Bearer` header on the upgrade request
- `invalid_token`: no bot has this token
- `replaced`: a newer connection took over this token; stop this client
- `suspended`, `banned`: the operator has suspended or banned this bot
- `retired`: its owner retired this bot; register a new one to play
- `account_banned`: an admin has banned the account this bot belongs to
- `rate_limited`: more than 5 messages in one tick, or more than 50 a second outside a match

## Competition ladders

See [multiple ladders](/docs/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](/docs/mcp). 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 returns `409 settings_conflict`; invalid values or unknown fields/ladders return `400 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):

```json
{"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:

```bash
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](https://github.com/Victris-AI/saintcon-arena-fighter-game/blob/master/docs/BENCHMARK.md) 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](https://github.com/Victris-AI/saintcon-arena-fighter-game/blob/master/docs/BENCHMARK.md#server-profiling-and-cpu-flamegraphs)
for stage deltas, flamegraphs, operation semantics and measurement overhead.
