13 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 Movement Guard
- 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: integer 10 for cardinal and 14 for diagonal.
Heuristic: octile distance using the same 10/14 scale. Queue entries
compare priority, accumulated cost, then linear cell index; stale entries are
rejected. This avoids floating-point route-order differences.
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 uses deterministic integer grid traversal and applies the same no-corner-cutting rule as A*. 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 runs one A* search against the requested static and
dynamic occupancy. If the destination is unreachable, the same search returns
the reachable cell geometrically closest to it. It never retries without
dynamic obstacles.
- While processing cells, it tracks
bestIndex— the visited cell with the smallest squared grid distance to the end cell. Equal distances use the lower linear cell index. - 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.
LocationGameState applies this behavior to players and 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:
LocationGameState::collectPathObstacles creates a stable-ID-sorted snapshot
from authoritative players and NPCs. PathFinder stamps circles into a
generation-tagged overlay and reuses A* buffers; it does not copy or mutate the
static walkability grid per query.
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 did not physically move during the previous simulation tick, unless it just blocked this requester and is promoted for a forced replan. An active path alone does not make a stalled character transparent.
Obstacle radius: the character's full collisionRadius, inflated by the
navigation map's agentRadius and one cell half-diagonal for conservative
rasterization. There is no obstacle-dropping fallback; an occupied destination
resolves to a nearby reachable cell.
7. Path Following
CharacterState::setTarget(destination, onArrived) sets a new walk target. It
calls the centralized location planner to generate a waypoint list. The result
is stored in pathWaypoints; the reachable endpoint is stored in walkTarget
and the original command destination in requestedWalkTarget.
Each fixed simulation tick in CharacterState::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.
CharacterState::isMoving() reports whether navigation remains active.
movedLastTick separately records actual displacement and controls dynamic
obstacle filtering.
Stopping in place: CharacterState::stopInPlace() sets walkTarget and
requestedWalkTarget to the current position, clears the path and callback, and
resets blockage state. It is used to cancel player/bot movement after a
persistent contact and by other explicit stop actions.
8. Character Movement Guard
Pathfinding steers around stationary characters. A second authoritative guard checks each proposed movement segment against stable-ID-ordered reservations initialized from tick-start circles. An accepted step updates that character's reservation before later characters advance. A step that approaches or sweeps through another circle is rejected rather than pushing either character.
Characters that moved during the previous tick are transparent during planning
but remain solid to the movement guard. Planning uses the full
collisionRadius (default 0.45 m); contact checks use the smaller
movementContactRadius (default 0.32 m). NPC JSON can override these as
navigationAvoidanceRadius and movementContactRadius. This tolerance avoids
false bumps from grid discretization while still preventing stacking.
When moving characters remain blocked head-on or while crossing for three
ticks, both stop. Player and bot destinations are cancelled; the user or bot
may issue another command. NPCs retain requestedWalkTarget and retry later.
Characters following in the same direction simply wait for spacing and do not
accumulate stuck ticks. A rejected movement step uses the standing animation.
9. Dynamic Replanning
Rejected non-following movement increments serialized movementBlockedTicks.
After three blocked ticks an NPC stops and schedules a retry. When its cooldown
expires, InternalGameServer promotes the blocker to a hard obstacle and
replans to requestedWalkTarget.
Each NPC has navigationRetryCooldownTicks, normally ten distinct values.
LocationDefinition deterministically shuffles the sequence from the stable NPC
ID, and every retry consumes the next value. The sequence and cursor are
serialized, preventing synchronized retry loops while preserving lockstep.
NPC JSON can override it with navigationRetryCooldownTicks or
navigationRetryCooldownMs.
Example NPC tuning:
{
"navigationAvoidanceRadius": 0.45,
"movementContactRadius": 0.32,
"navigationRetryCooldownMs": [
200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100
]
}
The configured values are clamped to positive tick counts and shuffled deterministically per NPC ID. Supplying different ten-value sets gives explicit control for unusually dense locations.
Navigation map switches, area availability changes, and added static obstacle polygons also force centralized replanning.
Opposing traffic in a one-character-wide corridor can wait until one route or blocker changes; this policy intentionally does not implement space-time cooperative reservations.
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 |
movementContactRadius |
CharacterState / NPC config |
0.32 m | Smaller movement-guard radius |
kWalkThreshold |
CharacterState.h |
0.05 m | Distance below which a waypoint is considered reached |
TARGET_REPLAN_THRESHOLD |
CharacterState.cpp |
0.25 m | Deduplication threshold in setTarget |
BLOCKED_REPLAN_TICKS |
CharacterState.cpp |
3 ticks | Blocked steps before stopping |
| default retry sequence | CharacterState.cpp |
2–11 ticks | Ten ID-shuffled NPC retry cooldowns |
NPC_TALK_DISTANCE |
Location.cpp |
1.35 m | Distance at which walking-to-NPC interaction fires |
Reference benchmark
GameServerPathFinderTests --benchmark is non-gating. On the development
Windows Release build with an 80×80 grid, the current implementation completed
100 obstacle-aware queries in 15 ms and 1000 queries in 176 ms. Treat these as
comparative measurements rather than cross-machine budgets.