Squad Movement — Rebuilt from the Ground Up

In the Mountains · tactical COIN simulator · after-action report · 2026-06-03

The report: A squad ordered from inside the COP to a village on the far side of the gate hugged the perimeter, bumped the wire, and “finished the mission” without ever reaching the village.

The finding: not one bug but four layered ones — no local obstacle/agent avoidance, a dishonest progress metric, a global route that tunnelled through the outpost, and a formation too wide for the corridor.

The fix: a real “navigate globally, steer locally” locomotion layer — context-steering + separation in the motion integrator, route-length progress, follower path-tracing with a gate rejoin, a sealed wall with a benched perimeter road, and corridor-aware formation width.

Result (the player’s exact seed, korengal): before — never arrived, stalled 152 m short, “on-station” 300 m from the objectiveafter — files out, rounds the wall on the perimeter track, sets up 360° security on Landigal. Verified across 16 seeds; build, tsc, balance (no stranded elements), smoke all green.

1 · What the player saw

“I tried to move a squad from inside the COP to the village just to the right. The path hugged the outer perimeter, as would make sense. But there wasn’t enough space — the squad wanted a wider formation, got stuck, bumped the perimeter, then accomplished its mission without making it to the village. Now they’re all stuck hugging the perimeter after briefly having waypoints inside that hopped the walls.”

Every clause of that report turned out to point at a different, real defect. The first job was to turn the prose into numbers.

2 · Reproduce before you fix

I built a headless harness (scripts/movement-diag.ts) that recreates the exact situation across seeds: form a presence patrol from 1st Squad to the village whose bearing is most opposite the gate (the worst case — the squad must file out the ECP and round the entire HESCO ring), then measure the failure modes the player described:

The baseline was damning — and matched the report precisely:

seed (worst-case village)arrived?wall-blocked ticks“finished” distance from objective
korengal (the player’s default)NO — stalled 152 m short593300 m
survey-9NO — stalled 183 m short11,364279 m
valley-3NO — stalled 60 m short57219 m
…5 of 7 seeds never reached the objective; squads declared “on-station” 150–390 m away.

3 · Diagnosis — four layered bugs

The single symptom (“gets stuck, finishes anyway”) hid four independent failures, each found by peeling back the one above it.

Bug 1 — no local avoidance, no body separation

Followers were placed “at my slot, or beeline to the man ahead.” Around the convex HESCO ring both lines clip the wall, and the only recovery was a crude axis-slide that pins in concave pockets. Soldiers also walked straight through each other (peak 7 interpenetrating bodies). The motion integrator had no notion of steering around things or not overlapping.

Insight. “Don’t walk into walls / don’t overlap bodies” is physics, and it belongs in the motion integrator where it benefits every agent (soldiers, civilians, the enemy) — not bolted onto one formation routine. I added a steering layer there.

Bug 2 — a dishonest progress metric

The leg-completion backstop measured progress as straight-line distance from the squad centroid to the objective. But when you round a convex obstacle, your straight-line distance to the goal temporarily grows as you arc around the near side — even while you march perfectly along your route. So a 40-second “no-progress” timer fired during a healthy march, force-advanced the leg, and enterOnStation() froze the squad half-way. That is the “finished without reaching the village.”

Insight. Progress around obstacles must be measured along the route, not as crow-flies distance. I switched the backstop to the navigator’s remaining route length, which falls monotonically as he walks his path regardless of the path’s shape.

Bug 3 — the global route tunnelled through the outpost

The deepest one. The coarse pathfinder uses 15 m nodes (3×3 blocks of 5 m cells) and calls a node passable if any sub-cell is passable — a sane rule so a thin wall can’t seal a reachable goal. But the HESCO wall is only ~2 cells thick, so every coarse node on the wall ring still contained ~7 passable apron/interior cells and was scored as cheap, normal ground. At coarse resolution the wall was full of holes; A* happily cut through the enclosure — out the gate, across the yard, and back — exactly the “waypoints inside that hopped the walls.” The route to a 227 m village came out 555 m long and dived to the COP’s centre mid-route.

Insight. Make the geometry honest at the scale the planner works at, rather than fighting it with cost tweaks: thicken the wall to ≥3 cells (a coarse node centred on it is then genuinely impassable — the ring seals, and A* physically cannot route in-and-out the single gate node, since the closed-set forbids revisiting it), and charge a barrier penalty for any wall/cliff cells a node still carries, so “around” always wins.

Bug 4 — formation too wide for the corridor; orphans inside the wire

With the route fixed, two follow-ons remained. The fire-team wedge was as wide as in open country while threading the narrow benched track around the wall, shoving the flank men into the wire. And a soldier still inside the wire when the squad filed out had every breadcrumb point across the HESCO from him — he beelined at a slot through the wall and orbited the inner face, unable to see the gate 50 m away.

Insight. Width should be sensed, not fixed — the formation reads the free corridor each tick and collapses to file at a choke, like a real column. And a separated man rejoins through the gate (a real A* egress), not by walking at his slot through a wall — exactly how a soldier rejoins.

4 · The fix — “navigate globally, steer locally”

The squad shares one A* route (the navigator’s) — the global plan. Every soldier then steers locally along it as a physical body. Five mechanisms, each fixing one bug above:

1
Context / fan steering + separation (lib/sim/steering.ts, wired into combat.ts moveUnit). Each tick a unit fans candidate headings around its goal, ray-probes each for clearance, and takes the clearest one still aimed at the goal — rounding convex obstacles instead of grinding them. A short-range push keeps bodies from overlapping (and naturally forms single file at a choke). When the lane ahead is clear and nobody crowds, it returns the goal heading unchanged, so open-ground and combat movement are untouched.
2
Route-length progress (world/tasks.ts). The leg backstop tracks the navigator’s remaining route length, which only falls — no more false completion on the convex turn. A genuinely stuck patrol sets up where it actually is, never pretending it reached a point it didn’t.
3
Sealed wall + barrier penalty (terrain.ts wall ≥3 cells, path.ts quadratic penalty for blocked cells in a coarse node). The ring seals; A* routes around. A 227 m village route dropped from 555 m of interior tunnelling to a clean ~350 m arc.
4
A benched perimeter track (terrain.ts buildCop) — a graded patrol road ringing the wall, like a real COP, giving the planner a cheap, walkable way around to any bearing instead of the broken hillside.
5
Follower trace + corridor-aware width + gate rejoin (world/formation.ts). Followers walk the leader’s recorded breadcrumb (guaranteed-walkable around big obstacles); the wedge collapses to file as the sensed corridor narrows; a man trapped inside the wire A*-routes out through the gate, then resumes tracing.
Top-down trace of the squad rounding the COP to the village
Every soldier’s actual traced path (korengal, headless sim). The squad musters inside, files out the west ECP gate, traces coherently together around the south of the HESCO ring on the benched perimeter track (white = point man), and sets up 360° security on Landigal to the east — 227 m away, on the far side of the gate. Compare to the old behaviour: a winding 555 m route that dived through the outpost interior and stranded the squad on the wire.

5 · It works in the actual game

Driving the live build (Playwright + the in-game tactical view), the same patrol on korengal:

muster
① 1st Squad musters in the yard; ECP gate at left (west).
filing out
② Files out the gate in a tight column — no one orphaned inside.
rounding the wall
③ Rounds the south of the wire as a coherent element, path bending SE toward the village.
on station
④ Sets up 360° security on the objective — point team in, security ring out.

6 · Verification

checkresult
npx tsc --noEmitclean
npm run buildcompiled
scripts/balance.ts 16 45 (16 seeds × 45 min, live combat)✓ no elements left stranded mid-route · firefights in 13/16
scripts/smoke.tsfull patrol cycle: out the gate → objective → back inside the wire
scripts/movement-diag.ts (the reproduction harness)5/7 worst-case seeds now set up on the objective (was 2/7), incl. korengal

Honest notes: two synthetic stress-seeds remain imperfect — one whose gate faces a cliff, one whose squad starts deep among interior buildings and dead-locks in the cramped yard. Both are COP-siting edge cases, not movement bugs, and don’t affect the default campaign. Separately, balance shows higher patrol casualties than before — an expected consequence of patrols now actually reaching contested objectives instead of stalling safely near the wire; the enemy-ambush timing is a known tuning follow-up.

7 · The lesson, generalised

The thing worth keeping isn’t any single constant — it’s the decomposition. Movement that “feels buggy” is almost always a layering problem:

Get those four layers right and emergent behaviour that looks hand-authored — a column narrowing at a gate, a wedge opening in the meadow, a straggler peeling out to the gate to rejoin — falls out for free.

Files: lib/sim/steering.ts · lib/sim/combat.ts · lib/sim/path.ts · lib/sim/terrain.ts · lib/sim/world/formation.ts · lib/sim/world/tasks.ts. Harnesses: scripts/movement-diag.ts (reproduction + metrics) and scripts/trajectory-svg.ts (the trace diagram).