Module 14 of 20 Phase: Intelligence & Camera

Enemies That Think
Patrols, Chasing & State Machines

A good enemy doesn't need real AI — it needs a handful of well-defined behaviors and clean rules for switching between them. This module builds patrol movement, line-of-sight chasing, a finite state machine, and boss phase patterns.

🧠
12 Lessons
This module
~3 hrs
Estimated time
📊
Advanced
Difficulty
🐍
FSM pattern
Core technique
Section 1

Patrol Movement

The simplest enemy behavior: walk between two points, reversing direction on arrival. This alone makes a level feel alive before you write a single line of "intelligent" logic.

patrol.py
class Patroller:
    def __init__(self, left_x, right_x, y, speed=80):
        self.pos = pygame.Vector2(left_x, y)
        self.left_x = left_x
        self.right_x = right_x
        self.speed = speed
        self.direction = 1   # 1 = moving right, -1 = moving left

    def update(self, dt):
        self.pos.x += self.direction * self.speed * dt
        if self.pos.x >= self.right_x:
            self.direction = -1
        elif self.pos.x <= self.left_x:
            self.direction = 1
Section 2

Line-of-Sight Detection & Chasing

Chasing is just "move toward the player's position each frame" — combined with a distance check that decides WHEN to start chasing (usually a detection radius, sometimes with a raycast to confirm nothing blocks the view).

chase.py
DETECTION_RADIUS = 220

def update_chase(enemy, player_pos, dt):
    to_player = player_pos - enemy.pos
    distance = to_player.length()
    if distance < DETECTION_RADIUS and distance > 0:
        direction = to_player.normalize()
        enemy.pos += direction * enemy.speed * dt
        return True   # currently chasing
    return False
Section 3

Finite State Machines

A finite state machine (FSM) keeps enemy logic readable as behaviors multiply. The enemy is always in exactly ONE named state, and transitions between states follow clear rules.

🚶
State
Patrol
👀
Sees player
Chase
⚔️
In range
Attack
enemy_fsm.py
class Enemy:
    def __init__(self, pos):
        self.pos = pos
        self.state = "patrol"
        self.speed = 90

    def update(self, dt, player_pos):
        distance = (player_pos - self.pos).length()

        if self.state == "patrol":
            self.patrol_update(dt)
            if distance < 220:
                self.state = "chase"

        elif self.state == "chase":
            if distance < 40:
                self.state = "attack"
            elif distance > 300:
                self.state = "patrol"      # lost the player
            else:
                direction = (player_pos - self.pos).normalize()
                self.pos += direction * self.speed * dt

        elif self.state == "attack":
            if distance > 60:
                self.state = "chase"        # player backed away
            # else: play attack animation, deal damage
Section 4

Simple Pathfinding Concepts

Straight-line chasing breaks the moment a wall is in the way. Real pathfinding (like A*) searches a grid for the shortest route around obstacles — a full implementation is beyond this module, but the concept is:

🗺️
Grid Representation
The level is divided into a grid of walkable/blocked cells (similar to Module 13's tile map collision layer).
🔍
A* Search
Explores neighboring cells, scoring each by distance-traveled-so-far plus estimated distance-to-goal, always expanding the most promising cell first.
💡
Practical tip: For most 2D games, you don't need full A*. A cheaper alternative — "wall-following" or waypoint graphs placed by hand in the level — solves 90% of real pathing needs with far less code.
Section 5 · Boss Behaviors

Boss Phases & Attack Timers

Bosses extend the FSM idea with phases tied to remaining health, and attack patterns driven by a cooldown timer rather than player proximity alone.

boss.py
class Boss:
    def __init__(self, max_hp):
        self.hp = self.max_hp = max_hp
        self.attack_timer = 0
        self.phase = 1

    def update(self, dt):
        if self.hp < self.max_hp * 0.5 and self.phase == 1:
            self.phase = 2   # gets more aggressive below 50% HP

        self.attack_timer -= dt
        if self.attack_timer <= 0:
            self.perform_attack()
            self.attack_timer = 2.0 if self.phase == 1 else 1.0

    def perform_attack(self):
        pass  # spawn projectiles, slam, etc — varies per phase
Section 6

Visualizing the Detection Radius

The DETECTION_RADIUS from the chase example is just a circle drawn around the enemy — anyone inside it gets chased, anyone outside it doesn't. The diagram below shows one player who hasn't been noticed yet, and another who has just entered range.

detection radius Enemy Player out of range — ignored Player detected — enemy gives chase
A player outside the dashed detection radius goes unnoticed; one who steps inside it triggers the enemy's chase state, shown by the arrow.

🧠 Quick Knowledge Check

1. What is a finite state machine, in the context of enemy AI?
2. In the chase example, what does `to_player.normalize()` give you?
3. Why does straight-line chasing break around obstacles?
4. What commonly triggers a boss to enter a new phase?
5. What's a cheaper alternative to full A* pathfinding for many 2D games?
Finished Module 14?

Mark it complete to track your progress through Complete Pygame Mastery 2026.

🗒 Cheat Sheet 📝 Worksheet