← Dev Log
BallOrcs Battle3 min read

BallOrcs Battle: an army you can see, orders you can give, robots to give them

Last time, BallOrcs Battle was an engine with no picture — a war simulated purely in numbers. This week it got a face.

In plain words

Last time, BallOrcs Battle was an engine with no picture — a war simulated purely in numbers. This week it got a face. Thousands of units now draw on screen under an isometric camera, and you can actually order them about: click to select, right-click to move, hotkeys to fire off abilities. Two abilities exist so far — a self-buff and a short teleport hop. There's a pause menu that genuinely freezes the world rather than just hiding it. And because a strategy game is far too big to test by hand, we built robot commanders instead: one that fires random orders, one that follows a written script, and one that replays a recorded match tick for tick.

Under the hood

  • Phase 2 — world on screen (5ebc196): SimServer bridges the pure C++ sim into Godot, with instanced unit rendering and an isometric camera. The sim core stays engine-agnostic; the renderer reads a RenderSnapshot and never writes back.
  • Phase 3 — the command stream (95f4651, 881d151, c1a4557, 27d1bdb), in slices. SimServer gained a GDScript-facing command API so bots drive units the way a player does — through the command stream, not by poking the sim. Then the determinism bug worth knowing about: the bot's decision cadence and its exec-tick stamp both depended on which physics frame observed a sim tick, not the tick itself. Invisible under matched frame timing, fatal under any variance. Fixed by construction with move_units_at/stop_units_at/debug_spawn_at — the command executes at a fixed offset from a caller-supplied decision tick and never reads world_->tick() at call time — plus a catch-up loop so a frame jumping tick 6 → 9 still fires the tick-8 decision. Verified: identical sim_hash between --fixed-fps 60 and --fixed-fps 5.
  • Three robot commanders. rts-monkey fuzzes MOVE/STOP against random selections; rts-script replays a deterministic JSON scenario — reusing the tests/sim/scripts/*.json shape verbatim, so a scenario committed for the C++ determinism test doubles as a playtest scenario unmodified; rts-replay re-issues a recorded run's commands at their exact sim ticks. Documented fidelity caveat: traces record {t, cmd, n, q, r}, not per-unit handles, so replay re-selects the first n living units — deterministic, but not byte-identical to the source run's hash. A grace-period bug was caught pre-ship: the first draft compared a sim-tick value against a physics-tick quantity and would have cut runs off before their own last command executed.
  • Abilities and menus (174fead): CommandType::ABILITY (role field = ability id, wire format unchanged), a static v1 table — 0 surge self-buff, 1 leap hex teleport — with per-unit cooldowns hashed and serialized at SNAPSHOT_VERSION 2, executed in fixed tick order (commands → ability timers → movement → spatial). The pause menu freezes ticks via set_running(false) with the sim hash unchanged across pause/resume. Golden hashes legitimately moved — the hash stream now covers ability state. New values recorded on GCC13 and reproduced on GCC11; MSVC agreement still needs confirming on the next Windows run to re-establish the cross-compiler determinism proof.
  • Playtest infrastructure: a trace-corpus regression suite now asserts an opt-in rts expectation block — sim_hash as an exact determinism anchor (it holds where float physics would drift) plus tick/unit/command counts. Also a performance oracle gating on regressions, a frontier exploration mode biasing bots toward never-visited cells, GlitchTip tagging so bot crashes dedupe apart from players', and a headless-Chromium web soak.
  • Build and CI fixes. The weekly soak wasn't exercising the game at all: main.tscn hosts a SimServer match with no PlayerBall, so the swarm's ball-avatar default failed no_player after 5s — CI now drives rts-monkey. The web export shipped a thread-enabled GDExtension against a thread-disabled engine (GODOT_SCONS_EXTRA was never forwarded to build_gdextension()), producing a mismatch in shared state of memory at runtime; the library map also still pointed at the pre-nothreads filename. The 2026-07-20 release train built all four platforms and then died at git tag -a with Committer identity unknown — the ephemeral job container has no git identity — now backported from the platformer's ci/release.sh along with tag auto-publish via RELEASE_PUSH_TOKEN.

Green this week: 64/64 sim tests on GCC13 and GCC11, 73/73 tooling tests.

#rts#rendering#playtest#determinism#abilities