Code of the Day
AdvancedGame AI

NPC state machine in code

Implement an Enemy class with PATROL, CHASE, and FLEE states driven by distance to the player — with hysteresis to prevent flickering.

Game DevAdvanced10 min read
By the end of this lesson you will be able to:
  • Implement an Enemy class with PATROL, CHASE, and FLEE states
  • Drive transitions with distance to the player and a hysteresis gap
  • Move the enemy toward or away from the player using normalised vectors

This lesson turns the diagram from the previous lesson into a working Enemy class. The full program renders a green player (arrow keys) and a red enemy that patrols, gives chase, and flees.

Pyodide (the in-browser Python runner) does not support pygame's display system. Read through this code carefully in the browser, then run it locally: pip install pygame followed by python npc_ai.py.

Enemy class

import pygame
import sys

pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("NPC state machine")
clock  = pygame.time.Clock()

Vector2 = pygame.math.Vector2

# ── Constants ─────────────────────────────────────────────────────────────────

DETECT_RADIUS = 180    # enter CHASE
LOSE_RADIUS   = 260    # hysteresis: drop back to PATROL
FLEE_RADIUS   = 300    # target distance when fleeing
LOW_HP        = 30     # % threshold for FLEE

PATROL_SPEED  = 80
CHASE_SPEED   = 140
FLEE_SPEED    = 160

PATROL_LEFT   = 80
PATROL_RIGHT  = 360

# ── Enemy ─────────────────────────────────────────────────────────────────────

class Enemy:
    W, H = 28, 28

    def __init__(self, x, y):
        self.pos   = Vector2(x, y)
        self.rect  = pygame.Rect(x, y, self.W, self.H)
        self.state = "PATROL"
        self.hp    = 100          # 0-100; reduced externally
        self.dir   = 1            # patrol direction (+1 right, -1 left)
        self.state_frames = 0

    def _set_state(self, new_state):
        if self.state != new_state:
            self.state        = new_state
            self.state_frames = 0

    def update(self, dt, player_pos):
        self.state_frames += 1

        to_player = player_pos - self.pos
        distance  = to_player.length()

        # ── Transitions ───────────────────────────────────────────────────────
        # Only transition after a minimum dwell (prevents flicker at boundaries)
        if self.state_frames >= 30:
            if self.hp <= LOW_HP:
                self._set_state("FLEE")
            elif self.state == "PATROL" and distance < DETECT_RADIUS:
                self._set_state("CHASE")
            elif self.state == "CHASE" and distance > LOSE_RADIUS:
                self._set_state("PATROL")

        # ── Behaviour per state ───────────────────────────────────────────────
        if self.state == "PATROL":
            self.pos.x += self.dir * PATROL_SPEED * dt
            if self.pos.x > PATROL_RIGHT:
                self.pos.x = PATROL_RIGHT
                self.dir   = -1
            elif self.pos.x < PATROL_LEFT:
                self.pos.x = PATROL_LEFT
                self.dir   =  1

        elif self.state == "CHASE":
            if distance > 0:
                direction  = to_player.normalize()
                self.pos  += direction * CHASE_SPEED * dt

        elif self.state == "FLEE":
            if distance > 0 and distance < FLEE_RADIUS:
                away       = -to_player.normalize()
                self.pos  += away * FLEE_SPEED * dt
            # Clamp inside screen
            self.pos.x = max(0, min(640 - self.W, self.pos.x))
            self.pos.y = max(0, min(480 - self.H, self.pos.y))

        self.rect.topleft = (int(self.pos.x), int(self.pos.y))

    def draw(self, surface):
        colour = {"PATROL": (180, 60, 60),
                  "CHASE":  (220, 100, 20),
                  "FLEE":   (60, 60, 200)}[self.state]
        pygame.draw.rect(surface, colour, self.rect)
        # State label
        font = pygame.font.SysFont(None, 20)
        label = font.render(self.state, True, (255, 255, 255))
        surface.blit(label, (self.rect.x, self.rect.y - 20))


# ── Player ────────────────────────────────────────────────────────────────────

class Player:
    W, H = 28, 28

    def __init__(self, x, y):
        self.pos  = Vector2(x, y)
        self.rect = pygame.Rect(x, y, self.W, self.H)

    def update(self, dt):
        keys = pygame.key.get_pressed()
        speed = 200
        if keys[pygame.K_LEFT]:  self.pos.x -= speed * dt
        if keys[pygame.K_RIGHT]: self.pos.x += speed * dt
        if keys[pygame.K_UP]:    self.pos.y -= speed * dt
        if keys[pygame.K_DOWN]:  self.pos.y += speed * dt
        self.pos.x = max(0, min(640 - self.W, self.pos.x))
        self.pos.y = max(0, min(480 - self.H, self.pos.y))
        self.rect.topleft = (int(self.pos.x), int(self.pos.y))

    def draw(self, surface):
        pygame.draw.rect(surface, (100, 200, 120), self.rect)


# ── Main loop ─────────────────────────────────────────────────────────────────

player = Player(300, 380)
enemy  = Enemy(200, 220)
font   = pygame.font.SysFont(None, 22)

running = True
while running:
    dt = min(clock.tick(60) / 1000.0, 0.05)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_h:
                enemy.hp = max(0, enemy.hp - 25)   # press H to damage enemy

    player.update(dt)
    enemy.update(dt, player.pos + Vector2(player.W / 2, player.H / 2))

    screen.fill((20, 20, 35))
    enemy.draw(screen)
    player.draw(screen)

    # HUD
    hud = font.render(
        f"Enemy HP: {enemy.hp}   State: {enemy.state}   H = damage",
        True, (220, 220, 220))
    screen.blit(hud, (10, 10))
    pygame.display.flip()

pygame.quit()
sys.exit()

Notes on the implementation

HysteresisDETECT_RADIUS (180) is smaller than LOSE_RADIUS (260). The enemy commits to CHASE at 180 px but does not abandon it until 260 px. This gap prevents the flickering that would occur if both thresholds were equal.

state_frames minimum dwell — transitions are blocked for the first 30 frames in a new state. This prevents a freshly-entering-CHASE enemy from immediately re-evaluating and returning to PATROL if the player is right on the boundary.

Normalised vectors for movementto_player.normalize() gives a unit direction. Multiplying by CHASE_SPEED * dt produces a frame-rate-independent displacement. Guarding with if distance > 0 avoids the ValueError that normalize() raises on a zero vector.

Press H to damage — reduces enemy.hp by 25 so you can observe the FLEE transition without needing a combat system.

The enemy in this demo passes through walls because there is no collision resolution. In the lab you will integrate this with the tile map from the intermediate tier and use PhysicsBody.move_and_collide to keep the enemy on solid ground.

Where to go next

Next: pathfinding concepts — why straight-line CHASE fails against complex maps, and how BFS and A* find a real path through a tile grid.

Finished reading? Mark it complete to track your progress.

On this page