Contracts, not code: building a Unity DOTS game with a team of AI agents

Sep 24, 2026 · 11 min read

  • unity
  • dots
  • ai-agents
  • architecture

I'm building a roguelike autobattler in Unity DOTS. Hundreds of units fight on the field, and instead of controlling individual soldiers you steer how the army behaves. Most of the code is written by AI agents. A Claude Code session runs the show and hands the actual work to subagents running other models.

A few weeks in, I ran into the problem I think everyone working this way runs into: the agents write code faster than I can read it. And ECS code is where I read slowest: job scheduling, structural changes, what Burst will and won't compile. So I stopped trying to read everything. Now I read the contracts and the test tables, and the code has to answer to them.

The game doesn't have real gameplay yet, so read this as notes from the middle of the work, not as advice.

Why I moved the review

When an agent hands you a system, there are two easy ways to review it. Both are bad.

You can read the whole diff. That works for a while, and then you're the bottleneck.

Or you can trust the green build. Agents are very good at making tests pass, including tests they wrote themselves to pass.

What I wanted was something small enough that I'd actually read it, that pins down behavior, and that an agent can't quietly rewrite while it works. In a modular codebase, that thing already has a name: the module's contract.

The idea isn't mine. It's Design by Contract, from Bertrand Meyer, and I first came across it in The Pragmatic Programmer. In the classic version, a contract belongs to a method or a class: preconditions, postconditions, invariants. What I'm doing is taking the same idea one level up, to the module. A promise is about what a whole module guarantees to the rest of the game, and the tests check it from the outside, through the module's public types.

What a module looks like

Each module lives in its own folder with up to four assemblies. Here's the Units module:

AssemblyWhat it holdsWho can reference it
Game.Units.ContractsPublic types: components, interfaces, configAny module
Game.Units.Contracts.TestsFixtures, fakes, contract testsOther modules' tests
Game.UnitsThe runtime: systems, jobsNo other module; the bootstrapper finds it at startup
Game.Units.TestsRuntime testsNobody

The one hard rule: a module's runtime only sees other modules' *.Contracts assemblies, never their runtime. Some integration tests and editor tools get an exception. Unity's assembly definitions turn this into a compile-time boundary. To reach into another module's internals, an agent would have to add an assembly reference, and a one-line change like that is easy to catch. The dependency graph between contracts has no cycles.

Next to the code sits Units.contract.md. It has four sections: what the module provides and requires, a numbered list of promises, the limits, and who owns which data. The limits section covers edge cases and the things the module deliberately doesn't do, and it can't be left empty. Promises are numbered U1, U2 and so on, limits U-S1, U-S2. A trimmed excerpt:

U1. Uniqueness. No two entities in a World carry the same SpawnId at the same time; even after an entity is destroyed, its value is never issued again during the battle.

U6. SpawnId assignment. Queue order = assignment order; SpawnCounter.Next starts at 0 and only increases. Same queue contents → same assignment.

U10. Upper bound. If live units + requests > MaxUnits, UnitSpawnSystem throws before processing the queue; no request is processed and the queue is not cleared. All or nothing: an error, not a silent drop.

U-S1. No sentinel. 0 is a valid SpawnId. "None / not selected" lives in the consumer's own flag.

SpawnId looks boring, but a lot depends on it. The whole simulation uses one pattern for determinism: compute in parallel, sort by a stable key, apply in order. SpawnId is that key. So U1 is one sentence for me to review, and every system downstream relies on it being true.

The three classic shapes of Design by Contract are all in there, just at module scale. U10 is a precondition: break it and the system refuses to do anything. U6 is a postcondition: what you can count on after a spawn. U1 is an invariant: it holds for the whole battle, whatever else happens.

Each module also has a TEST-TABLE.md. It maps every promise to the test that pins it down, or admits that it can't be tested:

PromiseTestKind
U1U1_DestroyedSpawnIdIsNeverReissuedruntime test
U6U6_SameQueueInTwoWorldsGivesSameIdsruntime test, two Worlds
U-S1US1_ZeroIsValidSpawnIdfixture validator
U-S3—not testable (scale limit; "no check" by design)

I read the table first, then the contract, and I only open the code when the table looks off. A missing row means a missing guarantee, and that's much easier to spot in a table than in a diff.

Why the tests come first

If the code comes first, the tests end up describing what the code does, not what it was supposed to do. With agents this gets worse, because an agent that writes both the code and its tests will make them agree. So the promises are written down before any implementation exists, and the tests come from the promises, not from the code.

It works like a relay race, and what matters is who runs which leg:

  1. I write the contract, often starting from an agent's draft.
  2. A fresh subagent writes the contract tests and the test table. It sees only the contract, the fixture rules and the assembly layout. It isn't the session that wrote the contract, so the contract's blind spots don't get copied into its tests.
  3. I review the table, then the tests.
  4. A different agent writes the implementation against those approved tests, and it isn't allowed to change them.

The test writer never writes the implementation, and the implementer never touches the tests.

The second reason is parallel work. Each module keeps one fake in its Contracts.Tests, and the fake has to pass the same contract tests as the real thing. Once a module has its contract, tests and fake, every module that depends on it can start right away without waiting for the real implementation. That's what lets the planner run tasks side by side instead of one after another.

Writing tests first doesn't guarantee they ever run, though. Story 4 below is about exactly that.

The loop

I plan the game in slices, each one a batch of related features. A slice starts with me, not with the agents. First come the contracts and test tables for everything the slice touches. Only after I've approved them does the planner break the slice into tasks, working from those same contracts.

The workflow is a Claude Code skill I wrote, called subagent-loop. The main session is the orchestrator, and it never edits project code directly. Every change goes out to a subagent with a fixed role:

A slice of work: I write and approve the contracts and test tables, the planner cuts tasks from them, each task goes through a developer and QA with up to two fix rounds, a final review follows, and the findings and the commit come back to me. Me contracts and test tables, first approved Planner Fable · contracts → waves of tasks Orchestrator briefs quote the contract Developer Sonnet · Haiku for exact briefs QA Sonnet · reports, can't edit defect Fix round max 2, then me all tasks pass Finish review Opus · whole branch, can't edit Me ranked findings, then the commit
One slice of work. It starts and ends with me. The reviewers can report problems but can't edit code.

A few decisions matter more than the rest:

  • Every brief quotes the contract items it touches, word for word. It never paraphrases them. I learned why the hard way (story 1 below).
  • The reviewers have no edit tool. QA and the final reviewer can only report. A separate dispatch applies their findings, so a review can't turn into quiet rework.
  • Each task gets two fix rounds. A third defect stops the loop and comes to me, even when the loop is running unattended. The same goes for anything that conflicts with a decision already on record.
  • The plan gets checked too. Before anything is dispatched, the orchestrator checks the plan against the real API and against the contracts of every module a task writes into.

What went wrong

The most useful file in the whole setup is a plain log. Every time something breaks, it gets a short entry: what happened, and what changed so it doesn't happen again. Four from the last slice:

1. The brief reworded the contract and got it wrong

The orchestrator wrote a brief that described a spatial hash's fields in its own words and filed Count under "internal". The contract lists Count as public. The developer did exactly what the brief said and never checked it against the contract. QA caught it, and it cost a fix round.

What changed: briefs now copy a type's public surface straight from the contract. If a brief and the contract disagree, the contract wins and the developer reports the difference.

2. An advisor overruled a decision

A brief pointed at a field that was internal in another module, so the code wouldn't compile. The developer asked its advisor (a stronger model it can consult) and got back "the disk wins": when the brief and the actual code disagree, trust the code. So it rebuilt the feature around a hashmap design that the project's decision archive had explicitly ruled out. Then it reported the task as done, with concerns.

What changed: "the disk wins" only applies to facts, like a line number or the order of a file. If the code on disk blocks a mechanism that a contract or a recorded decision names, the developer stops and asks for context instead of redesigning. An advisor doesn't get to overrule the project's contracts.

3. The bug was in the plan

The plan said unit size would come from the prefab's scale and flow into LocalTransform.Scale. The Units contract says the prefab's transform is only a placeholder and that spawning writes a scale of 1, and a contract test asserts exactly that. The developer reported it as "a bug in the Units module next door". It wasn't a bug. It was a promise with a test behind it, and the plan contradicted it.

What changed: when a developer reports a bug in a neighboring module, that's a claim, not a fact. The orchestrator checks that module's contract tests first. Often the "bug" turns out to be a tested promise, and the fix goes the other way.

4. The fixture that never ran

A test fixture's teardown disposed a query after it had already disposed the World the query came from. That throws. But at that stage the contract tests were pure functions that never created a World, so the teardown never ran, and it sailed through review. When the runtime code arrived, all 16 new tests failed in teardown.

What changed: fixture setup and teardown count as unread until at least one active test actually runs them.

Story 3 is the one that keeps me going with this approach. The contract didn't stop the mistake. But it made the mistake easy to see and easy to place, and the fix landed in the right module on the first try.

What it costs

Numbers from the last slice, which had 31 tasks (27 code, 4 docs):

  • 9 of the 27 code tasks needed at least one fix round, and 2 needed two. None needed a third.
  • After every task had passed its own review, the final whole-branch review still found 3 major and 17 minor issues. That last review isn't optional.
  • 457 EditMode tests passing at the end.
  • 16 modules and 67 assembly definitions. That's a lot of structure for a game that can't fight yet.
  • 130–230k tokens per task. Most of that goes to reading (contracts, existing code, fixtures), not writing. I've started trimming each brief's reading list down to the contract items the task actually touches. I haven't measured whether it helps yet.

One cost doesn't show up in the numbers: contracts drift. After the code lands, a docs task has to update the contract text, and those docs tasks have a way of ending up on the critical path.

What I don't know yet

  • Whether this survives design changes. So far it's all infrastructure: the simulation tick, spawning, movement, the camera. The real test comes when a mechanic turns out not to be fun and three contracts have to change at once.
  • Whether I can keep up with the review. Agents write the first draft of many contracts, and reviewing those drafts properly is real work. It's the part I'm most tempted to skip. Moving review to contracts only works if I actually read the contracts.
  • Whether any of this generalizes. It's one project with one developer.

The skill is open source at github.com/MustafaMese/subagent-loop.