I build two games, a game platform and a game framework from an iPad mini. AI agents in Claude Code write nearly all of the code, and a script I wrote decides whether any of it ships. This is how that script got good enough to sleep through.

Between 6 p.m. on a Saturday and 5 a.m. on Sunday, 29 pull requests merged into The Last Light, a small 2.5D horror game I'm making. I was asleep for most of them. Agents wrote the code, and an agent pressed merge. If you're wondering whether that's reckless, so did I, right up until I reread the script that stood between each pull request and main.
Nothing merged on anyone's say-so. Every pull request had main merged into it first, then ran this, and was pushed only if every line passed. Nearly every line ends with the same three words, || ok=0:
git merge --no-edit origin/main || { echo "CONFLICT:"; exit 1; }
npx tsc --noEmit || ok=0
npx vitest run --testTimeout=20000 || ok=0
npx vite build || ok=0
npm run pipe -- guard check --json || ok=0
# … a scan for API keys, then five camera
# zones shot and pixel-diffed (below)
p=$(node scripts/verify/playthrough.mjs | tail -n 1)
echo "$p" | grep -q "won" || ok=0
if [ $ok = 1 ]; then git push origin "HEAD:$B"
else echo "CHECKS FAILED, not pushed"; fiShortened from mergecheck.sh: log handling, environment variables and the server start are cut.
In order: the type check, several hundred Vitest tests, the build, a guard that enforces the game's locked decisions (a fixed 50 mm camera in gameplay, no real-time lights, no fetch() at runtime), a key scan, a pixel diff of five rooms against images I approved, and a scripted playthrough of the whole first act. If the playthrough doesn't print "won", nothing is pushed. It takes about seven minutes.
That script is the most important file in my projects, and it has an unusual property: it has never once been persuaded. An agent can say a test is flaky, or that a feature is finished because the code exists, in the same calm voice it uses for everything. It isn't lying. It's an optimist with no way to feel the floor give way. The script has no optimism, and everything else in this article is about keeping it that way.
This is part one. Part two is about four projects sharing one PC, one Mac and one very tired person.
The cast
The iPad is where I read specs, look at screenshots and tap decisions. Nothing heavy runs on it. Each project is a Claude project: a coordinator that knows the repository, and a thread for every task. Each thread gets a fresh cloud container with its own clone, thrown away when the session ends, so the only things that survive are commits.
Two machines at home do what a container can't. A Windows PC has the graphics card and runs the GPU playthroughs. An M1 Mac runs WebKit, the engine inside Safari, and holds Floor Fifty's screenshot baselines. Both are self-hosted GitHub Actions runners.

Four projects share all of it:
- Floor Fifty, a four-player browser race up a city skyline. The server and the browser step the same Planck physics world, so a race is fair only if both agree.
- The Last Light, the horror game above, in PixiJS 3D, with its art, puzzles and story made through pipelines behind an MCP server.
- Ruken, a platform for browser games that pays creators for real play.
- Automata Framework, the framework extracted from what the other three proved.
Everything below happened in the last few weeks, and every snippet is quoted from the repositories as committed.
A pixel diff of zero
The Last Light is a game about light, so the merge check compares pictures. Five rooms of the clinic, each with the screen effects off and on, are shot and compared against baselines I approved. The diff has to be exactly zero pixels. Not "close". Zero.

Zero is only possible if the game draws the same frame every time, on any machine, however busy. A real clock makes that hopeless: a slow machine runs fewer frames, the dust lands somewhere else, and the diff is never zero. So the screenshot script stops time. Its header says how:
// Shots are deterministic: the page runs on
// Playwright's paused fake clock with a seeded
// Math.random, and every wait steps the game by a
// fixed number of 1/60 s frames (`game.step`,
// ?debug only), so two runs of one commit give
// identical pixels (scripts/verify/pixeldiff.py).game.step exists only in debug builds. It stops the game's ticker and advances it by hand, exactly 1/60 of a second per frame.
That made the shots exact, and slow. The cloud machines draw WebGL in software, at about 130 milliseconds a frame, and every stepped frame was being drawn even though only the last one gets photographed. An agent's fix was to take the renderer off the ticker until the final frame:
const step = (frames: number) => {
const ticker = scene.app.ticker, app = scene.app;
if (ticker.started) {
ticker.stop();
manualTime = ticker.lastTime;
}
if (frames <= 0) return;
ticker.remove(app.render, app);
try {
for (let i = 1; i < frames; i++)
ticker.update(manualTime += 1000 / 60);
} finally {
ticker.add(app.render, app, UPDATE_PRIORITY.LOW);
}
ticker.update(manualTime += 1000 / 60);
};Reformatted from src/main.ts, with its comments cut.
The game state still advances every frame, so the picture is identical, and the proof was the check that always decides: the baselines still diffed to zero. The shot pass dropped from about 142 seconds to 24. That one change is a large part of how 29 merges fit in a night.
When I want the look to move, the diff fails on purpose. Then I get a before-and-after board on the iPad, room by room, and the new baselines are committed only if I approve them.

The nine-second wait
One Sunday, a rebuild of the second act's dock failed its merge check, and every thread blamed the dock. It was the newest and biggest change, so it had the motive and the opportunity. The agents read the logs, looked at the diff, and reached the reasonable conclusion.
Then the same check failed on main, where there was no dock at all.
The playthrough script plays the first act the way a player would. Near the end it shuts a valve to stop a leak, then throws a breaker in the fuse box. If water still reaches the box, the breaker shorts, and the first act never hands over to the second. Someone had waited for the leak to drain in the obvious way:
for (const id of ['closetDoor', 'shelf', 'valve']) await go(id);
-await page.waitForTimeout(9000);
+// With the valve shut the leak drains (R12); the
+// breaker thrown while it still reaches the box
+// shorts it (R4). Wait on the world, not the wall
+// clock, so a slow machine does not throw it early.
+await until(w => !w.puddles.some(p => p.fed));
await go('fuseBox');Nine seconds was plenty on the machine where it was written. On a slower cloud machine it wasn't, so the breaker shorted, the act never ended, and every merge check failed, main included. The dock was innocent, and nobody apologized to it.
The fix waits on the game's own state until no puddle is still being fed. A fixed wait is a guess about how fast somebody else's computer is. A wait on state can't be wrong that way, only slower.
The same commit also raised two other tests' timeouts, because the slower machine needed longer to run them. Its message ends with a sentence I'd put on a plaque: "Only timeouts change; no assertion does." Giving a slow test more time is fine. Making a test easier to pass is not, and The Last Light's rules file says so in plain words:
- **Never** skip, disable or loosen a test to get green.The bug only WebKit saw
Ruken is a platform for browser games, and any game page can be embedded on any website, the way you'd embed a video. In WebKit the embed page froze on start: the "Tap to play" button never turned on. Chromium was fine. Every unit test passed. The page just sat there looking healthy and doing nothing, like a coworker on a conference call.
The browser suite runs the real page in real WebKit, and it found the condition. The page broke only inside an iframe with loading="lazy", which, as the code comment helpfully notes, WordPress adds to every iframe. In that case WebKit accepts a history.replaceState call and then doesn't report the new state back. SvelteKit writes a record into the history entry during start-up and reads it straight back on the next line. The read came up empty, start-up threw, and the page never hydrated.
The fix is a probe and a small shim. The probe writes a value and reads it back:
/** Writes a test value and reads it back,
* then puts the original state back. */
function browserKeepsState(history: History): boolean {
const original: unknown = history.state;
history.replaceState({ probe: true }, "");
const readBack = history.state as { probe?: boolean } | null;
const kept = readBack?.probe === true;
history.replaceState(original, "");
return kept;
}If the browser keeps the state, nothing else happens, so every browser that works is untouched. If it doesn't, that one History object gets a copy of the last state written.
Then the agent did the part I'd have forgotten. A bug that only one engine can see is a bug you'll reintroduce the day nobody runs that engine. So the new test makes every browser forgetful on purpose:
await page.addInitScript(() => {
History.prototype.replaceState = () => {
// Takes the write and keeps nothing.
};
History.prototype.pushState = () => {
// Takes the write and keeps nothing.
};
});From embed-start.spec.ts: the test's other lines load the page and check that it started.
The commit says that before the fix, this test failed in Chromium "with the same TypeError at the same place as the WebKit failure". The bug now reproduces in the browser every CI job already runs.
The last part is the comment above the shim, which is long and ends by saying when to delete it:
/*
* When to remove it: once WebKit reports
* `history.state` after a write inside a lazy
* iframe, or SvelteKit's start-up no longer depends
* on reading its write straight back. Check by
* deleting the `init` hook in hooks.client.ts and
* running e2e/golden/embed-start.spec.ts in WebKit.
*/Agents write workarounds easily. What they won't do unless you ask is write down the exit: what the workaround is for, how to tell it's no longer needed, and which test proves it.
Rules the agent can't talk its way past
Every repository has an AGENTS.md that each session reads first. When an agent gets something wrong, the correction becomes a line in it. These three are from Ruken's, word for word:
- **Strict TDD.** Write a failing test, watch it
fail for the expected reason, write the least
code that makes it pass, then refactor.
- **Record nothing by hand (R-80).** Every fact
comes from an API, a webhook, telemetry or a
check. People only make decisions.
- **Report separate gates.** Distinguish
implemented, locally verified, CI verified,
deployed and Owner accepted.Each is shortened to its first sentences.
The last one does more work than it looks. "Done" has five meanings, and an agent's report has to say which of them passed. "Implemented and locally verified, CI pending" is a useful sentence. "Done" is a mood.
But a markdown file is advice, and some things need to be impossible. In The Last Light an agent can't approve its own work, can't call the paid image APIs directly (they go through a pipeline that estimates and caps the cost first), and can't edit the file where approvals are recorded. Those are in the project's Claude Code settings, not in prose:
"deny": [
"Bash(npm run pipe -- * approve*)",
"Bash(npm run pipe -- * reject*)",
"Bash(curl*api.openai.com*)",
"Bash(curl*api.meshy.ai*)",
"Edit(**/gates.json)",
"Write(**/gates.json)"
]Shortened: the full list also covers WebFetch and two more owner files.
Approvals happen through a GitHub workflow that only I run, from the GitHub app on the iPad if I like. A pre-tool hook stops any session from dispatching it, even through the GitHub tools:
if (/actions_run_trigger/.test(tool)) {
// A workflow dispatch through the GitHub MCP
// server: the owner's Approve gate workflow
// is theirs alone.
if (/approve[\s_-]?gate/i.test(JSON.stringify(input))) {
return { block: true, reason: 'Blocked: …' };
}
return { block: false };
}When a session on my Mac needs something only I can allow, it asks, and nothing runs until I tap.

From idea to merged
Behind every feature is a trail of documents, and they're real files in each repository. The work is cut into phases. Each phase gets a spec, written with Superpowers, an open-source set of skills for Claude Code, and I read it before any code exists. A planner turns the spec into a test-first plan, an executor carries it out, and a reviewer checks the result. When the phase is done, an agent audits it against its spec and its code, and what the audit finds becomes a spec of its own.
The plans are what keep the TDD honest. Every task starts with tests that must fail, and says why. This is from Floor Fifty's plan for race modes and replays:
- [x] **Step 2: Run them and watch them fail**
Run: `pnpm vitest run tests/server/rooms/lobby-rules.test.ts`
Expected: FAIL. `setMode` is not exported,
`modeId` is undefined, and the version is 11.Shortened: the real step runs three test files.
A test that fails because of a typo in an import would pass a step that only said "Expected: FAIL". Naming the reason forces the executor to read the failure. And before a fix merges, I ask the reviewer to undo it and show the test catching the bug again. A test that stays green either way is decoration.
What to copy
You don't need four projects or a GPU. For one repository:
- Write a merge script that only says yes. Merge
mainin first, run everything, push only if every line passed. - Make your tests deterministic before you make them strict. A fake clock and a seeded random number generator first, then an exact diff.
- Ban fixed waits. Wait on the state you actually need.
- Make every workaround say when to delete it, and name the test that proves it's still needed.
- Put hard rules in permissions and hooks, and keep
AGENTS.mdfor everything else. Add a line to it after every mistake. - Make the agent name why a new test will fail, and have the reviewer prove it catches the bug.
- Keep the decisions you care about out of the agent's reach, in a workflow only you can run.
None of it is clever. It's closer to plumbing, and like plumbing it matters most when it's invisible.
If your team wants agents held to checks like these, I build that. Email me or find me on LinkedIn.
Next, part two: what happens when four projects share one PC, one Mac and one person.