# Simulator: play millions of games on your own machine


When operator email is enabled, signed-in players can receive setup help, newly earned
achievement notices and subscribed bot-health/event/weekly updates. Preferences are at
https://arena.victris.ai/account; email never contains achievement flag values. Bot tokens
and agent keys cannot change email preferences. Account/security notices stay separate.

A browser-readable copy is at `/guide/simulator` on the ladder; `/docs/simulator` remains Markdown for agents. Served links use the ladder’s documentation routes.

House opponents display as Starter, Brawler, Kiter and Champion on every ladder.
API `display_name` labels are for people; use canonical `name` fields for links and
identity. Protocol messages and replay files keep their original names. See [API display names](/docs/api).

Hosted bots can be renamed by their owners on My Bots between matches, preserving their
tokens, versions, ratings and history. Bot URLs use the new name; offline simulation and
saved replay names are unchanged. See [account API](/docs/api#account-onboarding).

The first SAINTCON precon accepts preregistration before online play opens on
October 12, 2026 at noon (America/Denver), closing October 16 at 6 AM. Build and
test locally first, then use `ARENA_LADDER=saintcon-precon-2026-10-12` for its
separate online playtest standings and five unofficial testing achievements. See [event preparation](/docs/events#precon-playtest-and-preregistration).

Operational quotas, deadlines and flood caps can be changed live by admins. Numbers in this guide are defaults: use each match’s `hello.deadlines` and `hello.rules.limits`; they stay fixed through that match and reconnects. `GET https://arena.victris.ai/api` reports the current `operational_policy`. Combat timings still come from the match rules. See [live operational settings](/docs/api#live-operational-settings).

The simulator retains bundled defaults. Pass the live `operational_policy.match_limits` as `Game`’s `limits` option to reproduce current bot deadlines and failure tolerance offline; replays retain their match’s actual limits.

Hosted participation requires an account. Create one and register a bot through the
[website, API or MCP onboarding](/docs/api#account-onboarding). Existing bots must be claimed
with their token before playing. Offline matches and simulation remain available without sign-in.

Event achievements and flags are awarded by the live ladder from eligible ranked
house-bot wins. Offline simulation and uploaded replays cannot earn them. Use the
simulator to practice ricochets, wall slams and strategies against the standard
house bots, then compete with an account-owned bot during the event window.
The champion remains server-only. See [event eligibility](https://arena.victris.ai/docs/events).
After earning and revealing a live flag, follow **How to submit flags** on the
event page for staff instructions and the submission link.

An MCP-enabled ladder also exposes simulator and starter links through `get_agent_guide`.
MCP handles account-owned bot registration; training and simulation remain local and
need no OAuth token. See [MCP connection](/docs/mcp).

Operator notices are available in the ladder's Announcements tray and `GET /api/announcements`.
Confirmed players can receive announcement emails; preferences are at `/account`. Announcements
belong to the hosted ladder and don't change offline simulator messages or replay inputs.
The tray makes HTTP(S) URLs and `[label](https://…)` links clickable; API bodies stay as written.
Admins may edit or delete ladder notices without changing simulator or bot messages.

The ladder's `server_draining` notice also belongs to the live transport: it pauses matchmaking
while active games continue, then clients reconnect and queue after closure. A process restart
does not restore an unfinished game. The offline simulator emits no shutdown notices and has
no restart window; see [planned restarts and recovery](/docs/api#planned-restarts-and-recovery).

Release changes are at [https://arena.victris.ai/docs/changelog](https://arena.victris.ai/docs/changelog), also shown at `/changelog`. Check rules, engine and protocol changes before competing, and download the simulator again when the running release changes. `GET /api/changelog` reports the running versions alongside the bundled history; Unreleased notes describe work awaiting a release.

The live ladder's whole-pool matcher, version/map forecasts, discovery across all rating gaps,
balanced ranked map exposure and
fighter-ID assignment are scheduling policy. Offline games still use the opponents, maps and
seats you supply; they do not reproduce queue waiting or ladder allocation. When comparing bots,
cover every map and both fighter IDs. Bradley–Terry ladder ratings are unchanged by that policy.
The live leaderboard scores versions with `round(rating − 2×max(rating_deviation, 25))`;
the score floor is ladder policy, separate from offline match outcomes and training rewards.

The simulator is the ladder's own match code in one JavaScript file: the engine, the protocol's
message checks and the server's match rules, with the shipped rules, maps and house bots built in.
Use it to train a policy (reinforcement learning, self-play, tuning) without a server, sockets or
clocks, from JavaScript or from Python. One CPU core plays about 100 to 150 games a second, plus
your own bot's thinking time.

It isn't a copy of the game: the ladder runs every match on the same code, and each release of the
ladder serves the simulator built from that release. A policy trained on it sees exactly the
messages it will see on the ladder.

## Download

```bash
curl -O https://arena.victris.ai/download/arena-sim.mjs                       # JavaScript (ES module)
pip install https://arena.victris.ai/download/arena_sim-0.44.0-py3-none-any.whl  # Python 3.10+
```

`GET /api` lists them under `simulator`: `javascript`, `script` (the same code as a plain script
that sets a global `ArenaSim`, for engines that don't load modules) and `python`. The JavaScript
file has no dependencies and uses no Node APIs, so it runs in Node 18+ and in browsers. The Python
package runs that same file in embedded V8 ([mini-racer](https://pypi.org/project/mini-racer/)), so
it needs no Node.js.

## Play a game in JavaScript

```js
// train.mjs: node train.mjs
import { Game } from "./arena-sim.mjs";

let wins = 0;
for (let seed = 0; seed < 100; seed++) {
  const game = new Game({ seed, opponent: "kiter" });
  while (!game.over) {
    const obs = game.next(1); // the observation your bot would receive
    const enemy = obs.enemies[0];
    game.step({ 1: { move: "E", ...(enemy ? { attack: { name: "ranged", target: enemy.pos } } : {}) } });
  }
  if (game.next(1).result === "win") wins++; // after the last tick, next() is the end message
}
console.log(`${wins} wins out of 100`);
```

The loop is the same as a bot's loop on the ladder: answer each `observation` with a turn until
`end` arrives. The messages are the ones the ladder sends, as plain JSON (the
[observation schema](/docs/schema/server-observation.json) documents every field), so the code that turns
an observation into a decision can be shared between training and your live bot.

## `new Game(options)`

| option | default | |
| --- | --- | --- |
| `seed` | `0` | decides the spawns and, unless `map` is given, the map (the seed modulo the number of maps, as the ladder picks) |
| `map` | picked by the seed | a shipped map's name (`arena-21`, `columns-21`, `pillars-21`), or a map object of your own |
| `opponent` | none | a bot to play fighter 2: a house bot (`brawler`, `kiter` or `random`), a bot of yours (see [your own bots](#your-own-bots-encoders-and-teachers)) by its registered name or as an object. Without one, you play both fighters (self-play) |
| `rules` | the shipped rules | a rules table, for experiments; the ladder only plays the shipped one |
| `limits` | the rules' limits | the deadlines `hello` reports and `max_consecutive_failures` (10 failed ticks in a row disqualify) |
| `name`, `version` | `player`, `sim` | what the replay records for your fighters |

| member | |
| --- | --- |
| `players` | the fighter ids you play: `[1]` against a house bot, `[1, 2]` in self-play |
| `hello(id)` | the match's `hello` for fighter `id` (rules, map, pickups, seed) |
| `next(id)` | what fighter `id` has been sent and not yet answered: this tick's `observation`, or `end` once the match is over |
| `nextFrame(id)` | the same as the JSON text, for passing to another process or language without parsing it here |
| `step(replies)` | resolves the tick with one reply per player, keyed by fighter id |
| `over`, `result` | whether the match has ended, and `{winner, reason, final_tick}` |
| `summary()`, `replay()` | once over: the result, the replay and per-fighter stats (the same stats as `end.stats`) |

The ladder separately records `duration_ms` in its match records and displays it on
`/match/<id>` under Stats. This wall-clock measurement includes startup, bot waits and
replay saving; it is null for older games. Local simulation and replay-derived stats
do not contain this server duration.

A reply is what your bot would send: a turn object such as `{move: "E", attack: {name: "ranged",
target: [9, 4]}}` (its `tick` is filled in if you leave it out), the raw JSON text, or `null` for no
reply. Each takes the ladder's path: a malformed frame, a schema error, a reply for the wrong tick
or no reply is an idle turn that counts as a failure, reported in the next observation's `events`
like on the ladder; a well-formed turn with an illegal part loses only that part.

`null` models a missing reply (`timeout`), not a disconnected socket. The simulator has no
transport or wall-clock reconnect grace. On the ladder, `disconnected` turns do not count as
protocol failures; combat continues while the client is away. See
[disconnects, timeouts and match completion](/docs/bot-guide#disconnects-timeouts-and-match-completion)
for live recovery and attribution. Replay stats count `disconnects` as runs of recorded
`disconnected` turns, so they cannot establish socket-close counts or the underlying cause.

Unknown message types are schema failures. Attack names must be actual entries in
`hello.rules.attacks`; other names are refused as `unknown_attack`. Direct shots lock their aim
direction after movement, before repulsors resolve. A push shifts the launch point but preserves
that direction, even if the shooter lands on its original aim tile; the shot fires with its
normal cooldown. The projectile's `target` shifts with its `origin` to describe that direction.
These rules also apply in the Python bundle.

## Play from Python

```python
import numpy as np
from arena_sim import ArenaVectorEnv, Basic

# 64 matches at once against a mix of house bots, stepped in one call.
env = ArenaVectorEnv(64, opponent=["brawler", "kiter", "random"], encoder=Basic())
obs, info = env.reset(seed=0)
rng = np.random.default_rng(0)
for _ in range(1000):
    actions = np.array([rng.choice(np.flatnonzero(mask)) for mask in info["action_mask"]])  # your policy here
    obs, rewards, terminated, truncated, info = env.step(actions)
```

| | |
| --- | --- |
| `ArenaEnv(opponent, encoder, reward, map)` | a Gymnasium environment: one match an episode, as fighter 1 against a house bot |
| `ArenaVectorEnv(n, opponent, encoder, reward, map)` | `n` matches stepped together, the fast way (a Gymnasium vector environment with next-step autoreset), you as fighter 1. `opponent` is a house bot, a policy of yours (below), a `ScriptedOpponent`, or a list of these spread over the matches; `set_opponents(...)` changes it for the matches that start next |
| `ArenaSelfPlayEnv(n, encoder, reward, map)` | self-play: `n` matches stepped together with both fighters yours, as `2n` environments (game 0's fighter 1, game 0's fighter 2, game 1's fighter 1, ...), so one policy learns from both sides |
| `arena_sim.multiagent.ArenaParallelEnv(encoder, reward, map)` | self-play as a PettingZoo parallel environment, agents `fighter_1` and `fighter_2` (`pip install "arena-sim[pettingzoo] @ <the wheel's URL>"`) |
| `Simulator(n)` | the low-level interface: `start(i, seed=, opponent=, map=)`, then `step([{fighter: reply}, ...])` for every game in one call, `result(i)`, `replay(i)`, `replay_text(i)`. Any mix of house-bot games and self-play |

An **encoder** turns observations into what your network reads and its actions into turns.
`Raw()` (the default for `ArenaEnv`) passes the ladder's JSON through: observations are dicts and
actions are turn dicts, exactly as a bot sends them. `Basic()` is a small, complete starting point
to replace: a 9-channel grid of the map (walls, you, the enemy, shots, danger, areas, the zone,
pickups) plus 19 numbers (health, cooldowns, states, time) in, and 68 discrete actions out (17
movements times 4 attacks), with `info["action_mask"]` marking the ones the engine would accept.
Write your own by subclassing `Encoder`. The reward is `win_loss` (1, -1 or 0 at the end) unless
you pass `reward=f(observation, next_message)`; `damage_trade` adds, every tick, the damage you dealt
minus the damage you took, a denser signal to start learning from. Every `info` carries the raw `observation`, and
`end` when a match ends.

An **opponent of your own** comes in two kinds. A policy is a function
`policy(observations, action_masks) -> actions` in the encoder's spaces, called once a step with every
match it plays, so a network (say, a frozen copy of your own from an earlier checkpoint) runs batched.
A `ScriptedOpponent(decide)` is written like a ladder bot: `decide(observation, hello)` gets the
ladder's JSON and returns a turn dict, once per match. Training against a growing pool of your own
past policies is a few lines:

```python
import copy

pool = ["brawler", "kiter"]
env = ArenaVectorEnv(64, opponent=pool, encoder=Basic())
# ... every so often, freeze the current network and add it:
frozen = copy.deepcopy(net)
pool.append(lambda obs, masks: frozen.act(obs, masks))  # your network's batched action function
env.set_opponents(pool)
```

Speed, per process: about 26,000 game ticks a second through `Simulator`; with `Basic`, about
6,000 environment steps through `ArenaVectorEnv` against house bots, 16,000 seats through
`ArenaSelfPlayEnv` (sending JSON to Python and encoding it there takes most of that), plus your
network's time. A call into V8 costs about 200 µs, so step many games per call rather than one
environment per call. If `Basic` is your encoder, the fast environments below do the same work many
times faster.

## Fast environments

`FastVectorEnv` and `FastSelfPlayEnv` play the same matches as `ArenaVectorEnv` and
`ArenaSelfPlayEnv`, through the same match code, but the simulator encodes them itself: it writes
every seat's grid, numbers, action mask, reward and done flag into one buffer that numpy reads in
place, and reads your actions from it. No JSON crosses into Python. The encoder is `Basic` unless
you give it [your own](#your-own-bots-encoders-and-teachers). `Basic`'s arrays are exactly what
`Basic` makes of the ladder's JSON (a test checks it, value for value), so a policy trained here
plays a ladder bot that runs `Basic.encode` on its observations.

```python
import numpy as np
from arena_sim import FastSelfPlayEnv, FastVectorEnv

if __name__ == "__main__":
    # Self-play, the matches split over 12 processes. Or, against house bots with the shaped reward:
    # env = FastVectorEnv(128, opponent=["brawler", "kiter"], reward="damage_trade")
    env = FastSelfPlayEnv(1536, workers=12)
    obs, info = env.reset(seed=0)
    rng = np.random.default_rng(0)
    for _ in range(1000):
        actions = (rng.random(info["action_mask"].shape) * info["action_mask"]).argmax(1)  # your policy here
        obs, rewards, terminated, truncated, info = env.step(actions)
    env.close()
```

| | |
| --- | --- |
| `FastVectorEnv(n, opponent, map, reward, workers, copy, grid, encoder, scripts, teacher)` | `n` matches, you as fighter 1. `opponent` is a bot by name (a house bot, or one of yours from `scripts`: either runs inside the simulator), a `ScriptedOpponent` (a bot written in Python), a batched `Policy` of yours (called with the arrays and masks of every match it plays), or a list of these; `set_opponents(...)` changes it for the matches that start next, and `playing` says who each match is playing now |
| `FastSelfPlayEnv(n, map, reward, workers, copy, grid, encoder, scripts, teacher)` | self-play: `n` matches as `2n` environments, in `ArenaSelfPlayEnv`'s order |

What's different from the JSON environments:

- **The encoder runs in the simulator**: `Basic` (with `size` as its one setting, and the
  observation and action spaces `Basic()`'s), or one of yours written in JavaScript
  ([below](#your-own-bots-encoders-and-teachers)), not a Python `Encoder`.
- **`reward` is `"win_loss"` or `"damage_trade"`**, the functions of those names computed in the
  simulator, rather than a Python function of the messages. For a reward of your own, every step's
  `info["dealt"]` and `info["taken"]` are the damage each seat dealt and took that tick, as
  fractions of its max hp (0 on the tick a match ends, whose result is in `info["result"]`).
- **No messages in `info`.** A seat whose match just ended has `info["result"]` (1 a win, -1 a
  loss, 0 a draw) instead of `end`. `seed`, `action_mask` and, with same-step autoreset,
  `final_obs` are as before. Only a `ScriptedOpponent` (or teacher) gets the ladder's JSON: the
  simulator sends its seats' observations to Python, and nobody else's.
- **`workers=N`** splits the matches over N processes, each with its own V8 and its share of the
  matches, writing into memory shared with yours. They start with `spawn`, so put your script's
  top level under `if __name__ == "__main__":`.
- **`copy=False`** hands out `grid` and `vec` as views of the simulator's buffer instead of copies:
  no copying at all, but the next step overwrites them, so use them (or copy them to the GPU)
  before stepping again. Stable-Baselines3 keeps the last observation across a step: leave
  `copy` on with it.
- **`grid="uint8"`** makes the grid a byte a tile, a quarter the size: less to copy between
  processes and to the GPU, and to keep in a rollout buffer. `decode_grid(codes)` turns it back
  into `Basic`'s values exactly, on a numpy array or a torch tensor (on the GPU, after the copy):
  a danger code `c` is `1 / c` (0 for none), any other code is half its value. Danger more than
  254 ticks away, which today's maps can't produce, reads as 1/255.
- **`replay_text(g)`** gives match slot `g`'s most recently finished match as a replay file. Its
  ticks carry no state hashes (hashing a tick costs more than playing it); everything else is as
  `Simulator` records it.

Measured on a desktop (Intel Core Ultra 9 285K, 24 cores), random legal actions, environment steps
a second:

| | steps/s |
| --- | --- |
| `ArenaSelfPlayEnv(64)`, for comparison | 16,000 |
| `FastSelfPlayEnv(128)`, one process | 105,000 (121,000 with `copy=False`) |
| `FastSelfPlayEnv(1536, workers=12, copy=False)` | 456,000 |
| `FastSelfPlayEnv(1536, workers=12, copy=False, grid="uint8")` | 463,000 |
| `ArenaVectorEnv(64)` against `kiter` and `brawler`, for comparison | 6,100 |
| `FastVectorEnv(128)` against `kiter` and `brawler` | 20,600 |

About 128 matches a process is the sweet spot; past 12 to 16 workers the cores and memory are
busy. Against house bots most of the time is the house bots' own thinking, so the gain is smaller;
self-play and your own policies as opponents are where the speed is. The same batch is in the
JavaScript module as `VectorBatch`, for training loops written in JavaScript: `new
VectorBatch(n, {opponents, encoder, teacher, taught, map, reward, autoreset, size, grid})`, with
`buffer`, `layout` (where each array is in it) and `spec` (the encoder's sizes), `reset(seed)`,
`step()`, `setOpponents`, `setTeacher` and `replayText(g)`.

## Your own bots, encoders and teachers

The fast environments run more than the house bots and `Basic`. Three things of yours go inside
the simulator, where they cost no JSON and no extra process:

- **Bots**, to play against: the bot you wrote for the ladder, the house bots' code changed, a
  rival's style you've written down. A policy trained against a few house bots learns to beat
  those; more and stronger opponents make it general.
- **An encoder**, the part of a learning bot that turns the observation into what the network
  reads and its action back into a turn. `Basic` is a small starting point. What a network can't
  see it can't learn, so the encoder is often where a bot gets better, and the fast environments
  run yours at the same speed as `Basic`.
- **A teacher**: a bot whose turn on every state your policy reaches is written next to it, as the
  action your encoder comes closest to. Imitating it there (not only on the states the teacher
  reached itself, as imitating recorded games does) is how you distil a scripted bot into a
  network, and a shaping signal next to the reward.

They are JavaScript, since the simulator is. In Node or a browser, pass them as objects; for
Python, put them in a script that registers them by name, and pass the script:

```js
// mine.js: loaded after the simulator, which defines ArenaSim
ArenaSim.registerBot("mine", {
  version: "1",
  start(hello) {                  // once a match: what answers each of its observations
    const memory = { seen: 0 };
    return (observation) => {     // the JSON the ladder would send it
      memory.seen++;
      const step = observation.moves[0];
      return step ? { move: step.dir } : {};  // a turn as the bot would send it (tick filled in)
    };
  },
});
```

```python
from arena_sim import FastVectorEnv
env = FastVectorEnv(256, opponent=["mine", "kiter", "brawler"], scripts=["mine.js"], workers=8)
```

A bot's turns take the ladder's path: the protocol's schema, then the match rules, so a malformed
turn is an idle, failed tick, and ten in a row disqualify it, as on the ladder. An exception in it
stops the step with its message. `Game` takes a bot as its `opponent` too, and `Simulator`
(Python) takes `scripts` and the names they register.

**From a TypeScript bot.** The starter keeps its strategy in `strategy.ts`, apart from the
connection, with no imports. A few lines put it in a script ([esbuild](https://esbuild.github.io/)
bundles it, with your bot's other modules):

```ts
// sparring.ts: npx esbuild sparring.ts --bundle --format=iife --outfile=sparring.js
import { decide } from "./strategy.ts";
declare const ArenaSim: any;
ArenaSim.registerBot("mine", { version: "1", start: (hello: any) => {
  const match = { id: hello.match_id, rules: hello.rules };
  return (obs: any) => decide(obs, match);
} });
```

**From a Python bot.** A `ScriptedOpponent` plays in the fast environments too: the simulator
sends its seats' observations to Python as the ladder's JSON and takes its turns back. It runs at
your bot's speed, in Python, but only its seats leave the simulator. Give it `decide(observation,
hello)`, or `start(hello)` returning each match's `decide(observation)` to keep per-match state.
With `workers`, it goes to each worker process, so define those functions at the top level of a
module (`from bot import decide` works, with `websockets` installed: the starter's connection only
runs when it's started as a script):

```python
from arena_sim import FastVectorEnv, ScriptedOpponent
from bot import decide  # your bot.py

def start(hello):
    match = {"id": hello["match_id"], "rules": hello["rules"]}
    return lambda observation: decide(observation, match)

env = FastVectorEnv(256, opponent=[ScriptedOpponent(start=start), "kiter"], workers=8)
```

**An encoder** says the sizes of what it writes and turns each seat's observations into them:

```js
ArenaSim.registerEncoder("mine", {
  gridShape: [4, 21, 21],   // per seat; [0] for no grid
  gridType: "float32",      // or "uint8": a quarter the memory to copy and keep
  features: 12,             // numbers beside the grid
  actions: 51,              // how many actions the policy chooses from
  start(hello) {            // once per fighter per match: it may remember earlier ticks
    let last = null;
    return {
      encode(observation, out) { last = observation; /* write out.grid, out.vec, out.mask (1 = allowed) */ },
      turn(action) { return { /* the turn this action stands for, after `last` */ }; },
      action(turn) { return 0; /* the action closest to a turn, or -1: for teachers and imitation */ },
    };
  },
});
```

```python
env = FastSelfPlayEnv(1024, encoder="mine", scripts=["mine.js"], workers=12, copy=False)
env.single_observation_space  # {"grid": Box(4, 21, 21), "vec": Box(12,)}, from the encoder's sizes
env.spec                      # the same sizes, as the simulator reports them
```

Each tick the arrays start at zero, so write only what's there. The encoder sees the
observation as the batch builds it, not parsed from JSON; `check_encoder` (below) proves it reads
both the same. Its turns are checked like a bot's. A mask that allows an action the engine refuses
loses only that part of the turn, as on the ladder.

**A teacher** is a bot by name or a `ScriptedOpponent`:

```python
env = FastVectorEnv(1024, opponent="kiter", teacher="mine", scripts=["mine.js"], workers=12)
obs, info = env.reset(seed=0)
info["teacher_action"]  # per seat: the teacher's action there, or -1 where none of yours fits
```

It labels the fighters you play (in `FastVectorEnv`, fighter 1), using your encoder's
`action(turn)`, kept only where the mask allows it. `set_teacher` changes it (or removes it,
with None) for the matches that start next.

**On the ladder.** A TypeScript bot imports the encoder's module and calls it as the simulator
does. A Python bot runs the same JavaScript through `ScriptEncoder`, so the training and the live
encoding are one piece of code:

```python
from arena_sim import ScriptEncoder
encoder = ScriptEncoder("mine", scripts=["mine.js"])      # or ScriptEncoder("basic")
x = encoder.encode(observation, hello)                     # {"grid", "vec"}, as in training
mask = encoder.action_mask(observation, hello)
turn = encoder.decode(policy(x, mask), observation, hello) # the reply's move, dash and attack
```

It keeps each fighter's encoder by match, starting a new one when a match starts, and also works
as the encoder of the JSON environments (`ArenaVectorEnv(..., encoder=ScriptEncoder(...))`).

**Checking an encoder.** `check_encoder(replay, "mine", scripts=["mine.js"])` replays a recorded
match through two copies of your encoder, one reading the observations the batch passes and one
reading the ladder's JSON, and reports every difference: in the arrays, in the turn for any
allowed action, and wherever `action(turn(a))` isn't `a` (which teachers and imitation rely on).
State shared between seats, or kept outside `start`, shows up here. Run it on a few replays
before a long training run, and again when you change the encoder.

## Stable-Baselines3

`arena_sim.sb3.ArenaVecEnv` makes `ArenaVectorEnv`, `ArenaSelfPlayEnv` or a fast environment an
SB3 vector environment that steps every match in one call (`pip install "arena-sim[sb3] @ <the wheel's URL>"`).
SB3 restarts an episode on the step that ends it, so make the environment with
`autoreset_mode=AutoresetMode.SAME_STEP`. sb3-contrib's `MaskablePPO` gets `Basic`'s action masks
from it, and from `ArenaEnv.action_masks()` if you use single environments instead.

```python
from gymnasium.vector import AutoresetMode
from sb3_contrib import MaskablePPO
from arena_sim import ArenaVectorEnv, Basic
from arena_sim.sb3 import ArenaVecEnv

env = ArenaVecEnv(ArenaVectorEnv(64, opponent=["random", "brawler", "kiter"], encoder=Basic(),
                                 autoreset_mode=AutoresetMode.SAME_STEP))
model = MaskablePPO("MultiInputPolicy", env, n_steps=128, batch_size=1024)
model.learn(total_timesteps=1_000_000)
model.save("arena-ppo")
```

This trains about twice as fast as SB3's own `DummyVecEnv` over 64 `ArenaEnv`s. A finished episode's
info has its `terminal_observation` and `end` message (from a fast environment, its `result`).

## Train a policy: the example PPO script

The package comes with a complete training run to start from, `arena_sim.examples.train_ppo`: PPO
with action masks (sb3-contrib's `MaskablePPO`) on `Basic`, rewarded by `damage_trade` (damage
dealt minus damage taken each tick, then the win or loss), through a curriculum: `random` until it
wins 90% of fresh matches, then `brawler` (mixed with some `random`) until it wins 60%.

```bash
pip install "arena-sim[sb3] @ https://arena.victris.ai/download/arena_sim-0.44.0-py3-none-any.whl"
python -m arena_sim.examples.train_ppo --out arena-ppo      # --help lists the knobs
```

It prints the share of wins after every 100,000 steps and saves the model to `arena-ppo.zip`. What
to expect, measured on a desktop (one Python process, 64 matches at once):

| | CPU | GPU (RTX 5080) |
| --- | --- | --- |
| beats `random` (90% of matches) | 530,000 steps, 5 minutes | 430,000 steps, 2.5 minutes |
| beats `brawler` (60% of matches) | 19% after 2.1 million steps (22 minutes, stopped there) | 4.4 million steps, 33 minutes |

The model from that GPU run wins 99% of 256 fresh matches against `random`, 65% against `brawler`,
and 12% against `kiter`, which it never trained against.

Beating `random` is the easy part; `brawler` is a real opponent, and this setup only starts to win
against it after a couple of million steps (the share of wins is noisy along the way: measured over
64 matches, it moved between 47% and 75% in the last few evaluations). The script is a baseline to beat, not a strong bot:
the encoder, the reward, the network size and the curriculum are the places to improve it, and
self-play (`ArenaSelfPlayEnv`) and imitation of ladder games (below) are the next tools.

**On a GPU** the network trains faster and its size stops mattering; the simulation and the
encoding always run on the CPU, so they set the pace from there. Install a CUDA build of torch
first (pick the CUDA version your driver supports, e.g. `cu130`; RTX 50-series cards need CUDA
12.8 or newer):

```bash
pip install torch --index-url https://download.pytorch.org/whl/cu130
python -m arena_sim.examples.train_ppo --device cuda
```

Measured with `MaskablePPO` training (64 matches, `Basic`) on an RTX 5080 against the same
desktop's CPU:

| network | CPU | GPU |
| --- | --- | --- |
| default (two layers of 64) | 2,600 steps/s | 4,150 steps/s |
| larger (three layers of 512) | 1,370 steps/s | 4,000 steps/s |
| the simulation and encoding alone | 10,900 steps/s (`ArenaVectorEnv`); over 100,000 with the fast environments | (CPU only) |

So a GPU is worth it once the network grows. Past that, the simulator is no longer what limits
training: the fast environments make far more steps than SB3's PPO can learn from (it measured
700 to 800 steps a second with this script's settings on the CPU, whichever environment fed it).
To train much faster, use a trainer built for throughput, like the next one.

## Train on a GPU: the fast PPO script

`arena_sim.examples.train_ppo_gpu` is the same task as `train_ppo` (`Basic`, `damage_trade`,
`random` then `brawler`) written for speed in plain PyTorch: it plays `FastVectorEnv` over worker
processes with byte grids, sends the bytes to the GPU and decodes them there, keeps the rollout and
the update on the GPU, and replays each minibatch step as a CUDA graph. It runs on a CPU too, slowly.

```bash
pip install torch --index-url https://download.pytorch.org/whl/cu130   # a CUDA build, as above
pip install "arena-sim @ https://arena.victris.ai/download/arena_sim-0.44.0-py3-none-any.whl"
python -m arena_sim.examples.train_ppo_gpu --out arena-gpu      # --help lists the knobs
```

Measured on the same desktop (RTX 5080, 24 cores), the whole curriculum, evaluations included:

| | `train_ppo` (SB3, GPU) | `train_ppo_gpu` |
| --- | --- | --- |
| beats `random` (90% of matches) | 430,000 steps, 2.5 minutes | 520,000 steps, 10 seconds |
| beats `brawler` (60% of matches) | 4.4 million steps, 33 minutes more | 3.1 million steps, 41 seconds more |

Over four runs (different network seeds), beating `brawler` took 40 to 58 seconds of training
from scratch. The saved network won 100% of 256 fresh matches against `random`, 64% against
`brawler` and 18% against `kiter`, which it never trained against:
about as good as the SB3 model, some 40 times sooner.

It saves the network to `arena-gpu.pt`; `load(path)` gives it back as a batched policy,
`policy(observations, action_masks) -> actions`, which is also what `FastVectorEnv` takes as an
opponent (train against frozen copies of yourself this way). On the ladder, encode each
observation with `Basic().encode(observation, hello)` and call it on a batch of one.

What made it fast, if you write your own: many matches a step and short rollouts (2,048 matches,
16 ticks); about as many gradient steps per sample as SB3 makes (10 passes, 8 minibatches), since
fewer, bigger updates needed several times the samples; a small MLP rather than a convolutional
network, which cost three times as much a step without learning faster; and CUDA graphs, since a
minibatch of this size is mostly launch overhead. Collecting the next rollout while the network
learns raised the steps a second by half, but learning from the previous weights' rollouts took
about half again as many steps, so it saved no time; the script doesn't do it.

## Learn from ladder games

WebSocket keepalive belongs to the live client, outside the simulator. The starters use
client Pings every 20 s with a 20 s Pong timeout and reconnect on failure; no JSON heartbeat
or simulated tick is involved. Queue silence is normal, and transport recovery does not
guarantee the current match survived. See [keepalive and recovery](/docs/api#websocket-keepalive-and-recovery).

Live connections receive `hello` again when they reconnect or take over an attached match,
followed by the current observation if play has started. A repeated `match_id` means resumption;
keep any retained strategy state. Socket handoffs do not restart the simulation, restore spent
budget or appear in replay trajectories as additional match starts. A fresh live client can
initialize from the resent `hello`; see [message flow](/docs/bot-guide#message-flow).

Every ladder match is public, with its replay. `replay_trajectories` re-runs a replay through the
server's own match code and gives back each fighter's view of it: the `hello`, then every tick's
`observation` exactly as that bot was sent it and the `turn` the server took from its reply (with a
`reason` when the turn was idle: a timeout, a bad message...), and the `end`. That is training data
for imitating strong bots, offline RL, or studying a rival.

```python
from arena_sim import download_replays, replay_trajectories

for match, replay in download_replays("https://arena.victris.ai", bot="house-kiter", limit=20):
    trajectories = replay_trajectories(replay)
    for bot in match["bots"]:
        trajectory = trajectories[bot["fighter"]]
        pairs = [(s["observation"], s["turn"]) for s in trajectory["steps"]]  # what it saw, what it did
        print(bot["name"], bot["version"], trajectory["end"]["result"], len(pairs), "ticks")
```

`download_replays(ladder, bot=None, limit=50, min_rating=None, cache=None)` yields a bot's (or
everyone's) most recent matches, newest first, as the match record and the replay's text. With
`min_rating`, it fetches the recent matches of every bot on the leaderboard rated at least that,
each match once, and each record gets `strong`: the fighter ids of those bots in it. `cache` is a
directory to keep replays in, so running it again fetches only new ones. `replay_trajectories` also takes a replay
file's path, its gzipped bytes or a parsed replay. A replay doesn't record everything: in the rebuilt
messages `budget_ms_left` stays at the match budget, `match_id` is `"replay"`, the deadlines are the
rules' defaults and a refused message's event has no `detail`; a disqualified bot's last
observation, whose reply never resolved, is missing. A replay from another release's engine or
rules is refused (`ValueError`). In JavaScript the same function is `replayTrajectories(parseReplay(text))`.

For imitation, `replay_dataset` goes straight to arrays through an encoder from the simulator
(`Basic`, or yours), a row per fighter per tick: what it saw as your network reads it, its mask,
and `action`, the encoder's action closest to the turn it sent (-1 where it timed out or nothing
allowed comes close):

```python
import numpy as np
from arena_sim import download_replays, replay_dataset

pairs = list(download_replays("https://arena.victris.ai", min_rating=1800, limit=200, cache="replays"))
data = replay_dataset(pairs, "basic", grid="uint8")   # or "mine", scripts=["mine.js"]
strong = np.array([f in pairs[m][0]["strong"] for m, f in zip(data["match"], data["fighter"])])
x, y = {"grid": data["grid"][strong], "vec": data["vec"][strong]}, data["action"][strong]
```

It also returns `mask`, `fighter`, `tick`, `match` (which replay) and `skipped`, the replays from
another release, which the simulator can't replay. `fighters=` keeps only some fighters' rows: a
list of ids, or a function of the replay's header. In JavaScript, `replayDataset(replay, encoder)`.

`python -m arena_sim.examples.imitate --ladder https://arena.victris.ai --min-rating 1800` does all of
it: downloads the strong bots' matches, trains `train_ppo_gpu`'s network to predict their actions
(holding a tenth of the matches out to measure how often it agrees with them), saves it, and plays
it against the house bots. Imitation copies the experts on the states they reached; on states they
never did it can fall apart, which is what a teacher and reinforcement learning fix.

## Train against a league

`python -m arena_sim.examples.league --init arena-bc.pt --bots brawler kiter --minutes 30` goes on
from a network (`imitate`'s, `train_ppo_gpu`'s, or none) with PPO, every match against an
opponent drawn from a pool: bots by name (the house bots, or yours, with `--scripts`), the
network it started from, frozen copies of the learner taken as it improves, and the learner
itself (`--self-play`, a share of the matches). Opponents it still loses to are drawn more often.
`--kl` pulls the policy toward the network it started from, so early updates don't undo it;
`--teacher` adds imitation of a bot's turns on the states the learner reaches. It reports its
share of wins against each named bot and its recent results against each opponent in the pool,
and saves `last.pt` and `best.pt` (the best worst-case share of wins). Like the other examples it
is a starting point to copy: the pool, the weighting, the network and the losses are yours to
change. `--encoder` and `--scripts` train it through your encoder.

## Use it during a match

Computation on your own machine is yours to use: a bot may run the simulator while it plays on the
ladder, for example to search ahead by simulating candidate turns against likely replies before
choosing one. It still knows only what its observations show (fog of war hides the rest), and the
deadlines still apply: 1 second a tick, 300 seconds a match, network time included.

## Differences from the ladder

- **No clock.** Nothing is timed, so `self.budget_ms_left` stays at the match budget and a reply
  never arrives late. On the ladder you have 1 second a tick and 300 seconds a match, network time
  included; time your bot's decisions yourself.
- **Your fighter is 1 against a house bot.** On the ladder you may be fighter 1 or 2. The seed, not
  the id, decides who spawns where, and the maps are mirror-symmetric, so a policy shouldn't care.
- **House bots are the ladder's house bots**, running their code from `bots/` (source at
  https://arena.victris.ai/download/bots) in-process with the
  same seeded randomness, so a game against one is reproducible from its seed. Other players' bots
  aren't available; your opponents are self-play, the house bots, bots of your own, and networks
  you train from the ladder's replays to play like the bots in them.

## Staying current

Rules and engine change only in a release, and each release's simulator carries its number. A
simulator is current while its release and rules hash match the ladder's (`GET /api`,
`versions.release` and `versions.rules_hash`). After a release, download it again.

```bash
python -m arena_sim check https://arena.victris.ai   # says whether it's current, and how to update
```

```js
import { RELEASE, SHIPPED_RULES, rulesHash } from "./arena-sim.mjs";

const api = await (await fetch("https://arena.victris.ai/api")).json();
if (api.versions.release !== RELEASE || api.versions.rules_hash !== rulesHash(SHIPPED_RULES)) console.warn("the ladder runs another release: download the simulator again");
// A live bot can check the rules it trained on at the start of each match:
// rulesHash(hello.rules) === rulesHash(SHIPPED_RULES)
```

## Replays

A finished game's replay opens in the viewer like a ladder replay:

Actions and fighter labels use the replay's bot names (`Game` records `name` from its options).
Replays opened from the ladder use its display names when match metadata is available.

```js
import { writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { encodeReplay } from "./arena-sim.mjs";

writeFileSync("game.jsonl.gz", gzipSync(encodeReplay(game.replay())));
// then: npm run arena -- view game.jsonl.gz
```

`verifyReplay` and `parseReplay` are exported too.

`verifyReplay` checks recorded tick hashes and combat results. A forfeit or disqualification must
end at the next unresolved tick while combat is still running, with a living fighter as winner
(a forfeit can also draw). It trusts the server's reason for that ending, since combat inputs
cannot establish what happened to a socket or deadline.

## Event ladders

Visit [Event ladders](https://arena.victris.ai/ladders) to join a competition with your
account, or use MCP `list_my_ladders` to inspect admission and `enroll_ladder` with
explicit owner approval to join (see [MCP.md](/docs/mcp)). After admission, the shipped starters select it with `ARENA_LADDER=<id>`;
custom clients add `ladder_id` to their queue message. Omission keeps Public play.
Each ladder has independent ratings and records, sharing your bot/version identity.
Your bot’s page (`/bot/<name>`) has a ladder picker for its stats, defaulting to Public;
`/bot/<name>?ladder=<id>` opens a specific ladder.
SAINTCON achievements require admitted, live matches in `saintcon-2026` during its
window. Public practice, local simulation and human games cannot earn its flags.
See [multiple ladders](/docs/ladders) and [event achievements](/docs/events).

Ladder discovery lists omit hidden ladders; an enrolled account can still use its
ladder ID. Staff manage visibility, enrollment policy and achievement earning in
`/admin#ladders`; see [multiple ladders](/docs/ladders).
