MFB · Devlog

MFB-012

The World Plays Itself

Hello, this week I loaded a save from three patches ago and the clan war came up correct, which sounds like nothing until you know there is no clan strength stored anywhere in that file. In #3 I argued that your save file is mostly a number. This week I get to show the corollary: a political situation is mostly a number plus a list of things that happened.

The rule that holds the living world together is one sentence: simulate ledgers of events, derive everything else, persist almost nothing. Before I show the three systems built on it, let me name the design it replaced. The naive version of “clans get weaker when you fight them” is a persisted clanStrength number that combat resolution decrements, raids increment, and so on. I had that field sketched out. The problem is the “and so on”: every new system that touches the war (feuds, coronations, raids, succession) has to remember to update the number, and the first one that forgets makes it quietly wrong forever. Worse, the number freezes the rules at write time. If I rebalance how much a kill should matter, every existing save carries strength computed under the old rules, and a save from three patches ago is a lie. So the field never shipped. Here is what shipped instead.

A clan is nine names and an append-only list

Mutarch has six rival clans (Blood, The Verdant Grove, Khargol, Ossari, The Tallymen, and The Moult of Vhel), and each one maintains a cast of named champions, capped at 9 living per clan (CAST_CAP in src/game/world/championConstants.ts). A champion is the unit of memory in this game: it has a name, a rank in the clan pyramid (whelp, reaver, warlord, apex), and, crucially, a history array of append-only events. first_seen, we_defeated, fled, killed, crowned, won_feud, raided_us. Every one stamped with the world tick it happened on, and nothing ever edited or removed.

This is the load-bearing trick: a champion’s “personality” is a fold over its event history. What it remembers about you, what the Chronicle writes about it, how the war scores it, all of that is some function over the same list. Which means any system in the game can participate in the politics by doing exactly one thing, appending an event. Feuds, raids, and succession were added months apart and none of them needed to know about the others, because none of them write to shared state. They write to the end of a list.

Two thresholds give the cast its life cycle. A champion whose grudge against you reaches 50 (HUNTING_GRUDGE_THRESHOLD) flips from active to hunting, which is what gates raids on your colony; you can watch a rival cross that line and know what is coming. And at the other end, a whelp you never actually met, born more than 120 ticks ago (CAST_STALE_TICKS), is quietly retired by a garbage collector with a final vanished event. The code comment says “vanished into the rot” and I stand by it. Champions you have fought even once are sticky and never collected, so the cast turns over without deleting anyone you would recognize.

Hand-drawn diagram of the ledger pattern: an append-only champion event list on the left, three derivation arrows fanning out to clan strength, epithet, and hunting state, and below it a persisted clanStrength field crossed out

Nothing on the right is stored. The crossed-out box is the field that never shipped.

There is no clanStrength field

src/game/world/clanWar.ts opens with the sentence “the per-clan dynamic record, DERIVED, never persisted”, in those capitals, because I wrote it as a warning to future me. A clan’s war strength at tick t is a pure function of its champions’ ledgers: every event carries a signed weight (an executed named kill is -16, a plain kill -12, a coronation +6, a raid that landed on you +4), and each weight decays linearly to nothing over 160 ticks (STRENGTH_RECOVERY_TICKS). Strength sits at a baseline of 100, clamped to [40, 170], and is bucketed into four bands (broken, weakened, standing, ascendant) with boundaries at 70, 90, and 115. A clan you gutted last season has rebuilt by now. A clan riding a winning streak cools back to baseline once the streak ages. The world heals with no tick hook and no migration, because nothing is stored.

The region map consumes this directly. Strength over baseline becomes a territory reach factor, clamped to [0.6, 1.4] so borders move but realms never vanish or swallow the map, and the territory layer is re-grown with the same Dijkstra competition as worldgen, each realm’s steps weighted by one over its factor. Only the territory grid moves; capitals, sites, and roads stay pinned, so a weakened realm’s outlying sites end up standing on ground it no longer holds, which reads exactly as “the wilds crept in”. The factors are quantized to steps of 5 strength before regrowing so that near-identical war states share one regrown map and one cache entry, which keeps the whole political layer at two cached maps instead of a new flood fill per render.

The one place this pattern genuinely fought me was the Chronicle. I wanted entries like “House Khargol is broken”, stamped at the tick it happened. My first version compared the current band against the band from the last time the Chronicle was derived. It worked in the demo and was nonsense in play: entries got stamped at whatever tick the player happened to open the screen, so a clan broken on Tuesday was reported broken whenever you next checked your mail. The insight, which took me an embarrassing evening to see, is that the strength curve is piecewise linear, so the crossings do not need to be observed, they can be computed. Breakpoints sit at each event’s tick and each event’s full-decay tick 160 later; between breakpoints the curve is a straight line, and a straight line crosses a band boundary at a tick you can solve for exactly (clanWarCrossings). A later now only ever appends crossings, so the Chronicle is as deterministic as everything else from #3. Derived state has no “moment it changed” by default; you have to derive the moments too.

Line chart of one clan's derived war strength over 400 world ticks: baseline at 100, two sharp downward spikes labeled with the kills that caused them, the 160-tick decay sag back toward baseline, and the band boundaries at 70, 90, and 115 as dashed lines

Generated from the real clanWarStates fold over a scripted ledger. The cliffs are kills; the slow climbs are the decay healing the clan.

Three renders of the same region map at war strength 60, 100, and 160: the realm's borders pull inward past a pinned site at 60, sit on the dashed baseline at 100, and push past the old line at 160

The game’s own map painter, same seed, three strengths. Dashes mark the baseline border; the circled site on the left stands in ground its clan no longer holds.

Side note: you might ask why the world runs on ticks instead of real time. One world tick is one resolved expedition node, so the simulation only advances when the player does something, and only runs while someone is looking. That is what keeps the whole thing deterministic and replayable per #3, and it is why “decays over 160 ticks” means the same thing in every save.

The alpha’s aura is a skill that does not exist

Your own units run the same ledger pattern, pointed inward. Every clanmate accrues deeds (kills, elite kills, last stands, avenged allies, routs survived), and deeds convert to renown after each fight: 2 per killing blow, 8 extra for an elite, 25 for the killing blow on a named champion, which is the career moment it should be. Damage contributions are share-based pots (6 renown split by share of damage dealt, 4 by share taken) so renown stays comparable across tiers; absolute damage grows without bound as you go deeper, shares do not. The top unit with at least 60 renown and 3 battles becomes the alpha, if it leads second place by 1.25 times; closer than that and the claim is settled with a duel. There is a whole politics layer on top of this (authority, depositions, worse) that deserves its own post.

The alpha radiates an aura over the whole fielded clan, and its title is diagnosed from how the renown was actually earned: a body-count career crowns “the Ripper” (+8% ATK clan-wide), a damage-soaking one “the Unbroken”. The alpha itself gets +10% HP and ATK on top and is drawn 12% bigger, because the biggest one should be the biggest one.

Now the confession. Three of the epithet effects are triggered: Wavering (the clan falters for 6 s when the alpha dies), Clan Fury (a surge when a clanmate falls), and Last Light (a surge below 30% HP). These are implemented as synthetic SkillDef objects, built at combat construction time in src/game/politics/combat.ts with ids like alpha_wavering, injected into units at the tail of buildAllyCombatUnits, and never registered in the skill catalog. The skill system has no idea they exist until they arrive wearing a trench coat, claiming to be ordinary skills.

This is a hack and I want credit for knowing it. The honest alternative was a proper buff system: a parallel pipeline for combat-time stat and trigger effects that are not skills. I steelmanned it for about a day. It is the architecturally correct answer, and it would exist to serve exactly one feature, which means a second event-timing system, a second serialization path for the combat log, and a second thing to keep in sync with the first, maintained by the only other person on this team, who is me in six months and who would refuse. The synthetic skills ride machinery that already knows how to trigger, stack, expire, and appear in the combat log. The flat payloads are computed from each wearer’s own built ATK, so they scale with tier for free. The hack is load-bearing and the load is fine.

The squad power readout missed the aura

One shipped bug, told honestly. The aura is applied at the very end of building combat units, but the squad power number on the pre-fight screen is computed from base field stats well before any combat build happens, so it did not include the aura. The fight used it; the number promising you the fight did not. With a Ripper alpha fielded, the display was lying low by 8% across the clan plus the alpha’s own 10%, which is the difference between attacking a node and waiting a day. The fix is a mirror function, fieldUnitStatsForClan, that applies the same aura math outside combat; the power score and the stat tooltips now go through it, and a regression test asserts that a built unit’s ATK equals what the mirror predicted, so the two paths cannot quietly drift apart again. The lesson is older than this devlog: any number shown to the player must come from the same function as the number used by the simulation.

As always, let me know what you think.