A battle that ticks twenty times a second
The pictures are Unity’s. The outcome belongs to a plain C# loop.

Every battle in Narrow Front is decided by a small C# program that knows nothing about Unity. It advances twenty times a second, accepts or rejects orders, and reports what happened. The tanks, the smoke and the camera are drawn from those reports afterwards.
This note is about how that loop works and why I kept it apart from the engine.
Why the rules live outside Unity
The simulation is a set of plain C# assemblies. Core owns the match. Navigation owns geometry and routes, with no idea of factions or damage. Core calls Navigation; nothing calls back. Neither references the engine.
That boundary gives me three things I use every day. The rules run in ordinary .NET tests, which take seconds. The same code runs in headless tools that replay a recorded battle on my desk. And when I added local play between two phones, one phone could run the battle as the authority while the other simply drew it.
Positions live on a flat 2D plane. Team 0 is the player at the south end, team 1 the opponent in the north. Unity maps the simulation’s Y axis onto its world Z when it draws. Supply is counted in thousandths, as whole numbers, so income never accumulates rounding error.
One tick, in order
A tick is a fixed sequence of systems. The order matters, because each step sees what the previous one changed. Here is the core of it, simplified:
_state.tick++;
_state.rngState = SimState.AdvanceRng(_state.rngState);
RegenerateSupply();
PowerSystem.Resolve(_state, _emit);
CombatSystem.Acquire(_state); // engage before anyone moves
CombatSystem.Move(_state);
StructureSystem.EnterGarrisons(_state, _emit);
CombatSystem.Acquire(_state); // look again after moving
CombatSystem.Fire(_state, _emit);
StructureSystem.Fire(_state, _emit);
CombatSystem.Capture(_state, _emit);
CombatActions.EndIfHeadquarters(_state, _emit);
Units pick targets before they move, so a soldier cannot walk straight past a fresh engagement. They look again after moving and before firing. Captures resolve after the shooting, so a house goes to whoever is still standing in it. The match random number generator is part of the state and advances once per tick, which matters later for replays.
Orders are requests
The player never changes the battle directly. A card tap becomes a command: recruit this unit, cast this power at this point, set this stance. Each command carries a sequence number, and Core accepts it only if it is exactly one past that team’s last accepted command. A rejected command spends nothing, spawns nothing and does not advance the sequence.
The same gate has a dry-run mode. It returns the rejection code a command would get right now, without changing anything. The targeting overlay uses it, so when the game shows a strike as allowed, it is showing what the authority will actually decide.
Shots, cover and farmhouses
How a shot lands
When a unit fires at another unit, damage goes through a short chain. A squad’s bullets do nothing to a tank. Its grenades and charges do, once the tank is within a few metres. Armour depends on the arc: a vehicle is tougher from the front than from the side or rear, based on where the shooter stands. Then come the combat conditions, such as suppression and combined arms.
Cover is where the farmhouses earn their place. If the target is garrisoned in a standing building, the walls take the shot and the soldier behind the window takes what gets through. In code, the occupant’s share is cut by 60 % and the building takes structural damage of its own. A house that collapses throws its occupants out and hurts them on the way.
Rockets, bombs and other splash rounds land inside a scatter radius, chosen from the match random number generator. They deal area damage that falls off linearly from full at the impact to half at the edge of the blast. Every enemy soldier inside is suppressed, even when the damage rounds down to nothing. There is no friendly fire.
Taking a farmhouse
Capture is deliberately simple. Each tick, Core adds up the capture rate of living infantry inside a band around each objective, for each side. Vehicles cannot capture. A pinned soldier cannot capture either, which is why suppression matters so much. If both sides are present, nothing moves. If only one side is there, progress builds towards a fixed goal, and the objective flips when it gets there.
Held ground pays. Every standing house a side owns adds to its supply income on top of the base trickle. That is the loop the whole game hangs on: take a house, earn more, spend it on the next push.
An opponent that plays by the same rules
The computer opponent sends the same validated commands as a player, at most one per decision. It keeps no state of its own. Every choice is a function of the current snapshot and its profile, so a fresh opponent after a restore makes the same decisions as the old one.
It works through its concerns in order: repair what is hurt, answer a threat to its base, strike a worthwhile cluster, lift a hold it no longer wants, buy what the battlefield needs, then set its stance.
The five skirmish ranks, Recruit to Ace, differ in pace and habits. At the Recruit setting it waits about five seconds between orders and eighteen between tactics. At Elite it is about two seconds and under eight. Higher ranks counter-pick the player’s army, aim strikes at the densest group and save supply for armour. What a rank never gets is a bigger army. A mercy rule caps the opponent’s army at the same multiple of the player’s at every rank, unless its own half is under threat, and its cards play at the player’s average level.
The same battle twice
A battle should replay exactly from its commands. That sounds automatic for a fixed-tick loop. It isn’t. Sine and cosine come from each platform’s maths library, and they can round the last bit differently on Android, iOS and a desktop. Worse, Mono’s JIT in the Unity Editor kept some float intermediates at double precision, and my Editor runs diverged from the .NET reference.
Core now uses its own sine, cosine, arc sine and arc tangent: single-precision polynomials built only from add, subtract, multiply, divide and square root, with an explicit float cast at each step. Those casts look redundant, and the code says not to remove them. A test pins the functions’ fingerprint and scans the simulation for any use of the platform versions.
Replays record the player’s commands and compare a state checksum every 200 ticks. The checksum covers each unit’s identity, position bits and health, and it is cheap enough to compute on a phone. I am careful about what I claim here. Same-build restore and replay are tested. Cross-platform lockstep is not something I promise.
Drawing what happened
Presentation reads snapshots and events. Effects never decide a hit. A pool of 80 visual rounds draws shells, bullets and rockets in flight, and health can update before the shell visibly lands. Debris is cosmetic, capped and has no physics authority.
One performance fix came straight from this boundary. The battle view used to copy a snapshot every frame, and the opponent and the campaign director each copied their own every tick. A 140-unit snapshot is about 60 KB, so a phone was collecting several megabytes of garbage a second. Now the simulation hands out one shared, read-only snapshot until the match changes, then builds a new one.
The same split made two-phone play straightforward. The host runs this simulation at 20 Hz as the authority. The guest sends commands and draws authoritative state at up to 10 Hz.
What I would keep
If I started another strategy game tomorrow, I would draw the same line on day one: rules in a library that can run without the engine, commands as the only way in, and snapshots and events as the only way out. Most of the hard problems in this game were easier to see because that line was there.