Skip to main content

Pathfinding (A* Algorithm)

Intelligent Navigation with Pathfinding

Pathfinding is the foundation of intelligent game AI movement! Learn the A* algorithm, heuristics, optimization techniques, and how to create NPCs that navigate complex environments efficiently! ๐Ÿงญ๐Ÿค–๐ŸŽฏ

Understanding Pathfinding

๐Ÿ—บ๏ธ The GPS Navigation Analogy

Think of pathfinding like GPS navigation:

graph TD A["Pathfinding"] --> B["Graph Representation"] A --> C["Search Algorithms"] A --> D["Optimization"] B --> E["Grid-based"] B --> F["NavMesh"] B --> G["Waypoints"] C --> H["A* Algorithm"] C --> I["Dijkstra's"] C --> J["BFS/DFS"] D --> K["Hierarchical"] D --> L["Jump Point Search"] D --> M["Path Smoothing"]
An 8-by-5 pathfinding grid. Start cell on the left, goal on the right, separated by a vertical wall of three cells in column 3. The found path winds in gold up over the wall and across the top row to reach the goal. A spotlight on cell (2, 2) connects via a dashed leader to a callout that breaks down its A* values: g equals 2, h equals 5, f equals 7, parent (2, 3).
A* picks the next cell to expand by minimising f = g + h, where g is the actual cost from the start and h is a heuristic estimate of cost remaining to the goal (Manhattan distance, here). The wall in column 3 forces the optimal path up over the top; once the goal is reached, walking parent pointers back to the start reconstructs the route. Lightly-tinted cells show neighbours that landed on the closed list but were beaten by cheaper alternatives.

Interactive A* Pathfinding Demo

Click to set start (green), right-click to set goal (red), drag to create walls!

Tools:

Algorithm: A* | Path Length: - | Path Cost: - | Nodes Explored: 0

Time: 0ms | Open List: 0 | Closed List: 0 | Efficiency: -

A* Algorithm Implementation

import heapq
import math
from typing import List, Tuple, Optional, Dict, Set
from dataclasses import dataclass, field

@dataclass
class Node:
    """Node for pathfinding"""
    x: int
    y: int
    g: float = 0  # Cost from start
    h: float = 0  # Heuristic cost to goal
    f: float = 0  # Total cost (g + h)
    parent: Optional['Node'] = None
    
    def __lt__(self, other: 'Node') -> bool:
        return self.f < other.f
    
    def __eq__(self, other: 'Node') -> bool:
        return self.x == other.x and self.y == other.y
    
    def __hash__(self) -> int:
        return hash((self.x, self.y))

class AStar:
    """A* pathfinding algorithm"""
    def __init__(self, grid: List[List[int]], diagonal: bool = True) -> None:
        self.grid: List[List[int]] = grid
        self.height: int = len(grid)
        self.width: int = len(grid[0]) if grid else 0
        self.diagonal: bool = diagonal
        
        # Cost for different terrain types
        self.terrain_cost: Dict[int, float] = {
            0: 1,      # Empty
            1: float('inf'),  # Wall
            2: 3,      # Forest (higher cost)
            3: 10,     # Water (very high cost)
            4: 2       # Sand (medium cost)
        }
    
    def heuristic(self, node: Node, goal: Node, method: str = 'manhattan') -> float:
        """Calculate heuristic distance"""
        dx = abs(node.x - goal.x)
        dy = abs(node.y - goal.y)
        
        if method == 'manhattan':
            return dx + dy
        elif method == 'euclidean':
            return math.sqrt(dx**2 + dy**2)
        elif method == 'diagonal':
            return max(dx, dy) + (math.sqrt(2) - 1) * min(dx, dy)
        else:
            return 0
    
    def get_neighbors(self, node: Node) -> List[Node]:
        """Get valid neighboring nodes"""
        neighbors = []
        
        # Cardinal directions
        directions = [(0, -1), (1, 0), (0, 1), (-1, 0)]
        
        # Add diagonal directions if enabled
        if self.diagonal:
            directions.extend([(-1, -1), (1, -1), (1, 1), (-1, 1)])
        
        for dx, dy in directions:
            x, y = node.x + dx, node.y + dy
            
            # Check bounds
            if 0 <= x < self.width and 0 <= y < self.height:
                # Check if not a wall
                if self.get_cost(x, y) != float('inf'):
                    # For diagonal movement, check if path is clear
                    if abs(dx) + abs(dy) == 2:  # Diagonal
                        if (self.get_cost(node.x + dx, node.y) == float('inf') and
                            self.get_cost(node.x, node.y + dy) == float('inf')):
                            continue  # Both adjacent cells are walls
                    
                    neighbors.append(Node(x, y))
        
        return neighbors
    
    def get_cost(self, x: int, y: int) -> float:
        """Get movement cost for a cell"""
        if 0 <= x < self.width and 0 <= y < self.height:
            terrain = self.grid[y][x]
            return self.terrain_cost.get(terrain, 1)
        return float('inf')
    
    def find_path(self, start: Tuple[int, int], goal: Tuple[int, int]) -> Optional[List[Tuple[int, int]]]:
        """Find path from start to goal using A*"""
        start_node = Node(start[0], start[1])
        goal_node = Node(goal[0], goal[1])
        
        # Priority queue for open set
        open_set = [start_node]
        heapq.heapify(open_set)
        
        # Track visited nodes
        closed_set: Set[Node] = set()
        
        # Track best g scores
        g_scores: Dict[Tuple[int, int], float] = {(start_node.x, start_node.y): 0}
        
        # Statistics
        nodes_explored = 0
        
        while open_set:
            current = heapq.heappop(open_set)
            nodes_explored += 1
            
            # Goal reached
            if current == goal_node:
                path = self.reconstruct_path(current)
                print(f"Path found! Length: {len(path)}, Nodes explored: {nodes_explored}")
                return path
            
            closed_set.add(current)
            
            # Explore neighbors
            for neighbor in self.get_neighbors(current):
                if neighbor in closed_set:
                    continue
                
                # Calculate tentative g score
                move_cost = 1.414 if abs(neighbor.x - current.x) + abs(neighbor.y - current.y) == 2 else 1
                terrain_cost = self.get_cost(neighbor.x, neighbor.y)
                tentative_g = g_scores[(current.x, current.y)] + move_cost * terrain_cost
                
                # Check if this path is better
                if (neighbor.x, neighbor.y) not in g_scores or tentative_g < g_scores[(neighbor.x, neighbor.y)]:
                    # Update node
                    neighbor.g = tentative_g
                    neighbor.h = self.heuristic(neighbor, goal_node)
                    neighbor.f = neighbor.g + neighbor.h
                    neighbor.parent = current
                    
                    g_scores[(neighbor.x, neighbor.y)] = tentative_g
                    
                    # Add to open set if not already there
                    if neighbor not in open_set:
                        heapq.heappush(open_set, neighbor)
        
        print(f"No path found. Nodes explored: {nodes_explored}")
        return None
    
    def reconstruct_path(self, node: Node) -> List[Tuple[int, int]]:
        """Reconstruct path from goal to start"""
        path = []
        current = node
        
        while current:
            path.append((current.x, current.y))
            current = current.parent
        
        return list(reversed(path))
    
    def smooth_path(self, path: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
        """Smooth path by removing unnecessary waypoints"""
        if len(path) < 3:
            return path
        
        smoothed = [path[0]]
        current = 0
        
        while current < len(path) - 1:
            farthest = current + 1
            
            # Find farthest visible point
            for i in range(current + 2, len(path)):
                if self.line_of_sight(path[current], path[i]):
                    farthest = i
                else:
                    break
            
            smoothed.append(path[farthest])
            current = farthest
        
        return smoothed
    
    def line_of_sight(self, p1: Tuple[int, int], p2: Tuple[int, int]) -> bool:
        """Check if there's a clear line of sight between two points"""
        x0, y0 = p1
        x1, y1 = p2
        
        dx = abs(x1 - x0)
        dy = abs(y1 - y0)
        sx = 1 if x0 < x1 else -1
        sy = 1 if y0 < y1 else -1
        err = dx - dy
        
        while True:
            # Check if current position is walkable
            if self.get_cost(x0, y0) == float('inf'):
                return False
            
            if x0 == x1 and y0 == y1:
                break
            
            e2 = 2 * err
            if e2 > -dy:
                err -= dy
                x0 += sx
            if e2 < dx:
                err += dx
                y0 += sy
        
        return True

Best Practices

โšก Pathfinding Tips

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: A* Pathfinding with Cost-Externalized Terrain Map + Admissible-vs-Inflated Heuristic Selector + Heuristic-Drives-Search-Shape Visualization in One Pygame Window

Objective: Build a runnable pygame window in roughly 85 lines that shows A* pathfinding over a 24ร—15 cell grid (each cell 32px) with three orthogonal pathfinding disciplines visible per frame: (a) terrain cost externalized as a TERRAIN_COST dict (EMPTY=1, WALL=float('inf'), FOREST=3, WATER=10, SAND=2) so adding a new terrain type is one dict entry rather than an edit to the A* loop โ€” the data-driven externalization pattern at pathfinding scope; (b) heuristic selector switching between Manhattan / Octile / Euclidean / Zero (= Dijkstra) on keys H/O/U/Z so the same goal with the same map produces visibly different search shapes โ€” Zero explores in all directions equally (uniform expansion fan-out, Dijkstra's behavior), Manhattan / Octile direct toward the goal (anisotropic search wedge); (c) heuristic-multiplier toggle (1.0ร— admissible, 1.5ร— inflated) on keys + and โˆ’ so the inflated case visibly explores fewer nodes but may return a longer-cost path than the admissible case โ€” the trade-off itself is the design choice between optimality and speed.

Instructions:

  1. Create a 24ร—15 grid at 32px/cell (768ร—480 window plus 80px HUD strip below). Define terrain constants EMPTY=0, WALL=1, FOREST=2, WATER=3, SAND=4, HAZARD=5 and TERRAIN_COST = {EMPTY: 1, WALL: float('inf'), FOREST: 3, WATER: 10, SAND: 2, HAZARD: 5} โ€” the cost dict is the data-driven externalization. Color each cell by terrain (EMPTY=dark gray, WALL=black, FOREST=dark green, WATER=blue, SAND=tan, HAZARD=red).
  2. Implement A* using heapq as the priority queue ordered by f-score, where f = g + h * heuristic_multiplier. Push tuples of (f_score, counter, node) with a monotonic counter as tiebreaker (heapq cannot compare bare nodes when f-scores tie). Track g_score as a dict mapping (x, y) to actual committed cost from start; track came_from as a dict for parent pointers used in path reconstruction. On goal-reach, walk came_from back from goal to start to build the path list.
  3. Implement four heuristics: manhattan(a, b) = abs(a.x - b.x) + abs(a.y - b.y) โ€” admissible for 4-direction grids; octile(a, b) = max(dx, dy) + (sqrt(2) โˆ’ 1) * min(dx, dy) โ€” admissible for 8-direction grids; euclidean(a, b) = sqrt(dx*dx + dy*dy) โ€” admissible for any grid; zero(a, b) = 0 โ€” degenerates A* to Dijkstra. Switch between them via keys H / O / U / Z.
  4. Add a heuristic-multiplier toggle on keys + and โˆ’ cycling through 0.5 / 1.0 / 1.5 / 2.0. At 1.0 the heuristic is used as-is (admissible if the underlying heuristic is admissible). At 1.5 and 2.0 the heuristic is inflated (no longer admissible) โ€” A* explores fewer nodes but may return a suboptimal path. At 0.5 the heuristic is deflated (still admissible because 0.5 ร— admissible โ‰ค admissible โ‰ค true cost) โ€” A* explores more nodes but still returns optimal.
  5. Mouse left-click drag-paints cells with the current paint terrain; mouse right-click sets GOAL at the cell under the cursor; shift + left-click sets START. Keys 1/2/3/4/5 toggle the paint terrain to WALL/FOREST/WATER/SAND/HAZARD; key E paints EMPTY (eraser). Press SPACE to run A* and render result. Press R to reset grid to defaults (start at (2,2), goal at (21,12), no obstacles).
  6. After SPACE: tint cells in the closed list pink; cells still in the open list light green; draw the final path as a gold polyline through cell centers. HUD displays per-frame: current heuristic name, multiplier value with "ADMISSIBLE" / "INFLATED" flag, open-list size, closed-list size, nodes-explored count, final path cost, final path length, last-search wall-clock ms, plus a key-binding cheat sheet line.
  7. Verify three pillars on screen: (a) switch heuristic to Zero and run โ€” closed-list region grows in all four directions equally (Dijkstra's uniform fan-out); switch back to Manhattan and run โ€” closed-list region narrows into a wedge directed at the goal; (b) switch multiplier to 1.5 with Manhattan and run โ€” nodes-explored drops noticeably while path cost may rise vs the 1.0 baseline; (c) paint a few HAZARD cells (key 5) and run โ€” A* respects the HAZARD cost of 5 with zero edits to the A* loop, proving the externalization claim from Pillar (a).
๐Ÿ’ก Hint

Three things commonly bite first-time A* implementations: (1) push (f_score, counter, node) tuples to the heap with a monotonic counter as tiebreaker โ€” bare (f_score, node) tuples raise TypeError when f-scores tie because heapq tries to compare the node objects and Node-objects don't implement < by default; (2) update g_score for a neighbor only when tentative_g is strictly less than the existing g_score[neighbor] โ€” otherwise you re-process nodes already settled to better paths and the closed-list semantics break; (3) for diagonal moves the move-cost is sqrt(2) not 1 (the per-cell terrain cost from TERRAIN_COST applies in addition: step = move_cost * TERRAIN_COST[grid[ny][nx]]). For the heuristic-shape visualization, the Zero-heuristic case should produce a roughly circular closed-list region centered on start; the Manhattan-1.0 case should produce a closed-list region pointed at the goal with a narrower angular spread โ€” if both look the same, your priority queue is probably not actually using f-score for ordering.

โœ… Example Solution
import pygame, heapq, math, sys

pygame.init()
W, H, CELL = 768, 480, 32
COLS, ROWS = W // CELL, H // CELL  # 24 x 15
screen = pygame.display.set_mode((W, H + 80))
pygame.display.set_caption("A* Pathfinding: Terrain + Heuristic + Multiplier")
clock = pygame.time.Clock()
font = pygame.font.SysFont("monospace", 13)

EMPTY, WALL, FOREST, WATER, SAND, HAZARD = 0, 1, 2, 3, 4, 5
TERRAIN_COST = {EMPTY: 1, WALL: float('inf'), FOREST: 3, WATER: 10, SAND: 2, HAZARD: 5}
TERRAIN_COLOR = {EMPTY: (60, 70, 90), WALL: (20, 20, 20), FOREST: (40, 110, 50),
                 WATER: (60, 90, 200), SAND: (220, 180, 120), HAZARD: (200, 80, 80)}

grid = [[EMPTY] * COLS for _ in range(ROWS)]
start, goal = (2, 2), (21, 12)
heuristic_name, multiplier, paint = "manhattan", 1.0, WALL
closed_list, open_list, path = set(), set(), []
nodes_explored, search_ms, path_cost = 0, 0, 0.0

def manhattan(a, b): return abs(a[0] - b[0]) + abs(a[1] - b[1])
def octile(a, b):
    dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
    return max(dx, dy) + (math.sqrt(2) - 1) * min(dx, dy)
def euclidean(a, b):
    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def zero(a, b): return 0
HEURISTICS = {"manhattan": manhattan, "octile": octile, "euclidean": euclidean, "zero": zero}

def neighbors(p):
    x, y = p
    for dx, dy in [(-1,0),(1,0),(0,-1),(0,1),(-1,-1),(-1,1),(1,-1),(1,1)]:
        nx, ny = x + dx, y + dy
        if 0 <= nx < COLS and 0 <= ny < ROWS and TERRAIN_COST[grid[ny][nx]] != float('inf'):
            move_cost = math.sqrt(2) if dx and dy else 1
            yield (nx, ny), move_cost * TERRAIN_COST[grid[ny][nx]]

def astar():
    global closed_list, open_list, path, nodes_explored, search_ms, path_cost
    t0 = pygame.time.get_ticks()
    h = HEURISTICS[heuristic_name]
    open_heap, came_from, g_score = [], {}, {start: 0}
    counter = 0
    heapq.heappush(open_heap, (h(start, goal) * multiplier, counter, start))
    open_set, closed = {start}, set()
    nodes_explored, path, path_cost = 0, [], 0.0
    while open_heap:
        _, _, current = heapq.heappop(open_heap)
        if current in closed: continue
        open_set.discard(current)
        closed.add(current)
        nodes_explored += 1
        if current == goal:
            path = [current]
            while current in came_from:
                current = came_from[current]
                path.append(current)
            path.reverse()
            path_cost = g_score[goal]
            break
        for nb, step in neighbors(current):
            if nb in closed: continue
            tentative_g = g_score[current] + step
            if nb not in g_score or tentative_g < g_score[nb]:
                g_score[nb] = tentative_g
                came_from[nb] = current
                f = tentative_g + h(nb, goal) * multiplier
                counter += 1
                heapq.heappush(open_heap, (f, counter, nb))
                open_set.add(nb)
    closed_list, open_list = closed, open_set
    search_ms = pygame.time.get_ticks() - t0

def reset():
    global grid, start, goal, closed_list, open_list, path, nodes_explored, path_cost
    grid = [[EMPTY] * COLS for _ in range(ROWS)]
    start, goal = (2, 2), (21, 12)
    closed_list, open_list, path = set(), set(), []
    nodes_explored, path_cost = 0, 0.0

PAINT_KEYS = {pygame.K_1: WALL, pygame.K_2: FOREST, pygame.K_3: WATER,
              pygame.K_4: SAND, pygame.K_5: HAZARD, pygame.K_e: EMPTY}
HEUR_KEYS = {pygame.K_h: "manhattan", pygame.K_o: "octile",
             pygame.K_u: "euclidean", pygame.K_z: "zero"}
MULT_LIST = [0.5, 1.0, 1.5, 2.0]

while True:
    for e in pygame.event.get():
        if e.type == pygame.QUIT: pygame.quit(); sys.exit()
        if e.type == pygame.KEYDOWN:
            if e.key in PAINT_KEYS: paint = PAINT_KEYS[e.key]
            elif e.key in HEUR_KEYS: heuristic_name = HEUR_KEYS[e.key]
            elif e.key in (pygame.K_PLUS, pygame.K_EQUALS):
                multiplier = MULT_LIST[(MULT_LIST.index(multiplier) + 1) % len(MULT_LIST)]
            elif e.key == pygame.K_MINUS:
                multiplier = MULT_LIST[(MULT_LIST.index(multiplier) - 1) % len(MULT_LIST)]
            elif e.key == pygame.K_SPACE: astar()
            elif e.key == pygame.K_r: reset()
    mb = pygame.mouse.get_pressed()
    if any(mb):
        mx, my = pygame.mouse.get_pos()
        if my < H:
            cx, cy = mx // CELL, my // CELL
            if mb[0]:
                if pygame.key.get_mods() & pygame.KMOD_SHIFT: start = (cx, cy)
                else: grid[cy][cx] = paint
            elif mb[2]: goal = (cx, cy)

    screen.fill((30, 30, 30))
    for y in range(ROWS):
        for x in range(COLS):
            pygame.draw.rect(screen, TERRAIN_COLOR[grid[y][x]],
                             (x*CELL+1, y*CELL+1, CELL-2, CELL-2))
            if (x, y) in closed_list and grid[y][x] == EMPTY:
                pygame.draw.rect(screen, (255, 182, 193),
                                 (x*CELL+10, y*CELL+10, CELL-20, CELL-20))
            elif (x, y) in open_list and grid[y][x] == EMPTY:
                pygame.draw.rect(screen, (144, 238, 144),
                                 (x*CELL+10, y*CELL+10, CELL-20, CELL-20))
    if path:
        pts = [(x*CELL + CELL//2, y*CELL + CELL//2) for x, y in path]
        pygame.draw.lines(screen, (255, 215, 0), False, pts, 4)
    sx, sy = start; pygame.draw.circle(screen, (60,200,60), (sx*CELL+CELL//2, sy*CELL+CELL//2), 10)
    gx, gy = goal; pygame.draw.circle(screen, (220,60,60), (gx*CELL+CELL//2, gy*CELL+CELL//2), 10)

    adm = "ADMISSIBLE" if multiplier <= 1.0 else "INFLATED"
    paint_name = {WALL:"WALL",FOREST:"FOREST",WATER:"WATER",SAND:"SAND",HAZARD:"HAZARD",EMPTY:"EMPTY"}[paint]
    hud = [
        f"heuristic: {heuristic_name}  multiplier: {multiplier}x ({adm})  paint: {paint_name}",
        f"open: {len(open_list)}  closed: {len(closed_list)}  explored: {nodes_explored}  cost: {path_cost:.2f}  len: {len(path)}  ms: {search_ms}",
        "L-drag paint  R goal  Shift+L start  1-5 terrain  E erase  H/O/U/Z heuristic  +/- mult  SPACE run  R reset",
    ]
    for i, line in enumerate(hud):
        screen.blit(font.render(line, True, (220,220,220)), (8, H + 8 + i * 20))
    pygame.display.flip()
    clock.tick(60)

๐ŸŽฏ Quick Quiz

Question 1: A* orders its priority queue by f = g + h. What does combining the two terms give you that using only one would not?

Question 2: What does it mean for a heuristic to be "admissible", and what does inflating the heuristic by 1.5x trade away?

Question 3: The lesson's A* implementation reads per-cell traversal cost from a TERRAIN_COST dict (EMPTY: 1, WALL: float('inf'), FOREST: 3, WATER: 10, SAND: 2). What design property does the dict-as-cost-table give you that an inline if-elif chain in the inner loop would not?

What's Next?

Now that you've mastered pathfinding, next we'll explore state machines for creating intelligent NPC behaviors!