How to build a headless OpenRA battle lab from scratch: Python driver, Lua battle script, three game engines, zero GUI.
The goal: run real-time RTS battles in Red Alert, Dune 2000, and Tiberian Dawn without touching the mouse. Real units on a real engine — not a simplified model. The Python driver generates army rosters as Lua tables, boots OpenRA straight into a campaign mission, tails structured log lines from the Lua script, then kills the game and parses the results into JSON and video.
A battle of 100 units finishes in ~15 seconds headless. With recording: ~40s.
Python Lua (inside OpenRA) Output
────── ──────────────────── ──────
render config.lua ──► ArmyConfig = {...}
install map + hash ──► Launch.Map=<uid>
start Xvfb :99
boot openra-<mod> ──► WorldLoaded()
spawn armies, Hunt()
OnKilled → Emit("KILL",...)
tail lua.log ◄── print("E|KILL ...")
print("E|RESULT ...")
print("E|DONE")
kill game, stop ffmpeg
parse → stats.json out/*.json
Three things make this fast:
Launch.Map=<map-uid>boots directly into the mission. No xdotool, no menu clicking, no calibration that breaks when the UI changes.- Tiny window + 2fps cap when not recording. The software renderer (llvmpipe) was the bottleneck, not the sim — this alone roughly doubles throughput.
- Poll, don't sleep: tail
lua.logevery 50ms for the first event instead of hoping the game is ready after N seconds.
Each OpenRA game ships as a self-contained AppImage (~200-300 MB unpacked). Extract them; don't run them:
# Red Alert
curl -sLO "https://github.com/OpenRA/OpenRA/releases/download/release-20250330/OpenRA-Red-Alert-x86_64.AppImage"
chmod +x OpenRA-Red-Alert-x86_64.AppImage
./OpenRA-Red-Alert-x86_64.AppImage --appimage-extract
mv squashfs-root engine/openra
# Dune 2000
curl -sLO "https://github.com/OpenRA/OpenRA/releases/download/release-20250330/OpenRA-Dune-2000-x86_64.AppImage"
chmod +x OpenRA-Dune-2000-x86_64.AppImage
./OpenRA-Dune-2000-x86_64.AppImage --appimage-extract
mv squashfs-root engine/openra-d2k
# Tiberian Dawn
curl -sLO "https://github.com/OpenRA/OpenRA/releases/download/release-20250330/OpenRA-Tiberian-Dawn-x86_64.AppImage"
chmod +x OpenRA-Tiberian-Dawn-x86_64.AppImage
./OpenRA-Tiberian-Dawn-x86_64.AppImage --appimage-extract
mv squashfs-root engine/openra-cncThe extracted tree has everything: launcher binary, server binary, utility binary, .NET runtime, and C# DLLs with all the C# code for the Lua API.
Key paths inside each engine:
engine/openra/
├── usr/bin/openra-ra # launcher (boots the game)
├── usr/bin/openra-ra-utility # CLI tool (map hashing, content extraction)
├── usr/bin/openra-ra-server # dedicated server
└── usr/lib/openra/
├── mods/ra/ # mod rules, maps, assets
│ ├── mod.yaml # we rewrite the timestep here
│ ├── maps/ # we copy our arena map here
│ └── rules/ # campaign-rules.yaml etc.
├── Eluant.dll # C#/Lua bridge (the Lua API)
├── OpenRA.Game.dll # engine core
└── ... # .NET runtime, SDL2, OpenAL
Engines render nothing without the original game files. Dune and TD have official freeware quick-install packages:
# Dune 2000
curl -sL "$(curl -s https://www.openra.net/packages/d2k-quickinstall-v3-mirrors.txt | head -1)" -o d2k.zip
unzip d2k.zip -d ~/.config/openra/Content/d2k/
# Tiberian Dawn
curl -sL "$(curl -s https://www.openra.net/packages/cnc-mirrors.txt | head -1)" -o cnc.zip
unzip cnc.zip -d ~/.config/openra/Content/cnc/Red Alert has no freeware package. Either launch the RA engine once (use
./engine/openra/AppRun) and click "Install Content", or copy an existing
~/.config/openra/Content/ra/ tree from a machine that has it.
This is the heart of the whole setup. A campaign mission map that:
- is a flat, empty plain (no terrain obstacles)
- has two spawn waypoints 18 cells apart
- pins its game speed to a custom slot we rewrite at runtime
- loads our Lua battle script
maps/arena/
├── map.bin # binary tile grid (MapFormat 12)
├── map.png # preview image (72×72 downscaled, or any png)
├── map.yaml # metadata, players, factions, Rules include chain
├── rules.yaml # LuaScript declaration, GameSpeed, Shroud, GameOverDelay
└── config.lua # AUTO-GENERATED by Python each run: ArmyConfig table
MapFormat: 12
RequiresMod: ra # "ra", "d2k", or "cnc"
Title: Opera Battle Arena
Author: opera-battles
Tileset: DESERT # DESERT for RA/TD, ARRAKIS for Dune
MapSize: 72,72
Bounds: 1,1,70,70 # playable area (1-cell border is map edge)
Visibility: MissionSelector # appears in the mission picker
Categories: Campaign # treat as campaign, skip skirmish lobby
LockPreview: True
Players:
PlayerReference@Neutral:
Name: Neutral
OwnsWorld: True
NonCombatant: True
Faction: england
PlayerReference@Attacker:
Name: Attacker
Playable: True # human slot (no AI interference)
Required: True
Faction: soviet # faction varies by mod
LockFaction: True
Color: FE1100 # red
Enemies: Defender
PlayerReference@Defender:
Name: Defender
Bot: campaign # campaign AI (defends, doesn't build)
Faction: allies
Color: 5B7FE7 # blue
Enemies: Attacker
Actors:
CameraPoint: waypoint
Location: 36,36 # initial viewport centre
Owner: Neutral
AttackerSpawn: waypoint
Location: 27,36 # attacker spawns centre-left
Owner: Neutral
DefenderSpawn: waypoint
Location: 45,36 # defender spawns centre-right
Owner: Neutral
Rules: ra|rules/campaign-rules.yaml, rules.yamlThe Rules: line chains together the mod's built-in campaign rules (which set
up the mission framework) and our rules.yaml (which injects the Lua script).
Per-mod differences for map.yaml:
| field | RA | Dune 2000 | Tiberian Dawn |
|---|---|---|---|
RequiresMod |
ra |
d2k |
cnc |
Tileset |
DESERT |
ARRAKIS |
DESERT |
| Attacker faction | soviet |
harkonnen |
nod |
| Defender faction | allies |
atreides |
gdi |
| Rules chain | ra|rules/campaign-rules.yaml |
d2k|rules/campaign-rules.yaml |
cnc|rules/campaign-maprules.yaml |
Dune note: in D2K, both players get Bot: campaign because the human player
slot causes the engine to prompt for a game start. Also, Dune's campaign rules
spawn sandworms — third-party units eating your test subjects — so
arena-d2k/rules.yaml must remove the sandworm spawner (see below).
World:
LuaScript:
Scripts: config.lua, battle.lua
MissionData:
Briefing: Two armies smash into each other. Watch and record.
MapOptions:
GameSpeed: battle-turbo # custom speed slot we redefine per run
GameSpeedDropdownLocked: True
Player:
MissionObjectives:
GameOverDelay: 25 # end replay fast after last kill (default: 3000 ticks!)
Shroud:
ExploredMapCheckboxEnabled: True
ExploredMapCheckboxLocked: True
FogCheckboxEnabled: False
FogCheckboxLocked: TrueThe critical line is Scripts: config.lua, battle.lua. OpenRA executes these
in order. config.lua is generated fresh by Python each run; battle.lua is
the shared engine-side script.
GameSpeed: battle-turbo pins the map to a named speed slot. That slot doesn't
exist in vanilla OpenRA — we inject it into mod.yaml at runtime (Step 6).
Because mod.yaml is not part of the map hash, changing the timestep does NOT
change the map uid — so one install_map() call serves any speed.
For Dune: add -ActorSpawnManager: to the World: section to delete the
sandworm spawner (OpenRA's MiniYAML deletion syntax — a key prefixed with -):
World:
-ActorSpawnManager:The binary map format (MapFormat 12) is:
u8 format (12)
u16 width
u16 height
u32 offset of tile section
u32 offset of height section (0 if absent)
u32 offset of resource section
tiles: u16 template + u8 frame index, per cell
resources: u8 type + u8 density, per cell
Stock maps have cliffs, ore, trees — pathing obstacles that skew results. For a unit-vs-unit testbed, flatten every cell to a clear PickAny template:
import struct
from pathlib import Path
# Per-tileset "featureless ground" template
# RA/TD desert: template 255 ("Clear"), 16 frames
# Dune ARRAKIS: template 266 ("Rock"), 17 frames — no Clear exists here
ARENAS = {
"maps/arena": (255, 16),
"maps/arena-cnc": (255, 16),
"maps/arena-d2k": (266, 17),
}
def flatten(map_bin: Path, clear: int, frames: int):
d = bytearray(map_bin.read_bytes())
w, h = struct.unpack_from("<HH", d, 1)
t_off, hm_off, res_off = struct.unpack_from("<III", d, 5)
for i in range(w * h):
# vary the frame deterministically to avoid a visibly tiled floor
idx = (i * 7 + (i // w) * 3) % frames
struct.pack_into("<HB", d, t_off + i * 3, clear, idx)
# Wipe resources (ore/gems): type 0 = none
for i in range(w * h):
struct.pack_into("<BB", d, res_off + i * 2, 0, 0)
map_bin.write_bytes(bytes(d))This is the single most important file in the project. ~200 lines of Lua that run inside OpenRA's engine. It's shared across all three mods because the Lua API is engine-level, not mod-level.
Python generates config.lua as a plain Lua table:
-- AUTO-GENERATED by battle.py. Do not edit.
ArmyConfig = {
Attacker = { { type = "3tnk", count = 6 }, { type = "v2rl", count = 2 } },
Defender = { { type = "2tnk", count = 6 }, { type = "arty", count = 2 }, { type = "e3", count = 6 } },
GridCols = 4,
Seed = 0,
}The script reads it with a fallback so the map still runs manually:
ArmyConfig = ArmyConfig or {
Attacker = { { type = "3tnk", count = 5 } },
Defender = { { type = "1tnk", count = 5 } },
GridCols = 4,
Seed = 0,
}Every significant event is printed to stdout. OpenRA captures script print()
to ~/.config/openra/Logs/lua.log. Python tails that file.
The format is a prefix E| (easy to grep), a kind word, then key=value pairs:
function Emit(kind, kv)
local parts = { "E|" .. kind }
if kv then
for k, v in pairs(kv) do
parts[#parts + 1] = k .. "=" .. tostring(v)
end
end
print(table.concat(parts, " "))
endExample log lines:
E|BEGIN seed=0
E|SPAWN side=Attacker count=8
E|UNIT id=1 side=Attacker type=3tnk
E|UNIT id=2 side=Attacker type=v2rl
...
E|KILL id=3 x=354 y=271 tick=47 side=Defender unit=e3 killer=3tnk atk_left=8 def_left=5
E|RESULT winner=Attacker atk_spawned=8 atk_lost=3 def_spawned=8 def_lost=8
E|DONE
Critical detail: OpenRA's Lua API exposes math.random but it is NOT
reproducible across runs. Without a custom RNG, every seed produces an
identical battle. Fix: roll your own Park-Miller LCG:
RandState = 1
function SeedRandom(s)
RandState = math.floor(s) % 2147483647
if RandState <= 0 then
RandState = RandState + 2147483646
end
end
function NextRandom(n)
RandState = (RandState * 16807) % 2147483647
return (RandState % n) + 1
endThis is seeded from ArmyConfig.Seed before spawning. Without it, the seed
value is echoed into the logs but has zero effect.
function SpawnArmy(sideName, player, spawnCell, dir)
-- Flatten roster into one entry per unit, then shuffle
local order = {}
for _, group in ipairs(ArmyConfig[sideName]) do
for _ = 1, group.count do
order[#order + 1] = group.type
end
end
-- Fisher-Yates with our custom RNG
for k = #order, 2, -1 do
local j = NextRandom(k)
order[k], order[j] = order[j], order[k]
end
-- Auto-widen for large armies (200 units in 4 cols = 50 deep → off map)
local cols = ArmyConfig.GridCols or 4
local MaxRows = 48
if math.ceil(#order / cols) > MaxRows then
cols = math.ceil(#order / MaxRows)
end
local rows = math.min(math.ceil(#order / cols), MaxRows)
for i = 0, #order - 1 do
local col = i % cols
local row = math.floor(i / cols)
-- dir = +1 attacker (spread left), -1 defender (spread right)
local cell = CPos.New(
spawnCell.X - dir * col,
spawnCell.Y - math.floor(rows / 2) + row)
local unit = Actor.Create(order[i+1], true, {
Owner = player, Location = cell
})
-- Assign a small integer id for frame sampling
local id = NextId; NextId = NextId + 1
AllUnits[#AllUnits + 1] = { id = id, actor = unit, side = sideName, type = order[i+1] }
Emit("UNIT", { id = id, side = sideName, type = order[i+1] })
-- Wire up the kill handler
Trigger.OnKilled(unit, function(self, killer)
-- ... emit KILL, call CheckEnd()
end)
end
endKey design points:
- Every unit gets an integer id stored in
AllUnits— used by frame sampling - Formation is a grid.
dirspreads the attacker leftward, defender rightward, centred vertically on the spawn waypoint - Auto-widening: 200 units in 4 columns would be 50 rows deep and spill off the map, so the grid widens to keep depth ≤ 48
WorldLoaded = function()
Attacker = Player.GetPlayer("Attacker")
Defender = Player.GetPlayer("Defender")
Camera.Position = CameraPoint.CenterPosition
Emit("BEGIN", { seed = ArmyConfig.Seed or 0 })
SeedRandom((ArmyConfig.Seed or 0) + 1)
SpawnArmy("Attacker", Attacker, AttackerSpawn.Location, 1)
SpawnArmy("Defender", Defender, DefenderSpawn.Location, -1)
CheckEnd() -- handle empty-roster edge case
-- Charge after 1s delay (gives the engine time to settle)
Trigger.AfterDelay(DateTime.Seconds(1), function()
OrderHunt("Attacker")
OrderHunt("Defender")
end)
-- 5-minute safety timeout
Trigger.AfterDelay(DateTime.Minutes(5), function()
if not BattleOver then
BattleOver = true
Emit("RESULT", { winner = "Timeout", ... })
Emit("DONE", {})
end
end)
endThe 1-second delay before Hunt() is important — without it, some units never
start moving.
Critical gotcha: never defer Emit("DONE") with Trigger.AfterDelay. When
a side loses all its units, campaign rules fire MissionObjectives game-over
and the world stops ticking. Any deferred callback never runs, and Python
hangs until timeout. Emit DONE synchronously from CheckEnd():
function CheckEnd()
if BattleOver then return end
local a, d = Remaining("Attacker"), Remaining("Defender")
if a <= 0 or d <= 0 then
SampleFrame() -- one last frame so the timeline reaches zero
BattleOver = true
local winner = "Draw"
if a > 0 then winner = "Attacker"
elseif d > 0 then winner = "Defender" end
Emit("RESULT", { winner = winner, ... })
Emit("DONE", {}) -- SYNCHRONOUS — do NOT wrap in Trigger.AfterDelay
end
endEvery 5 ticks, dump one compact line of live-unit positions:
F|120|1,351,274,82;3,369,280,15;5,348,265,100;
Format: F|<tick>|<id>,<cellx>,<celly>,<hp%>;...
Positions are in tenths of a cell (CenterPosition.X * 10 / 1024) so the JSON
stays small and integer-only. The web replay divides by 10.
function SampleFrame()
local parts = {}
for _, u in ipairs(AllUnits) do
local a = u.actor
if a.IsInWorld and not a.IsDead then
local p = a.CenterPosition
local hp = 100
if a.HasProperty("Health") and a.MaxHealth > 0 then
hp = math.floor(a.Health * 100 / a.MaxHealth)
end
parts[#parts + 1] = string.format("%d,%d,%d,%d", u.id,
math.floor(p.X * 10 / 1024), math.floor(p.Y * 10 / 1024), hp)
end
end
print("F|" .. StartTick .. "|" .. table.concat(parts, ";"))
endDead units are simply omitted — Python infers their fate from the E|KILL
lines.
Without camera control, the viewport sits wherever the engine left it (usually a map corner), and any recording is mostly black. Track the centroid of all live units:
function TrackCamera()
local sx, sy, n = 0, 0, 0
for _, u in ipairs(AllUnits) do
local a = u.actor
if a.IsInWorld and not a.IsDead then
local p = a.CenterPosition
sx = sx + p.X; sy = sy + p.Y; n = n + 1
end
end
if n == 0 then return end
Camera.Position = WPos.New(math.floor(sx / n), math.floor(sy / n), 0)
endCritical: set Camera.Position outright, never try to ease toward the
target by reading it back first. The getter does not report what you set, so
easing from ~0 parks the camera in the corner.
Also disable edge scrolling via CLI flag: Game.ViewportEdgeScroll=false. The
mouse pointer sits wherever Xvfb left it, and edge-scroll fights the script
camera.
Tick = function()
StartTick = StartTick + 1
if BattleOver then return end
if StartTick % SampleEvery == 0 then
SampleFrame()
end
if StartTick % 10 == 0 then
TrackCamera()
end
endEach game is a dataclass:
class Mod:
def __init__(self, mod: str, engine_dir: str, arena: str):
self.mod = mod # "ra"
self.engine = ROOT / engine_dir / "usr/lib/openra" # engine tree
self.bin = ROOT / engine_dir / "usr/bin" # binaries
self.launcher = self.bin / f"openra-{mod}" # game launcher
self.utility = self.bin / f"openra-{mod}-utility" # CLI tool
self.map_src = ROOT / "maps" / arena # our arena
self.map_dst = self.engine / f"mods/{mod}/maps/opera-arena"
self.mod_yaml = self.engine / f"mods/{mod}/mod.yaml"
MODS = {
"ra": Mod("ra", "engine/openra", "arena"),
"d2k": Mod("d2k", "engine/openra-d2k", "arena-d2k"),
"cnc": Mod("cnc", "engine/openra-cnc", "arena-cnc"),
}Each battle goes through seven steps:
1. Render config: write config.lua from a battle spec dict:
def render_config(cfg: dict):
def army(groups):
items = ", ".join(
'{ type = "%s", count = %d }' % (g["type"], g["count"])
for g in groups)
return "{ %s }" % items
lua = (
"-- AUTO-GENERATED by battle.py. Do not edit.\n"
"ArmyConfig = {\n"
f"\tAttacker = {army(cfg['attacker'])},\n"
f"\tDefender = {army(cfg['defender'])},\n"
f"\tGridCols = {cfg.get('grid_cols', 4)},\n"
f"\tSeed = {cfg.get('seed', 0)},\n"
"}\n"
)
(M.map_src / "config.lua").write_text(lua)A battle spec is a JSON file:
{
"attacker": [
{ "type": "3tnk", "count": 6 },
{ "type": "v2rl", "count": 2 }
],
"defender": [
{ "type": "2tnk", "count": 6 },
{ "type": "arty", "count": 2 },
{ "type": "e3", "count": 6 }
],
"grid_cols": 4,
"seed": 1
}2. Install map: copy the arena into the engine's mod maps directory, copy
in battle.lua, compute the uid:
def install_map() -> str:
if M.map_dst.exists():
shutil.rmtree(M.map_dst)
shutil.copytree(M.map_src, M.map_dst)
shutil.copy(BATTLE_LUA, M.map_dst / "battle.lua")
out = subprocess.run(
[str(M.utility), "--map-hash", str(M.map_dst)],
capture_output=True, text=True, check=True,
env=dict(os.environ, DISPLAY=DISPLAY),
)
uid = out.stdout.strip().splitlines()[-1]
return uidbattle.lua lives once at maps/battle.lua and is copied in at install time.
This avoids a fork per arena.
3. Set speed: regex-rewrite the battle-turbo speed block in mod.yaml.
The map pins MapOptions.GameSpeed to battle-turbo; we redefine what that
speed means per run by changing its Timestep value (ms per simulation tick):
def set_speed(timestep: int):
timestep = int(timestep)
if timestep < 1:
raise ValueError("timestep must be >= 1ms/tick — 0 crashes OpenRA")
text = M.mod_yaml.read_text()
# Remove any existing battle-turbo block
text = re.sub(r"\t\t%s:\n(?:\t\t\t.*\n)+" % TURBO, "", text)
# Insert fresh one
block = (f"\t\t{TURBO}:\n"
f"\t\t\tName: options-game-speed.fastest\n"
f"\t\t\tTimestep: {timestep}\n"
f"\t\t\tOrderLatency: 6\n")
text = text.replace("\tSpeeds:\n", "\tSpeeds:\n" + block, 1)
M.mod_yaml.write_text(text)Defaults: 5ms/tick headless (~8× real time), 20ms/tick when recording (2×). Stock Red Alert is 40ms/tick = 25 ticks per real second.
4. Start Xvfb (virtual framebuffer) on :99:
def start_xvfb():
if sh("pgrep -x Xvfb >/dev/null").returncode != 0:
subprocess.Popen(
["Xvfb", DISPLAY, "-screen", "0", f"{W}x{H}x24", "-ac",
"+extension", "GLX", "+render", "-noreset"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Poll until display is ready
for _ in range(50):
if sh(f"xdpyinfo -display {DISPLAY} >/dev/null 2>&1").returncode == 0:
break
time.sleep(0.2)5. Launch OpenRA via systemd-run --user so we can kill it cleanly:
def launch_game(map_uid: str, record: bool = False):
LUA_LOG.write_text("") # clear previous log
# Park mouse in the bottom-right corner: no edge scroll, no hover tooltip
sh(["xdotool", "mousemove", str(MOUSE_XY[0]), str(MOUSE_XY[1])],
env=dict(os.environ, DISPLAY=DISPLAY))
# Write a shell runner script
gfx = [f"Graphics.WindowedSize={W},{H}"] if record else [
"Graphics.WindowedSize=320,240",
"Graphics.CapFramerate=true", "Graphics.MaxFramerate=2",
]
runner = ROOT / ".run_openra.sh"
runner.write_text(
"#!/bin/bash\n"
f"export DISPLAY={DISPLAY}\n"
f'cd "{M.engine}"\n'
f'export LD_LIBRARY_PATH="{M.engine}"\n'
f"exec {M.launcher} Game.Mod={M.mod} Sound.Device=null"
" Game.ViewportEdgeScroll=false"
" Graphics.Mode=Windowed " + " ".join(gfx) +
f" Launch.Map={map_uid}\n"
)
runner.chmod(0o755)
# Launch as a transient user unit
subprocess.run(
["systemd-run", "--user", f"--unit={GAME_UNIT}", "--collect", str(runner)],
capture_output=True, text=True, check=True)The key launch arguments:
Game.Mod=ra— which mod to loadSound.Device=null— no audio device neededLaunch.Map=<uid>— boot straight into the mission, bypass the lobbyGraphics.Mode=Windowed— required; fullscreen fights XvfbGraphics.WindowedSize=320,240+Graphics.MaxFramerate=2— starve the renderer when not recording; the sim runs at full speed regardlessGame.ViewportEdgeScroll=false— pointer sits at bottom-right, must not pan
6. Tail lua.log until E|DONE:
LINE_RE = re.compile(r"E\|(\w+)\s*(.*)")
FRAME_RE = re.compile(r"F\|(\d+)\|(.*)")
def parse_line(line: str):
m = LINE_RE.search(line)
if not m:
return None
kind, rest = m.group(1), m.group(2)
kv = {}
for tok in rest.split():
if "=" in tok:
k, v = tok.split("=", 1)
kv[k] = v
return kind, kv
def tail_until_done(timeout=360):
events, result, frames = [], None, []
start = time.time()
pos = 0
while time.time() - start < timeout:
if LUA_LOG.exists():
text = LUA_LOG.read_text()
# Only consume to the last newline — the game is still writing
end = text.rfind("\n", pos) + 1
if end <= pos:
time.sleep(POLL_S)
continue
new = text[pos:end]
pos = end
for line in new.splitlines():
f = parse_frame(line)
if f:
frames.append(f)
continue
p = parse_line(line)
if not p:
continue
kind, kv = p
events.append({"kind": kind, **kv})
if kind == "RESULT":
result = kv
if kind == "DONE":
return events, result, frames
time.sleep(POLL_S)Critical detail: only consume up to the last newline. The game is still writing; the tail of the file is often a half-written line. Taking it as complete silently drops units from frame dumps and puts spikes in the attrition graph.
7. Cleanup: kill the systemd unit, stop ffmpeg (SIGINT so it flushes the
moov atom), write stats.json.
def start_recording(out_mp4: Path):
proc = subprocess.Popen(
["ffmpeg", "-y", "-f", "x11grab", "-video_size", f"{W}x{H}",
"-framerate", "25", "-i", DISPLAY,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
"-pix_fmt", "yuv420p", str(out_mp4)],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
)
return procWhen recording, the window is 1280×720 at the recording timestep (default
20ms/tick = 2× real time). ffmpeg captures at 25fps in wall-clock time, so
the video is sped up relative to in-game time. The stats.json includes
ticks_per_second and start_tick so the web replay can sync the video to
the kill feed.
Runs are not bit-identical (the campaign bot reacts to wall-clock scheduling), but the cache is close enough — same winner most of the time:
def battle_key(cfg) -> str:
def norm(groups):
merged = {}
for g in groups:
if g["count"]:
merged[g["type"]] = merged.get(g["type"], 0) + g["count"]
return sorted(merged.items())
payload = json.dumps(
{"mod": cfg.get("mod", M.mod),
"attacker": norm(cfg["attacker"]),
"defender": norm(cfg["defender"]),
"seed": cfg.get("seed", 0),
"grid_cols": cfg.get("grid_cols", 4)},
sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()[:16]Timestep is deliberately excluded from the key — OpenRA's simulation is deterministic in ticks, so ms/tick changes wall-clock duration but not the outcome (verified identical results at 5, 20, and 40 ms/tick).
This deserves its own section because it's the trickiest part.
OpenRA's mod.yaml defines named "game speeds" as Timestep values (ms/tick):
\tSpeeds:
\t\tslowest:
\t\t\tName: options-game-speed.slowest
\t\t\tTimestep: 100
\t\t\tOrderLatency: 12
\t\tdefault:
\t\t\tName: options-game-speed.default
\t\t\tTimestep: 40
\t\t\tOrderLatency: 3OpenRA exposes no CLI flag for sim speed. The lobby speed dropdown is skipped when booting directly into a mission. The solution:
- The map's
rules.yamlpinsGameSpeed: battle-turbo(a slot that doesn't exist yet) - Python regex-injects a
battle-turboblock intomod.yamlwith the desired timestep - Because
mod.yamlis not part of the map hash, changing the timestep does NOT invalidate the map uid — oneinstall_map()serves any speed
Timestep must be ≥ 1. The engine derives ticks-per-second by dividing by it:
0 causes a DivideByZeroException on startup, before the map ever loads.
units.py walks each mod's mods/<mod>/rules/*.yaml to extract costs.
miniyaml.py parses OpenRA's MiniYAML dialect (tab indents, Inherits:
templates, -Key: deletions). Output: data/units.csv with columns
mod, code, name, category, faction, cost.
unitinfo.py reads HP and Cost from the same rules. These are needed because
HP percentages from the Lua script aren't comparable across unit types (a dog
has 1800 HP; a mammoth tank has 90000).
icons.py extracts sidebar sprites for the web UI. RA and TD use
openra-<mod>-utility --extract / --png. Dune packs all icons into one
DATA.R16 atlas; r8.py decodes it raw (RGB555 frames, no Pillow needed).
apt install python3 python3-venv xvfb ffmpeg unzip curl libsdl2-2.0-0 libopenal1 xdotoolPython packages (all optional — battle.py itself uses only stdlib):
marimo # web notebook
altair # charts
polars # data frames
wandb # experiment tracking (for sweeps)
Runs many battles (one at a time — they fight over Xvfb, systemd unit name,
and mod.yaml), logs each to Weights & Biases: one run per battle, step-wise
attrition chart, kill-feed table, raw stats JSON as artifact.
./wsweep.py battles/*.json --repeat 5 --project opera-battlesHigh-volume: one run per duel (~11s each), thousands of duels across unit pairs.
Reads out/duels.csv, scores every unit-vs-unit pairing under a choice of
metrics (cost-weighted, HP%, bodies, TTK). Outputs a text or HTML matrix.
Game picker, army roster sliders, Fight button, strength-over-time chart,
kills breakdown, optional video embed. Runs via marimo run --headless.
Systemd service in marimo.service.
apt installsystem deps (Step 8)./setup_engines.sh— download + extract 3 AppImages + freeware assetspython3 flatten_map.py— clear terrain on every arenapython3 units.py && python3 icons.py— extract unit data + sprites- Verify:
python3 battle.py --config battles/demo.json -q(~15s) - Sweep:
./wsweep.py battles/demo.json --repeat 5
opera-battles/
├── battle.py # Core Python driver (the conductor)
├── flatten_map.py # Clears terrain obstacles from map.bin
├── units.py # Extracts unit costs from mod rules
├── unitinfo.py # Reads HP/Cost for Lua hp% → absolute conversion
├── miniyaml.py # OpenRA MiniYAML parser
├── icons.py # Extracts sidebar sprites as PNGs
├── r8.py # Dune DATA.R16 atlas decoder
├── setup_engines.sh # One-shot: download + extract engines + assets
├── wsweep.py # Sweep: many battles → W&B
├── wduels.py # 1v1 unit duels
├── matrix.py # Counter matrix from duel results
├── notebook.py # Marimo web UI
├── envfile.py # Load .env before wandb imports
├── requirements.txt # marimo altair polars wandb
├── .env.example # WANDB_API_KEY, etc.
├── recipe.md # This file
│
├── battles/ # JSON battle specs
│ ├── demo.json
│ ├── cnc-demo.json
│ └── dune-demo.json
│
├── maps/
│ ├── battle.lua # ★ The Lua script (shared across all mods)
│ ├── arena/ # Red Alert arena
│ │ ├── map.bin
│ │ ├── map.png
│ │ ├── map.yaml
│ │ ├── rules.yaml
│ │ └── config.lua # Auto-generated each run
│ ├── arena-d2k/ # Dune 2000 arena
│ │ └── ... (same structure)
│ └── arena-cnc/ # Tiberian Dawn arena
│ └── ... (same structure)
│
├── engine/ # Extracted AppImages (gitignored)
│ ├── openra/
│ ├── openra-d2k/
│ └── openra-cnc/
│
├── data/ # Extracted unit data (committed)
│ ├── units.csv
│ └── icons/
│
└── out/ # Battle outputs (gitignored)
├── stats.json
├── battle.mp4
└── cache/
-
"Never reached the map": usually means the timestep is 0 (crashes OpenRA with DivideByZeroException before the map loads) or the map uid doesn't match what's installed.
-
Python hangs forever:
E|DONEwas deferred withTrigger.AfterDelaybut the world stopped ticking. Emit DONE synchronously. -
Every seed gives the same result: you're using
math.randominstead of a custom LCG. OpenRA'smath.randomis not reproducible. -
Recording is black:
Camera.Positionwasn't set, orViewportEdgeScrollis panning toward the mouse pointer. -
Units trickle around obstacles: the map wasn't flattened — cliffs and trees block pathing.
-
Dune sandworms eating units: forgot the
-ActorSpawnManager:deletion inarena-d2k/rules.yaml. -
"could not launch OpenRA":
XDG_RUNTIME_DIRorDBUS_SESSION_BUS_ADDRESSnot set.systemd-run --userneeds both to reach the user bus. -
Frame sampling drops units: consuming partial lines from
lua.log. Only read to the last newline. -
Camera stuck in corner: trying to ease toward the centroid by reading
Camera.Positionfirst. Set it outright. -
Unit tooltips in the recording: mouse pointer hovering a unit. Park it in a corner with
xdotool mousemovebefore launch.