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, aSPAWNcommand that charges each unit'sbase_cost(and pays partially when the stockpile runs dry rather than failing whole), and idle gatherers standing on a resource node accruinggather_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 staticSimWorld::Configis deliberately kept out of the hash. One resource, per the owner's markup ondocs/TRIBES.md. - Combat (#4,
b1404e4). Every IDLE unit whose role hasattack > 0strikes the nearest enemy withinattack_rangehexes 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) andmend(carrier, hex heal capped at max hp). A single per-unitrole_buff_ticksfield 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_MOVEadvances 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_divdid(__int128(a) << FIX_SHIFT), which is undefined behaviour for negativeaunder 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_combatbounds-checked a role againstrole_attackalone, then indexedrole_attack_rangeandrole_attack_cd— same length when built byconfig_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 atsim_world.cpp:365. - The tribes stopped being a document (
3a25560,d72ac90, #3/#5).roles.jsongrew from the lone orcling into the shared six-role skeleton;tribes.jsonarrived as a forward contract nothing loaded, then became a checked one — dense ids, unique names, an explicitplayableboolean, 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 makestribes.jsonname 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/balanceplays whole matches on the sim with no engine at all, both sides running the same scripted policy. Every report row carries its finalsim_hash, and--replay SEEDreproduces 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 idcom.helixplaygames.ballorcsbattle, target SDK 36, monotonic versionCode, release signing wired but dormant until the keystore variables exist here) and theArea3Dengine 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 noengine_revstamps, so a fresh patch application deletes the cached host editor — and in CI that deleted the editorbuild:linuxhad just handed over, killingbuild:windowsin 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.