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.
- 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
Hysteresis — DETECT_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 movement — to_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.
Finite state machines for NPC AI
Model NPC behaviour with a finite state machine — distinct states, explicit transition conditions, and cooldowns that prevent rapid state flickering.
Pathfinding concepts
Understand why straight-line movement fails in complex maps, how BFS explores a tile grid level by level, and how A* uses a heuristic to explore far fewer nodes.