# 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](#1-grid-representation) 2. [Building the Walkable Grid](#2-building-the-walkable-grid) 3. [A\* Path Search](#3-a-path-search) 4. [Path Smoothing](#4-path-smoothing) 5. [Approaching Unreachable Destinations](#5-approaching-unreachable-destinations) 6. [Dynamic Obstacles](#6-dynamic-obstacles) 7. [Path Following](#7-path-following) 8. [Character Movement Guard](#8-character-movement-guard) 9. [Dynamic Replanning](#9-dynamic-replanning) 10. [Key Constants Reference](#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` 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): ```json { "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. --- ## 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 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: ```json { "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.