Lesson 6: Advanced Pathfinding
Your A* from the Intermediate course finds a path for one unit. In this lesson you learn when you can trust its answer, how to trade a little path quality for a lot of speed, and how to move a hundred units with a single search, the way strategy and tower defense games do.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain admissibility and consistency, and check a heuristic against both on a grid.
- Debug a suboptimal path caused by an overestimating heuristic.
- Build weighted A* and measure how the weight trades path cost for expanded cells.
- Explain what Jump Point Search prunes and when it applies, without mistaking it for a general speed-up.
- Build a flow field with one reverse Dijkstra search and steer a crowd along it.
Project: Crowd Commander, 120 units that follow one flow field to wherever you click.
In This Lesson
๐ฏ When Can You Trust A*?
A* ranks cells by f = g + h: the known cost from the start plus a guess of the cost still to go. The guess is what makes A* fast, and it is also the only thing that can make A* wrong. Think of a taxi driver's estimate of the fare: if it is never more than the real fare, you can plan around it; if it sometimes overshoots, you may skip the route that was actually cheapest.
Two properties tell you how far to trust h:
- Admissible: h(n) never overestimates the true cheapest cost from n to the goal. With an admissible heuristic, A* returns an optimal path.
- Consistent (also called monotone): for every move from n to a neighbor m with cost c, h(n) โค c + h(m). Every consistent heuristic (with h(goal) = 0) is also admissible. The extra promise is that the first time A* pops a cell from the heap, its g is already the cheapest, so the closed set and the lazy-deletion skip you wrote in the A* Pathfinding lesson never throw away a better route.
| Heuristic | Formula (dx, dy = distance in cells) | Admissible when |
|---|---|---|
| Manhattan | dx + dy | 4-way moves, each costing 1 |
| Octile | max(dx, dy) + (โ2 โ 1) ยท min(dx, dy) | 8-way moves, diagonals cost โ2 |
| Chebyshev | max(dx, dy) | 8-way moves, diagonals cost 1 |
| Euclidean | โ(dxยฒ + dyยฒ) | 4-way, or 8-way with diagonals costing โ2 (never over, but a weaker guess on grids). Not when diagonals cost 1: one diagonal step scores โ2 |
| Zero | 0 | Always. A* becomes Dijkstra |
These are admissible on grids where every step costs at least the listed amount. If some terrain is cheaper than 1 per step (a road at 0.5, say), scale the heuristic by the cheapest step cost, or it can overestimate.
An overestimate in action
A common grid bug, and one you will see in plenty of strategy-game code, is allowing diagonal moves but guiding A* with Manhattan distance. Manhattan counts one diagonal step as 2, and even a diagonal that costs โ2 โ 1.41 is cheaper than that, so the heuristic overestimates. Here is a complete program that searches the same small map with both heuristics:
import heapq
import math
MAP = [
"S.....#.",
"#.......",
"....#...",
"#...#...",
"#...#...",
"..#....G",
]
SQRT2 = math.sqrt(2)
def walkable(x, y):
return 0 <= y < len(MAP) and 0 <= x < len(MAP[0]) and MAP[y][x] != "#"
def moves(x, y):
"""8-way moves, no corner cutting. Yields ((nx, ny), step_cost)."""
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if (dx or dy) and walkable(x + dx, y + dy):
if dx and dy and not (walkable(x + dx, y) and walkable(x, y + dy)):
continue
yield (x + dx, y + dy), SQRT2 if dx and dy else 1.0
def octile(a, b):
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
return max(dx, dy) + (SQRT2 - 1) * min(dx, dy)
def manhattan(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def astar(start, goal, h):
heap = [(h(start, goal), 0, start)]
g = {start: 0.0}
closed = set()
counter = 0
while heap:
_, _, cell = heapq.heappop(heap)
if cell in closed:
continue
closed.add(cell)
if cell == goal:
return g[cell], len(closed)
for nxt, step in moves(*cell):
new_g = g[cell] + step
if nxt not in closed and new_g < g.get(nxt, math.inf):
g[nxt] = new_g
counter += 1
heapq.heappush(heap, (new_g + h(nxt, goal), counter, nxt))
return math.inf, len(closed)
start, goal = (0, 0), (7, 5)
for h in (octile, manhattan):
cost, expanded = astar(start, goal, h)
print(f"{h.__name__:9}: path cost {cost:.2f}, cells expanded {expanded}")
It prints:
octile : path cost 10.24, cells expanded 24
manhattan: path cost 10.83, cells expanded 11
Manhattan expanded fewer cells and returned a path about 6% longer than the best one. That is the pattern to recognize: an overestimating heuristic makes A* greedier, which is faster and no longer optimal. Sometimes that's a fine trade. It just shouldn't happen by accident.
โ Growth Mindset: "It Works" Is a Hypothesis
A path that reaches the goal looks correct, which is why heuristic bugs survive for years in real codebases. When you aren't sure yet whether your A* is optimal, don't argue with yourself: compare it with Dijkstra (weight 0) on a few maps, as the warm-up's tests do. A second, slower algorithm that you trust is one of the best debugging tools there is.
โ๏ธ Weighted A*
Sometimes you want the greedy trade, deliberately and with a guarantee. Weighted A* multiplies the heuristic by a weight w โฅ 1:
heapq.heappush(open_heap, (new_g + weight * h(nxt, goal), counter, nxt))
With an admissible h, the path weighted A* returns costs at most w times the optimal cost (the idea of weighting the heuristic goes back to Ira Pohl's work around 1970). A weight of 1.5 can never return a path more than 50% longer than the best, and in practice the paths are usually much closer. Weight 0 turns the heuristic off entirely, which is Dijkstra.
Here is the warm-up program, weighted_astar_solution.py. It searches one map with five weights and draws the expanded cells; press 0โ4 to switch.
"""Weighted A*: Advanced Lesson 6 warm-up (solution).
The same map searched with different heuristic weights. Keys: 0 = Dijkstra
(no heuristic), 1 = A* (w = 1), 2 = w = 1.5, 3 = w = 2, 4 = w = 5.
Blue cells were expanded; the gold line is the path. Close the window to quit.
"""
import heapq
import math
import pygame
MAP = [
"..............................",
"..............................",
"......#################.......",
"......................#.......",
"..S...................#.......",
"......................#.......",
"......................#.....G.",
"......................#.......",
"..........#############.......",
"..............................",
"..............................",
]
CELL = 30
ROWS, COLS = len(MAP), len(MAP[0])
SQRT2 = math.sqrt(2)
WEIGHTS = {pygame.K_0: 0.0, pygame.K_1: 1.0, pygame.K_2: 1.5, pygame.K_3: 2.0, pygame.K_4: 5.0}
def find(ch):
for r, row in enumerate(MAP):
if ch in row:
return (row.index(ch), r)
raise ValueError(ch)
def walkable(cell):
x, y = cell
return 0 <= x < COLS and 0 <= y < ROWS and MAP[y][x] != "#"
def neighbors(cell):
"""8-way moves; a diagonal may not cut a wall corner. Yields (next_cell, step_cost)."""
x, y = cell
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == dy == 0:
continue
nxt = (x + dx, y + dy)
if not walkable(nxt):
continue
if dx and dy and not (walkable((x + dx, y)) and walkable((x, y + dy))):
continue # no corner cutting
yield nxt, SQRT2 if dx and dy else 1.0
def octile(a, b):
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
return max(dx, dy) + (SQRT2 - 1) * min(dx, dy)
def manhattan(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def astar(start, goal, weight=1.0, h=octile):
"""Weighted A*: f = g + weight * h. weight 0 is Dijkstra, 1 is plain A*.
Returns (path, cost, expanded) where expanded is the set of closed cells.
"""
counter = 0
open_heap = [(weight * h(start, goal), counter, start)]
g = {start: 0.0}
parent = {start: None}
closed = set()
while open_heap:
_, _, cell = heapq.heappop(open_heap)
if cell in closed:
continue # stale entry (lazy deletion)
closed.add(cell)
if cell == goal:
path = []
while cell is not None:
path.append(cell)
cell = parent[cell]
return path[::-1], g[goal], closed
for nxt, step in neighbors(cell):
new_g = g[cell] + step
if nxt not in closed and new_g < g.get(nxt, math.inf):
g[nxt] = new_g
parent[nxt] = cell
counter += 1
heapq.heappush(open_heap, (new_g + weight * h(nxt, goal), counter, nxt))
return None, math.inf, closed
def main():
pygame.init()
screen = pygame.display.set_mode((COLS * CELL, ROWS * CELL + 60))
pygame.display.set_caption("Weighted A* (0-4 change the weight)")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
start, goal = find("S"), find("G")
results = {w: astar(start, goal, w) for w in WEIGHTS.values()}
weight = 1.0
running = True
while running:
clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key in WEIGHTS:
weight = WEIGHTS[event.key]
path, cost, expanded = results[weight]
screen.fill((15, 23, 42))
for y in range(ROWS):
for x in range(COLS):
rect = (x * CELL, y * CELL, CELL - 1, CELL - 1)
if MAP[y][x] == "#":
color = (71, 85, 105)
elif (x, y) in expanded:
color = (30, 64, 120)
else:
color = (30, 41, 59)
pygame.draw.rect(screen, color, rect)
points = [(x * CELL + CELL / 2, y * CELL + CELL / 2) for x, y in path]
pygame.draw.lines(screen, (250, 204, 21), False, points, 4)
for cell, color in ((start, (74, 222, 128)), (goal, (248, 113, 113))):
pygame.draw.circle(screen, color, (cell[0] * CELL + CELL / 2, cell[1] * CELL + CELL / 2), 10)
name = "Dijkstra" if weight == 0 else f"A* w = {weight}"
text = f"{name}: path cost {cost:.2f} cells expanded {len(expanded)}"
screen.blit(font.render(text, True, (226, 232, 240)), (10, ROWS * CELL + 10))
screen.blit(font.render("keys 0-4 change the weight", True, (148, 163, 184)), (10, ROWS * CELL + 34))
pygame.display.flip()
pygame.quit()
for w in WEIGHTS.values():
path, cost, expanded = results[w]
print(f"w={w}: cost {cost:.2f}, expanded {len(expanded)}")
if __name__ == "__main__":
main()
When the window closes it prints the table below (the lab's checker verifies these exact numbers, so you'll get them too):
| Weight | Path cost | Cells expanded |
|---|---|---|
| 0 (Dijkstra) | 29.31 | 289 |
| 1 (A*) | 29.31 | 160 |
| 1.5 | 29.31 | 108 |
| 2 | 29.31 | 99 |
| 5 | 30.49 | 95 |
On this map, weights up to 2 still found the optimal path: weight 1.5 expanded about two-thirds as many cells as plain A* (108 vs 160), and weight 2 about 60% (99). Weight 5 gave up 4% of path quality and saved only 4 more cells. Another map will give other numbers, which is exactly why you measure on your own levels before picking a weight.
๐ฆ Jump Point Search (Concept)
๐งญ Concept only
This section explains what Jump Point Search does and when it applies. The course does not implement it, and the demo below deliberately has no JPS button: a "JPS" option that quietly ran ordinary A* would teach you nothing. If your game needs it, follow the paper linked in Going Further.
On an open grid where every step costs the same, there are usually many equally short paths: right-right-up, right-up-right and up-right-right all tie. Plain A* dutifully puts cells from all of them on the heap. Jump Point Search (Daniel Harabor and Alban Grastien, 2011) prunes those symmetric paths:
- From a cell, it keeps moving in a straight line (a "jump") instead of adding every cell on the way to the heap.
- It stops only at a jump point: the goal, or a cell with a forced neighbor, which appears where an obstacle creates a detour that a straight line can't cover.
- Only jump points go on the heap, so the heap stays small, and the result is still an optimal path.
The catch is the assumption: JPS relies on a uniform-cost grid. Mud, roads or any per-cell cost break the symmetry argument, and then you are back to A* (or a variant that the paper's later work describes). The Crowd Commander map in this lesson has mud, so JPS would not apply to it as is.
๐ Flow Fields for Crowds
Picture a stadium emptying: nobody plans their own route. Signs at every corridor point toward the exits, and everyone just follows the nearest sign. A flow field is those signs. Instead of searching once per unit from the unit to the goal, you search once from the goal outward and record, for every cell, which way to go.
- Integration field. Run Dijkstra (A* with no heuristic) starting at the goal. Each cell gets
cost[cell], the cheapest cost from that cell to the goal. A unit stepping into a cell pays that cell's terrain cost, so when the search at cell C relaxes a neighbor N, the move it is pricing is N โ C. - Direction field. For each cell, point at the neighbor with the lowest cost. Store it as a unit
Vector2. - Follow. Each frame, a unit looks up the arrow under its feet and steers toward it with the
steer_toward()you built in Steering & Flocking.
def integration_field(goal):
"""Dijkstra outward from the goal: cost[cell] = cheapest cost from cell TO the goal."""
cost = {goal: 0.0}
heap = [(0.0, 0, goal)]
counter = 0
done = set()
while heap:
c, _, cell = heapq.heappop(heap)
if cell in done:
continue
done.add(cell)
cx, cy = cell
enter_cost = TERRAIN_COST[MAP[cy][cx]] # a unit stepping INTO this cell pays it
for nxt, step in moves(cell):
new_c = c + step * enter_cost
if new_c < cost.get(nxt, math.inf):
cost[nxt] = new_c
counter += 1
heapq.heappush(heap, (new_c, counter, nxt))
return cost
def flow_field(cost):
"""For each reachable cell, a unit Vector2 toward its cheapest neighbor."""
field = {}
for cell, c in cost.items():
best, best_c = None, c
for nxt, _ in moves(cell):
if cost.get(nxt, math.inf) < best_c:
best, best_c = nxt, cost[nxt]
if best is not None:
field[cell] = pygame.Vector2(best[0] - cell[0], best[1] - cell[1]).normalize()
return field
The trade-off is clear once you count. One search on the exercise map visits every reachable floor cell (469 of them) once, however many units follow it. Per-unit A* is cheaper for one unit and gets more expensive with every unit you add. Flow fields win when many units share a goal (rally points, a base under attack, the end of a tower-defense lane); per-unit A* wins when every unit wants somewhere different. Cells missing from cost are unreachable, and a unit standing on one simply has no arrow, which is an honest answer rather than a crash.
Try both ideas in the demo below: the search buttons show which cells each algorithm expanded on the same map, and Flow field draws the arrows every cell would follow.
๐ก Why this matters
Crowds are where pathfinding cost explodes. A tower-defense wave or an RTS army can be hundreds of units; a flow field turns "hundreds of searches" into "one search and a lookup per unit", and it pairs naturally with separation so the units don't stack on one pixel.
๐งน Pathfinding Hygiene
The algorithms are only half the story. Three habits keep pathfinding from eating your frame budget:
- Don't search every frame. Re-plan when something relevant changes (the goal moved, a wall appeared) or on a timer measured in seconds, not frames.
- Remember failures. A search for an unreachable goal is the most expensive one of all, because it explores every reachable cell before giving up. Re-running it every frame is a classic way to lose your frame rate. Cache the failure and retry only after a delay or a map change.
- Share work. Units heading to the same place can share one path or one flow field. Units that are close together can share a path and use steering to spread out.
RETRY_AFTER = 2.0 # seconds
if unit.path is None and unit.retry_timer <= 0:
unit.path = find_path(unit.cell, unit.goal)
if unit.path is None: # unreachable: don't ask again next frame
unit.retry_timer = RETRY_AFTER
unit.retry_timer -= dt
๐๏ธ Practice Exercise: Crowd Commander
Objective: build a flow field with one search from the goal and send a crowd of 120 units along it to wherever you click.
Time: about 35 minutes. Starter file: flow_field_starter.py (your instructor has it). The map, the units, their separation and the drawing are done; the units stand still. The numbered to-do comments (1 to 4) in the file follow steps 2 to 5 below, in order.
- Run the starter. Press H and F: there's no heat map and no arrows yet. (โ 2 min)
- Write
integration_field(): Dijkstra from the goal, paying the terrain cost of the cell being entered. Press H: the heat map fades with distance. (โ 12 min) - Write
flow_field(): for each cell, a unit vector toward its cheapest neighbor. Press F: the arrows all flow downhill. (โ 8 min) - In
desired_velocity(), look up the arrow under the unit and scale it toMAX_SPEED. The crowd starts moving. (โ 5 min) - On a mouse click, rebuild the cost and flow fields for the new goal and count the rebuild. (โ 5 min)
You are done when:
- the heat map is lightest at the goal and darkest far away, and mud cells are visibly more "expensive";
- every arrow points to a cheaper neighbor and none cuts a wall corner;
- the crowd flows around the walls to the goal, and one click moves all 120 units with exactly one rebuild;
- clicking a wall does nothing.
๐ก Hint
Your integration field is the A* from the warm-up with the heuristic set to zero, started at the goal. The subtle part is the terrain cost: when you pop cell C and look at neighbor N, a unit would walk from N into C, so the step costs step * TERRAIN_COST[MAP[C's y][C's x]]. If arrows point away from the goal, you used N's terrain instead.
โ Example Solution
The lab file has a few extra lines marked lab runtime near the top and and frame_budget() in the loop, so the instructor's checker can run it automatically. They do nothing when you run it yourself, and they are left out here.
"""Crowd Commander: Advanced Lesson 6 practice exercise (solution).
One Dijkstra search from the goal builds a flow field, and 120 units follow it
at once. Click a floor cell to move the goal. F toggles the arrows, H the cost
heat map. Brown cells are mud (3x cost). Close the window to quit.
"""
import heapq
import math
import random
import pygame
MAP = [
"..............................",
"..............................",
"....#########.................",
"............#......~~~~.......",
"............#......~~~~.......",
"............#......~~~~.......",
"....#.......#......~~~~.......",
"....#.......#......~~~~.......",
"....#.......#......~~~~.......",
"....#.......########~~~#####..",
"....#.........................",
"....#.........................",
"....#######...................",
"..............................",
"..............................",
"..............................",
"..............................",
]
CELL = 32
ROWS, COLS = len(MAP), len(MAP[0])
WIDTH, HEIGHT = COLS * CELL, ROWS * CELL
TERRAIN_COST = {".": 1.0, "~": 3.0} # "#" is a wall: not in the table
SQRT2 = math.sqrt(2)
NUM_UNITS = 120
MAX_SPEED = 110.0 # px/s
MAX_FORCE = 400.0 # px/s^2
REACTION_TIME = 0.2 # s
UNIT_RADIUS = 5.0
SEP_RADIUS = 12.0 # px
GOAL_RADIUS = 1.5 * CELL # px: "close enough" to count as arrived
def walkable(cell):
x, y = cell
return 0 <= x < COLS and 0 <= y < ROWS and MAP[y][x] in TERRAIN_COST
def moves(cell):
"""8-way moves with no corner cutting. Yields (neighbor, step_length)."""
x, y = cell
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == dy == 0:
continue
nxt = (x + dx, y + dy)
if not walkable(nxt):
continue
if dx and dy and not (walkable((x + dx, y)) and walkable((x, y + dy))):
continue
yield nxt, SQRT2 if dx and dy else 1.0
def integration_field(goal):
"""Dijkstra outward from the goal: cost[cell] = cheapest cost from cell TO the goal."""
cost = {goal: 0.0}
heap = [(0.0, 0, goal)]
counter = 0
done = set()
while heap:
c, _, cell = heapq.heappop(heap)
if cell in done:
continue
done.add(cell)
cx, cy = cell
enter_cost = TERRAIN_COST[MAP[cy][cx]] # a unit stepping INTO this cell pays it
for nxt, step in moves(cell):
new_c = c + step * enter_cost
if new_c < cost.get(nxt, math.inf):
cost[nxt] = new_c
counter += 1
heapq.heappush(heap, (new_c, counter, nxt))
return cost
def flow_field(cost):
"""For each reachable cell, a unit Vector2 toward its cheapest neighbor."""
field = {}
for cell, c in cost.items():
best, best_c = None, c
for nxt, _ in moves(cell):
if cost.get(nxt, math.inf) < best_c:
best, best_c = nxt, cost[nxt]
if best is not None:
field[cell] = pygame.Vector2(best[0] - cell[0], best[1] - cell[1]).normalize()
return field
def cell_of(pos):
return (int(pos.x // CELL), int(pos.y // CELL))
def cell_center(cell):
return pygame.Vector2(cell[0] * CELL + CELL / 2, cell[1] * CELL + CELL / 2)
def steer_toward(desired, vel):
return ((desired - vel) / REACTION_TIME).clamp_magnitude(MAX_FORCE)
class Unit:
def __init__(self, pos):
self.pos = pygame.Vector2(pos)
self.vel = pygame.Vector2()
def desired_velocity(unit, field, goal):
"""Follow the field; arrive (slow down) inside the goal cell's neighborhood."""
to_goal = cell_center(goal) - unit.pos
dist = to_goal.length()
if dist < GOAL_RADIUS:
if dist < 1:
return pygame.Vector2()
return to_goal * (MAX_SPEED * dist / GOAL_RADIUS / dist)
direction = field.get(cell_of(unit.pos))
if direction is None:
return pygame.Vector2() # unreachable cell: wait
return direction * MAX_SPEED
def buckets(units):
"""Spatial hash by grid cell, so separation only checks nearby units."""
table = {}
for unit in units:
table.setdefault(cell_of(unit.pos), []).append(unit)
return table
def separation(unit, table):
push = pygame.Vector2()
cx, cy = cell_of(unit.pos)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for other in table.get((cx + dx, cy + dy), ()):
offset = unit.pos - other.pos
d2 = offset.length_squared()
if other is not unit and 0 < d2 < SEP_RADIUS ** 2:
push += offset / d2
if push.length_squared() == 0:
return pygame.Vector2()
return steer_toward(push.normalize() * MAX_SPEED, unit.vel)
def move_unit(unit, accel, dt):
"""Integrate, then resolve walls per axis: undo the axis that entered a wall."""
unit.vel = (unit.vel + accel * dt).clamp_magnitude(MAX_SPEED)
old_x = unit.pos.x
unit.pos.x += unit.vel.x * dt
if not walkable(cell_of(unit.pos)):
unit.pos.x = old_x
unit.vel.x = 0
old_y = unit.pos.y
unit.pos.y += unit.vel.y * dt
if not walkable(cell_of(unit.pos)):
unit.pos.y = old_y
unit.vel.y = 0
def update(units, field, goal, dt):
table = buckets(units)
forces = []
for unit in units:
follow = steer_toward(desired_velocity(unit, field, goal), unit.vel)
forces.append((follow + separation(unit, table) * 1.5).clamp_magnitude(MAX_FORCE))
for unit, accel in zip(units, forces):
move_unit(unit, accel, dt)
def spawn_units(seed=None):
rng = random.Random(seed)
floor = [(x, y) for y in range(ROWS) for x in range(0, 4) if walkable((x, y))]
units = []
for _ in range(NUM_UNITS):
cx, cy = rng.choice(floor)
units.append(Unit((cx * CELL + rng.uniform(4, CELL - 4), cy * CELL + rng.uniform(4, CELL - 4))))
return units
def arrived(units, goal):
center = cell_center(goal)
return sum(1 for u in units if u.pos.distance_to(center) < GOAL_RADIUS * 2)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Crowd Commander (click to move the goal, F arrows, H heat map)")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24)
goal = (26, 7)
cost = integration_field(goal)
field = flow_field(cost)
rebuilds = 1
units = spawn_units(seed=3)
show_arrows, show_heat = True, False
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.05)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
clicked = cell_of(pygame.Vector2(event.pos))
if walkable(clicked) and clicked != goal:
goal = clicked
cost = integration_field(goal) # ONE search for every unit
field = flow_field(cost)
rebuilds += 1
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_f:
show_arrows = not show_arrows
elif event.key == pygame.K_h:
show_heat = not show_heat
update(units, field, goal, dt)
screen.fill((15, 23, 42))
top = max(cost.values()) or 1.0
for y in range(ROWS):
for x in range(COLS):
ch = MAP[y][x]
rect = (x * CELL, y * CELL, CELL - 1, CELL - 1)
if ch == "#":
pygame.draw.rect(screen, (71, 85, 105), rect)
continue
if show_heat and (x, y) in cost:
shade = max(0, min(255, int(40 + 180 * (1 - cost[(x, y)] / top))))
pygame.draw.rect(screen, (shade // 3, shade // 2, shade), rect)
else:
pygame.draw.rect(screen, (92, 64, 51) if ch == "~" else (30, 41, 59), rect)
if show_arrows and (x, y) in field:
c = cell_center((x, y))
pygame.draw.line(screen, (100, 116, 139), c, c + field[(x, y)] * 11, 2)
pygame.draw.circle(screen, (248, 113, 113), cell_center(goal), 10, 3)
for unit in units:
pygame.draw.circle(screen, (250, 204, 21), unit.pos, UNIT_RADIUS)
hud = f"arrived {arrived(units, goal)}/{len(units)} field rebuilds {rebuilds}"
screen.blit(font.render(hud, True, (226, 232, 240)), (8, 6))
pygame.display.flip()
pygame.quit()
print(f"goal {goal}; field rebuilds: {rebuilds}")
print(f"units near the goal: {arrived(units, goal)} of {len(units)}")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Explain admissibility to a teammate using an everyday estimate (travel time, a price, a recipe). Why does "never overestimate" matter?
- Your game has 8 enemies that each want a different target and 200 minions that all rush the player's base. Which search strategy would you use for each group, and why?
- What weight would you choose for weighted A* in your own game, and how would you decide?
๐ Summary
A* is only as good as its heuristic. An admissible heuristic never overestimates and guarantees an optimal path; a consistent one also guarantees that a cell's first pop already carries its cheapest cost. Pick the heuristic that matches your moves (Manhattan for 4-way, octile for 8-way with โ2 diagonals) and compare against Dijkstra when in doubt. Weighted A* makes the greedy trade on purpose, with a cost bound of w times optimal. Jump Point Search prunes symmetric paths on uniform-cost grids only. Flow fields flip the problem around: one search from the goal gives every cell an arrow, and any number of units can follow it with the steering from the previous lesson.
๐ Key Takeaways
- Admissible: h never overestimates, so A* is optimal. Consistent: h(n) โค c(n, m) + h(m), so no cell ever needs reopening.
- Manhattan on an 8-way grid overestimates diagonals and quietly returns longer paths.
- Weighted A* (f = g + wยทh) returns a path at most w times optimal while usually expanding far fewer cells. Measure on your own maps.
- Jump Point Search needs a uniform-cost grid; with terrain costs, use A*.
- A flow field is one reverse Dijkstra plus an arrow per cell; it pays off when many units share a goal.
- Don't re-search every frame, and cache "unreachable" answers.
๐ญ Looking Ahead
Your agents can now move well and find their way. Next they need to decide what to do. In Behavior Trees you build the decision structure many modern games use for NPCs, with priorities that can interrupt long-running actions.
โ Common Questions
Is Euclidean distance ever the best choice?
On a grid, octile (8-way) or Manhattan (4-way) are admissible and tighter, so A* expands fewer cells. Euclidean is the natural choice when movement isn't tied to a grid, such as on a navigation mesh or a waypoint graph.
If weights above 1 are faster, why not always use a big one?
Because path quality degrades and paths start to look greedy: hugging walls, taking visibly odd detours. Players notice. Many games use a modest weight for long background searches and plain A* when the path is on screen.
Does a flow field work if the goal moves every frame?
Rebuilding every frame is expensive on big maps. Common options are rebuilding on a short timer, rebuilding only when the goal changes cell, or splitting the map into sectors and rebuilding only the parts that changed.
My units get stuck on corners. Is the field wrong?
Check two things. The field must forbid diagonal moves that cut a wall corner (the moves() rule from the A* Pathfinding lesson). And units have a radius: a unit whose center follows an arrow can still clip a corner, which the per-axis wall resolution in the exercise handles.
Where does hierarchical pathfinding fit?
For very large maps, games often search a coarse graph of regions first and then refine inside each region. It is a separate technique from the ones in this lesson; the idea to take away is the same as with flow fields: avoid searching more cells than you need.
๐ฏ Quick Quiz
Question 1: On an 8-way grid where diagonal steps cost โ2, why is Manhattan distance a poor heuristic for A*?
Question 2: Weighted A* with w = 2 and an admissible heuristic returns a path of cost 30. What do you know about the optimal cost C*?
Question 3: What does a consistent heuristic guarantee on top of admissibility?
Question 4: Two hundred units must all reach the same rally point. Why is a flow field a good fit?
Question 5: Why couldn't Jump Point Search be dropped into Crowd Commander as it is?
๐ Going Further
- Count the savings: add the
closedset's size to the Crowd Commander HUD by computing a per-unit A* to the same goal, and compare it with the one field build. - Moving goal: make the goal follow the mouse, and rebuild the field only when the goal changes cell. Count rebuilds per second.
- Roads: add a road terrain costing 0.5. Then fix the heuristic in the warm-up so it stays admissible (scale it by the cheapest step cost).
- Read the paper: Harabor and Grastien, Online Graph Pruning for Pathfinding on Grid Maps (AAAI 2011), and Amit Patel's heuristics notes.
- Coming up in Game Dev III: Advanced: Real-time Strategy moves armies with octile A* and the pathfinding hygiene from this lesson.