14 KiB
Pathfinding System
This document describes the grid-based pathfinding used for the player and all NPCs, including the collision avoidance and movement quality improvements.
Table of Contents
- Grid Representation
- Building the Walkable Grid
- A* Path Search
- Path Smoothing
- Approaching Unreachable Destinations
- Dynamic Obstacles
- Path Following
- Character Collision Resolution
- Dynamic Replanning
- Key Constants Reference
1. Grid Representation
The world is divided into a uniform 2D grid in the XZ plane (Y is ignored during pathfinding; all characters walk on a flat floor at floorY).
Each cell is either walkable (1) or blocked (0). The grid is stored as a flat std::vector<unsigned char> indexed by z * gridWidth + x.
Parameters (all configurable in the JSON config file):
| Parameter | Default | Description |
|---|---|---|
cellSize |
0.4 m | Width and depth of one cell |
agentRadius |
0.45 m | Half-width of a character — used to erode free space |
objectPadding |
0.25 m | Extra clearance added around obstacle polygons |
boundaryPadding |
0.0 m | Inward erosion from the edges of navigation areas |
floorY |
0.0 | Y coordinate placed on every path waypoint |
Grid bounds are computed from the union of all navigation area polygons plus a padding margin of cellSize * 2 + agentRadius + objectPadding on every side.
Cell coordinate conversion:
cell.x = floor((worldX - minX) / cellSize)
cell.z = floor((worldZ - minZ) / cellSize)
cellCenter.x = minX + (cell.x + 0.5) * cellSize
cellCenter.z = minZ + (cell.z + 0.5) * cellSize
2. Building the Walkable Grid
The grid can be loaded in two ways.
2a. Pre-computed grid (.txt file)
A plain-text file with a small header followed by rows of 1/0 characters:
cellSize 0.4
agentRadius 0.45
floorY 0.0
...
minX -5.0
minZ -5.0
gridWidth 50
gridDepth 50
11111111...
10000001...
This format is generated by PathFinder::saveGrid() after building from polygons and can be loaded much faster than recomputing from geometry.
2b. Polygon-based config (.json file)
The JSON file lists navigation areas (convex or concave walkable regions) and obstacle polygons (impassable zones within those regions):
{
"cellSize": 0.4,
"areas": [
{ "name": "main_room", "available": true, "polygon": [[x,z], ...] }
],
"obstacles": [
{ "name": "table", "polygon": [[x,z], ...] }
]
}
Build steps:
- Mark available areas walkable — every cell whose center lies inside any
availablenavigation area polygon getswalkable = 1. IfboundaryPadding > 0, cells too close to the outer edge of the area are left blocked. - Mark obstacle polygons blocked — cells whose center lies inside an obstacle polygon, or within
agentRadius + objectPaddingof its edges, are set to0.
Navigation areas can be toggled at runtime via PathFinder::setAreaAvailable(), which rebuilds the entire grid. This is used to open or close doors, gated areas, etc.
3. A* Path Search
PathFinder::findPath(start, end) runs a standard A* on the walkable grid.
Neighbor connectivity: 8-directional (cardinal + diagonal). Diagonal moves are blocked if either of the two adjacent cardinal cells is unwalkable (no corner-cutting).
Step costs: 1.0 for cardinal, √2 ≈ 1.414 for diagonal.
Heuristic: Euclidean distance in cell units to the end cell.
Start/end snapping: If the exact cell for start or end is not walkable, findNearestWalkableCell expands a square ring outward (up to radius 8 m) to find the nearest walkable cell. This makes clicking slightly outside the nav mesh still produce a valid path.
Path reconstruction: After A* completes, the cell chain is walked via cameFrom[] from end back to start, reversed, then smoothed (see §4).
First-waypoint trimming: If the first waypoint is within cellSize × 0.75 of start, it is dropped (the character is already close enough).
Last-waypoint precision: If the requested end maps to the same cell as the snapped end cell, the last waypoint is replaced with the exact end world position rather than the cell centre.
4. Path Smoothing
Raw A* paths follow the grid diagonals and produce staircase-shaped routes. A string-pulling (line-of-sight) pass compresses them:
anchor = path[0]
result = [anchor]
while anchor is not the last cell:
find the furthest cell 'next' from anchor with unobstructed line of sight
result.append(next)
anchor = next
Line-of-sight is checked by stepping along the segment in increments of cellSize / 2 and verifying that each sampled cell is walkable. The result is a minimal set of waypoints connected by straight, obstacle-free segments.
5. Approaching Unreachable Destinations
When a player clicks on a point in a disconnected region (e.g., across a thin wall), the original findPath returns an empty path and the character does not move. This is surprising — a click on a solid wall sensibly moves the character to the nearest reachable point, but a click into an inaccessible room does nothing.
findPathToNearest fixes this with a three-step cascade:
- Try
findPathwith dynamic obstacles (stationary characters are avoided). - If empty, retry
findPathwithout dynamic obstacles (an NPC blocking a doorway is ignored). - If still empty (destination genuinely unreachable), run nearest-reachable A*.
Nearest-reachable A*, implemented in findNearestReachableImpl:
- Runs the identical A* loop against the static walkable grid.
- While processing cells, tracks
bestIndex— the already-visited cell with the smallest Euclidean distance (in cell units) to the end cell. - If A* exhausts all reachable space without finding
end, it reconstructs and returns a path tobestIndex. - If
bestIndexis still the start cell (character is completely isolated), an empty path is returned and the character stays put.
The net effect: clicking anywhere in the world always moves the character as close as possible to the target, matching the behaviour of clicking on a solid wall.
findPathToNearest replaces the direct findPath call in Location::setupNavigation's path planner lambda, so it applies equally to the player and all NPCs.
6. Dynamic Obstacles
When a path is planned, other characters can temporarily mark cells as blocked to make the character walk around them rather than through them.
How it works:
In Location::setupNavigation, every character is given a PathPlanner closure. Before calling findPath, the closure builds a list of PathFinder::DynamicObstacle entries (position + radius) representing nearby characters. findPath copies the static walkable grid, stamps zeros in circles around each obstacle, then runs A* on the modified copy. The static grid is never mutated.
Which characters become obstacles:
A character is added as a dynamic obstacle only when all of these are true:
- It is not the character currently planning the path (
self). - It is alive and enabled.
- It is not moving — a moving character is transparent to pathfinding, so it does not block narrow corridors that it is actively passing through.
- Its position lies within
kDynamicObstacleInfluenceDist = 6 mof the direct line segment fromstarttoend(distant characters do not affect the search).
Obstacle radius: character.collisionRadius × 0.6. Using 60 % of the physical collision radius makes path planning less conservative; physical separation at full radius is still enforced by collision resolution (§8).
Fallback when dynamic obstacles block the only path:
If step 1 of findPathToNearest (with dynamic obstacles) returns empty, step 2 retries without any dynamic obstacles. This handles the common case of an NPC standing in a doorway: the player paths through the NPC's position, and the nudge logic (§8) pushes the NPC aside as the player passes.
7. Path Following
Character::setTarget(destination, onArrived) sets a new walk target. It calls the path planner to generate a waypoint list. The result is stored in pathWaypoints; the final destination is also stored in walkTarget and requestedWalkTarget.
Each frame in Character::update:
- Active target — if
pathWaypointsis non-empty, the character moves towardpathWaypoints[currentWaypointIndex]; otherwise it moves towardwalkTarget. - Movement — the character advances along the XZ direction at
walkSpeedm/s and rotates smoothly toward the movement direction atrotationSpeedrad/s. - Waypoint advance — when the character is within
WALK_THRESHOLD = 0.05 mof the current waypoint, it advances to the next one. When the last waypoint is reached,pathWaypointsis cleared and the optionalonArrivedcallback is fired. - State machine — the animation state switches between
STANDandWALKbased on whether the character is moving.
Character::isMoving() returns true if pathWaypoints is non-empty or the distance to walkTarget exceeds WALK_THRESHOLD. This is used by dynamic obstacle filtering and collision nudging.
Stopping in place: Character::stopInPlace() sets walkTarget and requestedWalkTarget to the current position and clears pathWaypoints. It is called when an external force (collision resolution) displaces a stationary player so that the player does not walk back to their previous target position.
8. Character Collision Resolution
Pathfinding alone does not prevent two characters from occupying the same space — it only steers paths around stationary characters. Physical separation is handled separately each frame by Location::resolveCharacterCollisions.
Algorithm (3 iterations per frame):
For every pair (A, B) of living, enabled characters:
- Compute the overlap:
penetration = (collisionRadius_A + collisionRadius_B) - distance(A, B). - If
penetration > 0, compute a push direction (A-to-B normal) and a push magnitude ofpenetration / 2per character. - Compute candidate new positions
newAandnewB. - Validate against the navigation grid (
PathFinder::isWalkable). If a pushed position is unwalkable, only the other character is moved. - Player stays put: if the player was not moving (
!isMoving()) before the push,stopInPlace()is called after the push so the player does not walk back to the old target. - NPC yielding: if one character was moving and the other was standing,
nudgeCharacterAsideis called on the standing character.
nudgeCharacterAside(standing, awayFrom):
Gives the standing NPC a short walk target so it steps out of the way:
- Compute the direction from
awayFromto the NPC's current position. - Try four candidate targets at distance
1.2 min directions: straight away, +90°, −90°, 180°. - Use the first candidate that is walkable (per
PathFinder::isWalkable). - Call
standing->setTarget(candidate)— the NPC takes a small step aside, then stands at the new spot. - The player is never nudged; combat NPCs can be nudged, but their attack AI immediately overrides the yield target on the next tick.
9. Dynamic Replanning
When characters move they can displace each other or enter each other's planned paths. Location::updateDynamicReplans handles this:
Every frame:
- Measure how much each character moved since the last frame. Characters that moved more than
kMovedEps = 0.05 mare collected as movers. - For each mover, find other characters that are currently walking. If the mover's position is within
kReplanTriggerDist = 1.8 mof the segment[walker.position → walker.nextWaypoint], trigger a replan for the walker viaforceReplan(). - A per-character cooldown of
kReplanCooldownMs = 500 msprevents the same character from replanning more often than twice per second.
Character::forceReplan() re-runs the path planner from the character's current position to its stored requestedWalkTarget, updating pathWaypoints in place. If the replanned path is empty, the character stops at its current position.
The relatively generous trigger distance (1.8 m vs the old 1.1 m) and cooldown (500 ms vs 300 ms) prevent micro-jitter: small position corrections from collision resolution no longer spam replanning events.
10. Key Constants Reference
| Constant | Location | Value | Description |
|---|---|---|---|
cellSize |
PathFinder config |
0.4 m | Grid cell size |
agentRadius |
PathFinder config |
0.45 m | Character half-width for grid erosion |
objectPadding |
PathFinder config |
0.25 m | Extra clearance around obstacles |
WALK_THRESHOLD |
Character.h |
0.05 m | Distance below which a waypoint is considered reached |
TARGET_REPLAN_THRESHOLD |
Character.h |
0.25 m | Deduplication threshold in setTarget |
kDynamicObstacleInfluenceDist |
Location.cpp |
6.0 m | Max distance from path for a character to become an obstacle |
kDynamicObstacleRadiusFraction |
Location.cpp |
0.6× | Fraction of collision radius used for dynamic obstacle footprint |
kNudgeDist |
Location.cpp |
1.2 m | Distance an NPC steps aside when yielding |
kReplanTriggerDist |
Location.cpp |
1.8 m | Mover must be this close to a walker's path to trigger replan |
kReplanCooldownMs |
Location.cpp |
500 ms | Minimum interval between replans for any one character |
NPC_TALK_DISTANCE |
Location.cpp |
1.35 m | Distance at which walking-to-NPC interaction fires |
kIterations (collision) |
Location.cpp |
3 | Push-apart iterations per frame |