How Pathfinding Works

Pathfinding in Standard of Iron is deliberately simple at the core: the game keeps one flat 2D navigation grid, and A* searches that grid. The grid is not a physics simulation, not a unit occupancy map, and not a navmesh. It is a compact answer to one question:

Per-tick navigation counters -- position tests, standability tests and the cells they scan, nearest-standable searches, group and individual routes, cache hits, cells expanded, heap operations and dirty cells rebuilt -- are described in PERFORMANCE_INSTRUMENTATION.md. Enable them with arena --profile, sim_benchmark, or a runtime benchmark run before claiming any navigation cost has changed.

A gate opening or closing used to rebuild the whole navigation grid. GateService::publish_navigation_blocker_change called mark_navigation_grid_dirty(), which sets m_full_update_required, and process_dirty_regions then filled the grid and re-derived terrain, forest, resource, building, gate and clearance for every cell. On Zama's 800x800 map that is 640,000 cells inside one simulation tick -- a measured 934 ms frame, with update_ms at 922 ms in the same frame, four times in a ten-minute run. GateBlocker already carries exact world bounds, so the publisher now marks only the regions the blockers vacated and the ones they occupy.

The shared grid geometry those queries are built from lives in game/systems/nav_grid_types.h: body_cell_range and cell_gap give the cell box and edge distance for a body circle (Walkability::can_stand and Pathfinding::is_world_position_walkable had separate copies of that arithmetic), and for_each_ring_cell walks the perimeter of a square ring. Both expanding searches -- Walkability::nearest_standable and Pathfinding::resolve_walkable_endpoint -- used to scan the whole (2r+1)^2 square per ring and discard the interior, which made an r-ring search O(r^3) instead of O(r^2). for_each_ring_cell visits the perimeter in the same row-major order the discarding scan produced, so tie-breaking between equal-score candidates is unchanged; tests/systems/nav_grid_geometry_test.cpp pins that order.

Is this grid cell free to pass, or is it blocked?

Terrain, buildings, bridges, hills, and resources feed into that answer. Unit radius is not part of path search. Formations, combat locks, and invalid-position recovery sit around the grid without adding extra public navigation concepts. Keeping that separation is what makes the system fast enough to use during normal RTS play without constantly revalidating every unit.

The Grid

Game::Systems::Pathfinding owns Pathfinding::NavigationGrid, a flat row-major array of std::uint8_t values. Conceptually it is a 2D grid:

grid index = y * width + x

       x=0 x=1 x=2 x=3 x=4
 y=0    0   0   0   0   0
 y=1    0   1   1   1   0
 y=2    0   0   0   1   0
 y=3    0   2   0   0   0

The actual memory is one vector:

[0,0,0,0,0, 0,1,1,1,0, 0,0,0,1,0, 0,2,0,0,0]

The current values are:

Value Name Meaning
0 Walkable Free movement cell
1 Blocked Mountain, river, bridge edge, building, or generic blocker
2 Tree Harvestable tree blocker
3 Boulder Harvestable boulder blocker
4 IronOre Harvestable iron ore blocker

Only Walkable is traversable. Resource cells are intentionally blocked but named, because the collect cursor must know whether the blocked thing is a tree, boulder, or iron ore.

World Space And Grid Space

Units move in world space with floating-point positions. Pathfinding runs in grid space with integer cells.

world position:  ( 12.4, y, -8.7 )
                         |
                         v
CommandService::world_to_grid()
                         |
                         v
grid cell:       ( 112, 91 )

CommandService owns this conversion. Gameplay code should not independently round world coordinates and query terrain/pathfinding by hand. Use the shared query functions instead:

Function Use
is_grid_walkable(point) Test one grid cell against the current navigation grid.
is_world_position_walkable(position) Convert world to grid, then test.
find_nearest_walkable_grid(origin, max_search_radius) Find a nearby valid cell without changing the grid.
snap_to_walkable_ground(position, max_search_radius) Snap orders, exits, formation slots, and delivery positions.

This boundary matters. Pathfinding owns cell values and A*. CommandService owns coordinate conversion and high-level navigation queries. Movement, formations, production, resource gathering, and UI helpers call CommandService.

Building The Grid

Every full rebuild starts with an all-free grid, then layers blockers in a fixed order:

1. Start all cells as Walkable
2. Apply static terrain from TerrainService
3. Apply harvestable resource props
4. Apply completed/loaded building footprints
5. Open doorways: gates, and the gaps a wall network leaves
6. Open map crossings: bridge decks and hill entrances

Pathfinding::update_region is the only place that composes a cell, and a full rebuild is just that function over the whole map. The order is the whole ruling: each layer may overrule the ones above it, and two rules keep a later layer from quietly deleting an earlier one.

A doorway is centred on the gate's own transform, the same position its footprint and its GateService movement blocker are placed from. The wall-network cell a gate snaps to for its connection mask is up to half the segment pitch away, and a doorway carved there leaves the real approach blocked while opening ground beside it.

One rasterisation rule

Anything that has to agree with the grid about where a rectangle is goes through Pathfinding::cells_covering: building footprints, doorways, map crossings, and the gate blocker that refuses movement at runtime. It returns the cells a rectangle covers with area, not the cells it merely touches β€” a two-by-two footprint centred on a cell centre has its edges exactly on cell boundaries, and counting those as coverage would make it three cells wide and swallow the gap its neighbour was leaving open.

A second rasterisation rule is not a rounding detail; it is a hole. Two descriptions of the same doorway that disagree by half a cell leave a strip that is walkable on the grid and covered by no blocker, and units file through the solid part of the wall.

A shut gate widens its blocker to the snapped cell range along the wall line only. Across the wall it stays the structure's own thickness, because a unit already inside the rectangle is free to move β€” that is how anyone gets back out of a doorway β€” and a rectangle reaching further out would hand that freedom to whoever is standing in front of it.

Dirty regions are merged before rebuilding. Building candidates come from the collision registry's spatial buckets rather than a scan of every registered building, and resource props are indexed by navigation cell. A resource revision marks only cells whose indexed value changed. A* results are cached by exact start/end cells and navigation revision; each worker thread owns independent generation-stamped search buffers while the immutable grid is protected by a shared read lock.

Group movement computes one corridor for units whose starts and destinations fall in the same local regions. Individual targets are appended at the corridor exit, with per-unit A* used only when sharing is unsafe. Expensive fallbacks are limited per command and placed in a bounded request queue; MovementSystem services a fixed number each simulation tick.

Terrain layer:

  . . . . . . . . .
  . . . M M M . . .
  . . . M M M . . .
  . . . . . . . . .

Building layer:

  . . . . . . . . .
  . B B B . . . . .
  . B B B . . . . .
  . . . . . . . . .

Resource layer:

  . . . . T . . . .
  . . . . . . I . .
  . . S . . . . . .
  . . . . . . . . .

Final navigation grid:

  . . . . T . . . .
  . B B B M M I . .
  . B B B M M M . .
  . . S . . . . . .

Legend:
  . = Walkable
  M = Blocked mountain
  B = Blocked building
  T = Tree
  S = Boulder/stone
  I = Iron ore

Units are not written into this grid. A unit standing in a cell does not make that cell blocked for other units. Unit-to-unit spacing is intentionally not a pathfinding concern, because writing every unit into the global grid would make movement unstable and expensive.

The same order is used for regional rebuilds: reset the region to terrain, reapply buildings/resources intersecting the region, then force mandatory traversal cells in that region to Walkable.

Forest cells and passability

CellValue::Forest marks the open ground inside an authored forest (forests in the map file). It is the one cell value whose walkability depends on who is asking:

enum class Passability : std::uint8_t { Light, Heavy };

Light treats Forest as walkable; Heavy does not. Everything else β€” Walkable, Blocked, Tree, Boulder, IronOre β€” reads the same for both. find_path, is_walkable, is_world_position_walkable, is_world_segment_walkable and find_nearest_walkable_point all take a Passability, defaulting to Light.

Which units are light is decided by Game::Units::can_enter_forest: commanders, archers, swordsmen, healers, builders, civilians, the Sepulcher's dead and wildlife. Spearmen, cavalry, catapults, ballistae and elephants are heavy. The answer is stamped onto MovementComponent once at spawn, in UnitFactoryRegistry::create, so no call site has to look up a spawn type to path.

Three details that matter:

Enemy Units Are Not Empty Ground

This is the most important separation in the system:

Navigation grid:
  "Can terrain/buildings/resources be traversed?"

Entity world:
  "Which units and buildings exist at this position?"

Visibility grid:
  "Which parts of the map are visible to this player?"

An enemy troop is not represented by a navigation CellValue. That does not make it the same as nothing. It is an entity with a TransformComponent, UnitComponent, owner_id, health, attack state, render state, and selection/picking identity.

Same map cell, different systems:

  NavigationGrid cell (12, 8):
    Walkable

  World entities near (12, 8):
    Entity #304
      UnitComponent.owner_id = 2
      TransformComponent.position = (12.1, y, 8.2)

  VisibilityService for player 1:
    Visible

Result:
  The cell is traversable terrain, but it is not "empty".
  Rendering, picking, targeting, AI, and combat can see an enemy entity there.

The game distinguishes enemy troops from empty ground through the entity layer:

Question System
Can I path through this terrain cell? Pathfinding::NavigationGrid
Is an enemy standing here? World entity queries and UnitComponent::owner_id
Is that enemy visible to me? VisibilityService
Did I right-click an enemy? PickingService::pick_unit_first()
Should AI react to this enemy? AISnapshotBuilder::visible_enemies
Should the renderer draw this enemy? SceneRenderer + fog visibility

This gives us the missing distinction without polluting pathfinding:

Cell state:

  . = walkable ground
  E = visible enemy entity on walkable ground
  F = friendly entity on walkable ground
  X = blocked navigation cell

  . . . . .
  . F . E .
  . . X . .

Navigation sees:

  . . . . .
  . . . . .
  . . X . .

Entity/visibility systems see:

  friendly at F
  enemy at E, if E is inside current visibility

That is intentional. If enemy units were written into the navigation grid, a moving army would constantly rewrite global pathfinding state, every path would invalidate other paths, and large fights would become much more expensive. Dynamic unit avoidance belongs in short-range movement, formations, combat steering, and attack/target selection. Long-range A* should route around terrain and durable blockers only.

If we later need stronger crowd avoidance, add a separate transient occupancy or influence layer. Do not add unit IDs or enemy/friendly values to NavigationGrid::CellValue.

What Keeps Bodies Out Of Each Other

Since the grid knows nothing about units, something beside it has to. Two stages do, and they are deliberately kept apart:

Stage File Job
LocalAvoidanceSystem game/systems/local_avoidance_system.cpp Traffic. Decides how fast a body may go and whether it leans aside. Moves nothing.
BodyContactSystem game/systems/body_contact_system.cpp Contact. Pulls bodies that ended up inside each other apart. The only authority on overlap.

MovementPipeline runs route following, then avoidance, then the motor, then contact, then the traversal layout β€” contact after the motor, because only then is it known where bodies actually ended up.

Avoidance is one rule and no state. For each friendly neighbour in front and inside the lane β€” the two body radii plus a little personal space β€” a body leans towards the side the neighbour is not on; dead ahead is the head-on case, and there both bodies lean to their own right, which is opposite in world space, so they pass. A neighbour walking the same way is followed, car-fashion: the body matches that neighbour's pace only when the gap to its disc is about to close, and never drops below k_min_speed_fraction of its own pace. There is no velocity-obstacle search, no candidate sampling, and nothing remembered between ticks.

Four rules bound it, and each exists for a reason:

Contact is one relaxation. Two overlapping bodies under way are pushed apart by half the overlap each; a body overlapping a standing friend is only eased on along its own travel, never back or sideways (the friend steps aside if it is idle, or is walked through), and every push is probed against Walkability first, so a crowd can never shove a body into a wall or off a bridge; a push that would land somewhere unstandable is dropped and the overlap survives the tick, which is always recoverable. Only a body that is under way is pushed β€” a builder at its site, a worker at a resource, a rank holding its ground, a duellist in a melee lock all anchor the pair, and whoever is moving takes the whole correction and goes around.

A moving body's correction is taken sideways, never along its own travel. slide_along_travel projects the push perpendicular to the direction the body wants to go, so contact can move it aside but cannot brake it. The anchor still takes the whole push, unprojected, which is what stops crowds stacking. Without that projection a body standing dead ahead subtracts from the mover's step every tick at roughly the mover's own speed, and the pair reaches a standstill that looks exactly like terrain. Route followers mostly avoided the corner because avoidance leans them aside first; a body steered by a player's hand cannot lean, which is how the missing rule was found (issue #1417).

Neither stage keeps a spatial structure of its own. Both query Engine::Core::WorldSpatialIndex, which is the one dynamic index over units.

The direct-control commander is a body like any other

There is one traversal model, and the camera the player is looking through does not select between variants of it. In RPG mode the player replaces the route follower and nothing else:

question answered by
where does the body want to go? the player, via CommanderControlController
how wide is the body, what can it cross? Game::Systems::body_profile_for()
may it stand here? may it step there? Walkability::can_stand / can_traverse
what happens when bodies overlap? BodyContactSystem

body_profile_for() (game/systems/body_profile.cpp) is the only place a body's radius, passability and facade rule are derived from its entity. MovementSystem, BodyContactSystem and App::Core::CommanderMotor all call it, so an RPG commander and an RTS-ordered commander are the same body against the same blockers. A commander is person-scale in both modes -- k_person_body_radius, and stops_at_building_facade so he walks to the drawn wall rather than to the navigation padding. That is a property of being a commander, not of the camera; keying it off fpv_controlled would mean entering RPG mode changed what is traversable.

RouteFollowSystem::publish_direct_control_intent is the seam. Under the DirectControl gate there is no route, so the commander's accepted velocity is written into MovementFacts::desired (tagged DesiredMotionSource::DirectControl) before avoidance and contact run. That single write is what puts him inside the shared dynamic-body layer: walking, he is under way and takes his own correction sideways; standing, he publishes nothing, anchors the pair, and friendly traffic flows around him instead of shoving him out of his own ranks.

The rule that follows from all of this: direct control may own how steering intent is produced and nothing else. An RPG-only walkability check, blocker lookup, clearance constant, occupancy grid, collision response or recovery path is a second source of truth even when it happens to agree today. There used to be one -- a per-soldier push-apart in the commander controller that summed every anchor in range and saturated its own clamp every tick, which is what made a friendly rank impassable. CommanderSharedTraversalTest fails if one comes back.

The motor and how fast a body may gain speed

The motor integrates velocity towards the steered target with a gain of 4 Γ— max_speed per second and a half-strength damping term while under way, so a body settles at roughly three quarters of its configured speed (that damping is per-tick, which is one of the reasons the simulation is frame-rate dependent) and gets there in a few ticks. For a foot soldier that reads as stepping off; for a horse it meant 0 β†’ 8 m/s inside one tick, and an elephant or a fleeing sheep likewise appeared at full speed on the first frame. Game::Units::body_acceleration (game/units/spawn_type.h) now caps how much speed a body may gain per second β€” 1.2 m/sΒ² for an elephant, 5 for a horse, 6 for a sheep, 8 for a wolf, unlimited for everything else β€” applied to the translated step against the previous tick's accepted velocity (MovementFacts::motor), not to the integrated velocity. That distinction matters: translation is scaled down while a body turns towards its heading, so a cap on the velocity let it wind up silently during the pivot and then release at full speed the moment the heading came within 20Β°. Capping the step keeps the steady state, the direction blending and the stop exactly what they were; only the wind-up is slower. The quadruped gaits already derive their cadence from ground speed, so the horse walks into its canter and gallop instead of appearing in one.

The mounted charge is the one consumer that cared: its intent is cancelled as SpeedLost after 0.15 s below 2.2 m/s, which a horse winding up from a standstill would always trip. MountedChargeComponent::last_observed_speed lets the processor skip that accumulation while the body is still gaining speed; holding below the cancel speed without gaining is still a lost charge.

The number to watch is SteeringFacts::body_overlap, which the contact pass records and the movement trace carries; MovementFindingKind::BodyOverlap fires when bodies are left standing inside one another, and tests/headless/movement_quality_gate_test.cpp budgets it across the eight capture scenarios.

Terrain Rules

Static terrain is converted into cell values by Pathfinding::terrain_cell_value().

Mountains are always blocked:

M M M
M M M   -> all Blocked
M M M

Rivers are blocked except for authored bridge traversal cells. Bridge cells and bridge centerline cells are forced back to Walkable at the end of grid construction:

The blocked river is wider than the authored river

TerrainType::River is stamped out to width * 0.5, but the ribbon in render/ground/linear_feature_geometry.cpp draws the water wider than that: width_scale times (1 + width_variation_scale) across, plus a meander that shifts the centreline by up to meander_amplitude * width. Blocking only the authored half therefore left a walkable strip under open water on both banks β€” on a 26 m river the blocked band was Β±12.5 m while the water was drawn out to Β±21 m, so units routed along the bank stood in the river. river_drawn_half_width() in game/map/terrain.h folds both allowances into the budget the renderer static-asserts against, and river_bank_standing_half_width() adds k_water_bank_clearance so a unit standing at the boundary does not overhang the water either.

That band is carried by m_water_blocked, a mask consulted by TerrainHeightMap::is_walkable. It deliberately does not change terrain type or the carved bed: the channel silhouette, the ground material and every shipped map's height profile stay exactly as authored, and only walkability moves. m_bridge_walkable is its counterpart on the deck β€” m_on_bridge still covers the full deck for height and traversal queries, while m_bridge_walkable insets it by the same clearance so nobody stands on the parapet. Both are rebuilt in restore_from_data, so a loaded save agrees with a fresh load. tests/map/river_bank_walkability_test.cpp pins all three properties.

River with bridge deck:

  . . R R R . .
  . . e C e . .
  . . e C e . .
  . . R R R . .

Navigation values:

  . . X X X . .
  . . . . . . .
  . . . . . . .
  . . X X X . .

Legend:
  R = river water
  e = visual bridge edge/deck cell
  C = bridge centerline
  X = Blocked
  . = Walkable

Pathfinding must never leave an authored bridge crossing non-traversable. Movement still projects bridge waypoints toward TerrainService::get_bridge_traversal_position() so units visually enter and exit from the middle instead of drifting into rails.

Bridge decks are fitted to the water they cross at load time by fit_bridge_span_to_riverbanks, which both extends a deck that stops short of a bank and trims one that runs far past it. Each end is clamped to [river_bank_standing_half_width + bridge_bank_landing, that + bridge_bank_overhang], so a deck always reaches past the drawn waterline onto walkable ground and never overshoots it by more than a short abutment. Reserving only the authored half β€” or capping the bank landing below the meander allowance, as it once did β€” leaves the deck ending in the water, so the last step off a bridge lands in the river. Two details keep that bounded on awkward maps: the required half-length for an oblique crossing is capped at k_max_oblique_bridge_span times the perpendicular half-width, so a bridge authored at a shallow angle to the river does not grow without limit; and when a deck runs alongside a river without crossing its centreline at all, the nearest river within range is used as the reference instead of leaving the deck unfitted. The overhang is derived from the river width rather than the deck width, because k_min_bridge_width forces every deck to at least eight units for traversal reasons and that minimum should not dictate how far the structure spills onto land.

Hills are authored as connected plateau and entrance cells:

Hill concept:

  X X X X X X X
  X H H H H H X
  X H P P P H X
  X H P P P H E
  X H P P P H X
  X H H H H H X
  X X X X X X X

Navigation:

  X X X X X X X
  X X X X X X X
  X X . . . X X
  X X . . . . .
  X X . . . X X
  X X X X X X X
  X X X X X X X

Legend:
  P = plateau cell
  E = entrance/ramp through the middle
  H = hill edge/slope
  X = Blocked
  . = Walkable

Pathfinding does not invent hill entrances. It consumes the terrain service's walkability mask, then forces authored hill entrance cells back to Walkable during grid construction. Plateau cells still come from the terrain mask. That means the terrain builder must guarantee that plateau cells connect through intended entrances and that edge cells remain blocked.

Dynamic Blockers

Buildings and resources are dynamic, but not per-frame dynamic.

Buildings update the grid when:

  1. A map loads or save state restores pre-existing buildings.
  2. Construction finishes and the building becomes solid.
  3. A building is destroyed or removed.
  4. A footprint changes.

The construction preview is not written into the global navigation grid. Once a building becomes solid, BuildingCollisionRegistry::register_building() marks the footprint dirty. Destruction calls unregister_building() and marks the old footprint dirty so the next grid update rebuilds those cells from terrain plus remaining blockers.

Resources update the grid when:

  1. A map loads authored trees, boulders, and iron ore.
  2. A builder harvests a resource prop.

Harvesting bumps TerrainService::world_props_revision(). If the harvest code marked a local dirty region, Pathfinding keeps the update regional. If a revision change appears without a known region, it performs a full rebuild.

Before harvest:

  . . . . .
  . . T . .
  . . . . .

Builder completes harvest:

  TerrainService removes prop
  world_props_revision increments
  Pathfinding marks local region dirty

After regional update:

  . . . . .
  . . . . .
  . . . . .

Movement Command Flow

A normal move order follows this path:

Player/AI order
    |
    v
CommandService
  - convert world target to grid
  - snap target to walkable ground
  - reject movement if unit is in RTS melee lock
  - use direct path for short clear moves
  - run A* synchronously for longer or blocked moves
  - convert grid path to world waypoints
  - pull the waypoint list taut (see below)
    |
    v
MovementSystem
  - follow waypoints
  - stop only for arrival, melee lock, hold mode, direct-control override, or explicit order reset
  - recover immediately if the unit is in an invalid cell
  - recompute the unit's own path if the next integrated step enters a blocked cell

The system does not continuously re-check every whole path. That would be expensive and would make large fights unstable. It checks the order up front, then movement checks the current segment and a few recovery cases.

Movement animation is not pathfinding state. World::finalize_motion_presentation_frame() treats a unit as moving only when it has active movement state, non-zero movement velocity, actual displacement, chase intent, direct control, or builder bypass. Stale goal_x, recent request history, repath cooldown, and unstuck cooldown are not movement intent and must not keep walk animation running after arrival.

A* runs on the navigation grid with eight-directional movement. It uses generation-stamped arrays for closed flags, costs, and parents so each search does not clear large buffers.

S = start
G = goal
X = blocked
* = path

  . . . . . . . .
  . S * * . . . .
  . . X * X X . .
  . . X * * X . .
  . . X X * X . .
  . . . . * * G .
  . . . . . . . .

Diagonal movement is allowed only when it does not cut through blocked corners. A* does not expand cells by unit radius. This is deliberate: a single-cell bridge, hill entrance, or tight building gap is passable if the cell itself is walkable. Unit radius is used for final arrival tolerance and visual footprint concerns, not for deciding whether a route exists.

A wall laid on the diagonal, one segment thick, is the shape that tests this: every pair of open cells across it meets corner to corner with a blocked cell on each flank, so the wall holds only for as long as nothing is willing to squeeze through a corner. path_diagonal_wall_seal in the arena is that wall.

What a step costs

straight step        10
diagonal step        14      (a diagonal really is longer)
+1  entering a cell that touches anything blocked
+1  turning instead of carrying straight on

Octile costs, and the heuristic is octile distance β€” the exact cost of the cheapest unobstructed route, so the search stays admissible. Charging a diagonal the same as a straight step makes every sideways detour free; the search then wanders across a plateau of equal-cost routes and returns whichever one the heap happened to pop. Manhattan distance has the opposite failure: it double-counts every diagonal, overestimates by up to a factor of two, and quietly turns A* into a greedy walk.

The two +1s are tie-breakers, worth a tenth of a step each:

Is this straight line clear?

is_world_segment_walkable answers the question movement asks before taking a shortcut, and it answers it with the grid: it walks every cell the segment actually touches and refuses a diagonal that squeezes between two blocked corners β€” the same rule the search applies to its own neighbours. Sampling the line at fixed intervals is a second, weaker rule that steps over the thin corner of an obstacle and hands out a shortcut through a wall that has no gap in it.

The path is pulled taut before it is followed

An 8-connected grid path is a staircase: a goal 27 degrees off-axis comes back as a run of diagonal cells, then a run of straight cells, then diagonals again. Followed cell by cell, a formation turns to face each leg in turn β€” the whole 20-horse body swinging 45 degrees one way and back roughly every second, at the formation turn rate β€” which on screen reads as troops flicking left and right rather than routing around anything. assign_path_to_movement therefore string-pulls the waypoint list before the movement system sees it: from the unit's position it keeps advancing the next waypoint while the straight segment to it is walkable (is_world_segment_walkable, the same corner-safe test above) and does not cross a bridge or hill-entrance portal, then keeps only the last reachable one and continues from there. Legs across a portal stay cell by cell, which is what keeps the bridge-alignment projection working. On massed_battle_1000 this took the cavalry columns from 2-8 heading reversals of more than 15 degrees during the approach down to the two or three legs the tree scatter actually forces, at no measurable simulation cost.

A second, finer hunt survived that: a unit chasing a moving target re-issues its move every few simulation ticks, and each fresh route from a slightly different start cell could pick the other side of the same tree, so the first leg's heading flipped by 5-7 degrees several times a second and the formation turned toward each in turn. assign_navigation_target now keeps the route it has when the re-requested goal has moved less than k_route_keep_goal_shift (1.5 m, about one cell) and the new goal is walkable β€” it slides the final waypoint and goal onto the new position instead of re-pathing. A goal that has moved further, or a unit without an active multi-waypoint route, re-paths as before.

Formations And Group Movement

The navigation grid is unit-agnostic. Formations add only initial target offsets:

Grid says:
  "These cells are traversable."

FormationPlanner says:
  "Use this target offset if the slot is walkable; otherwise collapse to the center."

MovementSystem says:
  "Each unit receives and follows its own independent path."

Formation planning uses shared walkability queries for every slot. It does not solve packing, overlap, or passage width. Tight spaces intentionally simplify to center movement:

Target center:

      slot slot slot
        \   |   /
         \  |  /
          center
         /  |  \
      slot slot slot

For each slot:
  1. Snap the center to walkable ground.
  2. Use the offset slot if that slot is walkable.
  3. Otherwise use the center target.

There is no special group route entity and no shared movement path. A multi-unit move is just a batch of individual unit orders. The only group-level behavior is target assignment at order time.

Normal ground:

  target slots:  a b c
                 d e f

Tight bridge / hill entrance / building gap:

  target slots collapse to center:
                 c c c
                 c c c

Resource Gathering And The Cursor

The resource cursor and builder movement intentionally ask different questions.

The cursor asks:

Is there a resource cell here?

The builder asks:

Is there a nearby walkable work cell for my radius?

Cursor hover:

  . . . . .
  . . T . .    cursor may select T
  . . . . .

Builder work search:

  . w w w .
  . w T w .    builder stands on w, never on T
  . w w w .

The resource cell remains blocked while the resource exists. If no radius-valid work cell exists near the resource, the preview is invalid and the order is rejected. Invalid-start recovery is the only flow allowed to use a zero-radius escape fallback.

Invalid Position Recovery

Units should not stand in invalid cells, but it can happen after map edits, save/load changes, construction finishing on top of a unit, terrain changes, or older bugs. Recovery is intentionally narrow.

Normal rule:
  valid -> blocked = stop/repath

Recovery exception:
  invalid -> valid = allow escape

Flow:

  1. MovementSystem checks the unit's current grid cell each frame.
  2. If the unit is already invalid, it searches for a nearby walkable recovery cell.
  3. If the unit had an active order, recovery splices a path from the safe cell toward the existing goal.
  4. If the unit was idle, recovery only moves it to the safe cell.
  5. Once the unit reaches valid ground, normal movement rules resume.

Recovery does not use stop reasons, cooldowns, pending path requests, or a separate recovery state. It assigns ordinary movement target/path data on the unit.

Animated But Not Progressing

The current system is leaner, but a unit can still appear to be "moving forever" if animation state and physical progress disagree. That is possible because these are separate facts:

Fact Owner
Unit has active movement target/path MovementComponent
Unit has non-zero velocity MovementSystem
Unit actually changed world position this frame World motion presentation snapshot
Unit is on a walkable cell CommandService / Pathfinding
Unit is visually playing walk animation render motion presentation

The dangerous state is:

has_target = true
velocity != 0 or animation sees movement intent
position stays in the same invalid/blocked cell
path is not cleared because arrival never happens
recovery does not produce a different reachable cell

This can still happen without old stop reasons or cooldowns:

  1. The next integrated movement step is reverted because it enters a blocked cell, but the unit keeps an active target and recomputes the same first blocked step next frame.
  2. The unit is already invalid, but the nearest recovery cell resolves to a target that the continuous movement step cannot physically enter from its current world position.
  3. The path is grid-valid, but the float-space position, rounding, arrival radius, or blocked-step revert keeps the unit oscillating around a cell boundary.
  4. A movement override outside pathfinding, such as melee lock, hold mode, direct commander control, builder bypass, or combat chase, keeps presentation state active while navigation progress is zero.
  5. The render/motion presentation layer treats intent or velocity as movement even when actual displacement has been zero for many frames.

Because we control the whole stack, this state is not allowed to persist. The mitigation is deterministic and centralized, not another scattered cooldown.

The Stuck Clock

RouteFollowSystem::track_objective_stall runs before anything else in follow() and owns MovementStallFacts on MovementFactsComponent. It exists because MovementProgressFacts::no_progress_seconds is measured against the current route and therefore restarts every time anything repaths: a unit that is replanned once a second can stand still forever without ever tripping it.

Two clocks run instead, and neither can be reset by route churn, order churn, or an AI re-issuing the same march:

Clock Measured against Catches
stalled_seconds net world displacement over a rolling one-second window wedged in place
no_closer_seconds the closest the unit has ever come to its objective walking laps

The objective is the requested goal, never the resolved one. Both clocks reset when the objective itself moves by more than 1.5 m, when a new order arrives, and when the unit is gated out of route following -- melee lock, hold mode, direct control and builder bypass mean standing still is the order, so the clock is not the unit's to run.

The Recovery Ladder

Whichever clock is further along picks the rung, and a rung only ever fires on the way up. Escalation costs a full rung interval of going nowhere (2.5 s of standstill, or 9 s of never getting closer), so a unit that starts moving again de-escalates without acting.

Rung Action
Replan recompute the route to the requested goal
Sidestep local recovery move to a nearby free cell, then continue
RelaxFormation drop navigation clearance to 35% and replan, so a block can pass as a column
Abandoned stop, clear the order intent, state Unreachable, latch objective_abandoned

RouteFollowSystem::abandon_objective is the single implementation of the last rung, shared with the Recovering -> Unreachable path in update_progress, so there is exactly one way an objective is given up on and exactly one place that records it.

Two rules keep this from oscillating. Recovery may not edit the requested goal: a sidestep resolves goal_x/goal_y to a cell metres away, and repathing against that is how a unit forgets where it was sent and reports Arrived somewhere it was never ordered to. And before an objective is written off, reachability is re-checked at the relaxed frontage -- a gate too narrow for a block is still a gate, and refusing it is how a formation ends up standing outside its own objective.

What The AI Does With It

MovementSnapshot carries the stall facts to the AI worker. A unit that is going nowhere counts as idle rather than busy in the census -- counting it as busy is what let a wedged formation sit out the rest of a match -- and Game::Systems::AI::update_stall_recovery gives it a small fixed number of approaches from alternating sides before standing it down from its task and dropping it from the wave, so the next planning cycle gives it something else to do. Workers are out of scope there: a builder at a site holds a movement target without moving as a matter of course.

Still Open

  1. Hard Invalid-Cell Ejection

    If a unit remains in an invalid cell after recovery assignment, do not keep trying ordinary steering forever. Move it smoothly but authoritatively toward the nearest walkable cell center, ignoring unit radius and formation offsets. If it cannot reduce invalid distance after a small frame budget, snap to that cell center.

    This makes invalid placement a temporary visual correction, not a navigation state.

  2. Blocked-Step Retry Budget

    When movement integration reverts a step because the new grid cell is blocked, count consecutive reverts for that unit and target. After a small limit, invalidate the current path and choose one of:

    • recompute path from current grid
    • advance to a later waypoint if the later segment is walkable
    • collapse to nearest walkable cell and clear movement

    This prevents a unit from requesting or following the same impossible first step forever.

  3. Animation Gated By Displacement

    Make walk animation require recent actual displacement, not just has_target or non-zero desired velocity. A unit may keep an active target, but if it has not moved for several frames it should visually idle or play a blocked/recovering state.

    This does not solve navigation by itself, but it prevents false feedback where the unit looks like it is walking while physically stuck.

Coverage

tests/headless/stuck_recovery_test.cpp drives the ladder directly: an order into a sealed pen, an objective inside one seen from outside, a march that must not be mistaken for a stall, a gate narrower than the frontage, and a check that giving up does not turn into a repath loop.

The nine stuck_* Arena scenarios reproduce the geometry that wedges formations in play -- gateways, town streets, close woods, a hill pinch, a rock field, half-blocked ruins, junction traffic, an objective ringed by stone, and an order changed mid-march. Each asserts NoPermanentStall: no unit may end the run holding an objective it has stopped advancing on, and a long stall that nothing noticed is a failure even if it resolved. stuck_hill_pinch additionally asserts StallRecoveryObserved, so the ladder is proven to run rather than assumed to.

Every Arena report prints a movement row per group whether or not it asserted anything: worst stall and when, the objective, the state it was in, repaths, recovery attempts, abandons, and anything left wedged.

Melee Lock

RTS melee lock is a combat rule, not a pathfinding state. A locked unit should not be pulled out of melee by a movement command.

Movement command issued
        |
        v
Is unit in valid RTS melee lock?
        |
     yes| no
        |
        v
 reject movement      continue path planning

CommandService::move_unit() rejects movement for units participating in melee lock. MovementSystem also clears active movement while the lock is valid and keeps combat orientation under combat control. Pathfinding does not break melee lock.

Dirty Regions

The grid is rebuilt only when inputs change.

Full rebuild:
  map load
  terrain restore
  explicit full navigation invalidation
  unknown world-prop revision change

Regional rebuild:
  building registered/destroyed/moved
  harvested resource with known position
  local footprint change

A regional rebuild resets just that rectangle to terrain, then reapplies buildings and resources intersecting the rectangle:

Dirty region:

  . . . . . . .
  . . [-----] .
  . . [-----] .
  . . [-----] .
  . . . . . . .

Only cells inside the brackets are recomputed.

That is the main performance contract: changes are sparse, so updates should be sparse.

File Responsibilities

game/systems/pathfinding.h : Defines Point, DirtyRegion, Pathfinding::CellValue, Pathfinding::NavigationGrid, A* search, and dirty-region state.

game/systems/pathfinding.cpp : Builds and updates the navigation grid, applies terrain/building/resource layers, forces mandatory traversal cells walkable, and runs A*.

game/systems/command_service.cpp : Owns the pathfinder instance, converts world/grid coordinates, exposes shared navigation queries, resolves move targets, and issues per-unit movement.

game/systems/movement_system.cpp : Follows waypoints, integrates velocity, suppresses movement during melee lock/hold/direct-control overrides, reverts blocked steps, and assigns immediate local recovery when a unit is in an invalid cell.

game/core/world.cpp : Builds motion presentation snapshots from active movement/combat state. It does not own pathfinding and must not derive walk animation from stale path request bookkeeping or stale goals.

game/systems/formation_planner.h : Computes initial formation target slots using shared walkability. Invalid/tight slots collapse to the resolved center target. It does not write units into the navigation grid and does not create group movement entities.

game/systems/building_collision_registry.cpp : Registers and unregisters solid building footprints and marks affected grid regions dirty.

app/economy/production_manager.cpp : Handles construction placement, collect cursor resolution, and harvest work-position selection.

Maintenance Rules

When adding a blocker type:

  1. Add or reuse a CellValue.
  2. Apply it in the navigation-grid build/update step.
  3. Mark the affected region dirty when it appears, disappears, or changes footprint.
  4. Add a focused test proving it blocks and clears correctly.

When adding a mandatory traversal feature, such as a bridge crossing or hill entrance:

  1. Author the cells in TerrainService/height-map data.
  2. Force those cells to Walkable after terrain, buildings, and resources are applied.
  3. Keep visual centering or passage projection outside A* path search.
  4. Add tests for full rebuild and regional rebuild behavior.

When changing terrain:

  1. Keep mountains blocked.
  2. Keep rivers blocked except bridge centerline crossings.
  3. Keep hill plateau and entrance paths connected.
  4. Add tests for the exact walkability contract.

Directly Controlled Commander

The navigation grid is sized for formations: whole cells are blocked around structures, trees, boulders and ore so that a marching body of troops keeps clear of them. A directly controlled commander is one person and must not inherit that margin, so CommanderControlController does not path β€” it moves a body:

rpg_obstacle_slide and rpg_close_quarters in the arena catalog are the regression contracts for this. Anything that makes the commander's clearance match the formation grid again will fail them.

When changing movement behavior:

  1. Use CommandService shared queries instead of duplicating terrain/pathfinder checks.
  2. Do not write units into the navigation grid.
  3. Keep invalid-position recovery narrow.
  4. Keep melee lock as a combat-owned movement override.
  5. Do not add public tight-passage, group-path, path-request ID, stop-reason, or movement-cooldown concepts.
  6. Any "cannot progress forever" mitigation must have a scenario test with a frame budget.