MFB · Devlog

MFB-006

Cowardice is a tunable

Hello, this week is about the evening I watched one of my own healers stroll into the enemy front line and get deleted in about 4 seconds. It was not a bug in the usual sense. The unit did exactly what it was told, because nobody had told it anything.

Units in Mutarch are spliced together by the player. There is no “archer” unit I can hand-script, because the thing you field might be a six-legged tank that you taught to spit acid at range, or a healer that you accidentally turned into a melee bruiser. The AI layer in src/ai exists because spliced units have no script, so the script has to be derived from the body. And remember from #4 that fights are recorded first and played back later: every decision in this post happens during recording, inside the simulation, never during playback.

Your role is what your body can do

The first thing the AI does with a unit is read its skills. src/ai/capabilities.ts scans them and extracts what the unit can actually do: heal, buff, shield, summon, and crucially its longest offensive reach. Only active skills count, and triggered item-affix procs are excluded on purpose, because a healing proc on a sword must not flip a striker into medic positioning. If the operating reach is above meleeRangeThreshold (9 sim units, on an arena of 150 by 90), the unit is ranged.

src/ai/archetype.ts then classifies the unit into one of six archetypes, and the ladder is short enough to recite. A unit that keeps allies alive (heal, buff, or shield, and is not a summoner) becomes a medic, and here is the design point I care about most: the medic splits by reach. Melee reach makes a battle_medic that wades into the front and heals in the thick of it. Ranged reach makes a field_medic that holds deep behind the line. Same intent, keep allies alive, opposite positioning, because standing next to your patient is correct exactly when you can take a hit for doing it. Non-support ranged units become marksman. Melee units with moveSpeed at or above fastMoveSpeed (19) become skirmisher. Slower melee with def + magicDef at or above durableArmorScore (6) become vanguard. Everything left is a brute.

No archetype is ever authored on a unit. Splice a long-reach attack into a brute and it is a marksman now: it holds behind the wall, it kites, it focuses the most dangerous enemy instead of the nearest one. You did not toggle any of that. You gave it a bow, and the brain followed the bow.

Cowardice is a tunable

The personality of every fight lives in one file, src/ai/tunables.ts, and it is my favorite file in the project. Small numbers, visible behavior. A curated tour:

ConstantValueWhat it feels like in play
meleeRangeThreshold9The line between fighting with your face and fighting from behind the wall
kiteDangerRange24 (combat tightens it to 12)How jumpy a marksman is when a melee unit closes in
kiteSpeedFraction0.6Kiters backpedal at 60% speed, so chasers still close and fights converge instead of becoming a footrace
retreatHpFraction0.3The panic line
reengageHpFraction0.55The courage line
hazardSoakHpFraction0.5Above half HP, an engaged tank stands in the fire and keeps swinging instead of forfeiting its DPS to shuffle out
doomedHpFraction0.12Triage gives up: below 12% HP and heavily focused, an ally is doomed, and the healer saves the heal for someone savable
flankBiasFraction / flankBiasMaxStep0.2 / 3How greedily a skirmisher drifts toward the weakest enemy: a fifth of the remaining gap per tick, capped at a 3-unit sidestep
cohesionLeeway24How far past the team’s front a unit may wander before the line reins it in

The kite range is worth a second look, because the framework default (24) and the combat value (12) disagree on purpose. At 24 a marksman backpedals constantly and contributes nothing. At 12 the dodge happens at the last moment, which reads as nerve rather than cowardice, and the team keeps its damage output.

Whether a unit retreats at all is data: each unit type has a retreatChance, and each unit hashes its own id (FNV-1a) into a stable roll against it. Deterministic, no RNG in decisions, but a swarm of twelve larvae with retreatChance 0.25 has about three cowards in it, and it is always the same three. The swarm frays at the edges instead of routing as one.

Top-down diagram, drawn to scale at 6 px per sim unit: an enemy bruiser with the melee
threshold ring at 9 and the kite danger ring at 12 around it, a marksman stepping out of the
inner band, and a ranged holder parked 18 units behind the ally wall.

Retreat at 30, come back at 55

The first version of wounded retreat had one threshold: flee below 30% HP. It looked fine on paper and insane on screen. A unit drops to 29% and turns to run. A heal tick lands and it is at 31%, so it turns around to fight. It takes one hit, 28%, turns again. On the boundary it flipped direction on essentially every AI recompute, vibrating in place like it was being controlled by two players fighting over the keyboard.

The fix is the oldest one in control theory: two thresholds with a gap. Your thermostat does not switch the heater at exactly 20 degrees in both directions, and neither should a monster. Retreat starts below retreatHpFraction (0.3), and the unit only recommits once it has recovered above reengageHpFraction (0.55). Inside the gap, whatever it was already doing wins. The flicker is gone by construction. Two guards keep it honest: a flee cooldown stops a unit from starting a new retreat every time it dips, and a maximum flee duration forces a chased unit that never recovers to turn and fight rather than backpedal for the rest of the recording.

Two HP charts of the same wounded unit. Left: a single threshold at 0.3 flips the posture
7 times as HP oscillates around the line, until the unit dies. Right: retreat at 0.3 and
re-engage at 0.55 produce one clean exit and one clean return.

Once I had named the pattern I started seeing it everywhere. Hazard dodges got the same treatment, an episode cap plus a cooldown, so a pathological zone layout can displace a unit for at most a short shuffle. Target locks too: an experiment that re-picked targets every tick thrashed hard enough to swing mirror-match win rates by more than 20 points by itself, so fresh picks are rationed and held locks are kept on raw distance. I now believe roughly half of game AI is hysteresis, and the other half is deciding what to be hysteretic about.

The lane system I mostly deleted

I built a lane system. Units were assigned to battlefield lanes, the squad cards grew little role chips telling you who was a holder and who was an aggressor, and the deployment board enforced it. I was proud of it for about a week, which is how long it took a playtester to say the accurate thing: it felt like the game was overriding their squad. They had spliced these units, named them, built them into a composition, and my system was relabeling everyone with its own opinions.

They were right, so most of it went. The chips are gone. What survives is a soft targeting bias: a unit with a resolved spawn lane prefers enemies inside that lane’s band via CROSS_LANE_PICK_COST_MULT (2). Pick cost is distance squared, so a cross-lane enemy has to be about 30% closer to out-bid an in-lane one, the player’s focus order is exempt because orders win, and held locks ignore the bias entirely so it can never cause thrash. Fights still read as two lines meeting front-to-front instead of an X-shaped blender, which is all the lane system was ever actually buying me.

Side note: you might ask why this is not a behavior tree or a utility AI. Maintainability, in one word. src/ai/decide.ts is a single pure function, perceptions in, intent out, a flat ladder of postures (retreat beats dodge beats kite beats flank beats hold beats cover beats advance), every knob a named constant. With tracing on, each decision attaches plain-language reasons (“retreat: hp 28% < retreat 30%”), which costs nothing when off. A guard test pins that src/ai imports nothing from the rest of the app, and the behavior tests pin whole sentences, my favorite being “a fragile ranged healer holds behind but NEVER kites”, which is the tombstone of a stalemate bug. A utility AI would blend all of this into scores I would have to reverse-engineer at 1 a.m. The flat ladder I can read at 1 a.m., and future me, the only other person on this team, has confirmed this experimentally.

As always, let me know what you think.