Make Tower, Win War
A mobile-first 3D co-op tower defence for Android and Windows, built on a deterministic simulation that is checked tick for tick in two languages.
- Role
- Founder · product owner · architect (built with an AI agent team)
- Timeline
- October 2026, ongoing
- Status
- Pre-launch
- Godot 4.7 (.NET)
- C#
- TypeScript
- Blender
- Cloudflare Workers
- Android
- 361Commits, 8 to 24 October 2026
- 14Golden games replayed tick for tick in C#
- 16Hand-built maps in six realms
- 60 fpsFrame rate on a Samsung S26 at wave 15

The problem
Dozens of towers and a crowd of creeps must be simulated and drawn on a phone, and up to four people must see the same game without a server running it. The hard part is making one game play out identically on every device, and then proving it. I wanted a classic custom-map tower defence built this way: touch controls, short sessions, no account, no ads.
What I built
A 3D low-poly tower defence in Godot 4.7 (.NET, C#) for Android and Windows, with Steam planned. It has 16 hand-built maps in six realms, heroes, daily and weekly games, Endless and Boss rush, and co-op for up to four players through a five-letter room code. The core game is free, with no ads and no timers to pay off; the one purchase adds maps and options, not power. It is pre-launch: the closed-test build is being prepared and Play Console set-up is under way.
Maps are validated JSON files, and a seeded generator makes more. Towers and creeps are scripted in Blender and exported as glTF. Ground textures, the painted textures on bosses, heroes and landmarks, and the title crest and button frames are AI-generated and hand-edited; the screenshots show them.
Architecture
The TypeScript simulation is the reference for the rules; it records golden games, which the C# port in the client must reproduce hash for hash.
Engineering highlights
Determinism is enforced, not hoped for
Co-op sends only inputs, so the same inputs must give the same state everywhere, and I made that mechanical. The simulation uses whole numbers only, with one seeded random stream per purpose. The state hash throws on a fraction. A test fails the build if the simulation source uses unseeded randomness, the clock, timers, trigonometry, powers, locale functions or float-to-text conversion. A change to how a game plays means bumping a version number and re-recording the golden games in the same commit. Evidence: packages/sim/test/determinism-guard.test.ts.
// packages/sim/src/hash.ts (the state hash: lines removed, none edited)
/** Mix in a 32-bit integer (little-endian byte order). */
addInt(value: number): this {
let v = value | 0;
for (let i = 0; i < 4; i++) {
this.h ^= v & 0xff;
this.h = Math.imul(this.h, FNV_PRIME) >>> 0;
v >>>= 8;
}
return this;
}
/**
* Mix in any safe integer (up to 2^53), as a low and a high 32-bit word. Throws on fractions,
* NaN or Infinity: a sim state that holds one has already gone wrong, and a silent hash would
* hide it from desync detection.
*/
addNumber(value: number): this {
if (!Number.isSafeInteger(value)) throw new RangeError(`StateHasher: ${value} is not a safe integer`);
const hi = Math.floor(value / TWO_32);
const lo = value - hi * TWO_32; // always in [0, 2^32)
return this.addInt(lo).addInt(hi);
}packages/sim/src/hash.tsThe same game in two languages, checked every tick
The client is C#, because the simulation stores integers up to 2^53 and relies on 32-bit multiplication, and GDScript has only signed 64-bit integers. So the rules exist twice, and to keep them equal godot/tests/SimCheck replays 14 golden games through the C# port and compares the state hash after every tick, over 111,000 ticks in all. The TypeScript side replays 13 under Node and under Bun, a different JavaScript engine, and checks a hash every 100 ticks (tools/verify-goldens.ts). The extra C# game is a Wellspring co-op replay with no TypeScript twin.
// godot/tests/SimCheck/Program.cs (the per-tick check: lines removed, none edited)
var game = new Game(map, seed, seats);
int next = 0;
while (game.State.Tick < ticks)
{
var batch = new List<InputFrame>();
while (next < frames.Count && frames[next].Tick == game.State.Tick) batch.Add(frames[next++]);
game.Advance(batch);
int i = (int)game.State.Tick - 1;
if (game.Hash() != expected[i]) return $"first difference at tick {game.State.Tick} (hash {game.Hash()}, expected {expected[i]})";
}godot/tests/SimCheck/Program.csCo-op through a relay that never understands the game
Players send commands to a relay, which every tenth of a second bundles them into a numbered turn of 3 ticks and sends the same turns to everyone. Each client plays a turn at its own tick and every 20 turns reports a state hash; if hashes differ, the majority wins and only the odd one out is told. The relay logic has no network code and no clock of its own, so it is tested in plain Node, including three simulated players with random latency (packages/net/test/lockstep.test.ts). In production it runs as one Cloudflare Durable Object per room, dropping idle connections because the free plan bills them for time.
Drawing 69 towers on a phone
Each tower and creep model is drawn as one MultiMesh, so a kind and tier costs about one draw call, and the tower line’s colour is a per-instance value in the shader. A governor watches 90th-percentile frame time and, when it stays over 18 ms for three seconds, removes what costs most and shows least, one of three steps at a time. A measured run on my Samsung S26 (docs/perf/runs, a repeatable autoplayed Citadel game) held 60 fps from wave 3 to wave 15, ending with 69 towers and GPU frame time of 9.4 to 11.7 ms. A review caught a real flaw: batches silently stopped at 192 instances, so big fights drew fewer creeps than existed; they now grow to 4,096.
Process & quality
I decided what the game is and what ships: Godot as the main game with the web build frozen as a reference, which maps are free, free-licence music, that the closed test runs the real locked build, and the brand emblem. My standing rule: I decide names, money, accounts and anything outward-facing, and agents ask before deploying the live relay.
Agents are in most of the history: 267 of the 361 commits carry an agent co-author line. Code-writing agents worked in separate worktrees, and a lead session merged and re-tested their work. Nine old worktrees held uncommitted work; I kept it as patches rather than merge it blind.
Checks are layered: over 600 test declarations across the simulation, network and relay packages, and a CI file of four jobs (Node tests, Bun goldens, the C# check, a Godot build). As of the 23 October handoff nothing from the Godot port had been pushed, so I have no CI run to point to; I run the same checks locally. A 12-step smoke test of about 108 seconds runs the real game headless: it plays two maps twice at once and requires equal final hashes, has two processes play co-op on the live relay, and forces a crash to require exactly one crash report.
Reviews are role-based and written by agents: eight role agents (design, engineering, art, audio, story, QA, UI, tech art) reviewed the build on 18 October, two “independent tester” reviews followed (an agent playing a first-time genre veteran through bots and debug switches), and a stronger model did a read-only head-of-development review on 24 October. The lead checked its findings against the code; one, about the minimum Android version, was wrong and withdrawn. The balance bots are only a floor, and Meadow on Normal is still too easy for most of them.
What I’d do differently
- Decide earlier where the rules live. Two implementations are safe thanks to the goldens, but a rules change costs a version bump, a re-recorded golden set and a C# port.
- Split the big game screen file sooner: it grew to about 1,700 lines before becoming nine partial files, and agents kept loading it.
- Build the failure paths first. The head-of-development review found that an exception while building the game screen left a black screen and no crash report. It is fixed and has a smoke step.
- Test the store earlier: the billing client has never run against the real store, and no test I wrote can reach it.
Source available on request.





