Wiki · main

cogame-magent-battle

Last edited by · ·

cogame-magent-battle

A port of MAgent2's battle_v4 to a Coworld, with two ARMY COMMANDERS instead of 162 per-unit RL policies.

Two armies of 81 identical soldiers meet on an open 45x45 integer grid. Every soldier has 10 hp, deals 2 damage to one adjacent enemy per tick, moves up to two cells per tick, and regains 0.1 hp per tick. Nobody plays a soldier.

Each of the two seats is an army commander. Once every 20 simulation ticks it issues one order to each of its nine squadsadvance, hold x y, focus <enemy squad>, flank left|right, retreat — and a deterministic squad controller turns those orders into the MAgent actions its soldiers actually take. A commander sees only what its own soldiers can see: an enemy is visible only when one of its own soldiers is within 6 cells of it.

The army with more soldiers standing when the game ends wins it. An episode is two games with the sides swapped, so upstream's own two-column spawn asymmetry cancels, and the seat that wins the pair wins the episode. Scoring is exactly zero-sum:

score[s] = sum over both games of ( 100 * outcome + survivors[s] - survivors[opp] )

A policy is just a prompt. A champion sets PLAYER_PROMPT to a strategy in plain English; the game server composes that prompt with the seat's own fogged view and asks Claude for nine squad orders. A filler sets PLAYER_SCRIPTED=line|pincer instead. Both come out of the same image.

Layout

src/magent_battle.nim          the game server entrypoint (seed randomisation lives HERE)
src/magent_battle_player.nim   the thin seat registrar -> /bin/magent-battle-player
src/magent/
  upstream.nim      every ported constant, beside the upstream line it came from
  arena.nim         the grid, the two CircleRange tables, upstream's generate_map
  units.nim         the soldiers, the occupancy grid, army-scale visibility
  sim.nim           the step loop; imports and RE-EXPORTS the sim modules
  sim_types.nim     GameVersion, the rune caps, GameConfig
  sim_config.nim    GameConfig lifecycle, config.update, the replay config JSON
  sim_state.nim     gameHash, the event sink, the lobby/game-over lifecycle
  control.nim       the deterministic squad controller
  directives.nim    the order schema and the tolerant, repairing validator
  baselines.nim     the `line` and `pincer` scripted baselines
  llm.nim           the Bedrock/Anthropic transport
  decide.nim        the per-turn PARALLEL batch, the deadlines, the fallback ladder
  episode.nim       one episode frame, shared by the server and the e2e test
  server.nim        the mummy server and the Coworld contract
  roster.nim        join/auth, the two name spaces, the results document
  replays.nim       the COWLDMAG codec
  replay_runtime.nim playback, the per-tick hash check, the load-time pre-scan
  broadcast.nim     the viewer state packet and the nine derived event kinds
  global.nim        the board payload (cell space, no pixel arena)
  labels.nim        the label vocabulary contract
  events.nim        the tier-2 JSON-lines analysis stream
client/             the broadcast chrome (see "The viewer" below)
replay-viewer/      the static wasm bundle: the SAME sim compiled to wasm
tests/              four balanced shards; run from the repo ROOT
tools/              CI, forensics and the art pipeline
vendor/upstream/    battle.py, byte-pristine at a pinned commit

Building and testing

Dependencies come from nimby; the Dockerfile is the canonical build recipe.

nimby use 2.2.4
nimby --global sync nimby.lock
nim c -r --path:src tests/tests.nim        # the whole suite
nim r -d:release --path:src tests/shard_1.nim   # one CI shard

CI runs the four shards as separate binaries. The repo variable NIM_TESTS is set to tests/shard_1.nim tests/shard_2.nim tests/shard_3.nim tests/shard_4.nim so the shard binaries are what run; with the variable unset, ci.yml falls back to every tests/*.nim, which is the same coverage and only slower.

One episode end to end, in raw docker, with the certification fixture's seat mix:

docker build --platform=linux/amd64 -t coworld-magent-battle:ci .
SMOKE_REQUIRE_REPLAY_JSON=0 ./tools/ci/docker_smoke.sh coworld-magent-battle:ci

The viewer

A static wasm bundle, never a pod. The manifest declares game.replay_viewer = {"bundle": "static-replay-viewer"} and tools/build_replay_viewer.sh (the coworld build hook, committed executable) compiles the same src/magent/sim.nim to wasm through Dockerfile.replay-viewer. In the browser the module re-derives every frame from the recorded orders and compares the per-tick gameHash against the recording, so one divergent bit is caught at the tick it happens and surfaced in #mmwarn.

The chrome is coworld-ctf's. client/chrome_common.js is copied byte for byte (a test pins its length and hash); client/replay_broadcast.html is the starter's page with the elements the design note removes deleted and a MAGENT-BATTLE block appended under a banner comment. tools/build_broadcast_page.py performs exactly that transformation and is committed, so the fork is auditable rather than a 4,700-line rewrite:

python3 tools/build_broadcast_page.py \
  --starter /path/to/coworld-ctf/client/replay_broadcast.html \
  --page-script client/page_script.js \
  --game-block client/game_block.html \
  --out client/replay_broadcast.html

What a spectator sees: the grid edge to edge with a baked cog chip per soldier, an army heat overlay, a chalk front line that breaks where the front breaks, a unit-count sparkline over the whole episode, two scorebug plates (real policy name + in-game alias + side chip + alive count), the match feed in plain language, and clickable labelled scrubber beats for firstblood, rout, wipe, fallback and end. Legibility is checked at 360 px, the width of the featured-match iframe, not at desktop width.

Forensics

curl -sSL "$replay_url" -o /tmp/ep.replay
python3 tools/replay_summary.py /tmp/ep.replay | jq .

tools/replay_summary.py is Python 3 stdlib only — no Nim, no Docker — and prints one strict-UTF-8 JSON object describing the whole episode: the config, the seed, the seat names, every squad order, every commander line and the full results document. Every string that lands in the replay is truncated on a rune boundary, which is what keeps that parse honest.

Licence

MIT (see LICENSE). The vendored upstream file keeps its own licence in vendor/LICENSE-magent2.