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 run on WebGL and Canvas2D, so they break in ways a unit test never sees. A board renders off-screen. A popup quietly covers a control dock. A 3D scene silently refuses to draw. You don't catch that by asserting on some return value. You catch it by looking. So the real question was never 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 turned out to be two rungs, and the interesting part is why neither one could carry the load alone.
What a broken game actually looks like
Testing needs an oracle first. Call it the short list of things that must hold for the page to count as working. Here it's small and blunt by design. On a cold first visit, nothing overlays the interactive dock or steals taps from it. The board fills a reasonable slice of the viewport. Turn on 3D mode and WebGL has to have actually drawn something without losing its context. No console errors. No page errors. No asset that failed to load. Both modes, 2D and 3D, settle into a coherent state. That list is the entire contract. Everything below judges against it, which is the whole trick, because that's how a loose nightly check and a picky merge check end up agreeing on the meaning of one word: working.
Rung one: a language model that looks at the screen
The nightly run? Only an LLM can do it. A capture harness boots headless Chromium with SwiftShader software WebGL so the 3D board renders with no GPU on a plain CI runner, then screenshots all 31 games in the library at an iPhone viewport, both 2D and 3D, from a fresh browser context for every shot. Claude Code reads the images back and grades each one against the invariants above, filing one labeled issue per failing game with the screenshot attached. Cron fires it at 07:00 UTC. Defaults to Sonnet. Swap in Opus 4.8 when you want a stricter eye. And here's the thing: a lot of these games have no DOM board grid, no stable end-state score. The only judgment you can make is this looks wrong. A language model can say that about a picture. Your test runner can't say it at all.
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's a Playwright Test run that boots the production build itself, drives an iPhone-class mobile-chromium project with the same SwiftShader flags, and 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. 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.
// 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
Why not fold these into one mechanism? Because it breaks both ways. Put an LLM verdict in the merge path and you've handed control to something probabilistic and slow that costs an API key every time, and the first flaky false alarm blocks somebody's real work. Go the other way, lean only on deterministic assertions, and you can only test what you managed to name ahead of time. Everything you haven't imagined yet slips right through.
Splitting the work is what resolves it. At night, judgment gets to be wrong cheaply: the worst case is a spurious issue somebody closes in the morning. At merge, enforcement asks only the questions with a definite answer. That's the trade. The night rung discovers new invariants. The gate rung freezes the ones you already trust so they can't quietly regress.
The bug the whole thing exists to catch
Why fresh context on every single shot? It looks like paranoia. It isn't. The whole thing was built around one bug. 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 while a brand-new visitor can't tap a thing. That's the exact defect a warm test profile hides, and it's why every smoke shot starts from a cold profile.
Honest limit, and I'll stamp it as such: in headless Chromium the install prompt doesn't auto-fire on a non-iOS user agent. The deterministic dock-reachability assertion passes today without a suppression mechanism because of that. The nightly iOS-user-agent run is what actually surfaces the obstruction. Naming that gap in the repo is part of the point. A test rig you can't trust to tell you what it doesn't 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 can't fully list wants the same shape. You need a language model that looks and judges where a wrong call is cheap. You need a deterministic gate that enforces the named invariants where a wrong call is expensive. And you need one written oracle both of them share, so they never argue 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 that same two-machines-one-oracle instinct runs through the harness essay and the reel of the games.
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 โ