28. Navigation and Pathfinding
Every NPC that walks from the market to the tavern, every enemy that chases you through town, every character that navigates around obstacles - all rely on pathfinding algorithms that transform spatial problems into traversable routes.
The Fractal Code Cycle Applied to Navigation
Recall our fundamental pattern:
Input → Processing → Output
For our navigation system:
- Input: Start position, goal position, walkable surfaces, obstacles
- Processing: Generate navigation grid, calculate optimal path using A*
- Output: Sequence of waypoints from start to goal
What Is Navigation Good For?
NPC Movement
- Characters walking between locations
- Enemies pursuing the player
- Scheduled NPC routines (going to work, returning home)
Dynamic Behaviour
- Avoiding obstacles in real-time
- Responding to changing environments
- Following complex patrol routes
Gameplay Mechanics
- Escort missions where NPCs follow the player
- Enemy formations and tactical movement
- Quest objectives requiring navigation
The Navigation Grid Concept
Rather than calculating paths through continuous 3D space, we discretise the world into a grid of traversable nodes. This transforms the spatial problem into a graph traversal problem.
Grid Generation
The navgrid generation process:
- Sample the ground plane: For each walkable surface (quad), create a grid of potential nodes
- Filter by obstacles: Reject nodes too close to static geometry
- Connect neighbours: Create edges between adjacent valid nodes
- Store in graph structure: Maintain nodes and edges for pathfinding queries
From the implementation:
navgrid_generate :: proc(
ground: []Quad,
obstacles: []OBB3,
allocator := context.allocator,
) -> Navgrid {
MIN_OBSTACLE_DIST :: f32(0.5)
MAX_EDGE_DIST :: f32(2.1)
// Create nodes on ground surfaces
// Filter out nodes near obstacles
// Connect valid neighbours
// Return completed navgrid
}
Spatial Hashing for Efficiency
To avoid checking every node against every other node during edge creation, we use spatial hashing:
spatial_map := make(map[Vec3i][dynamic]int, context.temp_allocator)
This groups nodes by their rounded world position, allowing us to only check nearby cells when creating edges.
The Graph Data Structure
Navigation requires a generic graph structure to represent nodes and edges. We've created a reusable graph package:
Graph :: struct($T: typeid) {
nodes: [dynamic]T,
edges: map[int][dynamic]int,
allocator: mem.Allocator,
}
This generic structure allows us to store any node type (in our case, Navgrid_Node containing position data) whilst maintaining efficient edge lookups via the map.
Graph Operations
The graph package provides fundamental operations:
init: Initialise the graph with an allocatoradd: Add a node, returning its indexconnect_by_index: Create an edge between nodesneighbors: Retrieve connected nodes
A* Pathfinding Algorithm
A* (pronounced "A-star") is the industry standard pathfinding algorithm. It combines:
- Dijkstra's algorithm: Finds the shortest path by exploring nodes in order of cost from start
- Greedy best-first search: Prioritises nodes that appear closest to the goal
The key is the priority function:
priority = cost_so_far + heuristic(current, goal)
Implementation Structure
astar :: proc(
ng: Navgrid,
start_idx: int,
goal_idx: int,
heuristic: proc(ng: Navgrid, a, b: int) -> f32 = nav_default_heuristic,
less: proc(a, b: Nav_Path_Node) -> bool = nav_default_less,
allocator := context.allocator,
) -> (path: [dynamic]int, ok: bool)
The Algorithm Steps
- Initialise frontier: Priority queue with start node
- Track visited nodes: Map from node index to previous node
- Track costs: Map from node index to accumulated cost
- Expand frontier: Process lowest-cost node, adding unvisited neighbours
- Reconstruct path: Backtrack from goal to start using the
came_frommap
Heuristic Function
Our heuristic estimates distance to goal whilst preferring flatter terrain:
nav_default_heuristic :: proc(ng: Navgrid, a, b: int) -> f32 {
VERTICAL_WEIGHT :: f32(-2)
diff := ng.graph.nodes[b].pos - ng.graph.nodes[a].pos
return math.sqrt(diff.x * diff.x + diff.y * diff.y * VERTICAL_WEIGHT + diff.z * diff.z)
}
The negative vertical weight encourages paths that minimise elevation changes.
Integration with Entities
Waypoint System Modifications
Entities now use dynamic slices for waypoints instead of fixed arrays:
Entity :: struct {
// ...
waypoints: []Vec3, // Changed from [8]Vec3
waypoint_count: int,
waypoint_index: int,
// ...
}
Waypoint Following Behaviour
The entity update loop now handles waypoint reversal for patrol routes:
if entity.waypoint_index == len(entity.waypoints) {
entity.waypoint_index = 0
slice.reverse(entity.waypoints)
}
This creates natural back-and-forth patrol behaviour.
Path to Waypoints Conversion
A simple conversion function extracts world positions from path indices:
waypoints_from_path :: proc(ng: Navgrid, path: []int, allocator := context.allocator) -> []Vec3 {
points := make([]Vec3, len(path), allocator)
for idx, i in path {
points[i] = ng.graph.nodes[idx].pos
}
return points
}
Vertical Movement Constraints
The navigation system respects vertical step limits when connecting nodes:
MAX_STEP_HEIGHT :: 1.0
if abs(pa.y - pb.y) > MAX_STEP_HEIGHT do continue
This prevents paths that require unrealistic vertical traversal.
Debug Visualisation
The implementation includes comprehensive debug rendering:
Navigation Grid
- Red boxes show all valid navigation nodes
- Cyan lines show graph edges
Active Paths
- Yellow boxes highlight the current path
- Entity waypoints shown as yellow bounding boxes
This visualisation is critical for verifying that:
- The navgrid covers walkable areas
- Obstacles properly block navigation
- Generated paths make sense spatially
Navigation System in the System Stack
Recall our System Stack organisation:
- Base Layer: Entities, Input, Time, Assets, Save/Load
- Presentation: Camera, Shaders, Animation, UI, Particles
- Interaction: Player, Collisions, AI/Pathfinding, Scenes
- Game Systems: Stats, Combat, Inventory, Abilities
- Content: Dialogue, Quests, NPC Schedules
Navigation sits in the World Layer because:
- It depends on scene geometry (walkable surfaces, obstacles)
- It depends on entities (for positioning and movement)
- It provides services to AI and NPC systems
- It enables higher-level gameplay systems (quests, schedules)
Design Considerations
Grid Resolution Trade-offs
Finer grids (smaller cell size):
- More accurate paths
- Smoother movement
- Higher memory usage
- Slower pathfinding
Coarser grids (larger cell size):
- Less memory usage
- Faster pathfinding
- More angular paths
- Potential gaps in coverage
Our implementation uses 1-metre cells, suitable for our game scale.
Diagonal Movement
The current implementation disables diagonal connections:
is_diagonal := pa.x != pb.x && pa.z != pb.z
if is_diagonal do continue
This simplifies pathfinding and creates more natural-looking movement for the game's isometric perspective. Enable diagonals if your game requires smoother, more direct paths.
Dynamic vs Static Navgrids
Our current approach generates the navgrid once during scene load. This works well for static environments but doesn't handle:
- Moving obstacles (doors, platforms)
- Destructible terrain
- Dynamic quest objects
For dynamic navigation, you'd need to:
- Regenerate portions of the grid
- Maintain multiple navgrids (one per configuration)
- Use hierarchical pathfinding for large worlds
Performance Optimisation Opportunities
Spatial Partitioning: The current implementation uses basic spatial hashing. For larger worlds, consider hierarchical spatial structures.
Path Caching: Frequently requested paths (between common locations) could be cached.
Hierarchical Pathfinding: Large worlds benefit from multi-level navigation - coarse paths between regions, fine paths within regions.
Path Smoothing: A* paths follow grid edges exactly. Post-processing can create smoother, more natural-looking routes.
Try It Yourself
- Modify the heuristic function to heavily favour paths near walls (useful for stealth games)
- Implement path smoothing that removes unnecessary waypoints when line-of-sight exists
- Add support for diagonal movement and observe how it affects path quality
- Create a "difficulty map" that makes certain terrain types have higher traversal costs
The Big Idea
Pathfinding transforms continuous spatial navigation into discrete graph traversal. By pre-computing a navigation grid and using A* to find optimal paths, we enable intelligent NPC movement whilst maintaining reasonable performance.
The key insight is the separation of concerns: navgrid generation happens once per scene, pathfinding happens on-demand, and entity movement consumes pre-computed waypoints. Each system focuses on its specific responsibility within the larger navigation pipeline.