Lesson 17

Pathfinding: make enemies move with purpose instead of drifting randomly.

Pathfinding is the bridge between a map and believable enemy behavior. It helps monsters chase, workers deliver, cars follow routes and tower defense enemies stay on the road.

IntermediateAIGrid Movement40-60 minutes

1. Start with fixed paths when the route is designed

Not every game needs A-star. Tower defense enemies usually follow a known path. A fixed list of grid points is simpler, faster and easier to balance. The enemy stores a `pathIndex`, moves toward the next waypoint and advances the index when it gets close.

const path = [
    { x: 0, y: 2 },
    { x: 3, y: 2 },
    { x: 3, y: 4 },
    { x: 7, y: 4 },
    { x: 7, y: 6 },
    { x: 11, y: 6 }
];

function moveAlongPath(enemy, dt) {
    const target = path[enemy.pathIndex + 1];
    if (!target) return "finished";

    const tx = target.x * TILE + TILE / 2;
    const ty = target.y * TILE + TILE / 2;
    const dx = tx - enemy.x;
    const dy = ty - enemy.y;
    const dist = Math.hypot(dx, dy) || 1;

    enemy.x += dx / dist * enemy.speed * dt;
    enemy.y += dy / dist * enemy.speed * dt;

    if (dist < 4) enemy.pathIndex++;
}

Source pattern: Supagames Guardian Towers stores path waypoints per map and moves enemies from one waypoint to the next.

Fixed paths are also good for teaching. You can draw dots on the road and see exactly where enemies should go.

2. Use breadth-first search for simple grid chasing

If enemies need to find the player through rooms and corridors, breadth-first search is often enough. It explores the grid outward from the start until it reaches the goal. For small maps, it is easy to implement and reliable.

function findPathBfs(map, start, goal) {
    const queue = [start];
    const cameFrom = new Map();
    const key = p => p.r + "," + p.c;
    cameFrom.set(key(start), null);

    while (queue.length) {
        const cur = queue.shift();
        if (cur.r === goal.r && cur.c === goal.c) break;

        for (const next of neighbors(map, cur)) {
            const id = key(next);
            if (cameFrom.has(id)) continue;
            cameFrom.set(id, cur);
            queue.push(next);
        }
    }

    return rebuildPath(cameFrom, start, goal);
}

This gives the enemy a list of cells. You can then move toward the center of the first or second cell in that list. Recompute every half-second, not every frame, unless the map is tiny.

3. Keep neighbor rules honest

The quality of pathfinding depends on neighbors. Can the unit move diagonally? Can it pass through doors? Does water slow it or block it? Put those rules in one function so movement, AI and validation agree.

function neighbors(map, cell) {
    const dirs = [
        { r: -1, c: 0 }, { r: 1, c: 0 },
        { r: 0, c: -1 }, { r: 0, c: 1 }
    ];
    const result = [];
    for (const d of dirs) {
        const r = cell.r + d.r;
        const c = cell.c + d.c;
        if (!map[r] || typeof map[r][c] === "undefined") continue;
        if (map[r][c] === WALL) continue;
        result.push({ r, c });
    }
    return result;
}

Diagonal movement is tempting, but it can cut through corners unless you add extra checks. Start with four directions. Add diagonals only when you need them.

4. A-star adds priority, not magic

A-star improves on breadth-first search by exploring promising cells first. It uses `g` for distance already traveled and `h` for estimated distance to the goal. For grid games, Manhattan distance is a good heuristic when movement is four-directional.

function heuristic(a, b) {
    return Math.abs(a.r - b.r) + Math.abs(a.c - b.c);
}

function chooseLowest(open, score) {
    let best = 0;
    for (let i = 1; i < open.length; i++) {
        if ((score.get(open[i]) || Infinity) < (score.get(open[best]) || Infinity)) {
            best = i;
        }
    }
    return open.splice(best, 1)[0];
}

For many browser games, the simple array version is enough. If your maps become huge, use a binary heap. Do not optimize pathfinding before you have measured it.

5. Make AI readable to players

Good pathfinding is not only about reaching the goal. It must look fair. Enemies should not instantly know the player changed rooms unless that fits the design. Guards can have vision cones, zombies can recompute paths slowly, and bosses can telegraph dashes before moving.

function updateEnemyPath(enemy, playerCell, dt) {
    enemy.repathTimer -= dt;
    if (enemy.repathTimer > 0) return;

    enemy.repathTimer = enemy.alert ? 0.35 : 1.2;
    enemy.path = findPathBfs(level, enemy.cell, playerCell);
    enemy.nextCellIndex = 1;
}

Slower path updates can make enemies feel more natural and reduce CPU use. They also give players a chance to outmaneuver enemies instead of being followed by perfect missiles.

6. Debug by drawing the path

When pathfinding fails, draw the path cells, blocked cells and current target. Most path bugs are visual: a wall tile is mislabeled, the enemy's cell conversion is wrong, or the goal is outside the map. Debug overlays turn those bugs from mysteries into coordinates.

function drawDebugPath(ctx, path) {
    ctx.fillStyle = "rgba(255, 217, 61, 0.35)";
    for (const cell of path) {
        ctx.fillRect(cell.c * TILE + 8, cell.r * TILE + 8, TILE - 16, TILE - 16);
    }
}

Pathfinding becomes much less intimidating once you can see it. The goal is not clever AI; the goal is readable behavior that supports the game.

Previous: Tile maps. Next: Boss fights.