A two-rung gate for AI-tested games
engineeringJuly 6, 20265 min read
By

A two-rung gate for AI-tested games

Screenshots are how you catch a game that renders wrong, but a screenshot judged by a language model cannot block a merge. So the games on this site get tested twice: an LLM looks at every game each night, and a deterministic Playwright gate re-checks the invariants on every pull request. Here is why one rung was never enough.

The games on this site are WebGL and Canvas2D, which means they fail in ways a unit test never sees: a board that renders off-screen, a control dock that a popup quietly covers, a 3D scene that silently never draws. You do not catch those by asserting on a return value. You catch them by looking. So the real question was not how to test a game. It was how to let a machine look at one, and then how to trust what it saw enough to block a bad merge. The answer became two rungs, and the interesting part is why neither rung could do the job alone.

What a broken game actually looks like

Before you can test something, you need an oracle: the short list of things that must be true for the page to be considered working. For a game here, that list is deliberately small and blunt. Nothing overlays or intercepts taps on the interactive dock on a cold first visit. The board fills a reasonable share of the viewport. If 3D mode is on, WebGL actually drew something and did not lose its context. No console errors, no page errors, no failed asset loads. Both the 2D and 3D modes reach a coherent state. That is the whole contract. Every rung below judges against exactly this list, which is the trick that lets a fuzzy nightly check and a strict merge check agree on what the word working means.

Rung one: a language model that looks at the screen

The nightly rung is the one only an LLM can do. A capture harness boots headless Chromium with SwiftShader software WebGL, so the 3D board renders with no GPU on a plain CI runner, and screenshots all 31 games in the library at an iPhone viewport, in both 2D and 3D, from a fresh browser context for every single shot. Then Claude Code reads the images back and judges each one against the invariants above, opening one labeled issue per failing game with the screenshot attached. It runs on a cron at 07:00 UTC and defaults to Sonnet, though it can judge with Opus 4.8 when you want a stricter eye. The reason this has to be a model and not an assertion is that many of these games have no DOM board grid and no stable end-state score. The only available judgment is this looks wrong, and that is a sentence a language model can say about a picture and a test runner cannot.

Two side-by-side panels. Left: a nightly LLM smoke run that screenshots every game and files issues. Right: a per-PR deterministic Playwright gate that asserts invariants and blocks merge. An arrow labeled 'promotes a trusted invariant' runs from left to right.
The night rung discovers what is wrong and files it. The gate rung freezes the invariants you already trust and refuses the merge. Findings flow one way: from judgment into enforcement.

Rung two: a deterministic gate that blocks merge

The per-PR rung is the opposite animal: no LLM, no API key, no judgment calls. It is a Playwright Test run that boots the production build itself and drives an iPhone-class mobile-chromium project with the same SwiftShader flags, then asserts the invariants directly. Is the dock reachable and unobstructed. Does the board fill its share of the viewport. Did the WebGL context survive. Were there zero console or page errors through a real interaction. It runs on every pull request to main and blocks the merge on failure. RECURRENCE is the worked example, and its spec points at the game's real markup rather than a mock, so a green run means the actual shipped page behaved. New games copy that spec as a template.

ts
// paths/recurrence.spec.ts (shape)
test('3D mode draws and the dock stays reachable', async ({ page }) => {
  await page.goto('/recurrence?render=3d');   // real deep-linkable toggle
  await startATurn(page);                     // cold load shows a cover first

  // invariant: WebGL actually drew, context not lost
  expect(await webglAlive(page)).toBe(true);

  // invariant: nothing overlays the interactive dock on a cold visit
  const dock = page.locator('.rc-dock .rc-build');
  await expect(dock).toBeVisible();
  await dock.click();                          // arm a tower, no obstruction

  // invariant: no console / page errors through the interaction
  expect(errors).toHaveLength(0);
});

Why two rungs and not one

You could imagine collapsing these, and it does not work in either direction. An LLM verdict is probabilistic, slow, and costs an API key, so it must never sit in the merge path where a flaky false alarm would block real work. But a deterministic assertion can only check what you were able to name in advance, so on its own it is blind to every failure mode you have not yet thought of. Splitting the labor resolves the tension. Judgment happens at night, where the cost of a wrong call is a spurious issue somebody closes in the morning. Enforcement happens at merge, where the only questions asked are ones with a definite yes or no. The night rung is how new invariants get discovered; the gate rung is how the ones you already trust get frozen so they cannot regress.

The bug the whole thing exists to catch

There is one specific bug this system was built around, and it explains the fresh-context-every-shot detail that otherwise looks like paranoia. On a cold first visit, a shared install prompt could pop over a game route and cover the dock, so a returning player never sees it but a brand-new visitor cannot tap anything. That is exactly the kind of defect a warm test profile hides, which is why every smoke shot starts from a cold profile. Honest limit, stamped as such: in headless Chromium the install prompt does not auto-fire on a non-iOS user agent, so the deterministic dock-reachability assertion passes today without a suppression mechanism, and it is the nightly iOS-user-agent run that actually surfaces the obstruction. Naming that gap in the repo is part of the point. A test rig you cannot trust to tell you what it does not yet cover is worse than no rig at all.

What transfers

None of this is specific to games. Any product with a visual surface that fails in ways you cannot fully enumerate wants the same shape: a language model that looks and judges where a wrong call is cheap, a deterministic gate that enforces the named invariants where a wrong call is expensive, and a single written oracle both of them share so they never disagree about what working means. The full quickstart is in PLAYTEST.md, the nightly job is playtest.yml, the merge gate is playtest-gate.yml, and the same two-machines-one-oracle instinct runs through the harness essay and the reel of the games.

Experience it yourselfPlay the games the gate protects
ShareXLinkedInHacker NewsEmail

Get the next one

An occasional note when something genuinely new ships here — essays, free tools, projects. No schedule, no filler, easy out.

Need something like this built?

I design and ship AI tools, full-stack apps, and data pipelines — end to end, to production. Tell me the problem in a sentence; I'll give you an honest read on fit within a day.

Work with me →