A random number generator played my game almost as well as I did

From the build of DOLLAR FOR DOLLAR

Two players, same sim, seven seeds each. One of them is a greedy water-filling assignment that knows every span's nominal capacity, reads how inflamed each border gate is, and hands the heaviest chain the healthiest lane. The other one picks a random truck route and points it at a random bridge, over and over, forever.

Careful play survived 23.2 days. The coin flip survived 21.9.

That is a 6% skill gap, and I found it five tuning rounds into a game whose entire midgame is supposed to be about deciding which factory gets to eat. I had spent those rounds moving numbers. The numbers were not the problem.

Tonight's game is DOLLAR FOR DOLLAR: the US and Canada trading tariff blows through one cross-river Detroit/Windsor factory machine, and both cities running off a single shared power meter. Every verb is a routing decision. If routing is worth nothing, the game is a screensaver with a news ticker.

The random player was not being clever, it was pooling capacity

Here is the entire "naive" policy from my headless harness. It fires a few times a second, same budget as the smart one:

if (opts.routing === 'random') {
  const leg = LEGS[Math.floor(rnd() * LEGS.length)]!
  const to = open[Math.floor(rnd() * open.length)]!
  if (sim.view.routes[leg.id] !== to) {
    sim.inputs.setRoute(leg.id, to)
    res.reroutes++
  }
}

And here is the line the careful player is built on, a per-direction capacity estimate using only things that are actually on screen:

cap.set(id, Math.max(0.05, spec.serviceRate * (1 - wound * 0.85) * (1 - load * 0.3)))

One of these is a model of the border. The other is Math.random(). They finished 1.3 days apart.

The mechanism is not subtle once it is named. Rerouting was instant and free, so shuffling a leg between gates every couple of seconds sprayed that leg's trucks across every open crossing at once. Random assignment, sampled fast enough, is a load balancer. It has no memory and no model, and with two or three crossings open it needs neither, because the only way to play badly is to pile everything onto one gate and the shuffle mathematically refuses to do that.

Jiggling beat thinking, and it beat thinking with less information.

Math.min(1, ...) is where skill went to die

The deeper problem was one clamp. A finished part hits the grid like this:

view.grid = Math.min(1, view.grid + T.productGain * value)

The shared grid was a plain 0..1 battery with a hard ceiling at full. A good player's reward for out-delivering the drain is that the surplus goes into the clamp and disappears. A sloppy player's punishment for under-delivering is that the meter dips slightly and then refills the moment they stumble back to competence.

So the distance between playing perfectly and playing adequately is the size of the room above the cap. That room is zero. The battery is not measuring skill, it is measuring whether the machine is roughly working, and the machine is roughly working under almost any policy.

There is a second symptom of the same bug that has nothing to do with balance. A competent player pinned the meter at 1.00 from the first minute all the way to Sept 8. Sixteen in-game days of the one instrument carrying the entire argument of the game sitting perfectly still, then a cliff. I had built a thesis meter that displayed a constant.

Five rounds of knobs moved the curve and not its shape

Round 5 of my balance gauntlet read like this: passive 11.9 days, random 21.3, careful 21.9.

I had been turning productGain and drainBase up and down, and every turn moved all three rows together. Of course it did. Scaling income and drain slides the whole survival curve along the day axis without changing anything about how the players differ. I was adjusting the volume of a song with one note in it.

The fix had to be structural, so I changed four things at once and re-ran. Tonight I went back and turned them off one at a time to find out which ones actually did the work. NAIVE is the coin flip, SMART is the water-filling player, seven seeds averaged:

shipped            NAIVE  16.9   SMART  21.5   gap   4.6   21%
no drainStall      NAIVE  20.3   SMART  22.1   gap   1.9    8%
no ceiling bite    NAIVE  18.2   SMART  23.3   gap   5.0   22%
no hot plants      NAIVE  12.9   SMART  20.7   gap   7.8   38%
no reroute stall   NAIVE  19.9   SMART  21.4   gap   1.6    7%
none of the four   NAIVE  21.9   SMART  23.2   gap   1.3    6%

Two of my four fixes carry the entire skill gap. The other two do something else entirely, and one of them does the opposite of what I told myself it did.

Sloppiness has to cost something in the meter, not just in the queues

The first load-bearing fix: an idle plant still burns power.

const drain =
  (T.drainBase +
    T.drainStall * stallLevel +
    drainAdd +
    T.drainPerDay * Math.max(0, view.day)) *
  drainMul

drainStall is 0.088 per second against a drainBase of 0.0326, so a machine at full stall drains more than three times as fast as a fed one. stallLevel is the average shortfall against quota across all nine plants, with a 0.12 floor so the natural slack of a healthy cycle reads as zero:

const hunger = Math.max(0, Math.min(1, 1 - f.rateEma / nominal))
f.starved += (hunger - f.starved) * Math.min(1, dt * 1.5)

That is measuring the right thing, which took a rewrite to get to. My first starvation metric asked "is this plant busy right now", and an intermediate factory in a healthy chain is idle a good fraction of the time waiting for the next crate. It read 0.45 starved on a perfectly fed machine. Every window in both cities would have been guttering from the opening frame.

Charging for the stall against that honest metric is worth 2.7 days of skill gap on its own. Without it the coin flip gets to 20.3 and careful play to 22.1, because a stalled lane only costs what it fails to deliver. With it, sloppiness is paid for twice: once in the product that never arrives, and once in the plants standing there burning grid while they wait.

It is also, by a wide margin, the truest line in the model. A factory with no inputs does not become free.

Make the twitch cost a beat and the twitch stops winning

The second load-bearing fix is four characters of tuning and one sentence of design:

/**
 * Seconds a leg's dispatch stalls after you re-point it at a new crossing.
 * Without this, the optimal strategy was to jiggle every leg constantly —
 * spraying a leg's trucks over every gate pools their capacity, and a random
 * router beat a thoughtful one in the harness. A convoy takes a moment to
 * re-form, so a reroute is now a decision, not a twitch.
 */
rerouteStall: 2.4,

2.4 seconds of dead dispatch on the leg that just moved. That alone takes the gap from 7% to 21%. A player who reroutes because they read a queue pays it once and gets the lane they wanted. A player who reroutes because rerouting is free pays it constantly and never holds a configuration long enough to benefit from one.

The general shape: if an action has no cost, the optimal policy is to spam it, and spamming approximates the average of all configurations. Averages are hard to beat when the thing being averaged is a load balance. Charge for the action and the average stops being available.

The fix I was wrong about, and the fix that made the game easier

Look at the no ceiling bite row again. Removing it leaves the gap at 22%, one point above shipped. The permanent damage ceiling contributes nothing measurable to skill expression, and I had absolutely filed it in my head as a balance fix.

const target = Math.max(T.ceilingFloor, 1 / (1 + T.ceilingBite * (woundSum / woundN)))
if (target < ceiling) ceiling = target

It earned its place for a completely different reason: it is what makes the meter move. Damage on this map is permanent and monotone, so the lights can only come back as far as the border allows, and every decree visibly takes a slice off the top. Across a competent run the grid now walks from 0.93 down to 0.74 over the fortnight instead of sitting at 1.00, and Sept 8 lands as a cliff rather than a blip: slope of -0.01 per day going into the mirror, -0.12 coming out.

DOLLAR FOR DOLLAR at 390px on day 1: the shared grid meter reads 98 percent, a single bright filament running between the Detroit and Windsor skylines, with trucks moving on lit roads on both banks of the river

Day 1, 98%. The filament is the shared grid, lighting from the river outward toward both skylines. There is no state where one city is winning.

The same meter on day 9 at 14 percent: the light has retreated to a short red-orange core at the centre of the channel, with a dark track and red damage bleeding past both ends toward the two skylines

Day 9, 14%. Same instrument. The light has retreated to the river and the red trail is what it used to reach.

Getting the ceiling to keep falling took one more correction. My first version drove it off the wound values the HUD displays, and those clamp at 1, and every northbound gate is pinned at 1 by day 6. The ceiling froze for the whole middle of the run. Damage has to keep accumulating after a gate is already "fully" inflamed, so the ceiling reads log of surviving service instead, which has no top:

woundSum += -Math.log(Math.max(0.02, c[dir].serviceMul)) * 0.45 + c[dir].tariff * 0.5

Then there is no hot plants, which is the row I did not see coming. Turning it off widens the gap to 38%, because it drops the coin flip from 16.9 days to 12.9 while the careful player only loses 0.8. Hot plants are worth four free days to a bad player and less than one to a good one.

The rule is that a stage-0 plant runs 1.2x when its own outbound gate is under 12% load, so capacity freed by giving up on a chain turns into product somewhere else. I added it as a depth mechanic. The measurement says it is a forgiveness mechanic: a thrashing player leaves lanes momentarily clear all the time, entirely by accident, and gets paid for it.

I kept it, with my eyes open this time. It is what makes the machine degrade in the right order, losing its reserve before it loses its quota, and a game where a first-time player dies at two minutes teaches nobody anything. But it is a generosity knob wearing a design mechanic's coat, and I would have defended it as the opposite an hour before I ran the table.

The test that costs ten minutes

Shipped numbers, seven seeds, after all of it:

PASSIVE       day   9.6   flow 0.84  burned $2.8M
NAIVE-ACTIVE  day  16.9   flow 0.75  burned $6.8M
SMART         day  21.5   flow 0.93  burned $15.1M

Reroute is worth 7.3 days now, surge 2.3, the two together 11.9. Parking everything on the Ambassador dies at 7.9, on the Gordie Howe at 14.0, surge-spamming with no routing at 14.2. Nothing dominates, and the eight upgrade combinations span 1.7 days, so no fork is the obvious pick.

The thing worth stealing is not any of my four fixes. It is the second player.

Write the dumbest possible policy that still presses the buttons at a plausible human rate, run it against the best policy you can write, and look at the distance between them. That number is the game. If it is small, do not touch the tuning constants, because tuning slides both rows the same direction and will happily convince you things are improving for five rounds. Go find the structural reason skill has nowhere to land. In my case there were three, and they are the three I would check first in anything:

Is there a clamp eating surplus, so playing well produces nothing storable? Is there an action with no cost, so spamming it approximates a decent strategy for free? Does being sloppy cost anything beyond the reward it forfeits, or can the machine idle at no charge?

I would rather find out from a random number generator at 2am than from someone who bounced off the game in ninety seconds because it played itself.

← All posts