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 bobThe 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
--realtimeit 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\dockerdocker-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 3The 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
.pyfile:alice.py, the same fileibf runplays; - a folder with
bot.py:alice/holdingbot.pyand the modules it imports (the bot's folder comes first on itssys.path, as withpython bot.py); - a native bot's
.exe, or a folder withbot.exe, with the DLLs it needs beside it:ibf_client.dllof 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 bobThe 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-17It 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:
- It makes a Docker network with
--internalfor 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. - 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. - 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. - 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 (
--keepleaves 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.pyand the modules it imports, or a single.pyfile; - a folder with
bot: a native bot built for Linux x86-64 against the Linux client (libibf_client.so, from the SDK'slib/linux-x86_64or 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 asbottoo. A Windows.exeis 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/botPIP_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 checkfails:docker run --rm inversus-arena:0.9.0 checkprints why. The game's start is inlogs/launch.logandshim.logof a match run.- Exit code 2, "cannot write to /arena/out": the folder is not writable by uid 1000,
so there is no
result.jsoneither. - 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, thenarena check, orXvfb :99 &andwine 'C:\Python\python.exe' -m ibf launch.