Files
shadow-over-bishkek001/PATHFINDING.md
T

13 KiB
Raw Blame History

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

  1. Grid Representation
  2. Building the Walkable Grid
  3. A* Path Search
  4. Path Smoothing
  5. Approaching Unreachable Destinations
  6. Dynamic Obstacles
  7. Path Following
  8. Character Movement Guard
  9. Dynamic Replanning
  10. 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:

  1. Mark available areas walkable — every cell whose center lies inside any available navigation area polygon gets walkable = 1. If boundaryPadding > 0, cells too close to the outer edge of the area are left blocked.
  2. Mark obstacle polygons blocked — cells whose center lies inside an obstacle polygon, or within agentRadius + objectPadding of its edges, are set to 0.

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.


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 to bestIndex.
  • If bestIndex is 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:

  1. Active target — if pathWaypoints is non-empty, the character moves toward pathWaypoints[currentWaypointIndex]; otherwise it moves toward walkTarget.
  2. Movement — the character advances along the XZ direction at walkSpeed m/s and rotates smoothly toward the movement direction at rotationSpeed rad/s.
  3. Waypoint advance — when the character is within WALK_THRESHOLD = 0.05 m of the current waypoint, it advances to the next one. When the last waypoint is reached, pathWaypoints is cleared and the optional onArrived callback is fired.
  4. State machine — the animation state switches between STAND and WALK based 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.