I let a blindfolded bot grade my timing cue

From the build of STAY DOWN

Thirty-five sells. Thirty-five perfect. Zero misses. Mean error +8.3 ms.

Those numbers came out of a Playwright driver that was not allowed to read a single variable from my game. No game state, no event bus, no windupT. It could look at exactly two rectangles on screen, measure the gap between them, and guess when they would touch. That was the whole test, and it was the only thing that convinced me the game was fair.

Tonight's game is STAY DOWN. You play the jobber in a scripted wrestling match and your only verb is the sell: tap on the exact frame contact lands, and the crowd buys it. One button, one timing window, three matches. Which means the entire game lives or dies on one question that is easy to hand-wave and miserable to actually answer: does the thing on screen telling me when agree with the code deciding whether.

For a while, mine did not. It could not have, because the two lived five hundred pixels apart.

The cue was a hairline in the corner

Here is what the game looked like before the fix, at 390px portrait, mid-windup on a jab.

STAY DOWN in portrait before the fix: the wrestlers are in the lower half of the frame and the only timing information on screen is a thin red bar inside the call sheet in the top-left corner

The man about to hit me is at the bottom. The only thing telling me when he lands is the red bar in the top-left.

The HUD is a producer's call sheet, taped up, photocopy grain, next spot scrawled in marker. I like it a lot. Underneath NEXT CALL / JAB there is a red progress bar, four pixels tall, and that bar was the entire timing cue. It fills as the windup runs and it is full on contact.

There is a line of copy at the bottom of the frame that says TAP THE INSTANT IT LANDS, which is instruction, not information. So the game asked me to watch a man wind up in the lower third of the screen and simultaneously read a 4px bar in the opposite corner. Nobody does that. I did not do that, and I wrote it. Every spot whiffed, and I spent a while blaming the windows.

The windows were not the problem. They are generous by rhythm-game standards:

// match.ts: perfect / good half-widths, in ms, per match on the card
windows: { perfect: 125, good: 250 },  // DARK MATCH
windows: { perfect: 105, good: 215 },  // TV TAPING
windows: { perfect:  92, good: 190 },  // PAY-PER-VIEW · MAIN EVENT

A 250 ms good window is enormous. Missing that means the cue is carrying no information at all, and no amount of widening fixes it. Widening a window to compensate for an unreadable cue produces a game that feels mushy and unfair, which is impressively bad value.

What the judge is actually measuring

Before rebuilding the cue I had to be honest about what the judge does. It is four lines:

if (s.phase === 'windup' && !this.tapped) {
  this.tapped = true
  const deltaMs = (this.t - this.impactAt) * 1000 - LATENCY_MS
  const w = this.match.windows
  let grade: SellGrade
  if (deltaMs < -w.good) grade = 'early'
  else if (Math.abs(deltaMs) <= w.perfect) grade = 'perfect'
  else if (Math.abs(deltaMs) <= w.good) grade = 'good'
  else grade = 'late'

this.t is seconds into the current phase. this.impactAt is when contact lands, computed once when the spot starts from the move's windup length, the match tempo and whether the spot is in a rhythm string. LATENCY_MS is 42, and that constant is the interesting one:

/** touch and frame latency between "the player decided" and tap() running.
 *  Without this the whole game reads late and honest players get punished. */
const LATENCY_MS = 42

tap() runs synchronously inside a pointerdown handler, but it reads a clock that only advances once per frame in update(dt). So at the instant a thumb lands, this.t is stale by however long ago the last frame started, averaging half a frame, and that is before any touch hardware gets involved. Measured on the shipped build at 390x844, keydown to a visibly changed canvas is 32 to 46 ms. Subtracting a flat 42 ms from the judged delta made the game feel fair without widening a single window, which is the tell that latency was the real bug rather than difficulty.

That is fine as far as it goes. It fixes the relationship between the player's thumb and the judge. It does nothing about the relationship between the picture and the judge, and the picture is what the player is actually reacting to.

The gate is derived from the judge, not drawn next to it

The replacement is an impact gate: a hard-cam telestrator plate laid on the mat directly under the wrestlers, in the same eye fixation as the man winding up. Two blades close on a fixed white centre tick and meet on the frame contact lands.

Close-up of the impact gate: two gold blades closing on a white centre tick, with a wider amber band and a narrow gold band straddling the tick, and the caption TAP THE INSTANT THEY MEET

The narrow gold band straddling the tick is the perfect window. Its width is measured, not chosen.

The blade positions are trivially linear:

const k = 1 - v
const run = `${k.toFixed(4)} * (${GATE_HALF} - ${BLADE_W})`
leftRef.current.style.transform = `translate(calc(-100% - ${run}), -50%)`
rightRef.current.style.transform = `translate(calc(${run}), -50%)`

v is state.windupT, which the engine sets to Math.min(1, this.t / this.impactAt). So the gap closes linearly in time by construction. That is not an aesthetic choice, it is the whole point: a human can extrapolate a constant-velocity approach to zero with startling precision, and cannot do it at all through an ease curve. Any easing on that transform would have made the cue prettier and useless.

The part I am happier about is the window bands. The obvious implementation is to hardcode the gold zone at some pleasant fraction of the plate. Instead it measures the live tell rate and derives the width:

if (phase === 'windup' && v > 0 && v < 1) {
  if (!anchored) {
    anchored = true
    anchorAt = now
    anchorV = v
  } else if (now - anchorAt > 70) {
    rate = (v - anchorV) / ((now - anchorAt) / 1000)
  }
}
// ...
const pf = rate > 0 ? Math.max(0.05, Math.min(0.5, (windows.perfect / 1000) * rate)) : 0.16
const gf = rate > 0 ? Math.max(0.1, Math.min(0.92, (windows.good / 1000) * rate)) : 0.34

rate is windupT per second, sampled off the actual running spot after a 70 ms anchor so frame jitter cannot wobble it. Multiply a window in seconds by that rate and you get the window expressed in the gate's own coordinate. A slow tell draws a wide gold band. A fast one draws a narrow band. A rhythm-string spot, which runs its windup at 0.78x, draws a narrower band than the same move outside the string, with nobody having to remember to update a constant.

The gate cannot misrepresent the difficulty because it does not know the difficulty. It knows the window in milliseconds and it measures how fast time is passing on screen.

STAY DOWN in portrait after the fix: the impact gate is drawn on the mat directly under the two wrestlers, blades wide open at the start of a chop windup

Same phone, same phase. The cue now sits in the same glance as the punch.

The stranger test

At this point I had a cue I believed in, which is worth roughly nothing. I wrote it, I know the windup lengths, I know when to tap without looking. My hands are contaminated. Play-testing your own timing game tells you what your muscle memory thinks, not what a stranger's eyes can extract.

So I built the stranger. It is a Playwright driver deliberately blindfolded to everything except two getBoundingClientRect() calls:

const gate = document.querySelector('[data-gate]')
const visible = !!gate && getComputedStyle(gate).opacity > 0.5
const blades = gate ? gate.querySelectorAll('.sd-blade') : []
if (!visible || blades.length !== 2) { /* reset and wait */ }
const a = blades[0].getBoundingClientRect()
const b = blades[1].getBoundingClientRect()
const gap = b.left - a.right

Pixels of gap, and a timestamp. That is all it gets. It keeps the last twelve samples, fits a least-squares line through them, and refuses to commit until the slope is clearly negative and the meeting is close enough to be worth acting on:

const slope = den ? num / den : 0
if (slope < -0.005) {
  const toMeet = gap / -slope        // ms until the blades touch
  if (toMeet < 220) {
    committed = true
    const delay = Math.max(0, toMeet + 42 + gauss() * noiseMs)
    setTimeout(space, delay)
  }
}

Two things in that delay are doing real work. The + 42 is the driver deliberately being a slow human: it taps 42 ms after the instant it predicted, which is what a thumb actually costs, and which the judge then subtracts back out. If the bias constant is wrong in either direction, that shows up immediately as a systematic error. The gauss() * noiseMs is motor noise, Box-Muller, so I can dial in how sloppy a hand I am simulating.

And it reads its own grades the way a player does, by scraping the flash card text off the DOM and regexing the delta out of it. No back channel. If the on-screen grade said something different from the internal one, I would never know, which is exactly the condition I wanted.

+8.3 ms

First full run at 45 ms of motor noise: 35 sells, 35 perfect, zero misses, mean error +8.3 ms. Cranked to 90 ms of noise it still took zero misses.

The +8.3 is the number I actually cared about. A clean sweep of perfects only proves the windows are survivable. The mean tells you whether the picture and the judge are describing the same instant, and 8 ms is around half a frame at 60 Hz. It is rAF quantisation and setTimeout granularity, not a design error. If it had come back at +60 ms I would have shipped a cue that drew the moment of contact somewhere the game did not think contact happened, and every player on earth would have felt that as cheating without ever being able to name it.

The nice side effect: because the gate is on the mat under the wrestlers, and the camera director frames the live bounding box of both bodies, the cue travels with the action. It never ends up in the corner of a shot the operator has moved away from.

For the record, the shipped build holds this at 60fps on the target: p95 18.6 ms over 2099 frames at 390x844 headed, p99 19.4, full three-match card in 214 s.

The thing worth stealing

A timing cue is a claim about the judge, and nobody checks it. The window constants get a code review; the graphic that communicates them gets an eyeball and a vibe.

Test the claim with something that has no vibes. A driver that can only see what a player can see, acting on the pixels alone, grades compared against the judge afterwards. It costs about an hour and it catches a class of bug no unit test reaches, because the bug is not in either half. It is in the gap between them.

Smaller and cheaper: if a cue is meant to be extrapolated, keep it linear in time, and make its geometry a function of the tolerance rather than a designer's guess. Then it cannot drift away from the rules, because it is the rules, rendered.

← All posts