Lesson 18 of 22, in world 6: Gameplay Systems
AI: Steering & Pathfinding
Game AI isn’t about being smart — it’s about being fun to play against. Most enemies are a state machine (lesson 15) plus two skills: moving convincingly toward or away from things, and finding a path around obstacles.
Steering: moving like a living thing
The vector recipe from lesson 5 (“subtract, normalize, scale”) gives a desired velocity. Instead of setting velocity to it directly, steering behaviours nudge the current velocity toward it (a capped acceleration — lesson 6). The result is smooth curves and natural-looking overshoot.
Move the mouse — it is the target. Each bug computes a desired velocity and steers toward it a little each frame (desired − current velocity = steering force). That's why they swerve and overshoot instead of snapping.
const desired = scale(normalize(sub(target, me.pos)), MAX_SPEED) // seek
let steer = sub(desired, me.vel) // how to get there
steer = limit(steer, MAX_FORCE * dt)
me.vel = add(me.vel, steer)Pathfinding: getting around walls
Seek walks straight into walls. To navigate a maze, treat the tilemap (lesson 12) as a graph: each walkable cell is a node, connected to its neighbours. Then search it:
- Breadth-first search (BFS) explores outward in rings. Guaranteed shortest path when every step costs the same — but it explores in all directions.
- Dijkstra is BFS with different step costs (mud costs 3, road costs 1).
- A* is Dijkstra plus a heuristic: a guess of the remaining distance (e.g. Manhattan distance). It explores toward the goal first and usually checks far fewer cells.
Click/drag to draw walls (or pick a tool to move the start or goal). Blue cells are explored, the yellow line is the shortest path, and the red enemy walks it. Compare how many cells each algorithm explores — both find a shortest path, A* just searches toward the goal.
// A*: always expand the open cell with the lowest f = g + h
// g = steps from the start so far, h = estimated steps to the goal
open = [start]
while (open.length) {
const cur = popLowest(open, c => g[c] + manhattan(c, goal))
if (cur === goal) return rebuildPath(cameFrom, goal)
for (const n of neighbours(cur)) if (!blocked(n) && !seen(n)) {
cameFrom[n] = cur; g[n] = g[cur] + 1; open.push(n)
}
}Putting it together: an enemy brain
switch (enemy.state) { // FSM from lesson 15
case 'patrol': followWaypoints(); if (canSee(player)) enemy.state = 'chase'; break
case 'chase': followPath(aStar(enemy, player)); if (near(player)) enemy.state = 'attack'; break
case 'attack': swing(); if (!near(player)) enemy.state = 'chase'; break
}Key takeaways
- Steering: desired velocity − current velocity, limited, gives natural motion.
- Pathfinding treats the level as a graph. BFS for uniform grids; A* to search smarter with a heuristic.
- Combine an FSM (what to do) with steering + pathfinding (how to move).
Check yourself
Q1. What makes A* usually faster than BFS?
Q2. In "arrive", why does the bug slow down near the target?
Q3. A* with h = 0 for every cell behaves like…
Exercises
- Wall off the goal completely. What should an enemy do when there is no path?
- Build a long corridor maze. Does A* still beat BFS by much? When does the heuristic help least?
- Combine behaviours: "seek the player but flee if health < 30%". Which lesson’s tool would you use to switch?