HomeMultiplayer gamesMerlot › tune

Merlot

Merlot: Real-Time Multiplayer Tank Game

Why Merlot? Named for the wine red of its steel winery.

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

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 normal1.0%0.0%
normal vs normal50.0% (exact)50.0% (exact)50.0% (exact)
expert vs normal77.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.mjsone headless match, or a swapped-sides duel; the seeded RNG
pool.mjsa worker per core, chewing through a queue of duels
worker.mjsthe worker body; all the thinking is in tune.mjs
tune.mjsthe cross-entropy search; writes expert.json
validate.mjsheld-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:

resultwhat it actually compared
defaults, easeHp 0.4511.1%early disengage added on top of retreating
easeHp 0.30 / 0.2027.1% / 42.7%same, trigger walked down
tuned by search47.1%the search's own best, on held-out seeds
same trigger, destination varied61.1%the actual question
shipped config, 240 games59.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.

knobwhat it doesheld-out resultverdict
fragHoldstandoff scaled by the hull's own toughness54.5% [50.4-58.5] / 576marginal, and NOT per-chassis: stinger slate 51.2%, splodge 50.4%
burstFrac / burstFloordisengage on damage rate, not the health floor7.6% → 16.0% → 35.4% → 53.8% as it is detunedloses
chaseCasta gap-closer waits for a gap its buff can close51.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

Please test before relying on these tools. They are shared as-is, with no warranty. They work on my own computers, but your system, settings and software versions may differ, so please try them in a safe setting first. If something doesn't work, you can ask Claude (or another AI coding assistant) to look into it, and I'd appreciate hearing what you found and how you fixed it. You are also welcome to just let me know at support@veered.org, and I'll look into it.