Bots in a Linux container: the arena

The arena is a Docker image that plays matches between bots on a Linux machine: the game, the framework and the bots in one container, under Wine. A server can take bots that people submit, run each match in a fresh container, and hand back the results, the recording and, if asked, a video.

docker run --rm --network none -v ./bots:/arena/bots:ro -v ./out:/arena/out inversus-arena:0.9.0 match alice bob

The bots are the same files that play on Windows: a .py bot, or a native bot's .exe. Nothing about a bot changes for the arena.

For bots that should not share a container with the game or with each other (strangers' bots in a ladder, say), docker/launch.py plays a match with each bot in a container of its own, connected to the game over TCP.

How it works

Everything Windows in the container runs under Wine 10: the game with the framework in it, a 64-bit Windows Python with the ibf package, and the bots. The bots have to be there with the game: they reach it through the framework's shared memory, which is Windows shared memory, and Wine keeps that within one Wine. The game draws its Direct3D 9 through Wine's OpenGL into a virtual display (Xvfb) with Mesa's software renderer, so the machine needs no graphics card; nothing is shown, and the game plays silent.

arena match (the image's entry point, docker/arena.py) does what a person does on Windows: it starts the game (ibf launch), runs ibf match with the bots, and writes the results. Around that it keeps a deadline, captures the video, and gathers the logs.

Two things make a container quick to start:

  • The how-to-play is already done. A new save makes the first versus match play the game's how-to-play (about 80 s; getting started). The image build plays one match (arena warmup) and keeps the save it leaves, so every container starts past it. That match is also the build's test: an image that builds is one that plays.

  • A small window. The renderer runs on the CPU, so the window's size sets the frame rate. The game waits for the bots between steps (below), so it takes one step a frame, and below 60 frames a second a match runs slower than real time:

    Window (--size) Frames a second A match runs at
    320x180 121 real time
    480x270 (default) 66 (70 with the overlay off) real time
    640x360 42 0.7× real time
    1280x720 13 0.2× real time

    Measured with the overlay on and 8 CPUs given to Docker. The game never steps faster than 60 times a second, so a faster renderer buys only spare CPU. With --realtime it steps 60 times a second whatever it draws.

From docker run to the results takes about 35 s for hunter against idle: 6 s to start Wine and the game, 20 s of match, the rest the menus and the stop. The bots' wake-up after each tick costs more under Wine than on Windows (publish-to-wake p50 about 400 µs, p99 about 600 µs, against 144 µs at p99 on Windows), well inside a tick's 16.7 ms.

Building the image

The image is built from two things that are not in the repository's docker\ folder: the ibf wheel for 64-bit Python, and the game's files. On Windows, from a checkout:

.\scripts\package.ps1                     # the wheels in dist\ (or pass -Package below)
.\scripts\docker-build.ps1                # inversus-arena:0.9.0 and :latest
.\scripts\docker-build.ps1 -Package -Test # build the wheel, the image, then tests\docker

docker-build.ps1 takes the game from ..\game (the folder setup-game.ps1 makes), or from -GameDir: any folder with Inversus.exe (as Steam has it, or patched), Global\ and Scenes\. It needs Docker Desktop with Linux containers. A first build takes about 10 minutes, most of it Debian's packages and the warm-up match; after a change to the wheel, 3 to 4. The image is 3.9 GB, most of it Wine for two architectures and the software renderer.

Each build leaves its layers in Docker's build cache, 4 to 6 GB a time, on the drive that holds Docker Desktop's data (C: by default). When builds pile up, docker system df shows it and docker builder prune clears it, and Docker Desktop gives the space back to Windows. A full drive makes Docker fail: its engine crashed with a bus error, and the image it was unpacking came out broken, with empty files.

On Linux, with the wheel and the game's files copied over, the same build is:

docker build -t inversus-arena:0.9.0 \
    --build-context dist=<folder with ibf-0.9.0-py3-none-win_amd64.whl> \
    --build-context game=<folder with Inversus.exe, Global/, Scenes/> \
    docker/

Or build on Windows and move the image: docker save inversus-arena:0.9.0 | gzip > arena.tar.gz, then docker load < arena.tar.gz on the server.

Build argument Default
PIP_EXTRA numpy Windows packages for the bots, e.g. "numpy scipy". Bots play offline, so what they import must be in the image
PYTHON_VERSION, PYTHON_SHA256 3.13.16 Windows Python, from python.org's NuGet package, checked against the hash
UID 1000 the user that runs Wine and the bots

The image holds the game's files. Push it only to a private registry.

Running a match

docker run --rm --network none \
    -v /srv/match-17/bots:/arena/bots:ro \
    -v /srv/match-17/out:/arena/out \
    inversus-arena:0.9.0 match alice bob.exe --matches 3

The bots folder is mounted read-only at /arena/bots; the results go to /arena/out, which the container's user (uid 1000) must be able to write: on Linux, chown 1000 it or build with --build-arg UID=$(id -u). Each bot named on the command line plays a pad, from pad 0, and is one of:

  • a .py file: alice.py, the same file ibf run plays;
  • a folder with bot.py: alice/ holding bot.py and the modules it imports (the bot's folder comes first on its sys.path, as with python bot.py);
  • a native bot's .exe, or a folder with bot.exe, with the DLLs it needs beside it: ibf_client.dll of its bitness (both work), from the SDK;
  • the name of an example: hunter, idle, random_walk, painter.

The bots run with the working directory a scratch folder of the run's own: a bot that reads its own files finds them from __file__. They have the standard library, ibf, and what PIP_EXTRA installed.

How the bots play. In real time, with a whole tick for every move. Each tick the game waits until every bot has answered it, or 17 ms (a tick is 16.7 ms) have passed. A bot that answers later misses that tick: the game plays its last input again, as a controller held still would, and the next tick waits for it again. So every bot gets the same time for every move whatever the server is doing, and a slow bot's moves land a tick or two late. The game never runs faster than real time: when the bots answer in time, it goes on at 60 steps a second. While a bot is late every tick, each tick costs the whole wait plus the game's own work, so the match slows a little: a bot that needs 30 ms a move (two ticks) missed all of its 1,555 ticks against hunter, which missed none, and the game ran at 48 steps a second (0.8× real time). Waiting for that bot would have made it 33. A bot that has not answered for 250 ms is taken for hung: its pad goes neutral and the game stops waiting for it. match.log and each bot's latency.lockstep_timeouts in result.json count the ticks a bot missed.

--realtime waits for nothing: a bot's time to answer then depends on when the game happens to draw, and can be well under a tick. --lockstep-timeout 1000 --lockstep-miss neutral is ibf match --lockstep's patient lockstep: the game slows to the slowest bot.

Option Default
--matches N 1 matches to play, each first to three rounds
--map N 0 the map select's number
--mode versus|arcade, --players 1|2 versus, 1 as ibf match: two versus two takes four bots, pads 0 and 1 against 2 and 3
--realtime off never wait for the bots (above)
--lockstep-timeout MS 17 how long each tick waits for the bots' answers
--lockstep-miss hold|neutral hold a late bot keeps its last input for the tick, or reads neutral until it catches up (lockstep)
--timeout S 600 gives up on a match after this long
--deadline S matches × timeout + 180 stops everything after this long
--no-record records no .ibfrec recordings
--video no video also match.mp4: the game's window at 30 frames a second, retimed to the game's own speed (a lockstep match that ran at 0.6× real time plays at 1×)
--overlay off the framework's overlay: the status bar and the bots' drawings, in the video
--size WxH 480x270 the game's window (and the video's size); above
--max-seconds S until game over arcade: end a run after this long in play; it counts, with its score then
--video-live DIR none also stream the video as HLS into DIR while it plays (index.m3u8 and 2-second segments; a mounted folder)

arena check starts the game, prints the framework's status and stops: a quick test of an image on a new server.

What comes back

out/
  result.json          the verdict, each bot, each match
  match1.ibfrec        each match's recording (ibf replay plays it to a bot)
  match.mp4            with --video
  logs/
    arena.log          the arena's own steps
    match.log          ibf match's output, with every bot's lines ([alice] ...)
    ibf-<time>-<pid>.log  the framework's log in the game; shim.log; crash-*.dmp if any
    launch.log, wine.log, xvfb.log, ffmpeg.log, game-debug.txt (the game's own log)

result.json:

{
  "status": "ok",
  "error": null,
  "exit_code": 0,
  "started": "2026-10-05T22:30:00+00:00",
  "seconds": 31.2,
  "settings": { "mode": "versus", "players": 1, "map": 0, "matches": 1, "run_mode": "lockstep",
                "lockstep_timeout_ms": 17, "lockstep_miss": "hold", "...": "..." },
  "versions": { "ibf": "0.9.0", "wine": "wine-10.0 (Debian 10.0~repack-6)", "python": "3.13.16" },
  "bots": [
    { "pad": 0, "bot": "alice", "file": "alice/bot.py", "sha256": "…", "name": "alice",
      "ended": "stopped", "latency": { "lockstep_timeouts": 0, "…": "…" } },
    { "pad": 1, "bot": "bob.exe", "file": "bob.exe", "sha256": "…", "name": "bob", "ended": "stopped" }
  ],
  "matches": [
    { "number": 1, "ended": "game over", "winner": 0, "winner_bot": "alice", "scores": [3, 1],
      "rounds": [ { "round": 1, "winner": 0, "winner_bot": "alice", "ticks": 318 } ],
      "ticks": 1402, "seconds": 21.0, "recording": "match1.ibfrec" }
  ],
  "host": { "pid": 292, "version": "0.9.0+…" },
  "video": null,
  "logs": "logs"
}

winner is a pad, and so an index into bots (in two versus two, a pad of the winning team): it is what decides a match, and winner_bot is only that pad's bot's name. name is the name the bot gave itself (its class's name). sha256 is the submitted file's, or for a folder, of every file in it (a submission with a symbolic link in it is refused). latency is what ibf match measured (matches). A bot's ended is stopped when it played to the end, or exited with N when it ended by itself.

status Exit code
ok 0 every match ended with a winner
incomplete 1 a match did not: a bot failed (error names it, match.log has its traceback), or --timeout
timeout 1 --deadline came first
invalid 2 the command line or a bot is wrong; nothing was started
error 3 the arena failed: the game did not start, or the framework did not answer
stopped 143 docker stop: result.json says so, with the logs so far; the matches it stopped are not in it

Running untrusted bots

Run each match in a container of its own, removed after (--rm), with no network and limits, its output on the machine's disk limited too:

docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges \
    --pids-limit 1024 --memory 4g --cpus 4 \
    --log-driver local --log-opt max-size=20m --log-opt max-file=1 \
    -v bots:/arena/bots:ro -v out:/arena/out inversus-arena:0.9.0 match alice bob

The arena keeps the bots' output to 20 MB in match.log and on its own output, and reads a line without an end 64 KB at a time, so a bot that floods its output costs neither the memory nor the disk.

tests\docker runs every match under exactly these flags (it copies the bots in rather than mounting them, so that it runs where the daemon cannot see its folders too, as in a CI job). Inside, Wine and the bots run as an unprivileged user, the bots folder is read-only, and nothing outlives the container.

What this protects, and what it does not. The container keeps a bot away from the server: its files, its network, the other matches. It does not keep the bots in one match away from each other or from the game. They share one Wine, as they share one Windows on a desktop: a bot could read or change the game's memory or the other bot's, end the other bot's process, or flood the CPU. For ranked play between strangers, give each bot a container of its own (next).

Each bot in a container of its own

docker/launch.py plays a match with the game in one container and each bot in another, on a network of the match's own. It runs where docker runs, on Windows or Linux, with Python 3.11 or later and Docker 28 or later (older Docker leaves internal networks a gateway on the machine, which the bots could reach: the launcher refuses it), and nothing else:

python docker/launch.py match bots/alice bots/bob --matches 3 --out results/match-17

It takes the same bots as arena match, built for Linux, and the same options (--matches, --map, --video, ...), and leaves the same result.json and logs in --out (default arena-results/<time>). Its exit code is the arena's (above). Step by step:

  1. It makes a Docker network with --internal for each bot, its gateway isolated (no address on the machine), and puts the game on every one of them: a bot reaches the game and nothing else, not the other bots, not the internet, not the machine's services.
  2. It starts the game's container (the arena image, arena match --remote). The framework listens for bots over TCP there (port 47800, [bridge.tcp]) and pins a fresh random token to each bot's pad.
  3. It starts a container per bot from the bots' image, copies the bot into it, and gives it its token. The bot connects to the game over TCP (tcp://game:47800) and plays its pad.
  4. When the game's container ends, it copies out the results and the logs (plain files and folders: a link or a special file the game's container left there is dropped), adds each bot's container's exit code and output, and removes the containers, their volumes and the networks (--keep leaves them, to look into). A stop (Ctrl+C, SIGTERM) on the way stops the game's container, which writes its results, and still cleans up; one that comes while docker makes a container or a network waits for it, so nothing is left.

A bot is one of:

  • a folder with bot.py and the modules it imports, or a single .py file;
  • a folder with bot: a native bot built for Linux x86-64 against the Linux client (libibf_client.so, from the SDK's lib/linux-x86_64 or the Linux wheel; the bots' image has it too, so the bot need not bring it) (C and C++ bots); a bare native file is played as bot too. A Windows .exe is refused: build it for Linux;
  • the name of an example: hunter, idle, random_walk, painter.

The bot is copied in owned by root and read-only to the bot (files 0644, the native bot 0755, no links, at most 200 MB, checked before anything starts), so it cannot change itself during a match. Every container's output is kept on the machine's disk to 8 MB. The container's root filesystem is read-only too; the bot may write in its home folder and /tmp, each 64 MB in memory.

The bots' image (inversus-arena-bot:0.9.0, docker/bot/Dockerfile) is Linux Python 3.13 with numpy and the ibf wheel for Linux, whose client speaks TCP only: no Wine and nothing of the game, about 270 MB. scripts\docker-build.ps1 -Package builds it with the arena's image (it builds the Linux client first, in a Debian container); on its own:

docker build -t inversus-arena-bot:0.9.0 \
    --build-context dist=<folder with ibf-0.9.0-py3-none-linux_x86_64.whl> \
    docker/bot

PIP_EXTRA works as for the arena's image, with Linux packages. The bots' image holds nothing of the game.

Option Default
--out DIR arena-results/<time> where the results go
--game-image, --bot-image inversus-arena:<version>, inversus-arena-bot:<version> the images
--game-cpus, --game-memory 4 (or fewer CPUs), 4g the game's container's limits
--bot-cpus, --bot-memory 1, 1g each bot's container's limits
--bot-wait S 180 how long a bot keeps trying to connect while the game starts
--remote-wait S 120 how long the game waits for every bot to take its pad, once it is up
--bot-runtime NAME Docker's an OCI runtime for the bots, e.g. runsc where the daemon has gVisor
--writable-root off the bots' root filesystem writable (default read-only)
--live DIR none the game's video as HLS, live, into DIR (the arena's --video-live); on Linux the folder is made writable by the game's user (uid 1000) and the launcher's group, not by everyone
--keep off leave the containers and the networks

What comes back is the arena's result.json (above), with each bot's entry telling more:

{ "pad": 0, "bot": "bots/alice", "file": "bot.py", "sha256": "…", "name": "alice",
  "remote": true, "ended": "disconnected", "fault": null, "connections": 1,
  "container": { "exit_code": 0, "oom_killed": false, "log": "logs/bot0-alice.log" },
  "latency": { "…": "…" } }

name is the bot's label (its folder's or file's name, or alice:0 and alice:1 for the same bot twice): letters, digits, ., _, : and -, at most 31, never starting with -. A bot names itself when it connects, but the launcher's label is what counts: the name it gave, when it differs, is kept as client_name, and every winner_bot is the label of the winner's pad. A bot's ended is disconnected: at the end the match lets its token go, and the bot's connection ends with it; the bot's container then ends by itself, usually with exit code 0. fault is null for a bot that played to the end, or what went wrong with it (matches): a bot that never connects is no_connect and makes the match incomplete after --remote-wait, with error naming it; one that crashes or goes silent during the match is crashed or hung, and loses it. logs/bot<pad>-<label>.log holds the bot's own output (its first 2 MB), and result.json's launch gives the match's id, the images, the bots' runtime and whether their roots were read-only.

What the bots can do. Each bot is in its own container, as an unprivileged user, without capabilities or new privileges (--cap-drop ALL, no-new-privileges), with a read-only root and limits on its processes, memory (no swap), open files and CPU. On its network it can reach the game's TCP port and nothing else (not the machine: the network's gateway is isolated), and its token lets it do only what a bot needs: play its own pad, draw on the overlay, publish its parameters and statistics, and log. The framework refuses it the console commands, the other pads and the host's log, and refuses any connection whose token it did not pin. A bot cannot see the other bot's process or memory, nor the game's, and cannot reach the internet. The game's container runs with the same flags; the bots' input reaches it only through the framework's TCP server, which checks each frame's size and type, drops a connection that sends garbage or goes silent for 1.5 s, and takes 16 connections at most (4 from one address). During the match no bot can open the game's pause menu: Start and Back stay up on bot pads while the referee holds the match lock.

What it costs. A bot in a container of its own sees each tick about 4 ms after the game publishes it (p50 4.1 ms, p99 4.4 ms, measured with hunter against random_walk), where a bot in the game's container sees it in about 0.4 ms: the game's sockets under Wine are slow. In lockstep's 17 ms that leaves a bot about 12 ms to answer, the same for every bot. A match with the launcher took 47 s from start to results, against about 35 s with arena match.

tests\docker\test_launch.py plays such matches: two bots, a native Linux bot, a bot that never connects, a bot that keeps pressing pause, and a probe that tries the internet, a console command, the other pad, a guessed token, writing to its own files and the image, and the game's other ports, all refused.

From Python (the website's worker): docker/launch.py is a library too. launch.run(launch.Options(bots=[...], out=Path(...))) plays one launch and returns its exit code and result.json; Options takes the same settings as the command line, plus observers: containers of the caller's (launch.Observer(image, command, ...)) that watch the match with a watch token (IBF_URL, IBF_TOKEN, IBF_MATCH in their environment: snapshots and state, nothing else, configuration) on a network of their own with the game, and join any network they name (one that reaches the website, say). An on_event callback hears when the match's containers have started and when it is over.

A bot may also be bundle:<path>: a tar of the bot's folder, prepared and checked by the caller (the website unpacks and checks each upload in a sandbox of its own, then keeps it as such a tar). It is copied into the bot's container as it is, after the launcher's own cheap checks (plain files and folders, no absolute paths or .., no setuid, at most 200 MB), with the owners and modes a folder gets (root's, 0644, the native bot 0755), and its entry is found as for a folder (a leading ./ is fine); the results name it by the tar's SHA-256. Options.labels names the bots in the game and the results, per pad (None keeps the launcher's own name).

For a scheduler of your own (Kubernetes, Nomad), do what the launcher does: the game's container runs arena match --remote [options] -- <label> <label> ... (labels as above; after --, so none is read as an option) with ARENA_TOKENS (one token per bot, comma-separated, in pad order) and, optionally, ARENA_TCP_TOKEN and ARENA_WATCH_TOKENS (spectators'); each bot's container gets IBF_URL=tcp://<game>:47800, IBF_TOKEN, IBF_PAD, IBF_WAIT and IBF_BOT_NAME, and its bot in /bot (or ARENA_EXAMPLE). Keep each bot on a network of its own with the game, with no route to the machine or anything else, and limit every container's log.

When something goes wrong

  • arena check fails: docker run --rm inversus-arena:0.9.0 check prints why. The game's start is in logs/launch.log and shim.log of a match run.
  • Exit code 2, "cannot write to /arena/out": the folder is not writable by uid 1000, so there is no result.json either.
  • A bot's traceback: logs/match.log, under its name.
  • A slow match: lockstep runs only as fast as the game draws: a smaller --size, more CPUs (--cpus), or --realtime.
  • Looking inside: docker run --rm -it --entrypoint bash inversus-arena:0.9.0, then arena check, or Xvfb :99 & and wine 'C:\Python\python.exe' -m ibf launch.