← Dev Log
BallOrcs Battle10 min read

BallOrcs Battle: the Hollow learns to walk, and fails its own fairness test

All five playable tribes now have a unit and a spell that only they get. The Cinderkin field a walking bomb and lay a line of fire nobody may cross.

In plain words

All five playable tribes now have a unit and a spell that only they get. The Cinderkin field a walking bomb and lay a line of fire nobody may cross. The Brinekin ferry a squad over water in one leap and drag enemies toward them. The Amberkin build temporary walls and freeze a squad solid. The Glintkin copy whatever they are standing next to, and swap two squads across the map. Tribes can also nudge the shared units' numbers now, so a green orc really is a little tougher than a red one.

The bigger change is the Hollow — the grey thing on the map that nobody plays. It used to appear and then stand still forever. It now walks to your food and eats it, it takes your fallen orcs, and you can walk out and take them back. Made to work properly, it immediately failed the test that asks whether it decides matches. That failure is this week's honest headline.

Under the hood

  • All five tribes are signed (#5). Following Rootkin: Cinderkin (f358a10) — the Cinderling carries detonate_radius: 1 and, instead of striking, deals its attack to every enemy in radius and dies in the same pass, a loss for its own faction with no kill credited to anyone ("the unit IS ammunition"); Firebreak lays 5 burning hexes through the target along the hex axis most nearly perpendicular to the caster→target direction, so a caster facing an attacker walls across their path. A burning hex refuses entry (a move that would carry a unit onto one halts at the edge, velocity 0, order kept), always allows leaving, and burns every faction standing on it. Then Brinekin (4e1c68e) — Tidecaller wave-leap and Undertow; Amberkin (6690495) — Wall-Warden barricades and Amberize; Glintkin (d0826bc) — Prismling mimicry and Refract. Each is data plus one mechanic, removable by editing tribes.json/roles.json, which is the design rule holding.

  • Tribe flavour, inside a band the loader enforces (0eb637d, #5). tribes.json gains an optional stat_mods per role — {hp, attack, attack_cooldown_ticks, base_cost} in percent — and the loader is strict: the role must exist and be a skeleton role (no tribe may touch another tribe's signature unit), every percentage must sit inside stat_band_pct (15), and attack or cooldown mods require a combatant. All four flow through role_*_for(role, faction), so spawn hp, SPAWN cost, hit damage, cooldown and the three heal caps read the same rows. apply_stat_pct() is integer-only, round-half-away-from-zero, never below 1, because it feeds hashed state. An unbound faction is byte-identical to before. Rootkin's committed flavour: orcling hp +10 %, skirmisher hp −5 %, bulwark hp +10 % and cooldown +5 %, picked from measured alternatives (the chosen one reads 52.1 % against the plain skeleton).

  • Two placeholders stopped being placeholders (6ba84e9, #6): movement speed now comes from roles.json rather than one shared constant, and terrain actually blocks movement. Alongside it, gate 2 — the full tribe-matchup matrix — plus signature abilities becoming faction-wide cooldowns rather than per-unit (f24459f, #5, #6).

  • The Hollow, settled on paper first (b6e5d69, #7). It is environmental PvE pressure that every player faces, not a lobby seat: tribes.json's existing playable: false on tribe 5 now has a precise meaning. What makes it the Hollow rather than generic creeps is that it consumes — ignoring it costs you the map even if you never lose a fight, which gives both players a reason to spend on something other than each other. Nothing was implemented in that commit, deliberately; every number in it is a starting value to be measured.

  • Conversion and redemption (9cf6278, snapshot v10; e605ece, the environment events and map block, snapshot v11). A Hollowling's kill converts the victim, and a runner can take it back — the ability is called redeem rather than rescue because there is no window after which a unit is lost. hollowed_ticks counts up and saturates; what grows with it is the price of the unit, never the impossibility. A hard cutoff makes rescue a reflex — you either had a runner in position or you did not — while a growing cost makes it a judgement. REDEEM_SCALE was then retuned 4 → 64 to fit the 20 min – 2 h match target (b118243).

  • Eating nodes, without making the map mutable (3b99c92, #10, snapshot v12). The mechanic was never the hard part: map_hash_ is computed once at set_map() and everything leans on it staying put, so depletion had to become authoritative world state. It is hashed and snapshotted in canonical (q, r) order, and that order is part of the format — a snapshot whose drained list arrives out of order is refused, because it would hash differently on the peer that read it: a desync wearing the costume of a successful restore. The drain pass draws no RNG. A spent hex yields nothing to anyone, the Hollow included, and keeps reporting a resource to every query except update_economy() — deliberately the same shape a flooded hex already had, because "a resource that is not paying" should have one shape in this sim and not two. A Hollowling on a spent node holds it down, or the hex would regrow a tick's worth, stop being spent, be eaten again and flicker the hash every tick; there is a test named after it. 14 goldens moved, proved harmless the established way: identical winner, outcome, tick count, kills and value on all 1024 ranked seeds with the hash column differing uniformly — the signature of a change in what is hashed rather than in behaviour. 234 tests pass, and the same 234 are clean under ASan+UBSan+LSan.

  • Then it learned to walk, and the numbers changed completely (91208b7, #14). Until this, nothing moved a Hollowling — no policy drives them, because the Hollow is not a player — so consumption fired only where an event epicentre happened to land on a node: 2 nodes eaten dry per 1024 matches, every other reading identical to the same build with consumption switched off. One rule fixed it: walk to the nearest resource hex that still pays. Never toward player units, because the pressure the design wants is economic — a Hollowling that hunts orcs is a monster, one that sits on your nodes is the Hollow. It uses ATTACK_MOVE, so a seeking Hollowling still fights what walks past it; it has no pathfinder (it can be walled off by its own earthquake's rubble and simply fail to arrive, which suits a slow menace); it uses no RNG at all, so the Hollow's stream position stays a pure function of tick count; and only idle Hollowlings re-target, so one cannot vibrate between two equidistant nodes. Same binary, same map, node_seek the only difference, 1024 seeds:

    standing still seeking
    nodes eaten dry 2 154
    total bite 5.68 nodes' worth 978.67
    units taken 1012 2552
    gate 4 comeback 9.3 % 14.6 %
    gate 6 0.8 % 4.5 % — fails the 3 % band
  • Gate 6 failing is the interesting result, not something to hide (the gate itself added in 76e8559, #7). It asks whether the Hollow decides matches, and its 3 % band was drawn from measurement — 0.9 % shipped and 1.5 % counterfactual, with the arithmetic written into tools/balance/harness.h rather than guessed. The catch, recorded in docs/HOLLOW.md: both readings were taken on a Hollow that could not move, so the band was calibrated against the wrong game. Whether the answer is tuning the Hollow or redrawing the band is an owner judgement and is written down as one. Separately, the Hollow lengthens matches by about half (median 1289 → 1924 ticks) and roughly doubles comebacks (6.3 % → 14.4 %) on the same map with no economy change. The same commit taught the robot army to prefer the opponent over the Hollow: it had been marching at Hollowlings and killing the very bodies its own runner was channelling on — 290 of 341 broken channels were "somebody killed my target".

  • The battle-priest, and the regression it created on the way in (c9806c2, #11, snapshot v13). Redeeming another player's wounded unit is the strongest kind of ability in an RTS, because every conversion is a two-unit swing, so it ships with four guard rails: the price scales with what you take (on the tribe-flavoured cost, so a tribe that discounts a role is symmetrically cheaper to steal from), only units at or below 50 % hp can be turned, a 960-tick faction-wide cooldown, and it can be undone — that last one needing no code at all, since a converted unit is simply an enemy unit again and its original owner's runner takes it back under the same rules. Tug-of-war over a wounded veteran falls out for free, and both sides pay runner-time for it. The honest part: widening redeemable() silently repealed the documented rule that two runners channelling one target resolve in slot order. Once the lower runner took the body it became an enemy unit, so the higher runner's channel stayed valid and completed in the same tick — at the cheap Hollow price, past the hp gate, and without setting the cooldown, because all three are cast-time gates and it never cast again. Three guard rails bypassed by standing still. Reproduced first, then fixed, with a test named after it, and the lesson recorded: widening a predicate repeals every invariant that depended on it being narrow. It also almost never lands — 289 casts, 105 refusals, 6 completions — and whether one completion per 170 matches is the intended rarity is an owner call.

  • A CI job that could never go green, whatever you did to it (42a8950, #15). gh_latest piped curl straight into grep -m1; grep exits on match and closes the pipe, curl dies of SIGPIPE, and under pipefail the || echo '?' fired in addition to the tag already printed — so latest became "<tag>\n?", which can never equal the pin. Bumping every version in versions.lock would have left the job red tomorrow, permanently. Being a race on response size, it hit a different subset of the six repos each run (4 of 6, then all six, then 2 of 6), which is why it read as noise rather than a bug. It also quietly defeated the exit-3/exit-4 split added in August: a failed query stopped looking like "I never looked" and started looking like "a successful query returning a strange version". Fixed by capturing the body before parsing — and the check was not weakened to make it green. allow_failure also moved per rule, since a job-level flag cannot be advisory on merge requests and binding on the Monday release train, which is the one place it was ever meant to bite.

  • Each runner now declares its own build parallelism (1385acb, #17). --jobs 2 was hardcoded with sound reasoning — about one machine, the 8 GB-capped WSL guest that also hosts GitLab, GlitchTip, Postgres and Valkey. Box 2 is a dedicated CI box and inherited the laptop's constraint, compiling on two of its four cores while carrying 89 % of this project's build seconds. BUILD_JOBS now comes from each runner's own environment block (unset means 2, so the laptop keeps its number and its reasoning). The merge request's own pipeline was the experiment — same machine, same warm scons cache, minutes apart, one variable: build:linux 982 s → 577 s (1.70×), build:windows 553 → 361 s, total 3265 → 2562 s, so 703 seconds off a full three-platform pipeline, with the control reproducing the established 980 s baseline to within two seconds. build:android barely moved (1.07×), which is worth knowing before anyone tunes further: cores are not that job's lever. The commit also corrects its own branch's earlier claim that Box 2 was compiling inside a 4 GB cap — it had no cap at all; that figure described Box 1 and was applied to Box 2 without reading that machine's config. The 10 GB cap now set there is a safety bound, not a prerequisite.

  • A GPU-rendered playtest lane, and proof it actually got a GPU (82c9eb4, 81bb9e3). Box 2 holds the only graphics card in the fleet, so the lane is tagged gpu and the job now proves the device reached the container instead of assuming it. A shellcheck follow-up (84790c8) records why the probe's single quotes are load-bearing: taking SC2016's advice would expand the argument in the wrong shell, and the probe would exec nothing while reporting success — a test of no GPU at all.

  • An Android build with no upper bound (639cf5a). The godot-android image ships NDK 28.1.13356709 while versions.lock and Godot's own detect.py require 29.0.14206865, so every android build shells out to sdkmanager for ~1 GB — via subprocess.check_call, with no timeout. That fetch normally succeeds, so this was never a build that failed; it was a build with no ceiling, free to run to CI's 4 h limit and leave an orphaned container behind. Observed once at 14407 s = 4h00m07s. A preflight now retries twice at 10 minutes each and then dies naming the cause, so a stall costs ~20 minutes and produces a diagnosis instead of four hours of silence. Explicitly a stopgap: the real fix is rebuilding the image with the pinned NDK baked in, which removes the fetch from all three projects sharing that digest. Three follow-ups (d3793c2, baaa483, cf206de) made every runtime string in build/ ASCII-only — the build sets LC_ALL=C, so an em dash in a fatal message renders as a replacement character at exactly the moment someone is reading it to find out what went wrong.

#rts#simulation#balance#determinism#ci