← Dev Log
BallOrcs Battle14 min read

BallOrcs Battle: you can buy an army now, and the unfairness had a compass direction

Until this week you could move your orcs and that was very nearly all.

In plain words

Until this week you could move your orcs and that was very nearly all. Now you can order them to advance and fight whatever they meet, use any of their twelve abilities instead of the two that happened to be wired up, and buy new units from a panel that shows what you can afford and says plainly why it will not sell you something. The robot that plays thousands of practice matches to check our balance can now be your opponent too — the same brain, not a weaker copy. And a fairness bug was caught: when two piles of food sat exactly the same distance away, the grey menace always walked toward the same corner of the map, which on a mirrored map is always the same player's corner.

Under the hood

The game layer catches up with the simulation

  • Attack-move reaches the player (b8a41d6, #36). The sim has had CommandType::ATTACK_MOVE since the balance harness needed it to stage fights; nothing above the sim could issue it, so the only order a player could give was a plain move. Exposure only, no sim change, so no goldens move: SimServer::attack_move_units{,_at} mirror the MOVE pair exactly, including the deterministic exec-tick contract (decision_tick + command_delay + MAX_CATCHUP_STEPS) that keeps headless and windowed hashes identical. The load-bearing part is the replay policy branch: that chain ends in an else treating any unknown record as a MOVE, so without it a recorded attack-move replays as a plain move and diverges the hash silently — the exact failure #3 found for abilities and #6 for despawns. Both call sites are guarded on has_method(), so a build predating the API records the intent with 0 commands rather than quietly issuing the wrong order.

  • Ten abilities existed and could not be cast (56566a1, #37). The HUD and the controller both knew ability 0 was surge and ability 1 was leap. ABILITY_SURGE/ABILITY_LEAP are gone: ability now casts the lowest-id self ability the selection can use and ability_hex the lowest-id hex one, both read from abilities_for() and ability_target_kind() — union, not intersection, so the order still goes to the whole selection and the sim rejects per unit exactly as before. For today's table that resolves to surge and leap, so behaviour is unchanged. The bar derives each shown key the same way instead of from a positional array, so it cannot claim a binding that does not exist — unreachable abilities show -. And a unit that simply does not have an ability used to render 0/N (cd 0), identical to one on cooldown; it now reads n/a, with the ready fraction taken over the units that actually have it.

  • A shop, and a panel that does not guess why it refused (10d2cfa, 95236c0, #38). The economy existed in the sim — food(), spawn_at(), per-unit accept/reject — but nothing above it could price a unit, name a role or find a faction's spawn hex. The new reads are deliberately thin wrappers over the sim's own arithmetic: role_cost() calls the same role_base_cost_for() that apply_spawn() prices with, tribe adjustment included, because recomputing base_cost * pct in the panel would be a second implementation of a number the player is about to spend; and role_available() mirrors the signature gate, so the panel can say why the Grovemother is refused rather than offering a button the sim rejects. spawn() is the real-player "now" path, submitting at tick + command_delay — not spawn_at's decision-tick formula, because a human click has no decision tick to derive from. The refusal reporting is the interesting part: apply_spawn() refuses on five conditions and the panel splits them honestly —

    knowable in the panel the sim's own
    unpriceable role, signature gate, affordability — reported before issuing, nothing sent hex out of bounds or not land-passable, unit store full — reported after, by watching spawn_rejected_count() move

    Re-implementing the hex and capacity checks in GDScript would be a second copy of the sim's rules, and a panel that guesses wrong about why a purchase failed is worse than one that waits a beat and says. Tab cycles the highlighted role and B buys one — two new InputMap actions rather than clickable buttons, because a bot can press an action, and a control the bot cannot press is a control CI cannot protect. Buttons are a follow-up, not a substitute.

  • The balance robot becomes an opponent (f454b23, b3bf838, ea12f49, #39). GreedyArmy is lifted out of the balance harness into src/ai with byte-identical balance output, its match setup follows as ai::init_greedy_army, and then SimServer learns to run it for a faction — so a side can be played by the same code the gates measure, rather than by a weaker thing written twice. Off unless asked: add_ai_faction() is the only way to bind one, the list starts empty, start() clears it, and the tick hook short-circuits on an empty list, so every existing trace, golden, soak and determinism run steps through exactly the code it did before. Decisions are taken on a tick, inside the catch-up loop and before world_->step(), at decision_every = 16 — the harness's cadence, because the gates measure the policy at that cadence and a different cadence is a different opponent. Commands go through the normal CommandBuffer, so an AI side is indistinguishable from a remote player at the sim boundary, and add_ai_faction() refuses rather than guesses on every malformed case.

  • The rule for when a match is over moves next to the thing it judges (4dc75cf, #40). run_match() privately decided all of it — a side is out when nothing of it is alive and it cannot afford the cheapest thing it could buy; both out is a draw; the opening ticks are suppressed; at the tick limit the higher army value wins. Every one of those is a rule the game layer needs, and the game layer does not end at all: it just keeps running after one side is gone. Now src/ai/match_end.{h,cpp}, with two things worth knowing: side_is_out() is not "has no units" — a side wiped but still holding food is rebuilding, and those matches play on for another thousand ticks — and elimination_outcome() takes steps_taken, the tick after the step and not the harness's loop index, which is one less; the header says so, because that off-by-one is the only way to get this wrong while still looking right.

The fairness thread

  • A tie-break is a direction, and a direction is a bias (d36e512, #30). A Hollowling walks to the nearest paying node and broke ties by lowest (q, r) — not a neutral rule but a heading. hollow_mirror_25 and ranked_mirror_25 are the same geometry and differ only in the hollow block, and only the former sat off 50 %. Side 0 win rate, 8192 seeds each:

    tie-break side 0
    lowest (q, r) — main 47.56 %
    highest (q, r) — diagnostic, two characters changed 53.68 %
    per-(unit, node) mix — this fix 50.54 %

    Ties now go to a HashStream mix of (unit slot, node index): no RNG is drawn, so the Hollow's stream position stays a pure function of tick count, and the choice is stable per unit, so an idle Hollowling still cannot vibrate between two equidistant nodes. ranked_mirror_25 is bit-identical across 8192 seeds, and all 234 existing sim tests are unchanged.

  • The goldens could not see the Hollow, and neither could the corpus (ebdedb7, 4a1ad38, #33). Two separate blind spots with the same shape. run_script() called the one-arg set_map, which drops the map's hollow block, so every scripted determinism scenario ran with the Hollow switched off whatever map it named — and the trace corpus's case_run_args() never passed --map at all, so every case ran the match scene's default. The #30 targeting change is a real behaviour change on any map with a hollow block, and every golden in the suite was entitled to stay green. They did. Both fixes are measured no-ops for what existed: all eight goldens unchanged, and the five corpus cases produce byte-identical arguments (5 same, 0 changed, diffed directly). Then a Hollow case in each — asserting facts before hashes (5 events fired, 9 of the map's 10 nodes carrying a drain entry, units taken by conversion), because a run where the Hollow never fires reproduces its hash perfectly well and pins nothing.

  • An anchor has to be read, not invented (b82ac69). The new corpus case shipped with a deliberately invalid sim_hash so it would fail on its first pipeline and print the observed value, which was then committed: -9009907501739500685, from pipeline 864 job 8136. Not chosen, not derived — reported by the job. The gap was also demonstrated rather than described: flipping the #30 tie-break back fails the new golden and only the new golden (237 tests, 2 failures), restored to 237 tests, 0 failures.

  • Statistical fairness turned out to be weak evidence (ad9b23a, 80439a2, #34). Gate 1 asserts fairness statistically, and #31 showed that can hide things: ranked_mirror_25 reads 50.06 % because two ~0.4 pp biases cancel on that layout. So the property is now asserted directly — two worlds, same seed and stream, B being A reflected through the map centre; positions mirror to 1 ULP in Q32.32 and no unit changes hex, over 1500 ticks on three seeds, with 24 units fighting from 1216 hp down to 500 and a non-vacuity check refusing a face-off where nothing happens. Three things it deliberately does not assert, each measured rather than assumed: spawn placement (spawn_offset() offsets in absolute world axes), anything while a unit is crossing a hex boundary (six ULP is enough to round it the other way), and alive/hp. That last one came with a correction: the comment blamed the spatial index for bucketing in cartesian space, and that was simply wrong — SpatialHash buckets by hex in offset coords. Measured on seed 7 instead: 1020 hex flips during the settle ticks, attack cooldowns parting company at tick 108, and the first visible divergence a slinger with attack_cd 17 in one world and 12 in the other at the same correctly mirrored hex. They were shooting on different clocks before the window opened. The wrong guess is kept as a parenthetical rather than deleted, because it read like a finding and shipped as one.

  • Gate 6's 4.5 % was a sampling artefact (694872b, 783b617). The gate runs on 1024 seeds where its own standard error is 0.65 points. At 8192 seeds it reads 3.6 % (291 of ~8,100 eliminations), and 3.6 % again at 32768 seeds in a disjoint region. Over the 3.0 % band by 0.6 points, not 1.5. CI now runs the balance gates on the Hollow map too (81745c8) — gate 6 had never run in CI before.

  • And the comfortable explanation for it is wrong (e14f401). Both HOLLOW.md §9 and #14 hoped the Hollow was merely administering last rites to an already-beaten player. Tested on 8192 seeds, it fails on four independent measures:

    Hollow (n=291) other (n=7828) z
    loser's min value share 108.4 pm 94.0 pm +4.3
    winner's min value share 403.7 pm 451.3 pm −9.7
    winner's final army value 227.6 282.0 −9.8
    match length 2159 ticks 1655 ticks +8.8

    The loser was less far behind, the winner had been further behind and finished weaker, and the matches ran 30 % longer — a close attritional game, not a rout being tidied up. The Hollow is deciding contested matches, the reading the section hoped to rule out. What drives the rate is match length, monotone across the range gate 3 allows: 0.62 % under 800 ticks rising to 12.64 % past 3400, a twentyfold swing. So the threshold is a property of today's match-length distribution, and #12 records that the shipping target is 6× to 36× longer than today's p95. Printed as readings; no gate or threshold moved.

  • Same instrument, opposite call (731d854, #22). The issue asked for gate 5's seed count to be raised the way the main sweep was. Measured instead: skirmisher reads 71.9 % at 256 seeds and 70.2 % at 4096, and eight disjoint 256-seed blocks give mean 68.8 with sample sd 2.2 against a binomial standard error of 2.9 — no extra-binomial variance to chase. Sixteen times the work moves the number 1.7 points, to buy precision that cannot change a verdict missed by 14 points, at ~2 minutes on every pipeline. The knob lands so the table is reproducible; the default stays 256.

  • The Hollow's docs now describe the Hollow that exists (40b740c, #23). TRIBES.md still gave it a signature unit "Husk" and ability "Drain", formatted exactly like the five playable tribes — neither appears in HOLLOW.md, roles.json or abilities.h, yet old handoff notes listed them as remaining scope, which is how a superseded sketch nearly got built. The cells now say Hollowling and node drain, and §9 records Husk and Drain as superseded, not pending, with the question that must be answered before reviving one: a Husk is by construction unredeemable, and redemption is the part of the Hollow players aim at.

Toolchain and CI

  • The MSVC proof the goldens were waiting on had already happened (9c3de13). test_determinism.cpp carried thirteen notes saying the cross-compiler confirmation was outstanding. All false: sim-tests-msvc ran on main at 51b0603c (job 5924, pipeline 704) under cl /std:c++17 /O2 /W4 /WX /EHsc and reported 234 tests, 0 failures with all eight golden tests green.
  • The engine moved; the sim goldens did not (2f55fa3, 4edd348). Godot 4.7 → 4.7.2-stable, lock pins and submodule gitlink — the first attempt moved only the lock and build/common.sh refused in 6 s rather than 20 minutes in. godot-cpp went 10.0-master-20260629 → 10.0.0-stable now upstream has promoted it. Verified twice: pipeline 724 cold (linux 2581 s, windows 1421 s, android 2242 s) and 725 warm on the same commit (800 / 549 / 1324).
  • A vendoring step that copied the bytes and dropped the modes (9e03661, #32). python3 -m zipfile -e ignores external_attr by design, so upstream's three Linux crashpad_handler binaries, shipped at 0755, were committed at 0644 — and nothing noticed, because the tree digest pins whatever the vendor step produced. Extraction now restores the declared mode and refuses when the archive's executable count does not match the count restored. Alongside it, sentry-godot vendored to 2.1.1 and then 2.2.0, both size- and sha256-verified before unpacking, bringing two fixes to where we ship: Android GDScript frames had no line numbers on Godot 4.6+, and capture_event() returned a valid-looking id for events that were discarded.
  • Placement stopped being chance (bfb586e, c94ddea, #25). Both Linux runners took untagged jobs, so which box compiled was luck. One build:linux took 7670 s on box 1 against a 735 s median on box 2, holding the project-scoped engine-build resource group — and therefore every other compile — for the whole of it. The three heavy compiles are now tagged; the light jobs are deliberately left untagged. Honest correction the next day: a second box-1 sample landed at 1115 s, so the typical penalty is about 1.5×, not the 10.4× the first comment quoted off n=1. The 7670 s is a tail, and the reason the pin stands never depended on the multiplier.
  • A GPU lane that fails rather than lies (21f118d). playtest:gpu runs the weekly soak a second time on box 2's GTX 1070 instead of the container's software renderer. A separate lane, not a flag: GPU and llvmpipe screenshots are not comparable, and sharing a baseline would flag every shot as changed. The probe fails the job if it did not get a GPU, because a silent fallback would make the lane a lie.
  • A release train that refuses in seconds (b15edc1, 380c151, 26a558b, #18). ci/release.sh builds its push URL from a variable that does not exist at any scope on this server, and under set -eu the real behaviour on a re-enabled weekly schedule is an unbound-variable abort in the last stage, after ~25 minutes of build matrix, weekly and unwatched. A preflight job now runs the same check five stages earlier. Two more alongside: last_tag read unguarded outside the branch that sets it, so a repo with zero tags died opaquely at the same point; and freshness now separates exit 3 (a pin that selects a toolchain — the build would use the wrong thing, fatal) from exit 5 (records only — yellow, reported line by line, does not block). A release train that cannot ship until every pin is current gets switched off, which is precisely how #18 started.
  • Asking each runner whether it can actually fetch what the lock pins (a38b20e, bbf55b6, and the four follow-ups that made it honest). The toolchain images are repinned to the rebuild that bakes the D3D12 deps, and a new check asks each runner directly — after which it had to learn to tell "absent" from "not allowed to look" (9cc22a3), to say whether the login worked at all (936b2ad), and that docker login cannot be used against an HTTP registry (119d9a7). A check that reports the same red for four different causes is the failure mode this whole week kept running into.
  • Also: artifacts expire in 5 days rather than 2 weeks (acec69b, ~11 GB/week measured, steady state 22 GB → 7.9 GB, chosen so a Friday build is still retryable on Wednesday); Android is blocking and default: retries covers infrastructure only (575064c); D3D12 and the NDK are baked into the image rather than fetched every build (c2a1e2f, ef73b70, 716331d — upstream's installer is not standalone, and a half-bake is worse than none); box 2's cache was recreated with the missing host entry (01a2eba, #152); and diagnose-registry now reports the one cause it found instead of four at once (61f12a4).
#rts#ui#ai#simulation#balance#determinism#ci