MFB · Devlog

MFB-009

Budgeting enemies like a pit boss

Hello, a few weeks ago a test clan walked into a tier 1 elite node that the offer card rated an even fight, and got erased to the last larva. The card was not lying about the budget. The director had spent exactly what the card promised, and then quietly handed the elite a 35 percent stat bonus on the house. This post is about the system that assembles every enemy squad in Mutarch, and about the rule that keeps it honest: the number the game shows you must be the number the director actually spent.

One point budget, spent like money

Mutarch’s expedition ladder has no top. The content tier is an unbounded integer, so hand-authoring encounters means either a content treadmill I lose by definition (I am one person) or fights that go trivial the moment the player outgrows the last thing I wrote. So nobody authors fights. A director does, in src/game/combat/encounterDirector.ts, and it works like a pit boss with a till.

Every offer on the expedition board freezes a requiredPower at spawn. Engage a node and the director’s point budget is that frozen number times a node multiplier (NODE_MULT: 1.0 for a regular fight, 1.25 for an elite stand, 1.6 for a boss), swung by a variance roll of plus or minus 10 percent (BUDGET_VARIANCE_PCT = 0.1). It then picks a composition idea and a template (a ratio recipe like “tank plus ranged”), and fills slots with creature cards until the money is gone. Each card’s price is its power score grown by the same per-level curve the unit’s stats actually get, 1 + 0.12 × (level − 1), so a point spent is a point of fielded threat at every level. There is deliberately no body cap; fight size is emergent from budget divided by price.

Why variance at all? Because identical fights at the same tier read as stamped from a mold. 10 percent is wide enough that scouting both stands on a route is worth your time, and narrow enough that the power rating stays honest: the card’s number is the center of the spend, and the worst case is 10 percent hot.

Hand-drawn mock of an offer card next to the encounter debug meta of its elite stop, an
arrow matching the card's 1,225 peak demand to the node's pointBudget of 1,178, which is
1,225 times that node's 0.962 variance roll

Two guards backstop the degenerate end: a budget too small to afford anything still fields one cheapest body (an enemy node with no enemies reads as a bug, not a gift), and a comp that resolves to a single battlefield body appends the cheapest card anyway. Both overspend on purpose and flag it (guardOverspend) so the sim harness can tell honesty from accident.

The elite that did not pay rent

Back to the tester’s wipe. On elite and boss nodes the director elevates one pick to a leader: 1.35 times stats (ELITE_STAT_MULT) plus 3 bonus levels (ELITE_LEVEL_BONUS), compounding through the level curve. The original sin was sequencing. The fill loop charged the leader its plain card price, and the elevation happened after the loop, so the entire premium landed on the field as free threat on top of a fully spent budget. At tier 1, where one buffed body is most of the fight, “free premium” translated to a stomp the card had no way to warn about.

The obvious fix, and the one I shipped first, was to make the elite pay after the fact: compose the squad, charge the real premium, then trim escorts cheapest-first until the spend fits the budget again. It even sounds principled. It was wrong in a way I only saw in the offline ladder sim: cheapest-first trimming kept eating the same victims, the cheap offensive chaff, while expensive support bodies survived the cut. Healer comps came out of the trimmer as a leader, a healer, and nothing that could kill anything. Those fights did not get harder, they got longer, stalling to the tick cap and resolving as draws, and the trim could eat the whole escort and stall composition outright. I had built a machine that pays its debts by selling its furniture.

The insight, embarrassingly late, is that you do not balance a budget by un-spending it. You balance it by not spending money you have already promised to someone else. The shipped director prices the premium into a reserve up front: before filling a single slot, it computes the worst-case premium (the priciest elite-capable body in the pool, priced at the top of the level jitter band) and carves that off the till. The template’s ratio recipe then fills a balanced comp into what is actually affordable, and the real premium is charged when the leader is elevated. Reserve is always at least the premium, so spend stays at or under budget, no trimming, ever. The tester’s even fight is now an even fight.

Hand-drawn diagram of the director composing a squad: a budget bar with the elite reserve
carved off first and creature cards filling the remainder, above a crossed-out rejected
version where the premium overflows past the end of the bar and the cheapest cards are
struck out by the trim

One dial: max(0.8, 0.25 + 0.25 × tier)

Where does requiredPower come from? One function, tierFactor in src/game/balance/progression.ts, so simple I hesitate to graph it:

tierFactor(tier) = max(0.8, 0.25 + 0.25 × tier)

Enemy level is your squad’s average level times this factor. The point budget is your power anchor times this factor (times the node multiplier). One dial scales both axes of difficulty together, over every integer tier: 0.8 at tiers 1 and 2, 1.0 at tier 3, 1.25 at tier 4, 1.5 at tier 5, then a straight line, 2.75 at tier 10, 5.5 by tier 21. No lookup table to fall off, no clamp to hit. One dial means one place to be wrong, which is a feature: every balance bug in this system so far has been findable by staring at one line of arithmetic.

Line chart of tierFactor from T0 to T25 on a log scale, the 0.8 floor at T1 and T2 and the
anchors at T3 = 1.0, T4 = 1.25, T5 = 1.5, T10 = 2.75, T21 = 5.5 labeled, with the un-floored
raw line dashed underneath the floor

The floor is the dial’s one war story. The raw line gives tier 1 a factor of 0.5, and a fresh clan’s anchor of roughly 400 times 0.5 is a 200-point budget: exactly one full-price card, often one body. The opening fights of the entire game were 1v5s. Flooring tiers 1 and 2 at 0.8 puts about 320 points in the till, which reliably affords 2 or more cards and 4 or more bodies, and the openers read as packs again.

Divergent progression gets the same one-formula treatment. When your deepest clan fights at frontier tier F, an offer at tier t below it softens: effective difficulty is tierFactor(t) × max(0.5, 1 − 0.08 × (F − t)), 8 percent per tier of lag, floored at half strength about 6 to 7 tiers back. I could have built a level-sync system, but the synchronization would touch every stat read in combat, and future me would refuse to maintain it. Instead outgrown tiers get progressively stompier by design, and the offer card labels them “Farm” instead of pretending they are a challenge.

Heatmap of effective difficulty with offer tier across and frontier tier up, every cell
computed from the real formula, the softening band fading left of the frontier diagonal and
flattening into the 0.5 floor wedge about 7 tiers behind

Texture comes from elsewhere. From tier 3 up, offers can roll affixes (src/game/world/offerAffixes.ts): Frenzied enemies swing 20 percent faster, Veteran spawns field 20 percent higher levels, Swarming widens the budget 15 percent but spends all of it on the cheapest body available, each priced into the run’s reward multiplier. There were six; Entrenched got cut when forced-single-affix measurement showed it spiking wipe rate by 24.6 percentage points, which is not texture, that is a wall. And because deep play needs a place, not just a number, every 5-tier band past the authored arc (Border through Apex Verge) gets a generated name, so tier 23 lives in the Rootmaw Descent rather than in “tier 23”.

The offer card is a contract

Offers freeze requiredPower at spawn and never re-anchor. A player who outgrows the board quickly can therefore engage a stale offer whose budget buys a comically small fight. The tempting fix, re-anchoring stale offers to your live power, breaks a promise: the offer card already described the fight (“a spitter brood with carapace cover”, derived from the composition), and a re-anchored director might compose entirely new species into it. You would engage the spitter brood and meet something else.

So padding follows a description-honesty rule: an outgrown comp is topped up by duplicating its own cards, round-robin, never new species. The fight fields more of exactly what it promised. The padded demand is one shared formula, paddedRequiredPower: the larger of the frozen budget and 0.6 times your live anchor times tierFactor, capped at 3 times frozen so a pathologically stale offer cannot swell into a perf-hostile horde. The 0.6 floor sits deliberately below 1; outgrown offers staying stompy is the farm content working, padding only removes the degenerate one-card-versus-an-army case.

The gotcha that forced “one shared formula” into a single exported function: my first padding pass computed the pad target inside the director, while the offer card’s power rating still banded against the frozen number. The board said trivial, the fight padded up to substantial, and the card was lying in the safe-looking direction, which is the worst direction. Now offerPeakDemand on the card calls the exact same paddedRequiredPower the director spends through, so the UI cannot drift from the sim without a compile error.

Side note: you might ask whether plus or minus 10 percent and live-anchor padding add up to hidden rubber banding. The variance is rolled as the first draw off the encounter’s seeded rng (the forked-stream setup from #3), so a given node always rolls the same budget, the pre-fight preview already includes it, and it never reacts to how you are doing. The padding is on the card before you click. Rubber banding is the game adjusting to you behind your back; this is the game telling you what it spent.

As always, let me know what you think.