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:
| Constant | Value | What it feels like in play |
|---|---|---|
meleeRangeThreshold | 9 | The line between fighting with your face and fighting from behind the wall |
kiteDangerRange | 24 (combat tightens it to 12) | How jumpy a marksman is when a melee unit closes in |
kiteSpeedFraction | 0.6 | Kiters backpedal at 60% speed, so chasers still close and fights converge instead of becoming a footrace |
retreatHpFraction | 0.3 | The panic line |
reengageHpFraction | 0.55 | The courage line |
hazardSoakHpFraction | 0.5 | Above half HP, an engaged tank stands in the fire and keeps swinging instead of forfeiting its DPS to shuffle out |
doomedHpFraction | 0.12 | Triage gives up: below 12% HP and heavily focused, an ally is doomed, and the healer saves the heal for someone savable |
flankBiasFraction / flankBiasMaxStep | 0.2 / 3 | How greedily a skirmisher drifts toward the weakest enemy: a fifth of the remaining gap per tick, capped at a 3-unit sidestep |
cohesionLeeway | 24 | How 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.

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.

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.