← Dev Log
BallOrcs Battle6 min read

BallOrcs Battle: the orcs learn to eat and fight, and a robot plays 1024 matches to check it

Until this week Battle's orcs could walk. That was it. They had no way to feed themselves, no way to fight, and no reason to be anywhere in particular.

In plain words

Until this week Battle's orcs could walk. That was it. They had no way to feed themselves, no way to fight, and no reason to be anywhere in particular.

All three arrived at once. There is now food — gatherers standing on a resource node bring it in, and every new orc costs some. There is combat — orcs strike the nearest enemy in reach, and two orcs killing each other in the same instant both die, which is the honest outcome. Each of the six kinds of orc has its own move: a healer, a shield-brace, an arrow volley, a dodge, a rage, and a forager.

And because nobody can playtest a strategy game by hand often enough, a robot now plays a thousand matches against itself and reports whether the sides are actually even. The first verdict was blunt: an army of nothing but skirmishers beats the balanced army four times out of five.

Under the hood

  • Economy (#8, 8e7c108). A single per-faction food stockpile, a SPAWN command that charges each unit's base_cost (and pays partially when the stockpile runs dry rather than failing whole), and idle gatherers standing on a resource node accruing gather_per_minute * biome yield_pct. Food, the fractional accumulator and the spawn counters are all authoritative — hashed and snapshotted, with a read-side range check on the accumulator — while the static SimWorld::Config is deliberately kept out of the hash. One resource, per the owner's markup on docs/TRIBES.md.
  • Combat (#4, b1404e4). Every IDLE unit whose role has attack > 0 strikes the nearest enemy within attack_range hexes once its cooldown expires, ties resolving to the lowest slot. Damage lands in ascending-slot order but the dead are reaped only after the pass, so hits within one tick are genuinely simultaneous and mutual kills are real. step() order is now commands → timers → movement → spatial rebuild → combat → economy: the index is rebuilt after movement so combat targets this tick's positions, and rebuilt again only on ticks where something died. Nothing here draws RNG.
  • One ability per role, and one timer to carry them. forage (orcling), frenzy (skirmisher, halved cooldown), volley (slinger, hex AoE), brace (bulwark, halved incoming damage, minimum 1), evade (runner, untargetable by auto-engagement) and mend (carrier, hex heal capped at max hp). A single per-unit role_buff_ticks field carries every timed effect — one role ability per role is the invariant that keeps it one field rather than six.
  • Attack-move, because plain MOVE units refuse to fight (542b61c). Units under a MOVE order never engage, which is exactly why the balance harness could not stage sustained battles: armies had to reach an exact approach hex first. ATTACK_MOVE advances like a MOVE but holds still and lets combat fire whenever an enemy is in range. It rides in the existing state byte, so the snapshot format is unchanged and no determinism golden moved.
  • A fixed-point bug that no test could have caught (d0db64c, #9). fix_div did (__int128(a) << FIX_SHIFT), which is undefined behaviour for negative a under the project's C++17. GCC and Clang both emit the arithmetic shift today, which is precisely why every golden agreed and nothing looked wrong — but a core fixed-point primitive that is correct only by the compiler's grace is a desync waiting for a toolchain bump. Rewritten as a multiply: fully defined, exact for |a| < 2^95, same bits out. Found by running the suite under UBSan.
  • Adversarial review found a real out-of-bounds read (df5eeed). update_combat bounds-checked a role against role_attack alone, then indexed role_attack_range and role_attack_cd — same length when built by config_from(), but a hand-built Config need not be. A role now counts as a fighter only below the shortest of the three columns. The regression test passes on the previous commit (the read happened to return a harmless value — undefined behaviour, not a safe result) and fails under AddressSanitizer with a heap-buffer-overflow at sim_world.cpp:365.
  • The tribes stopped being a document (3a25560, d72ac90, #3/#5). roles.json grew from the lone orcling into the shared six-role skeleton; tribes.json arrived as a forward contract nothing loaded, then became a checked one — dense ids, unique names, an explicit playable boolean, at least one playable tribe, well-formed signature slots. The file is optional (absent = today's tribe-less world) but present-and-unreadable is an error, not "absent".
  • Rootkin first (901b987, #5). A faction→tribe binding lives in the lobby-supplied Config, not in the data, so an empty binding changes nothing. Gates are the AND of role and tribe: a tribe-gated role can only be spawned by a faction bound to it, and a signature ability is refused to anyone else. The Grovemother is slow, tanky, expensive, a non-combatant, and heals friendlies within a radius each tick; Overgrow slows enemies on a hex and heals friends on it. A load-time cross-check makes tribes.json name a real role of that tribe and a real ability, so the two files cannot drift apart. The risky part — automatic sapling spawning — was deliberately deferred until the balance harness and a feel build exist.
  • Five balance gates, and seeds as the statistics (aba33a6, 66b70a0, #6). bin/balance plays whole matches on the sim with no engine at all, both sides running the same scripted policy. Every report row carries its final sim_hash, and --replay SEED reproduces a match byte-exactly, so a flagged matchup is its own reproduction. Gate 1 (mirror fairness) wants side-0 wins in 50 ± 5; the measurements show why sample size is the whole argument — 16 seeds read 62.5%, 64 read 43.8%, 256 read 45.1%, 1024 read 48.2%, and three disjoint 1024-seed sets read 52.4 / 52.5 / 49.7. Gate 3 caps median decided-match length, and treats no decided match as a failure, never a vacuous pass. A ranked map that is not point-symmetric is a data error, checked under axial negation.
  • The first real balance finding, left informational on purpose. Gate 5 pits a single-role "spam" army against the mixed baseline, alternating sides to cancel gate-1 bias. On the ranked map today, all-skirmisher wins 79.7% — slinger 13.7, bulwark 1.6, runner 0.4, carrier 0. Gate 4 (comeback rate) reads 6.9% over 1024 seeds, inside its proposed band. Both new gates are informational: the harness still exits on gates 1 and 3 only, so the pipeline is not blocked on proposed thresholds or on a finding the owner has not ruled on yet.
  • A correction worth recording (65eaf7c, #8). Scoping the balance work against the actual sim found the mirror-fairness gate's "runnable before combat" claim was simply wrong: starting resources were parsed and never used, maps had resource slots with no gathering behind them, and pre-combat there was no outcome to measure. The economy phase was filed as the real prerequisite and the doc corrected, rather than leaving a claim the next reader would build on.
  • Ported from the platformer, same week it landed there (f787883, f635bd6, #1/#2). The Android Play-readiness batch (permanent package id com.helixplaygames.ballorcsbattle, target SDK 36, monotonic versionCode, release signing wired but dormant until the keystore variables exist here) and the Area3D engine patch with the crash-report fixes — same engine pin, so the patch applies verbatim. The port's first pipeline then found a difference between the two repos: this scaffold has no engine_rev stamps, so a fresh patch application deletes the cached host editor — and in CI that deleted the editor build:linux had just handed over, killing build:windows in a container with no compiler (c2d4aa9). The deletion now runs only outside CI.

Phase 1 — the deterministic sim core — is where the work sits: hex map, units and commands were already in, and this week added economy, combat, role abilities and the first tribe. The sim suite stands at 122 tests, all passing, ASan- and UBSan-clean on the host toolchain; the MSVC cross-compiler proof is run per-MR and is still pending on some of these branches. No claim is made about Phase 2 or 4.

#rts#simulation#balance#determinism#testing