Skip to content

Instantly share code, notes, and snippets.

@koaning
Created August 16, 2026 19:37
Show Gist options
  • Select an option

  • Save koaning/835982a6b9b3898fc4d56fd651535dff to your computer and use it in GitHub Desktop.

Select an option

Save koaning/835982a6b9b3898fc4d56fd651535dff to your computer and use it in GitHub Desktop.
openra simulation recipe

Opera Battles — Recipe

How to build a headless OpenRA battle lab from scratch: Python driver, Lua battle script, three game engines, zero GUI.


Overview

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.


Big picture

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:

  1. Launch.Map=<map-uid> boots directly into the mission. No xdotool, no menu clicking, no calibration that breaks when the UI changes.
  2. Tiny window + 2fps cap when not recording. The software renderer (llvmpipe) was the bottleneck, not the sim — this alone roughly doubles throughput.
  3. Poll, don't sleep: tail lua.log every 50ms for the first event instead of hoping the game is ready after N seconds.

Step 1: Get the engines

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

The 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

Step 2: Get the game assets

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.


Step 3: Build the arena map

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

3a. Directory structure

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

3b. map.yaml — the map manifest

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

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

3c. rules.yaml — hooking the Lua script

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

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

3d. map.bin — the tile grid (make it flat)

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

Step 4: The Lua battle script (maps/battle.lua)

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.

4a. Receiving the config from Python

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,
}

4b. Structured logging — the Lua→Python channel

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, " "))
end

Example 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

4c. Reproducible shuffling

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
end

This is seeded from ArmyConfig.Seed before spawning. Without it, the seed value is echoed into the logs but has zero effect.

4d. Spawning armies

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
end

Key design points:

  • Every unit gets an integer id stored in AllUnits — used by frame sampling
  • Formation is a grid. dir spreads 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

4e. Combat and completion

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

The 1-second delay before Hunt() is important — without it, some units never start moving.

4f. The DONE trap

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
end

4g. Frame sampling for the browser replay

Every 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, ";"))
end

Dead units are simply omitted — Python infers their fate from the E|KILL lines.

4h. Camera tracking

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

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

4i. Per-tick hook

Tick = function()
    StartTick = StartTick + 1
    if BattleOver then return end
    if StartTick % SampleEvery == 0 then
        SampleFrame()
    end
    if StartTick % 10 == 0 then
        TrackCamera()
    end
end

Step 5: The Python driver (battle.py)

5a. The Mod abstraction

Each 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"),
}

5b. Per-run sequence

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 uid

battle.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 load
  • Sound.Device=null — no audio device needed
  • Launch.Map=<uid> — boot straight into the mission, bypass the lobby
  • Graphics.Mode=Windowed — required; fullscreen fights Xvfb
  • Graphics.WindowedSize=320,240 + Graphics.MaxFramerate=2 — starve the renderer when not recording; the sim runs at full speed regardless
  • Game.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.

5c. Recording (optional)

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 proc

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

5d. Caching

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


Step 6: Speed control deep-dive

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

OpenRA exposes no CLI flag for sim speed. The lobby speed dropdown is skipped when booting directly into a mission. The solution:

  1. The map's rules.yaml pins GameSpeed: battle-turbo (a slot that doesn't exist yet)
  2. Python regex-injects a battle-turbo block into mod.yaml with the desired timestep
  3. Because mod.yaml is not part of the map hash, changing the timestep does NOT invalidate the map uid — one install_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.


Step 7: Unit data extraction

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


Step 8: System dependencies

apt install python3 python3-venv xvfb ffmpeg unzip curl libsdl2-2.0-0 libopenal1 xdotool

Python packages (all optional — battle.py itself uses only stdlib):

marimo          # web notebook
altair          # charts
polars          # data frames
wandb           # experiment tracking (for sweeps)

Step 9: Additional Python tools

wsweep.py — sweep many battles, log to W&B

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

wduels.py — 1v1 unit duels

High-volume: one run per duel (~11s each), thousands of duels across unit pairs.

matrix.py — counter matrix from duel results

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.

notebook.py — marimo web UI

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.


Step 10: Boot order (checklist)

  1. apt install system deps (Step 8)
  2. ./setup_engines.sh — download + extract 3 AppImages + freeware assets
  3. python3 flatten_map.py — clear terrain on every arena
  4. python3 units.py && python3 icons.py — extract unit data + sprites
  5. Verify: python3 battle.py --config battles/demo.json -q (~15s)
  6. Sweep: ./wsweep.py battles/demo.json --repeat 5

Reference: file layout

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/

Common pitfalls

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

  2. Python hangs forever: E|DONE was deferred with Trigger.AfterDelay but the world stopped ticking. Emit DONE synchronously.

  3. Every seed gives the same result: you're using math.random instead of a custom LCG. OpenRA's math.random is not reproducible.

  4. Recording is black: Camera.Position wasn't set, or ViewportEdgeScroll is panning toward the mouse pointer.

  5. Units trickle around obstacles: the map wasn't flattened — cliffs and trees block pathing.

  6. Dune sandworms eating units: forgot the -ActorSpawnManager: deletion in arena-d2k/rules.yaml.

  7. "could not launch OpenRA": XDG_RUNTIME_DIR or DBUS_SESSION_BUS_ADDRESS not set. systemd-run --user needs both to reach the user bus.

  8. Frame sampling drops units: consuming partial lines from lua.log. Only read to the last newline.

  9. Camera stuck in corner: trying to ease toward the centroid by reading Camera.Position first. Set it outright.

  10. Unit tooltips in the recording: mouse pointer hovering a unit. Park it in a corner with xdotool mousemove before launch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment