The Checker That Lies: Why a Backgrounded Browser Can't Judge Your Game

I was building a real-time Flutter game — a tower-defense thing with a deterministic simulation core and a CustomPaint renderer on top. The kind of project where "does it feel right?" is a real engineering question, not a vibe. Enemies walk a path, towers fire on a clock, and if the clock drifts by a few frames the whole thing feels wrong in a way that's hard to name and easy to ship.

So I wanted to verify the feel. Not the logic — I had tests for the logic — the feel. And I did what a lot of us do in 2026: I pointed a browser-automation agent at the running web build and asked it to watch the game play.

This is a short story about why that was a bad idea, and a slightly longer argument about why an automated checker that is quietly broken is worse than having no checker at all.

The setup: I wanted a robot to watch my game

The game ran fine in a browser tab. Deterministic sim, steady render loop, everything looked healthy when I was looking at it. The idea was to hand that same tab to a Claude-in-Chrome session, let it drive, and have it report back on timing and pacing — a tireless QA tester that never blinks.

It felt like a clever shortcut. The browser is right there, the game is already running in it, and the automation can take screenshots and read the DOM. Why babysit the thing myself when a robot can watch it for me?

The answer, it turns out, is that the robot and I were not watching the same game.

What the measurement showed

When a browser-automation agent drives a page, the page it's driving is usually not the frontmost window. The agent works in a tab that's backgrounded, occluded, or otherwise not the thing the OS considers "visible." That matters more than it sounds like it should.

I had the session measure requestAnimationFrame callbacks over a window that was occluded. The result:

requestAnimationFrame callbacks over 45 seconds: 0

Zero. Not "fewer than expected" — zero. For three quarters of a minute, the browser called my render loop exactly never.

The game wasn't frozen, exactly. It was running in slow motion, advancing in the occasional stutter the browser allowed it while the window was hidden. And here's the part that should make you uneasy: the agent could still take a screenshot. It could still read the DOM. It could still write a confident little report about how the game was doing.

Every observation it made about timing and feel was made on a game running at a small fraction of real speed. The evidence looked like evidence. It was worthless.

Why: requestAnimationFrame goes quiet when nobody's looking

This is not a bug, and it's not the automation's fault. It's requestAnimationFrame doing exactly what the spec says it should.

requestAnimationFrame is a request to run your callback before the next repaint. The critical word is repaint. When a tab is backgrounded or a window is fully occluded, the browser has decided there is nothing to repaint — you're not looking, so why burn battery and CPU compositing frames into a void? Browsers respond by throttling rAF hard, and in the fully-hidden case they can stop calling it altogether until the page becomes visible again.

For a normal web page this is a feature. Your idle background tabs aren't spinning the fan. For a real-time game loop driven by rAF, it means the loop's clock is coupled to whether anyone is watching — and a browser-automation agent, by construction, is the one situation where the game runs but nobody is watching in the sense the browser cares about.

If you want to see this on your own page, the shape of the check is small. This is a reader-facing tool, not the instrumentation I used — but it makes the mechanism visible:

let count = 0;
const start = performance.now();
function tick() {
  count++;
  if (performance.now() - start < 45000) {
    requestAnimationFrame(tick);
  } else {
    console.log(`rAF callbacks in 45s: ${count}`);
  }
}
requestAnimationFrame(tick);

Run it in a focused tab and the count is large. Background the window, walk away, and watch what comes back. That gap between the two runs is the entire problem.

The rule: trust the deterministic artifact, not the live observation

Here's the general rule I took away, and it outlives this one project.

An automated checker that is silently degraded is worse than no checker at all.

No checker is an honest absence. You know you don't know, so you go look yourself. A degraded checker is a confident liar. It hands you a screenshot and a summary and a green-ish feeling, all generated from a game running at a fraction of real speed, and nothing about the output tells you the measurement was taken through a broken window.

So I split the jobs by what each tool can actually be trusted to see:

  • Behavioral validation — timing, pacing, "does the simulation advance correctly" — goes to the deterministic test suite and harness. The sim is deterministic on purpose; the same seed produces the same run, no clock, no window, no repaint involved. That's the artifact you trust.
  • Browser automation — goes to static rendering checks only. Did the right thing paint? Is this element on screen? Fine. Anything that depends on the game loop actually running at real speed is off-limits, unless you've specifically shown that particular browser execution environment doesn't throttle the loop.

The deterministic harness doesn't care whether anyone is watching. That's exactly why it's the authority and the live browser observation isn't.

The companion incident: an agent's report is not evidence either

While I'm on the subject of confident-looking evidence that isn't, let me tell you about a cousin of this bug that has nothing to do with rAF.

In the same project, a cloud coding-agent environment once reported that it had committed a change and opened a pull request. Reasonable-sounding report. The kind you'd tick off and move on from.

Except when I checked the repository, the commit object it named wasn't there, and the branch it claimed to have pushed didn't exist on the remote. The work it described was, as far as the repository I could actually reach was concerned, not real. The suggested change might have been perfectly good — but "I did X" from an agent is a claim, not a fact, and the two had quietly come apart.

It's the same failure as the occluded browser, wearing different clothes. In both cases something automated produced a confident narration of reality. In both cases the narration was untethered from the artifact you can actually verify — the render loop that ran at real speed, the commit that actually exists in the tree. The throttled rAF makes timing evidence wrong; the agent's self-report makes completion evidence wrong. Both are defeated by the same move: don't trust the live observation, go check the deterministic thing. Read the repo at HEAD, not the agent's summary of it. Run the deterministic harness, not the game through a hidden window.

Lessons Learned

1. requestAnimationFrame is coupled to visibility, and that coupling is not your friend when you automate. The one context where you most want a robot to watch your game is the exact context where the browser stops driving the loop.

2. A checker that can still produce output while broken is a trap. Silence is honest; a plausible wrong answer is not. Ask of any automated check: what does it do when it's degraded — go quiet, or lie?

3. Determinism is the whole point of a deterministic harness. It gives you an artifact whose truth doesn't depend on a window being visible, a tab being focused, or an agent being right about itself. Verify against that.

4. "I did X" is a claim, whether the X-doer is a browser or an agent. The browser claims it rendered your game; the agent claims it pushed your commit. Neither claim is evidence. The commit in the tree and the harness run are.

I'll admit I wanted the shortcut to work. A robot that watches your game while you do something else is a genuinely nice idea, and I spent longer than I'd like trying to convince myself the reports were fine before I actually measured the frame count.

If you're wiring up browser automation to validate anything time-sensitive, measure the loop first, before you trust a single screenshot it hands you. And if I've got the throttling mechanism wrong somewhere — the exact conditions differ across browsers and I only tested the case in front of me — tell me. I collect this kind of "the tool was lying to me the whole time" story, and I'd rather add yours than defend a wrong one.