Tier 2 · Game AI and navigation

Game AI: pathfinding & steering

Engine-agnostic flow fields, steering and crowd rules that survive blocked goals.

Engine-agnostic flow fields, steering and crowd rules that survive blocked goals.

  • A flow field must survive its goal becoming blocked — never bail to an empty field. Lane/base goals routinely end up inside structure footprints once buildings stamp walkability (the enemy castle IS the goal). If the BFS returns an empty field when the goal cell is unwalkable, every follower's "field not ready → head straight at goal" fallback activates forever: units beeline across the whole map, walk through buildings, and orbit the goal point in a clump. In the build job, redirect a blocked goal to the nearest walkable cell (ring search) and build anyway. Same class of bug: a follower standing IN a blocked cell (fresh stamp, avoidance shove, spawn inside a footprint) must escape to the cheapest valid nearby cell, not beeline. (Seen in a survival RTS: every pathing symptom the developer reported traced to this one empty-field bail.)

  • Lane/corridor marching = route-seeded multi-source BFS, not a single-goal flood. A flow field flooded from one goal point takes the shortest walkable path — units ignore the authored lane and cut straight across open terrain. Seed the BFS with cells sampled along the authored route polyline, cost pre-loaded with the REMAINING route distance (in step-cost units): off-route cells then descend toward the corridor and along it, with no hard rails. Post-pass: seeds are sources, so BFS never assigns them a direction — point each at its cheapest reachable neighbor or followers read them as "goal/blocked".

  • Spawned units face their march direction and spawn OUTSIDE their producer's nav footprint. FromPosition-style spawns leave identity rotation (everyone stares at world +Z), and spawning at the building's center puts units inside the stamped-unwalkable cells where they can't sample the field. Pass the spawn facing into the spawn helper and push the spawn origin out past the footprint half-extent (+ margin) along the spawn direction.

  • "At most one actor reacts" = roll EVERY eligible actor, then draw a winner from those that said yes. Never pick one actor first and roll only that one. The two read as equivalent and are not: select-then-roll makes the whole group's observed rate the average member chance, and lets a single unwilling member veto the moment. With per-actor probability curves the collapse is brutal — one member whose curve is 0 in the current situation turns every draw that lands on it into a guaranteed no, even when a 75% member was standing right there. The tell: designers report a behaviour is "much rarer than the numbers say", and the authored chances all look fine when read individually. Correct shape: eligible.Where(a => a.Rolls).ToList, return if empty, then winners[rand(0, winners.Count)]. Same guarantee (exactly one actor acts), and the group rate becomes 1 - Π(1 - pᵢ) — the number the curves actually promise. When a codebase has both shapes, they diverge silently: audit every "pick a random actor then ask it" site. (Seen in a mobile card game: AI out-of-turn Force drew one card-holder at random then rolled it, so a personality with a 95 pile floor vetoed turns where a 55-floor personality was also holding — measured 24% vs the 59% the curves promised at pile 80.)

  • AI-WAIT-IS-A-DISTRIBUTION — a fixed think time makes every bot read as one scripted actor. Roll the wait, and keep the authored number as the MEAN. A pacing table returning absolute seconds ("2s under pile 50, 3s under 75, 4s beyond") is easy to tune and instantly legible as machinery: every opponent shares one clock and every turn is identical, which players report as "they all follow a strict wait rule". Fix it with multipliers centred on 1, so the authored table keeps meaning what it says — a per-turn jitter roll (Random.Range(1-j, 1+j)) AND a per-actor tempo, because jitter alone makes each actor vary against itself while the group still averages identical. Then VERIFY the mean survives: sample the shaped value a few hundred thousand times and check it lands back on the authored number. It only does while the authored actor pool averages a tempo of 1.0, so that is a constraint on future authoring, not a one-off measurement — record it. Derive the per-actor tempo from data the actor ALREADY carries (its personality / archetype index) rather than rolling and storing it: in a session that can migrate hosts or resume from a snapshot, a rolled tempo needs a new field in that snapshot — a wire change — while a derived one reproduces itself on any machine. (a mobile card game: bands returned exactly 2/3/4s; jitter 0.25 plus 60% of each personality's think scale gives 1.60-3.55s at the 2s band, mean 2.04s.) A second lesson from the same tuning pass: a FLOOR is the only clean fix for the fast tail, and it is the one term that breaks the preserved mean — while "the whole band feels quick" is not a variance problem at all, because half of every band's draws are below it by construction. Say that out loud when a designer reports it, or the next fix is another knob that cannot work.

  • AI-WAIT-IS-A-DISTRIBUTION (second half) — a randomised actor delay must be railed against the timeout ALREADY RUNNING, not just against an authored ceiling. That timeout does something punitive on expiry — auto-play, auto-pass, flag the actor idle/AFK — and the flag routinely SHORTENS the next timeout, so one overrun cascades instead of costing a single turn. Clamp to a fraction of the live remaining timer (Min(delay, remaining * 0.8)) as the LAST step, after every floor: a floor authored above the timeout is exactly the case an authored ceiling cannot catch, and reading the LIVE timer is what makes the rail follow a halved deadline it never knew about. Publish the CLAMPED value to anything that schedules off it — reaction windows, counter-play timers — or those fire past the end of the turn they belong to.

  • NAVMESHBUILDER-BOUNDS-SPACES — NavMeshBuilder.CollectSources takes WORLD bounds; BuildNavMeshData takes bounds LOCAL to its position argument. Passing the same bounds to both bakes an EMPTY navmesh with sources > 0 and no error. (a survival game)

  • CHASE-NEEDS-SPEED-MARGIN — a melee chaser at the target's own speed tier never lands a strike on a mover. The chase runs a sprint floor with NO approach braking; arrival deceleration inside the stopping range is what keeps it perpetually one step short. (a project docs/pitfalls/combat-melee.md:29.)

  • PATH-GOALS-RINGS-AND-RADII. A hard keep-out ring around blockers makes goals INSIDE the ring un-arrivable — depenetrate the goal with the same resolve as the agent, or pawns grind and give up beside every placeable; bound unreachable-goal flood-fill (MaxExpansions, plumbed all the way into the job — a windowed rewrite silently dropped it once, 11x the cost); and the waypoint-advance radius stays ABOVE the stopping distance or every corner stalls a tick. (a project docs/pitfalls/pathfinding-walkability.md:32-48.)

  • STEERING-YAW-CAP-IS-AN-ARC-NOT-A-NUMBER — a body that moves along its steering vector while its rotation lags separately will crab, and one that yaws faster than its animation can depict will pivot on the spot. Two defects that hide each other, because each masks the other's symptom. (1) A flat "turn degrees per second" authored by feel drifts far above what the turn clips actually walk — measured across one animal pack, authored caps ran 2–7× the clips' own root yaw (220 deg/s against a 45 deg/s turn clip), finishing a 90° heading change in 0.41 s: too brief for any blend tree, procedural lean or IK to cover, so every downstream animation fix is handed a signal it cannot use. Derive the cap instead: a legged or wheeled body turns by traversing an arc, so yaw = min(ceiling, max(pivotRate, degrees(speed / minimumTurnRadius))) — three numbers you can watch and verify against footage, floored by a pivot rate because a stationary body has no arc. (2) Translating along the desired direction while rotation chases it independently is free heading change; it is invisible at a high yaw cap and an obvious crab walk at a realistic one. Resolve heading FIRST, then travel along the resulting forward, so a heading change costs an arc. Fix both in one change or the second will look like a regression caused by the first.

  • STEERING-CANNOT-REACH-INSIDE-ITS-OWN-TURNING-CIRCLE — an arrival radius smaller than the turning circle is an infinite orbit. Once travel follows facing, a body physically cannot reach a point inside the tightest circle it can walk; it rings the point until whatever timeout abandons the goal. A single arrival constant shared across agents of different sizes WILL break on the fastest-walking one first (measured: 3.2 m walking arc against a 1.5 m arrival disc, while five smaller species in the same table turned inside 1.4 m and were fine — so the bug looks species-specific and is not). Test arrival against max(arrivalRadius, speed / radians(yawRateAtThatSpeed)). The tell is an agent circling a waypoint at constant radius; the cause is never the pathfinder.