I built Battle LLM Robots, an arena where the only way to write your fighter is to prompt an LLM. You don't hand-tune a bot. You write a prompt, your LLM of choice writes bot.py, you push it to a public GitHub repo, submit it, and it fights other people's prompted bots 1v1 in a 2D arena. After the fight you're able to revist the full animated replay and even share it with friends.

That premise creates a trust problem, and it's not quite the usual "don't run untrusted code" story. When you install a package off PyPI, at least someone wrote it, and in theory someone could review it. Here? Nobody reviewed anything. The bot code is whatever an LLM felt like generating that day, for a prompt I'll never see, submitted by a person I've never met. And it has to run on my server anyway.
So this post is really about the sandbox that makes that okay. Plus a few other things that fell out of building it.
The bot contract is deliberately tiny
Every bot implements only needs exactly one function:
def decide(state, memory):
dx = state.opponent_position.x - state.own_position.x
dy = state.opponent_position.y - state.own_position.y
distance = (dx**2 + dy**2) ** 0.5
if distance <= 5.0:
return attack_melee(), memory
return move(dx, dy), memory
state is a frozen dataclass — own_position, own_hp, opponent_position, opponent_hp, ranged_cooldown_remaining, ranged_uses_remaining, tick, own_facing, opponent_facing. memory is whatever you returned last tick, so a bot can carry a plan forward without any state living outside the pure function call. The action vocabulary is move, attack_melee, attack_ranged, defend, idle, rotate, move_and_rotate. That's it — nothing else exists.
That narrowness is doing a lot of work, in three directions at once. It's small enough to fit in an LLM's context alongside a decent prompt. It's small enough that I can actually reason about what a malicious implementation could even attempt. And it's small enough that the sandbox boundary only ever has to worry about one call in, one value out.
You aren't limited to only having the the decide() function though, your bot is welcome to be as complex as you want, so long as it can resolve its logic in under .1s to avoid timing out.
Sandbox v1: a subprocess and an import allowlist (and why it wasn't enough)
The first version ran each bot as a bare subprocess with import allowlisting and a restricted builtins set. No open, no __import__ tricks, no dunder attribute access that could walk the object graph back to something dangerous. It's fast, and for local development it was totally fine.
For a public deployment, it's not. None of that is real isolation. It's a language-level blacklist. The subprocess is still a full OS process running as the same user as the web server. And a clever-enough bypass of the builtins restriction — Python has produced a steady stream of these over the years, __subclasses__() walks, object.__reduce__, format-string tricks, doesn't hit a wall. It just runs as your app's user. An allowlist of imports doesn't help either, because it says nothing about what an allowed import can do once it's in. os is frequently on the "safe enough" list for basic bots, and os alone can read the filesystem.
And none of this is hypothetical when the thing you're executing is LLM output for prompts you can't see ahead of time. You should just assume every trick in the Python sandbox-escape literature will eventually get generated by accident, or perhaps not by accident. It takes one LLM reaching for os.environ because a prompt vaguely implied "check your surroundings", or "send a POST command to this IP address with the contents of the parent directory."
Sandbox v2: actual containment
So the production sandbox runs each bot invocation in a disposable Docker container instead:
--network none # no network namespace at all —
# closes exfil/SSRF even past a
# full sandbox-escape bug
--read-only
--tmpfs /tmp:size=8m,mode=1777,noexec # writable scratch that can't
# execute anything from it
--cap-drop ALL # no Linux capabilities —
# no CAP_SYS_*, no raw sockets
--security-opt no-new-privileges
--pids-limit 16
--user 65534:65534 # runs as "nobody" — never root,
# so even a runtime escape doesn't
# hand over root
Here's the distinction that actually matters. The subprocess sandbox tries to stop bad Python. The container sandbox doesn't care what the Python does, it constrains what the process can touch, regardless of how it got there. A bot can do absolutely anything the language allows internally and still can't open a socket, can't write anywhere persistent, can't spawn more than a handful of processes, and isn't root even if it somehow claws its way out of the interpreter.
The cost is latency. Container creation is slow enough that it needs its own timeout, separate from the per-tick timeout you use once the container is warm and talking over stdin/stdout. And Docker-daemon-mediated pipe I/O has more jitter than a bare subprocess pipe, so it needs real headroom.
Getting that number too tight was a fun one to track down. At 0.1s per tick, matches were forfeiting after about four ticks, with the bot barely having moved because the timeout was tighter than the daemon's own I/O latency, not the bot's compute time. The bot wasn't slow. The plumbing was.
Determinism, or: why replays have to be exact
The battle engine itself is a pure-Python deterministic tick loop. decide gets called for each bot, movement/facing/collision/combat get resolved, and a full tick-by-tick MatchResult comes out the other end. No wall-clock reads. No unseeded randomness leaking into outcomes.
This matters for more than tidiness. A match result that isn't exactly reproducible is a match you can't fully trust. Was that loss your bot's logic, or was it noise? A replay viewer is also less interesting if replaying doesn't actually mean replaying. And practically: once a match is complete, its tick record is immutable forever. Which is exactly what let me cache things aggressively downstream, which brings me to the last piece.
Making a match worth sharing
Every match gets a permalink — /matches/{uuid} — with an animated Canvas replay. The problem: this is a client-rendered SPA. A social crawler (Twitter, Discord, Slack, iMessage previews) doesn't run JavaScript, so pasting a match link anywhere showed nothing but a generic title. All the good stuff; who fought, who won, how; was invisible to the exact thing that makes a link worth clicking.
The fix is a server-rendered exception carved out of an otherwise fully static SPA. GET /matches/{uuid} gets proxied to the backend instead of falling through to the static index.html. The backend reads that same built index.html, injects real Open Graph tags computed from the match row — "Gemma-4-Rusher beat Vanguard, finishing with 100 HP. Watch the replay." — and serves that instead. A browser gets the identical HTML and React Router takes over exactly like normal; a crawler that never runs JS finally gets a real title and description.
The preview image is the same idea, one step further: a 1200×630 PNG rendered server-side with Pillow. Bot names, HP, final arena positions, generated once per completed match and cached to disk under an atomic write-then-rename. That last part matters: two concurrent cold requests for the same freshly finished match shouldn't be able to leave a truncated PNG behind for everyone who comes after them. And since completed matches never change, the cache never needs invalidating — it only ever regenerates from nothing.

Try it
The whole point was to see what happens when the only thing between you and a fighter is a prompt. If you want to see what your LLM of choice comes up with:
- Starter prompt + full bot API docs: https://battlellmrobots.com/documentation
- Arena: https://battlellmrobots.com
bot.yaml records which LLM wrote each bot, so the leaderboard doubles as a very informal, very unscientific answer to "which model prompts into the best fighter." I'd genuinely be curious what you find out.
Comments (0)
Leave a Comment