Published: 2026-07-28 | By: Carlos Montiel | Reading time: ~4 minutes
An enemy that only walks in a straight line reads as fake immediately. Today we give your NPCs the ability to chase the player believably.
Two Levels of "AI" in an Enemy
In the previous lesson we organized the game's flow with a state machine. Now we'll use that exact same idea, but applied to a single enemy: what it's "doing" at any given moment (patrolling, chasing, attacking), and separately, how it calculates the route to reach the player (pathfinding). We separate these two problems because mixing them is the number one source of enemies that behave erratically.
Behavior with a Simple State Machine
enum class EnemyState { PATROLLING, CHASING, ATTACKING };
struct Enemy {
float x, y;
EnemyState state = EnemyState::PATROLLING;
float detection_range = 150.0f;
float attack_range = 30.0f;
};
float distance(float x1, float y1, float x2, float y2) {
float dx = x2 - x1;
float dy = y2 - y1;
return std::sqrt(dx * dx + dy * dy);
}
void update_enemy_state(Enemy& e, float player_x, float player_y) {
float dist = distance(e.x, e.y, player_x, player_y);
if (dist <= e.attack_range) {
e.state = EnemyState::ATTACKING;
} else if (dist <= e.detection_range) {
e.state = EnemyState::CHASING;
} else {
e.state = EnemyState::PATROLLING;
}
}
Here we do use `sqrt()` (unlike the collision detection in lesson 5) because we need the real distance to compare it against two different thresholds, not just find out which of two distances is larger.
Simple Patrolling Between Two Points
struct Enemy {
float x, y;
float point_a_x, point_a_y;
float point_b_x, point_b_y;
bool heading_to_b = true;
float speed = 80.0f;
EnemyState state = EnemyState::PATROLLING;
};
void patrol(Enemy& e, float delta) {
float target_x = e.heading_to_b ? e.point_b_x : e.point_a_x;
float target_y = e.heading_to_b ? e.point_b_y : e.point_a_y;
float dx = target_x - e.x;
float dy = target_y - e.y;
float dist = std::sqrt(dx * dx + dy * dy);
if (dist < 5.0f) {
e.heading_to_b = !e.heading_to_b; // arrived, reverse direction
return;
}
e.x += (dx / dist) * e.speed * delta;
e.y += (dy / dist) * e.speed * delta;
}
Direct Chase vs. Pathfinding
Chasing the player in a straight line (same as patrolling, but aiming at the player) works in open spaces, but breaks down as soon as there are walls or obstacles: the enemy gets "stuck" against a wall instead of going around it. For that we need real pathfinding, and the industry-standard algorithm for this is A* (A-star).
The A* Algorithm on a Grid
A* finds the shortest path between two points on a map divided into cells, prioritizing exploration of the most promising cells first (the ones closest to the destination in a straight line) instead of exploring blindly the way a breadth-first search (BFS) would.
#include <vector>
#include <queue>
#include <unordered_map>
#include <cmath>
struct Cell { int x, y; };
bool operator==(const Cell& a, const Cell& b) {
return a.x == b.x && a.y == b.y;
}
struct CellHash {
size_t operator()(const Cell& c) const {
return std::hash<int>()(c.x) ^ (std::hash<int>()(c.y) << 1);
}
};
float heuristic(const Cell& a, const Cell& b) {
return std::abs(a.x - b.x) + std::abs(a.y - b.y); // Manhattan distance
}
struct AStarNode {
Cell cell;
float f_cost; // g_cost (traveled) + heuristic (estimate to destination)
};
struct CompareNodes {
bool operator()(const AStarNode& a, const AStarNode& b) {
return a.f_cost > b.f_cost; // min-heap: lower f_cost has priority
}
};
std::vector<Cell> get_neighbors(const Cell& c, const std::vector<std::vector<bool>>& blocked_map) {
std::vector<Cell> neighbors;
const int dx[] = {0, 0, 1, -1};
const int dy[] = {1, -1, 0, 0};
for (int i = 0; i < 4; ++i) {
int nx = c.x + dx[i];
int ny = c.y + dy[i];
if (ny >= 0 && ny < (int)blocked_map.size() &&
nx >= 0 && nx < (int)blocked_map[0].size() &&
!blocked_map[ny][nx]) {
neighbors.push_back({nx, ny});
}
}
return neighbors;
}
std::vector<Cell> find_path_a_star(
const Cell& start, const Cell& goal,
const std::vector<std::vector<bool>>& blocked_map) {
std::priority_queue<AStarNode, std::vector<AStarNode>, CompareNodes> open_set;
std::unordered_map<Cell, Cell, CellHash> came_from;
std::unordered_map<Cell, float, CellHash> g_cost;
open_set.push({start, heuristic(start, goal)});
g_cost[start] = 0;
while (!open_set.empty()) {
Cell current = open_set.top().cell;
open_set.pop();
if (current == goal) {
std::vector<Cell> path;
while (!(current == start)) {
path.push_back(current);
current = came_from[current];
}
std::reverse(path.begin(), path.end());
return path;
}
for (const Cell& neighbor : get_neighbors(current, blocked_map)) {
float new_cost = g_cost[current] + 1.0f;
if (g_cost.find(neighbor) == g_cost.end() || new_cost < g_cost[neighbor]) {
g_cost[neighbor] = new_cost;
float priority = new_cost + heuristic(neighbor, goal);
open_set.push({neighbor, priority});
came_from[neighbor] = current;
}
}
}
return {}; // no path found
}
Using the Computed Path to Move the Enemy
Once A* returns the list of cells, the enemy simply moves toward the next cell in the path each frame, recalculating the full path only at intervals (not every frame, which would be too expensive).
struct EnemyWithPathfinding {
float x, y;
std::vector<Cell> current_path;
size_t path_index = 0;
float time_since_recalc = 0.0f;
const float RECALC_INTERVAL = 0.5f; // recalculates twice per second
};
void update_chase(EnemyWithPathfinding& e, Cell target,
const std::vector<std::vector<bool>>& map, float delta) {
e.time_since_recalc += delta;
if (e.time_since_recalc >= e.RECALC_INTERVAL) {
Cell start { (int)e.x, (int)e.y };
e.current_path = find_path_a_star(start, target, map);
e.path_index = 0;
e.time_since_recalc = 0.0f;
}
}
A Key Tip for This Lesson
A* is one of the algorithms where it's most worth using your AI assistant to verify your implementation, because bugs here are subtle: a badly computed heuristic doesn't break the build, it just makes the found path suboptimal or makes the algorithm explore far more cells than necessary. Ask your AI assistant to trace through, cell by cell, a small 5x5 map with a couple of obstacles, and compare the path it predicts against what your code actually computes — it's the fastest way to find a logic error in the heuristic or in how `g_cost` is handled.
In the next lesson we leave "local" enemy AI behind to make a different kind of leap: how to get two programs talking to each other over a network, the first step toward multiplayer.