~/matenemeth

Moving my agent loop to a game

2026-10 · 8 min read

Until this week, my autonomous coding loop had worked on exactly one project: Tallyroot, my personal finance app. TypeScript, Postgres, a main branch, a test suite that runs in a few minutes. Since it left Claude Code it runs on its own runtime, and a runtime that only ever runs one project hasn’t proven much.

So I pointed it at the most different thing I had. Blackstart is a game I’m building in Unity: a factory roguelite about an intelligence crossing a field of dead worlds. It’s C#, the branch is master, there’s no CI, and a fresh Unity import takes ten to fifteen minutes. Months on a finance app had taught the loop a lot about money and customers’ data. There’s none of either on a dead planet.

It works now. It found a lot of things the loop assumed without ever saying so.

The numbers

Rounds on Blackstart so far 6
PRs merged 2
Places the script said main 104
Checks that passed C# without reading it 4
Fast Unity test run, warm about 30 seconds
Rounds spent re-reviewing a PR that was already done 4

It all runs on a Claude Max subscription, so nothing here was actually billed. A round came to between $10 and $12 at API prices when it built something new.

Tallyroot, everywhere

The loop is one long workflow script, and Tallyroot was written all over it. The repo name, the folders, the stack, and the review rules: money signs, currency units, workspace scoping. A reviewer on a game has no workspaces to scope.

Now every fact about a project comes from two files in that project. config.json says which tracker to use, which command the merge gate runs, and which paths only a person may change. profile.json says what the product is, who uses it, and what a risky change looks like there. For Blackstart that last one is conservation, determinism and solver convergence, not money.

The test I cared about was Tallyroot. With its own profile, the script has to come out the same as before. I evaluated all 91 top-level constants both ways, and 90 of them matched. The one that didn’t is a schema description that has to stay a literal.

That pass found most of it. The rest only showed up when a round actually ran.

A gate that can’t afford Unity

The merge gate runs the tests on the exact commit it’s about to merge, in a fresh checkout. On Tallyroot that’s cheap. On Blackstart, a fresh checkout means a fresh Unity import, which takes ten to fifteen minutes per PR.

So the gate’s one command is a small script. It syncs whatever checkout it runs in into one copy that stays on disk, and runs the fast tests there. Only what changed gets reimported, so a warm run takes about 30 seconds. Unity locks a project folder, so the script takes a lock and waits. Two runs at once just queue.

Most of that script is about saying what went wrong. Unity in batch mode fails in many quiet ways. The first time, it exited with code 198, and the log said:

No valid Unity Editor license found.

So the first thing the gate ever caught on Blackstart wasn’t a bug. It was me, not signed in to Unity Hub. Now the script says that, in those words, and exits with a code that means “no result” rather than “tests failed”. A compile error prints the compiler’s lines, and a hang gets killed after twenty minutes. I made each of those happen once before I trusted the script.

Checks that passed by not looking

The first real round fixed a bug where propellant was counted twice after landing. Then the loop ran its own retrospective, and that turned up three problems.

The first was the branch. The script said main in 104 places, and Blackstart has no main. Agents diffed against a branch that doesn’t exist. The cleanup step refused to run and printed:

ROOT HIJACKED: on master, expected main

It got the reason wrong, but at least it said something. The other problem didn’t.

Each PR description cites the lines of code it changed, and four small checkers verify that the citations are still true. They were written for TypeScript, so they skipped .cs files. Then they found nothing to check, and reported a match.

Hand-drawn sketch: a PR description cites five lines in Crossing.cs. A checker written for TypeScript reads .ts, skips .cs, finds zero citations, and reports MATCH, which looks like a pass. Underneath: it didn’t read it, it passed it.Hand-drawn sketch: a PR description cites five lines in Crossing.cs. A checker written for TypeScript reads .ts, skips .cs, finds zero citations, and reports MATCH, which looks like a pass. Underneath: it didn’t read it, it passed it.

A check that can’t read its input isn’t a pass. It just looks like one, and the review downstream believes it. So I changed the checkers to read C# wherever their check doesn’t depend on the language, and to say UNSUPPORTED for anything they can’t read. Every prompt that runs them got the same rule: unsupported is never a pass, whatever it names gets checked by hand, and the PR description says so.

The next day, the next round’s retrospective found the same problem again:

On PR 31 they found zero citations and reported a PRESENCE no-op, so five Crossing.cs citations had to be checked by hand.

The fix was right, just in the wrong place. The loop runs its checkers from Tallyroot’s main checkout, and the fix was still on a branch. So I’d fixed the checks that didn’t look, in a copy nothing was looking at. At least the agents checked the citations by hand and said so, which is what the new rule asks for. The branch has merged since, and Blackstart gets the new checkers now.

Players to think as

On Tallyroot, the loop’s feature ideas come from customer personas. A game needs players instead. I wrote five, from verdicts I’d collected in playtests: a factory-game veteran, someone who plays for the story, an engineer who notices invented physics, a roguelite player chasing a score, and someone in their first twenty minutes of any factory game.

One discovery pass filed five bugs. It also filed two questions the loop isn’t allowed to answer itself: what a more expensive jump between worlds should buy you, and whether recipes should balance by mass. Those get a needs-decision label, and the loop skips them until a person rules. I picked orbital mechanics for one and mass balance for the other. The rulings went into the design specs, and the work into four new issues the loop can take.

Four approvals on one PR

The second PR fixed something a player would have noticed. The game shows you a few worlds you can jump to, with their distance and conditions. Leaving then rolled a different world from a seed that didn’t depend on your pick. A candidate advertised at 1.190 AU landed you at 1.395 AU.

Tallyroot’s loop merges on its own. On Blackstart, I wanted to be careful: the gate stops at “ready” and leaves the merge to me, until the loop has earned more trust there. Tallyroot had never run in that mode.

After the first round, PR 31 sat ready and waiting. The next round looked for unfinished PRs, and found one that had been reviewed but had no comment from the merge gate. That’s the shape of a round that died before the gate ran, so it reviewed the PR again, applied a nit, ran the gate, and stopped at “ready” again. The gate still left nothing on the PR. Rounds three, four and five did the same.

Hand-drawn sketch: rounds 2, 3, 4 and 5 each approved PR 31 again, pushed a nit, and stopped at ready, then the next round started over, because nothing on the PR said ready. Underneath: 4 approvals, $10.66, no new work. The most reviewed fix I ever shipped.Hand-drawn sketch: rounds 2, 3, 4 and 5 each approved PR 31 again, pushed a nit, and stopped at ready, then the next round started over, because nothing on the PR said ready. Underneath: 4 approvals, $10.66, no new work. The most reviewed fix I ever shipped.

By the time I looked, the PR had four approval comments and three extra nit commits. It’s the most thoroughly reviewed bug fix I’ve ever shipped. Being careful came to $10.66 at API prices, and started no new work. The cap meant to stop a PR from being resumed more than twice didn’t help, for the same reason: it counts the gate’s comments, and there weren’t any.

The fix fits the rule from the last post: a decision belongs to a commit. When the gate refuses a commit, it already leaves a marker on the PR, and the loop never tries that commit again. Now “ready, waiting for a person” leaves one too. The next round sees it and picks a new issue instead. A new commit clears it.

What carried over

Most of it, and that’s what I’m happiest about. The merge gate, the reviews, the playbook and the backlog all worked on a game in a language they had never seen. What needed changing was every place where the loop knew something about Tallyroot without saying so, and two places where a check passed without looking.

What’s next

The loop is running on Blackstart again as I write this, five rounds at a time, and I merge what it leaves.

One thing it can’t do yet is play the game. Blackstart’s working rules say anything a player touches has to be tried by hand, because a script that calls the simulation directly never presses a button. The screenshots the loop can take render the world, but not the panels on top of it. Teaching it to see those is the first feature in its own backlog.

If you’d like to run something like this on your own project, I’m packaging it up. Join the waitlist below and you’ll get one email when it’s ready.

Get the agent-loop kit when it's ready

One email when it launches. The two questions help me build the right thing.

Kit sends a confirmation email first. See the privacy notice.

New posts by email

No schedule, no spam. Unsubscribe any time.

Kit sends a confirmation email first. See the privacy notice.