Skip to content
Subterrans

The seven principles

Seven non-negotiable architectural rules for Subterrans, why each one matters, and what breaks if you skip it.

(Editor’s note, 2026-05-17: Phase numbering has been updated since this post was published. See the current roadmap for the active phase structure.)

Subterrans has seven architectural principles I won’t compromise on. They sound austere written down. They are. The reason they’re non-negotiable is that every one of them protects something I want — replay, future multiplayer, deterministic debugging, AI agents that can actually reason about the code — and the cost of relaxing any of them shows up later, when fixing it requires rewriting half the simulation.

This post is the long version of AGENTS.md, with the reasoning expanded.

1. Strict separation of simulation from rendering

src/sim/ is pure TypeScript. Zero dependencies on Phaser, the DOM, the browser, or any rendering framework. The simulation takes inputs (player commands, time deltas) and produces state. The rendering layer reads that state and draws it. The simulation must be liftable into Node.js with no code changes.

What this protects: headless tests, deterministic replay, future server-authoritative multiplayer, AI agents that can reason about the simulation in isolation.

What breaks if you skip it: every test that touches the simulation has to spin up Phaser. Every replay has to render. The “model” and the “view” tangle into one ball of mud, and a year from now any change to either one risks breaking the other in a way that wasn’t possible to predict.

The lint config flags any import from outside src/sim/ into src/sim/ as a hard violation. PR reviewers — both human and AI — block on it.

2. Fixed timestep at 20 Hz

The simulation advances exactly 50 milliseconds per tick. The renderer runs at the browser’s framerate (typically 60fps), interpolating between sim ticks for smooth visuals. Variable timestep is forbidden.

What this protects: determinism. Same inputs from the same seed produce the same final state, tick for tick.

What breaks if you skip it: SimAnt-era games could get away with frame-rate-tied physics because everyone’s hardware ran at roughly the same speed. In 2026, your laptop might render at 144fps and your friend’s tablet at 30fps, and a variable-step simulation produces different worlds on each. Goodbye replay, goodbye multiplayer, goodbye reproducible bugs.

20 Hz is enough resolution for ant-scale movement and slow enough that the simulation has headroom to do interesting work each tick. The 60fps render layer covers any visual smoothness gap via interpolation.

3. Lightweight ECS-flavored architecture

Entities are integer IDs. Components are typed arrays (structure-of-arrays for hot data) or plain maps. Systems are pure functions that operate on entities matching certain components.

No class Ant. No class Colony. Data is data; behavior is functions.

No ECS library in Phase 1 — no bitecs, no miniplex. The lightweight approach is migration-compatible if I ever need a real ECS, and the absence of the library means there’s no framework to fight when the architecture wants to evolve.

What this protects: cache-friendly hot loops, the ability to serialize the entire world cheaply, and a model that AI agents (and humans coming back to the code six months later) can hold in their heads.

What breaks if you skip it: classes accrete behavior. An Ant class quickly grows methods that touch the colony, the world, the renderer, and audio playback. By the time you notice, you can’t change one without breaking five others. ECS-shaped data avoids that by making the relationships explicit and the data flat.

4. Seeded deterministic random number generation

The simulation uses a single Mulberry32 PRNG instance, seeded at world creation. Every random decision in the entire game flows through this one instance. The seed is saved with the save file. Math.random() is banned in src/sim/.

What this protects: replay. Given a seed and an input log, the world plays back identically every time. That’s how I debug timing bugs, how I write determinism tests, and how multiplayer (Phase 4) becomes feasible at all.

What breaks if you skip it: subsystems quietly create their own RNGs. A worker’s path-finding uses Math.random. A larva’s hatching uses Date.now() % 100. Over a 10-minute round, the divergence between two replays accumulates into completely different worlds. Determinism is binary — you either have it everywhere or you don’t have it at all.

5. No wall-clock time in the simulation

Date, Date.now(), performance.now(), and any other wall-clock APIs are banned in src/sim/. The simulation knows only its own tick counter. Elapsed simulation time is tickCount * msPerTick.

What this protects: identical results regardless of when the simulation runs. A replay started at 3am produces the same world as one started at noon.

What breaks if you skip it: subtle bugs that are unreproducible. “Sometimes the queen lays eggs at the wrong rate.” “Sometimes a worker behaves differently.” If wall-clock time is part of the simulation, every “sometimes” is unfixable without rebuilding from first principles.

This rule is also what makes the game pause-able and step-able. You can call the tick function 100 times in a row in a test and the simulation can’t tell the difference between that and 5 seconds of real time.

6. Fixed-point integer math for all simulation quantities

Floats are banned in src/sim/. Positions, velocities, food quantities, pheromone strengths — all integers, with an implicit scale factor (e.g., 1 unit = 1/256 of a tile). Floats are fine in the rendering layer for visual smoothness.

What this protects: bit-identical results across platforms. IEEE 754 floating-point math is almost deterministic across CPUs but not quite — different SIMD paths, different rounding modes, different math libraries on different OSes can produce off-by-one-ULP differences. Over a 10-minute simulation those differences accumulate into divergent worlds.

What breaks if you skip it: my replay test passes on my Mac and fails on someone’s Linux box. The “deterministic” multiplayer desyncs after three minutes. The headless Node test runs different than the browser one.

The cost is that I write addFixed(x, y) instead of x + y and the math is mildly less ergonomic. The benefit is that determinism is mechanically guaranteed instead of crossed-fingers hoped-for.

7. Snapshot saves with replay logging

Save files are JSON snapshots of the world state. In addition, every player input is logged alongside the seed, enabling deterministic replay for debugging and (later) multiplayer. Binary save format is deferred until JSON becomes a real bottleneck (likely never at our scale).

What this protects: debugging power, replay-driven QA, and a foundation for multiplayer where the network layer just shuttles input logs around.

What breaks if you skip it: debugging colony behavior is guesswork. “It happened around minute three” is the bug report; without replay you can’t reproduce it. With replay, you reload the seed plus input log and watch the same bug happen, every time.

JSON also means saves are inspectable. I can grep them, diff two save states, and write tools against them without writing a binary parser first.


Why these are non-negotiable

Each of these rules is annoying at the moment you write the code. Fixed-point math is annoying. Banning Date.now() is annoying. Refusing to import Phaser into src/sim/ is annoying. Each of them, individually, you could relax for “just this one case.”

You can’t, though. Determinism is a system property, not a per-feature one. The day you let one float sneak into the sim, the property is gone, and you find out three months later when the multiplayer prototype desyncs and you can’t tell which subsystem to blame.

The seven principles together are a contract. They’re enforced via lint rules, PR review (one human, two AI agents — that’s a whole separate post about the dual-reviewer setup), and architectural tripwires that fail CI when they’re violated. The cost is paid up front, in friction. The benefit is paid out continuously, in things working the way you’d expect.

If you’re building anything where determinism matters — a game, a simulation, a deterministic-replay debugger, a multiplayer system — the specific rules will look different than these, but the shape is the same. Pick the properties you can’t compromise on. Make them mechanically enforceable. Refuse to relax them when it’s convenient.

That’s the whole thing.