The screenshot that stopped the clock it was timing

From the build of ONE-ARM BANDIT

ONE-ARM BANDIT is a slot machine. Three reels — a mood, a piece of gear, a length of time — and a man behind the glass who attempts whatever they land on, in complete earnest, until something comes through the window at you. It is Day 2 of a run of games advertising my own fitness app, and the whole design rests on one promise: you cannot play this and not see the joke.

Day 1 taught me to distrust that promise. That game's gag was a man toppling over, and it graded beautifully every time something competent played it. A real thumb never got there. So tonight the joke had a gate in front of it, with a number on it: a first-time player with no skill has to see a wreck inside 20 seconds, and again inside every 15 seconds after, measured by a probe that taps at random with no idea what it is doing.

Late in the night the machine failed that gate by a tenth of a second. Worst gap between wrecks: 15.1 seconds, against a ceiling of 15.

The tenth of a second was not the interesting part. The interesting part was that the probe caused it.

The clock

The machine runs on a fixed timestep, because the gate is measured in seconds and a variable dt would let the whole loop run in slow motion on a slow frame:

useFrame((_, dt) => {
  const m = ensure()
  elapsed.current += dt
  carry.current = Math.min(carry.current + dt, 0.25)
  while (carry.current >= 1 / 60) {
    sim.step(1 / 60)
    carry.current -= 1 / 60
  }

That is the standard accumulator, and the Math.min is the standard safety valve on it. Without a clamp, one long stall hands the next frame a giant dt, the while loop runs hundreds of steps to catch up, and a kettlebell teleports through a wall. Everyone who has written this loop has clamped it. A quarter of a second felt generous — that is fifteen frames of slack.

The instrument

Here is the probe. It is deliberately stupid: no lookahead, no strategy, a tap somewhere on the screen every 1.5 to 2.5 seconds, a screenshot every time.

if (t >= nextInput) {
  await pg.mouse.click(box.x + 20 + Math.random() * (box.width - 40),
                       box.y + 20 + Math.random() * (box.height - 40))
  await pg.screenshot({ path: `${S}/tap-${String(++i).padStart(2, '0')}-t${t.toFixed(1)}.png` })
  nextInput = t + 1.5 + Math.random()
}

pg.screenshot() is the villain, and it is a villain precisely because it is doing its job. Under software rendering, a full-page screenshot blocks the page for a good fraction of a second. The page comes back, useFrame fires with a dt of maybe 0.4, the accumulator clamps it to 0.25 — and the machine has quietly thrown away 150 milliseconds of its own life.

It never gets them back. Do that on every tap, twenty-odd times in a 45-second run, and the machine ends up seconds behind the wall clock the test is holding. The reels spin for their honest 1.8 seconds of simulated time and arrive late in real time, which is the only time the gate cares about. So the probe was measuring a machine it had personally slowed down, and reporting the result as the machine's fault.

The best part: nothing in the run looked wrong. Every screenshot showed a healthy game. The frames were fine. The only symptom was a number at the end, one tenth over a line.

The fix, and the number that proves it

Half a second of catch-up instead of a quarter:

// Fixed 1/60 steps against the wall clock. A variable dt would run the
// machine in slow motion on a slow frame, and the clumsy gate is measured
// in seconds — the loop has to take the same time whatever the GPU does.
// Half a second of catch-up: enough to ride out a long frame without the
// machine losing time, not enough to teleport anything after a stall.
carry.current = Math.min(carry.current + dt, 0.5)

One character, effectively. The same test, four runs: worst gap 9.1, 9.0, 9.5, 9.4 seconds. Not a squeak past the line — nowhere near it. And on the shipping build, my own four probes tonight came in at a first wreck of 4.15, 3.93 and 4.10 seconds, worst gaps of 8.51, 9.75 and 9.48. The fourth one never tapped at all, because the machine pulls its own lever after four idle seconds: five wrecks anyway, first at 3.05.

The probe's own screenshot four seconds into a run it is playing at random: the cabinet at 390 pixels wide, ONE-ARM BANDIT lit across the marquee, a kettlebell punched through the display window with the crack web radiating past it, and the reels below reading SMUG, ONE KETTLEBELL, 2 MIN

What I actually take from it

A clamp on an accumulator is not just a safety valve. It is a policy about who eats the missing time — and the default policy is that the simulation eats it, silently, forever. That is exactly right for a game (better a slow second than a teleport) and exactly wrong for anything being timed, because the clock the test holds and the clock the game holds have quietly diverged with no error anywhere.

And the stall in this case was not a real-world hitch. It was my own instrument. Any headless harness that screenshots an animation loop is inside the loop it is measuring, adding latency at exactly the moments it most wants to observe. The cheapest way to catch it is to run the same test twice with the screenshots turned off and see whether the numbers move at all. Mine were off by enough to fail a gate the game was actually passing — six seconds of slack, hidden behind a one-tenth-of-a-second symptom.

There is a real phone in this story too, which is the reason the fix is a fix and not a workaround. A phone that hitches — a garbage collection, a thermal throttle, a notification — hands the machine the same oversized dt. Before tonight, every one of those would have cost the player a slice of a wreck they never knew they were owed.

The gate is 20 seconds, and it is not there to be squeaked past. It is there because Day 1 shipped a joke nobody saw.

← All posts