MFB · Devlog

MFB-004

The Fight Already Happened

Hello, this week I am going to tell you about the most load-bearing trick in Mutarch: by the time you see the first sword swing, the fight is over. When you click Engage, the engine runs the entire battle to its terminal state, records every tick, and what you watch afterwards is a replay of something that finished before the first frame rendered. The renderer is a video player with opinions. If that makes you feel cheated, hold the thought, I will get to it at the end.

The live simulation I built first

The first combat prototype did what every game does: it stepped the simulation inside the render loop. To be fair to that version, the live approach has real virtues. There is exactly one world and the screen shows it, so there is no recording to store, no cursor to manage, no question about which state is the real one. If Mutarch were a twitch action game where your input lands on every frame, live stepping would not just be simpler, it would be the only option.

But Mutarch is an auto-battler. You do your thinking at the splice bench and the muster screen, and the fight resolves itself, with only sparse input from you (a retreat order, a focus target). And after post #3, combat was deterministic all the way down: every roll comes out of a seeded, forked stream, so a fight is a pure function of its inputs.

The live loop failed me anyway, in two ways. The first was drift. My tick accumulator stepped the sim per animation frame, which means the sim’s pacing inherited every hitch the browser felt like having. The question that killed it: what happens when you alt-tab mid-fight? The browser throttles the animation loop to almost nothing, and on return the accumulator either drops the missed time (the fight silently pauses) or burns through it in a catch-up burst (the fight fast-forwards through its own climax). Both are wrong, and both turn rendering conditions into gameplay. The second failure was quieter: I could not hand a fight to the test suite. Asserting anything about a whole battle meant spinning the loop manually and praying nothing in the harness perturbed the timing.

The insight, which took me embarrassingly long, is that determinism had already paid for the alternative. If the sim is a pure function, running it to the end up front costs nothing extra, and once the whole fight exists as data, every problem above stops existing.

Hand-drawn diagram, two halves. Left, the live prototype: browser frame, step sim, and draw
in a loop, with a lightning bolt labeled hitch striking the arrow into the sim. Right, the
shipped version: sim runs alone to the end, into a FightRecording, into a render loop that
just reads it, with the same lightning bolt left hanging in the air with nothing to
break

One frame per tick, shared until it changes

The recording lives in src/game/combat/replay/fightRecording.ts. The sim ticks at 10 Hz (COMBAT_TICK_SECONDS = 0.1 in src/game/combat/balance.ts), and the recording keeps one frame per tick. Frame 0 is the pre-fight bootstrap, the state before anyone has acted. Each frame holds the cloned unit rows for that tick plus a watermark, logLength, the battle log’s length after that tick. The full log is stored once, and playback at tick N shows the prefix log.slice(0, frames[N].logLength). The same watermark trick keeps the outcome from leaking: only the terminal frame carries a winner, so no UI element can accidentally know how the fight ends before the cursor gets there.

Hand-drawn timeline in three bands. Top, sim frames from tick 0 (bootstrap) to tick N
recorded in a burst, with only the terminal frame knowing the winner. Middle, the battle log
stored once, with dashed logLength watermark arrows from frames into prefix positions.
Bottom, the 60 fps render row blending between frame N and N+1 with alpha
values

Storing a frame per tick sounds expensive, so here is what it actually costs. The clones are necessary because the simulator reuses pooled row objects that mutate every tick, but they are made with structural sharing: before cloning a unit row, the recorder compares it against the previous frame’s clone, and if the observable state did not change, the new frame keeps the same object reference. A dead unit stops changing the moment it dies, so corpses are nearly free, and so is anyone standing around waiting on a cooldown.

I measured it rather than estimating (vitest on my dev laptop, median of 5 runs after a warm-up). A 10v10 skirmish runs 202 ticks, produces 289 log entries, and the recording retains about 3.3 MB by the byte estimator that walks the object graph. The same recording with every row deep-cloned every tick comes to 4.7 MB. I expected that bar chart to be more dramatic, and the reason it is not is honest: even the naive clone shares the static parts of a row (skill definitions, gear cues) by reference, and in a healthy fight most rows really do change most ticks, because units are moving. The sharing earns its keep at the other end of the spectrum: the napkin version says a worst-case 1800-tick fight would retain about 42 MB with naive cloning, and a fight that long is by definition a stalemate, which is exactly the situation where rows stop changing and sharing flattens the curve.

Recording is also fast: that 10v10 records in 91 ms median, which is 20.2 seconds of battle captured at roughly 220 times real time.

Bar chart of recording memory for the same 202-tick 10v10 fight: structural sharing at
3.3 MB and naive deep clone at 4.7 MB, both measured, next to a tall hatched napkin bar of
42 MB for naive cloning at the 1800-tick cap

1800 ticks of insurance

MAX_COMBAT_TICKS = 1800, which at 10 Hz is 3 minutes of simulated combat. The cap exists because two squads of healers can politely refuse to lose to each other forever. This is not hypothetical: an enemy comp that fields membrane_medic squads casting mend_mycelium behind a striker screen once out-healed a reasonable player squad’s entire damage output, and the fight ground on until the cap stopped it. There is now a regression test that fights those comps headlessly across tiers and asserts they resolve well before 1800.

Hitting the cap used to throw an error, which is a strange way to lose a fight. Now it terminates through the normal outcome path as a draw: no winner, both sides withdraw, survivors live. The recorder keeps its own guard a hair above the sim’s cap (`MAX_COMBAT_TICKS

  • 8`), and if that ever trips, something is genuinely broken and the game falls back to the live path rather than hanging.

The renderer is a video player with opinions

Playback interpolates. The recording has a frame every 100 ms, the screen draws at 60 fps, so the renderer blends between frame N and N+1 with an alpha factor. Units glide instead of teleporting 10 times a second.

One practical wrinkle: recording a big fight in one synchronous gulp would freeze the UI, since 91 ms for a 10v10 already blows the 16 ms frame budget several times over. So the recorder is resumable and advances in 10 ms slices between paints, behind a progress bar labeled “Choreographing the battle”. The bar is honest, it tracks recorded ticks, not a fake animation. How I found out this needed to exist is a war story for another week.

What the recording buys, beyond smooth playback:

  • A golden test pins an entire fight. src/game/combat/golden/combatGolden.test.ts runs the 4v4 demo fight and snapshots the outcome: ally win, 208 ticks, 904 total damage, plus every unit’s damage contribution. Any engine change that moves combat math by one point fails CI.
  • Bug reports are replays. The recording stores the seed and the command log, so “fight went weird” reproduces exactly, every time, on my machine.
  • One source of truth. The post-fight summary, the contribution stats, the battle log feed, and the timeline scrubber’s kill markers all read from the same recording.
  • Your orders still work. A retreat or focus command issued mid-playback re-simulates the fight from the same seed with the command injected, and every tick before the order is byte-identical to the timeline you already watched. The past never changes, only the future.

Which brings us to the cheated feeling. Some of you read “the fight is pre-resolved” and felt the game become a movie. Here is why I am comfortable with it. Your agency in Mutarch was always in the prep: the squad you spliced, the skills you socketed, the fight you picked. That is the genre contract of an auto-battler, and pre-resolving changes nothing about it: the same inputs produce the same fight either way. The difference is invisible by construction (that is what the determinism tests prove) and everything it enables, scrubbing, golden tests, honest replays, mid-fight orders that splice cleanly, lands on your side of the screen. I will take an invisible trick with visible payoffs every time.

As always, let me know what you think.