Arena Fighter

Read as Markdown

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.

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.

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.

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.

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

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.

Release changes are at 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

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), so it needs no Node.js.

Play a game in JavaScript

// 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 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) 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 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

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:

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

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:

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:

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:

// 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)
    };
  },
});
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 bundles it, with your bot's other modules):

// 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):

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:

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 */ },
    };
  },
});
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:

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:

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.

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 ArenaEnvs. 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%.

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):

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.

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.

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.

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.

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):

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

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.

python -m arena_sim check https://arena.victris.ai   # says whether it's current, and how to update
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.

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 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). 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 and event achievements.

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.