Summary
Pathfinding depends on two ideas students often meet before they have names for them: the heuristic that guides search, and the spatial representation the search runs over, such as a navmesh, grid, or waypoint graph.
Pathfinding algorithms find routes through a discretised representation of game space. A* (A-star) is the near-universal default in game AI: it combines Dijkstra’s optimal shortest-path search with a heuristic estimate of remaining distance to focus exploration toward the goal. This page covers A* fundamentals, critical implementation optimisations, advanced variants (JPS+, Theta*), and how the choice of search space representation interacts with algorithm performance.
(Rabin and Sturtevant, Game AI Pro 360: Guide to Movement and Pathfinding, Ch. 1, 2, 10, 12; see source-game-ai-pro-360-movement-pathfinding)
Key ideas
The A* cost formula
f(x) = g(x) + h(x)
g(x)— the actual cost paid to reach nodexfrom the start.h(x)— the heuristic estimate of cost fromxto the goal.- Nodes are sorted on the open list by
f(x)and expanded in lowest-cost order. - A heuristic is admissible if it never overestimates the true remaining cost; admissibility guarantees an optimal path.
When the weight is adjusted: f(x) = g(x) + h(x) × weight
weight = 0→ degenerates to Dijkstra (no heuristic, uniform expansion)weight = 1→ classic A*weight > 1→ weighted A* (epsilon-A*): faster, possibly suboptimal — weight 1.1–1.5 is a practical sweet spot for games
Heuristic choice on a grid
For an 8-directional grid, the octile heuristic is the correct choice:
h = max(Δx, Δy) + 0.41 × min(Δx, Δy)
Manhattan distance overestimates and violates admissibility for diagonal movement. Euclidean (straight-line) distance underestimates, so it is admissible but less tight. Octile distance matches the actual movement model and minimises wasted node expansions.
Open and closed lists
- Open list — nodes discovered but not yet expanded; typically a min-heap (priority queue) sorted by
f. - Closed list — already expanded nodes; prevents re-expansion.
- Tie-breaking towards higher
g(closer to the goal) whenfvalues are equal — this substantially reduces unnecessary expansions on maps with many equal-cost paths.
Search space representations
The choice of representation is more impactful than any other optimisation because it determines how many nodes must be considered (Sturtevant, Ch. 2).
| Representation | Nodes | Pros | Cons |
|---|---|---|---|
| Grid | High (one per cell) | Simple, easy to edit dynamically, easy localization | Memory-heavy for large worlds; produces 45°/90° paths unless smoothed |
| Waypoint graph | Low (manually placed) | Very fast search, easy to understand | Poor path quality if sparse; hard to dynamically modify; localization issues |
| Navigation mesh | Medium (one per polygon) | Accurate world representation; free-angle movement; compact | Complex to build; harder to update dynamically; geometric edge cases |
Hierarchical pathfinding combines representations at multiple levels of abstraction (e.g., room-to-room at high level, cell-to-cell within rooms). Used in Dragon Age: Origins (two grid abstraction levels) and Company of Heroes (hex grid + square grid). Enables fast high-level planning for large worlds.
Grids suit tower-defence and top-down 2D games. Navigation meshes suit 3D character navigation — Unity’s built-in NavMesh is a production-ready navmesh implementation. Waypoint graphs remain useful when fast planning matters more than path precision.
A* implementation optimisations
Eight optimisations from most to least impactful (Rabin and Sturtevant, Ch. 1):
1. High-quality heuristics
The tighter the heuristic, the fewer nodes A* expands. Three approaches:
Roy–Floyd–Warshall (precomputed lookup): Precompute every pairwise shortest path offline and store in a lookup table. Path generation is O(p) (just follow the table). Memory is O(n²), which becomes impractical for large node counts but is feasible on zone-divided graphs.
Euclidean embedding: Store single-source shortest-path distances from a small number of pivot nodes. The heuristic h(x,y) = |d(p,x) – d(p,y)| for pivot p improves accuracy significantly over simple geometric distance. Multiple pivots allow taking the maximum. O(kn) memory where k is pivot count. Good pivot locations: map exits in RPGs, flag locations in CTF.
2. Optimal search space representation
Use a navmesh or waypoint graph rather than a fine-grained grid wherever possible — fewer nodes = less work.
3. Preallocate all memory
Never allocate memory during search. Use a fixed-size node pool and reuse it. Per-node “search ID” flags replace clearing the closed list between searches.
4. Weighted heuristic (overestimating)
Multiply h(x) by a weight > 1. Weight 1.1–1.5 trades near-unnoticeable path quality for substantial speed gains, especially in open terrain. Weights up to 2–3 work well when backtracking is rare.
5. Better heuristics
Use the octile heuristic on 8-directional grids; use straight-line distance on navmeshes. Select the heuristic most closely matching the actual movement model.
6. Open list sorting
Use a binary heap or similar priority queue. Cache the last inserted node before insertion — in many searches it is immediately removed and re-inserting would be wasteful. When f-costs are integers or have few unique values, a bucket-sorted open list (one array per f-cost value, treated as a LIFO stack) can be dramatically faster.
7. Don’t backtrack
Never consider the parent node as a neighbour. This is free and removes ~1/branching-factor nodes from consideration (1/8 for grids, ~1/3 for navmeshes).
8. Cache successors
Store each node’s neighbours explicitly rather than recomputing from map data each expansion.
JPS+ — Jump Point Search Plus
JPS+ (Rabin and Silva, Ch. 10) is an extreme A* optimisation for static, uniform-cost grids that achieves up to 116× speedup while remaining optimal.
Core ideas
Pruning: When expanding a node, most neighbours can be provably skipped because the same or shorter path would visit them via the parent. Only forced neighbours (neighbours that would be blocked by a wall if not for the current node) need consideration alongside the natural progression direction.
Jump points: Rather than placing every node on the open list, JPS places only jump points — nodes through which at least one optimal path must pass. This dramatically reduces open-list operations, which are a major bottleneck.
JPS+ preprocessing: JPS identifies forced neighbours at runtime via recursive scan. JPS+ precomputes and stores, for every node and every direction, the distance to the next jump point (or wall if none). A positive distance means “jump point this far”, a negative distance means “wall this far”. Runtime is then a simple lookup.
Four jump point types
| Type | Description |
|---|---|
| Primary | Node with a forced neighbour — identified once during preprocessing |
| Straight | Node from which a cardinal direction reaches a primary jump point before a wall |
| Diagonal | Node from which a diagonal direction reaches a primary or straight jump point |
| Target | Synthesised at runtime when the goal is in the direction of travel and closer than the stored jump-point distance |
Performance
- On open maps: up to 116× faster than A*, ~10× faster than JPS.
- On dense mazelike maps: ~2.5× faster than optimised A*.
- Trade-off: requires static map and 8 numbers stored per grid cell; map changes require full preprocessing.
// Pseudocode for JPS+ runtime (per Rabin & Silva, Ch. 10)
// Distance > 0 → jump point that many cells away in that direction
// Distance ≤ 0 → wall that many cells away (store as negative)
while (!openList.IsEmpty())
{
Node cur = openList.Pop();
if (cur == goal) return PathFound;
foreach (Direction dir in ValidDirs(cur.parent))
{
int dist = cur.distances[(int)dir];
if (IsCardinal(dir) && GoalInDirection(cur, goal, dir)
&& DistToGoal(cur, goal, dir) <= Mathf.Abs(dist))
{
// Goal is reachable directly — add goal as successor
}
else if (dist > 0)
{
// Jump point in this direction — add it as successor
}
}
}Theta* — any-angle pathfinding
Theta* (Nash and Koenig, Ch. 12) solves the problem that A* on a grid constrains paths to grid edges, producing unnatural paths 8% longer than the continuous-space optimum.
The key modification
A* only allows a vertex’s parent to be a visible neighbour. Theta* allows the parent to be any vertex with line-of-sight to the current vertex. The single change is in ComputeCost:
ComputeCost(s, s′):
// Path 2 — skip s entirely if parent(s) can see s′
if lineofsight(parent(s), s′):
if g(parent(s)) + dist(parent(s), s′) < g(s′):
parent(s′) = parent(s)
g(s′) = g(parent(s)) + dist(parent(s), s′)
else:
// Path 1 — standard A* update
if g(s) + dist(s, s′) < g(s′):
parent(s′) = s
g(s′) = g(s) + dist(s, s′)
The triangle inequality guarantees Path 2 is never longer than Path 1 when line-of-sight holds.
Results
- Paths approximately 4% shorter than A* paths.
- Approximately 2–3× slower than A* with the octile heuristic.
- Approximately 2× faster than A*-PostSmoothed with the straight-line heuristic.
- Nearly identical runtime to A* when using the straight-line heuristic for both.
Lazy Theta* defers the line-of-sight check to expansion time rather than generation, reducing total checks and improving performance in dense environments.
Path post-processing (rubber-band smoothing)
A simpler alternative to Theta* is to run A* normally and then postprocess the path by removing waypoints while line-of-sight holds. This shortens paths by 1–3%. Simpler to implement than Theta* but less reliable — the A* path variant chosen at search time affects how well rubber-banding can shorten it.
Common mistakes (bad ideas)
| Anti-pattern | Why it fails |
|---|---|
| Simultaneous searches (time-slicing N paths) | Requires N open lists; cache thrashing negates any benefit |
| Bidirectional A* | Both searches can back up behind barriers simultaneously, potentially doing twice the work in the worst case |
| Caching successful paths | Path space is enormous; cache hit rate is negligible while memory cost is high |
In practice (Unity)
Unity’s built-in NavMesh system uses a navigation mesh representation with ORCA-based local avoidance. For most 3D games this is the correct default.
Custom A* is appropriate when:
- Using a 2D tilemap (grid representation makes A* trivial)
- Needing JPS+ performance on static maps
- Building an RTS with thousands of units (flow field approach — see steering-behaviours)
- Requiring custom heuristics or movement costs Unity NavMesh cannot express
Popular Unity A* packages: A* Pathfinding Project (Aron Granberg) implements JPS, hierarchical pathfinding, and navmesh variants with Unity integration.
// Minimal A* on a 2D grid (conceptual)
public List<Node> FindPath(Node start, Node goal)
{
var openList = new SortedSet<Node>(Comparer<Node>.Create(
(a, b) => a.FCost.CompareTo(b.FCost)));
var closedSet = new HashSet<Node>();
start.GCost = 0;
start.HCost = OctileDistance(start, goal);
openList.Add(start);
while (openList.Count > 0)
{
Node current = openList.Min;
openList.Remove(current);
if (current == goal) return ReconstructPath(goal);
closedSet.Add(current);
foreach (Node neighbour in current.Neighbours)
{
if (closedSet.Contains(neighbour)) continue;
float tentativeG = current.GCost + Distance(current, neighbour);
if (tentativeG < neighbour.GCost)
{
neighbour.Parent = current;
neighbour.GCost = tentativeG;
neighbour.HCost = OctileDistance(neighbour, goal);
openList.Add(neighbour);
}
}
}
return null; // no path
}
float OctileDistance(Node a, Node b)
{
float dx = Mathf.Abs(a.x - b.x), dy = Mathf.Abs(a.y - b.y);
return Mathf.Max(dx, dy) + 0.41f * Mathf.Min(dx, dy);
}Trade-offs
| Algorithm | Optimal | Speed | Constraints | Best use |
|---|---|---|---|---|
| Dijkstra | Yes | Slow (no heuristic) | Any graph | When no heuristic available |
| A* | Yes | Good | Any graph | General pathfinding |
| Weighted A* | Near | Very good | Any graph | Games where slight suboptimality is acceptable |
| JPS+ | Yes | Extreme | Static uniform-cost grid only | 2D tactical games, RTS |
| Theta* | Near | Good | Grid or navmesh | When path naturalness matters (no post-processing) |
| Flow fields | N/A (per-goal) | O(1) per agent | Grid | Many agents, shared goal |
| Precomputed (RFW) | Yes | Instant (lookup) | Static world, memory budget | MMO servers, small node sets |
Related
- steering-behaviours — how agents follow paths and avoid each other
- context-steering — context maps as an alternative to vector-based steering
- navigation-mesh-construction — how navmeshes are built
- npc-performance-at-scale — hierarchical pathfinding, precomputed tables, large-scale AI
- game-ai-agent-design — the 3-layer model: action selection → steering → locomotion
- source-game-ai-pro-360-movement-pathfinding — primary source for this page