tune/ — finding the expert doctrine by self-play
The bots do not learn from watching anybody. There is no game data to learn from: the room records one row per finished match (who won, how long, how many bots) and nothing about how it was played, so there is no corpus of human play to imitate and never has been. What there is is a game that runs headless at about two hundred times real speed, which means the bots can be improved the other way — by playing millions of seconds of it against themselves and keeping what wins.
Nothing in here runs in production. The output is sixteen numbers that get pasted into DOCTRINE_EXPERT in src/game.js; the Worker itself gains no model, no inference, and no new dependency.
What is being searched
DOCTRINE_BASE in src/game.js is every number a bot consults while deciding what to do — when to break off, when to come back, how close to stand to ground it is taking, how much a team-mate's objective pulls it, how often it reaches for a power. The search varies those and only those. It cannot touch health, reach or damage: an expert tier has to out-think you, not out-gun you.
Running it
node tune/tune.mjs --gens 8 --pop 20 --seeds 3 --workers 10 # ~15 minutes
node tune/validate.mjs --seeds 20 --workers 10 # ~10 minutes
tune.mjs writes tune/expert.json. The score it prints is not a result — it is measured on the same few seeds the population was selected against, which is the standard way to be fooled. validate.mjs is the verdict: it replays the candidate on seeds it has never been played on and prints a Wilson interval. Paste the doctrine in only on a PASS.
Why the numbers can be trusted
- Seeded. The sim draws on
Math.randomfor objective picks, kamikaze runs and spawn jitter, so two runs of one doctrine differ by more than the doctrine does.arena.mjsswaps in a mulberry32 stream, so a match is a pure function of its seed and the two doctrines. - Common random numbers. Every candidate in a generation is played on the same seeds and the same line-ups. A candidate that scores better did something better; it did not draw easier matches.
- Both ends of the map. Every pairing is played twice with the sides swapped. This is exact rather than approximate: two identical doctrines on one seed produce the identical match whichever end they start from, so the swap cancels the side bias completely and
normal vs normalreads 50.0% with no spread at all.validate.mjsprints that row first — if it is not 50.0%, something is broken and the other rows mean nothing. - Mirrored line-ups. Both sides fly the same hulls, so a result is about the doctrine and not about which side drew the better tank.
- Held-out seeds. The search draws seeds under 10000; validation starts at 500000.
What the first run found
Eight generations, population 20, three seeds a generation — about nine minutes on ten cores. The doctrine it converged on is in expert.json and pasted into DOCTRINE_EXPERT. Validated on seeds it had never played:
| 1v1 (96 games) | 2v2 (240 games) | 4v4 (144 games) | |
|---|---|---|---|
| easy vs normal | 1.0% | 0.0% | — |
| normal vs normal | 50.0% (exact) | 50.0% (exact) | 50.0% (exact) |
| expert vs normal | 77.1% [67.7-84.4] | 89.2% [84.6-92.5] | 95.8% |
It was searched at 2v2 only, so the other two sizes are the check that it is a doctrine rather than a fit to one room size. It gets stronger as the side gets bigger, which is what groupPull at twice the shipped value should do: the more hulls there are to arrive together, the more a side that arrives together wins.
It is one idea rather than sixteen numbers: pick a point, go there together, and never disengage. Breaking off at 2.7% instead of 15%, holding at three-quarters of weapon reach instead of the full length, twice the pull toward a team-mate's objective and twice as long committed to it.
The interesting part is what that says about the shipped doctrine. RETREAT_HP and BASE_MEND were reasoned out together — driving home to mend beats giving the hull up above 60% health, so break off at 15% and rally at 60%. The arithmetic is right and the search still threw it away, which means the thing it leaves out dominates: a hull driving home is not on an objective, and this game is decided on ground held. Worth knowing before hand-tuning anything else.
The honest caveat
The fitness is beats the normal bots. That is not the same thing as is hard for a person, and no amount of self-play closes that gap on its own — a human plays the map differently from anything in the population. Treat the tuned tier as a strong opponent that has been measured, then play it a dozen times and say whether it earns the name. That is the one place a human's play genuinely matters here, and a dozen games is plenty for it.
Files
arena.mjs | one headless match, or a swapped-sides duel; the seeded RNG |
pool.mjs | a worker per core, chewing through a queue of duels |
worker.mjs | the worker body; all the thinking is in tune.mjs |
tune.mjs | the cross-entropy search; writes expert.json |
validate.mjs | held-out verdict, with the null and the easy tier alongside |
Garrisoning (2026-08-12)
the designer's own play was the hypothesis: hold territory and heal rather than retreat. The mechanism is recoveryPost() in src/game.js — an argmin over ground we hold, costing the walk, the wait at that post's mending rate, and the ground given up, gated on the post being momentarily safe.
Measuring it took four runs and three wrong answers, which is the point of having ab.mjs:
| result | what it actually compared | |
|---|---|---|
defaults, easeHp 0.45 | 11.1% | early disengage added on top of retreating |
easeHp 0.30 / 0.20 | 27.1% / 42.7% | same, trigger walked down |
| tuned by search | 47.1% | the search's own best, on held-out seeds |
| same trigger, destination varied | 61.1% | the actual question |
| shipped config, 240 games | 59.2% [52.9-65.2] | garrison on vs off |
The first three read as "the idea does not work" and all three were measuring the wrong thing: they varied when a hull disengages at the same time as where it recovers. Hold the trigger at the 15% it always was, vary only the destination, and holding ground beats driving home by a clear margin.
Two lessons worth keeping. An A/B whose two arms differ in more than one thing answers a question nobody asked. And a search over a mechanism's own knobs (47.1%) can miss what a single controlled comparison finds (61.1%), because the search was handed a confound too.
Three mechanisms from the human profile, 2026-08-13 — and one of them paid
The replay recorder made a comparison possible for the first time: the designer's seat against the bots in the same matches (13 tapes, games he actually played). Damage per minute 324 vs 287 and kills per minute 0.77 vs 0.73 — the same output — against deaths per minute 0.68 vs 1.25, damage per death 545 vs 270, time inside a tower ring 32% vs 25%. He also casts fewer abilities than they do (4.6/min vs 5.0). So the edge is death economy and objective time, and "use powers more often" was never it. scratchpad recipe: replayMatch with an onTick, count cooldown 0→+ transitions for casts, CAP_RADIUS for objective time.
Three mechanisms were built to encode that, all as knobs defaulting to off, so the shipped bots are unchanged — proved by replaying three production tapes to an exact match after the edits. tune/frag-burst-ab.log has the numbers.
| knob | what it does | held-out result | verdict |
|---|---|---|---|
fragHold | standoff scaled by the hull's own toughness | 54.5% [50.4-58.5] / 576 | marginal, and NOT per-chassis: stinger slate 51.2%, splodge 50.4% |
burstFrac / burstFloor | disengage on damage rate, not the health floor | 7.6% → 16.0% → 35.4% → 53.8% as it is detuned | loses |
chaseCast | a gap-closer waits for a gap its buff can close | 51.7% [45.4-57.9] | no effect on the result |
burstFrac deserves its own line. It is the mechanism that most looks like the human number it was built from, and it loses in exactly the shape the garrison work lost in: disengaging early costs ground, and ground decides the match. That is now twice. Anything of the form "leave the fight sooner" should be assumed dead until a controlled comparison says otherwise.
chaseCast is the third mechanism to fix visibly stupid play without moving the result (after meleeChase). The waste it removes is real and measurable — Cutter cast Overdrive at a median gap of 242px and a p90 of 503 against a buff that covers ~315px in its lifetime; the gate takes casts from 306 to 256 and p90 to 324 — and the win rate does not care.
What did pay was changing the fitness, not adding a knob. The default search plays candidates against normal, and the standing caveat is that beating normal is not the same as being hard for a person. --baseline expert (now on both tune.mjs and ab.mjs) makes the shipped tier the opponent:
node tune/tune.mjs --baseline expert --size 2 --gens 10 --out expert-vs-expert.json
Ten generations found a set beating the shipped expert 65.3% [59.6-70.5] at 2v2 and 63.5% [57.8-68.9] at 4v4 on held-out seeds (in-search it read 83.3%, which is the usual flattery). It went to the floor on combatHold, objNearBias and easeHp — the same "get on the point and stay there" doctrine, harder — and it chose burstFrac 0 and fragHold 0.027, throwing away two of the three mechanisms above by itself. It kept chaseCast at 1.25.
Two tooling bugs found on the way, both silent
- **
ab.mjs --with <tank>never restricted the slate.** It computedSLATEand then loopedLINEUPS. Every per-chassis number quoted from it before this date — including the CuttermeleeChase49.3% — is a full-slate number. - **
tune/ability-probe.mjs* (new: what every power is cast at, and how much of it lands on nothing) is only as good as its yardstick. Its first version measured "nothing in reach" against each hull's *own range, which scores every Cutter gap-closer as waste by construction (26px reach), and its heal check forgot that an allies-aura heals the caster too. Two of its first three findings were artefacts of the metric, not the bots.