State Machines for NPCs
Creating Intelligent NPC Behaviors
State machines are the backbone of NPC AI! Learn to create NPCs with distinct behaviors, smooth transitions, complex decision-making, and personalities that bring your game world to life! 🤖🎭🧠
Understanding State Machines
🎭 The Actor's Script Analogy
Think of state machines like an actor following a script:
- States: Different scenes or moods (happy, angry, searching)
- Transitions: Stage directions (when player enters, switch to alert)
- Actions: What to do in each scene (patrol, chase, attack)
- Conditions: Cues for changing scenes (see player, lose health)
- Memory: Remembering previous scenes
- Director: The state machine controller
canSeePlayer() check fails if the player is outside either — too wide an angle, or too far away. The interactive demo below visualises this as the yellow wedge attached to each NPC.Interactive NPC State Machine Demo
Control the player (green) with Arrow Keys/WASD. Watch how NPCs react to your presence!
Spawn NPCs:
NPCs: 0 | Player Health: 100 | Items Collected: 0 | Alerts: 0
State Machine Implementation
from enum import Enum
from typing import Any, Dict, List, Optional
import random
import math
class NPCState(Enum):
"""NPC state enumeration"""
IDLE = "idle"
PATROL = "patrol"
ALERT = "alert"
CHASE = "chase"
ATTACK = "attack"
FLEE = "flee"
SEARCH = "search"
INVESTIGATE = "investigate"
RETURN = "return"
DEAD = "dead"
class State:
"""Base state class"""
def __init__(self, name: NPCState, npc: 'NPC') -> None:
self.name: NPCState = name
self.npc: 'NPC' = npc
self.entry_time: float = 0
def enter(self) -> None:
"""Called when entering the state"""
self.entry_time = 0
def execute(self, dt: float) -> None:
"""Update state logic"""
self.entry_time += dt
def exit(self) -> None:
"""Called when exiting the state"""
pass
def handle_message(self, message: Dict[str, Any]) -> bool:
"""Handle incoming messages"""
return False
class IdleState(State):
"""NPC idle behavior"""
def __init__(self, npc: 'NPC') -> None:
super().__init__(NPCState.IDLE, npc)
self.idle_timer: float = 0
self.look_direction: float = 0
def enter(self) -> None:
super().enter()
self.idle_timer = 0
self.look_direction = random.uniform(0, math.pi * 2)
self.npc.velocity = (0, 0)
def execute(self, dt: float) -> None:
super().execute(dt)
self.idle_timer += dt
# Look around occasionally
if self.idle_timer > 2:
self.look_direction += random.uniform(-math.pi, math.pi)
self.idle_timer = 0
self.npc.facing = self.look_direction
# Check for threats
if self.npc.can_see_player():
self.npc.change_state(NPCState.ALERT)
elif random.random() < 0.001: # Random chance to patrol
self.npc.change_state(NPCState.PATROL)
class PatrolState(State):
"""NPC patrol behavior"""
def __init__(self, npc: 'NPC') -> None:
super().__init__(NPCState.PATROL, npc)
self.waypoints: List[Dict[str, int]] = []
self.current_waypoint: int = 0
def enter(self) -> None:
super().enter()
self.generate_waypoints()
def generate_waypoints(self) -> None:
"""Generate patrol route"""
self.waypoints = []
for _ in range(4):
self.waypoints.append({
'x': random.randint(50, 550),
'y': random.randint(50, 350)
})
def execute(self, dt: float) -> None:
super().execute(dt)
if not self.waypoints:
return
target = self.waypoints[self.current_waypoint]
dx = target['x'] - self.npc.position[0]
dy = target['y'] - self.npc.position[1]
distance = math.sqrt(dx**2 + dy**2)
if distance < 20:
# Reached waypoint
self.current_waypoint = (self.current_waypoint + 1) % len(self.waypoints)
else:
# Move towards waypoint
speed = self.npc.patrol_speed
self.npc.velocity = (dx/distance * speed, dy/distance * speed)
# Check for threats
if self.npc.can_see_player():
self.npc.change_state(NPCState.ALERT)
class AlertState(State):
"""NPC alert/detection behavior"""
def __init__(self, npc: 'NPC') -> None:
super().__init__(NPCState.ALERT, npc)
self.alert_duration: float = 1.0
def enter(self) -> None:
super().enter()
self.npc.velocity = (0, 0)
self.npc.last_known_player_pos = self.npc.get_player_position()
# Alert nearby NPCs
self.npc.alert_nearby_npcs()
def execute(self, dt: float) -> None:
super().execute(dt)
# Face player
player_pos = self.npc.get_player_position()
dx = player_pos[0] - self.npc.position[0]
dy = player_pos[1] - self.npc.position[1]
self.npc.facing = math.atan2(dy, dx)
if self.entry_time >= self.alert_duration:
if self.npc.can_see_player():
# Decide action based on personality
if self.npc.personality == 'aggressive':
self.npc.change_state(NPCState.CHASE)
elif self.npc.personality == 'coward':
self.npc.change_state(NPCState.FLEE)
else:
self.npc.change_state(NPCState.CHASE)
else:
self.npc.change_state(NPCState.INVESTIGATE)
class ChaseState(State):
"""NPC chase behavior"""
def __init__(self, npc: 'NPC') -> None:
super().__init__(NPCState.CHASE, npc)
def execute(self, dt: float) -> None:
super().execute(dt)
player_pos = self.npc.get_player_position()
dx = player_pos[0] - self.npc.position[0]
dy = player_pos[1] - self.npc.position[1]
distance = math.sqrt(dx**2 + dy**2)
if distance < 30:
# Close enough to attack
self.npc.change_state(NPCState.ATTACK)
elif distance > 200:
# Lost player
self.npc.change_state(NPCState.SEARCH)
else:
# Continue chase
speed = self.npc.chase_speed
self.npc.velocity = (dx/distance * speed, dy/distance * speed)
self.npc.facing = math.atan2(dy, dx)
class StateMachine:
"""Finite State Machine for NPC AI"""
def __init__(self, npc: 'NPC') -> None:
self.npc: 'NPC' = npc
self.states: Dict[NPCState, State] = {}
self.current_state: Optional[State] = None
self.previous_state: Optional[State] = None
self.global_state: Optional[State] = None
# Statistics
self.state_history: List[Dict[str, Any]] = []
self.transition_count: int = 0
def add_state(self, state: State) -> None:
"""Add a state to the machine"""
self.states[state.name] = state
def change_state(self, new_state: NPCState) -> None:
"""Transition to a new state"""
if new_state not in self.states:
print(f"State {new_state} not found")
return
# Exit current state
if self.current_state:
self.current_state.exit()
self.previous_state = self.current_state
# Record transition
self.state_history.append({
'from': self.current_state.name,
'to': new_state,
'time': self.current_state.entry_time
})
# Enter new state
self.current_state = self.states[new_state]
self.current_state.enter()
self.transition_count += 1
def update(self, dt: float) -> None:
"""Update state machine"""
# Execute global state
if self.global_state:
self.global_state.execute(dt)
# Execute current state
if self.current_state:
self.current_state.execute(dt)
def handle_message(self, message: Dict[str, Any]) -> bool:
"""Handle message in current state"""
if self.current_state:
return self.current_state.handle_message(message)
return False
def revert_to_previous_state(self) -> None:
"""Return to previous state"""
if self.previous_state:
self.change_state(self.previous_state.name)
class NPC:
"""NPC with state machine AI"""
def __init__(self, x: float, y: float, personality: str = 'neutral') -> None:
self.position: list[float] = [x, y]
self.velocity: tuple[float, float] = (0, 0)
self.facing: float = 0
self.health: int = 100
self.personality: str = personality
# Movement speeds
self.patrol_speed: int = 50
self.chase_speed: int = 100
self.flee_speed: int = 120
# Perception
self.vision_range: int = 100
self.vision_angle: float = math.pi / 3
self.hearing_range: int = 150
# Memory
self.last_known_player_pos: Optional[tuple[float, float]] = None
self.home_position: tuple[float, float] = (x, y)
# State machine
self.state_machine: StateMachine = StateMachine(self)
self.initialize_states()
def initialize_states(self) -> None:
"""Setup state machine"""
self.state_machine.add_state(IdleState(self))
self.state_machine.add_state(PatrolState(self))
self.state_machine.add_state(AlertState(self))
self.state_machine.add_state(ChaseState(self))
# Add more states...
# Set initial state
self.state_machine.change_state(NPCState.IDLE)
def update(self, dt: float) -> None:
"""Update NPC"""
# Update state machine
self.state_machine.update(dt)
# Update position
self.position[0] += self.velocity[0] * dt
self.position[1] += self.velocity[1] * dt
def change_state(self, new_state: NPCState) -> None:
"""Change NPC state"""
self.state_machine.change_state(new_state)
def can_see_player(self) -> bool:
"""Check if player is visible"""
# Implementation depends on game world
return False
def get_player_position(self) -> tuple[float, float]:
"""Get player position from world"""
# Implementation depends on game world
return (0, 0)
def alert_nearby_npcs(self) -> None:
"""Alert other NPCs in range"""
# Implementation depends on game world
pass
Best Practices
⚡ State Machine Tips
- Keep States Simple: Each state should have one clear purpose
- Clean Transitions: Use clear conditions for state changes
- Entry/Exit Actions: Initialize and cleanup properly
- Global State: Handle common behaviors across all states
- State History: Track previous states for debugging
- Message System: Allow external events to trigger transitions
- Hierarchical FSM: Use sub-states for complex behaviors
- Concurrent States: Run multiple state machines for different systems
Key Takeaways
- 🤖 State machines create believable NPC behaviors
- 🎭 Each state represents a distinct behavior mode
- 🔄 Transitions define when behaviors change
- 🧠 Memory allows NPCs to remember past events
- 👁️ Perception systems trigger state changes
- 🗣️ NPCs can communicate and coordinate
- 📊 State history helps debug AI behavior
- 🎮 Personality types create variety
🏋️♂️ Practice Exercise
🏋️♂️ Exercise 1: Four-State NPC FSM + Personality-Driven Transition + State History Log
Objective: Build a runnable pygame window in roughly 85 lines that shows a five-state NPC finite state machine (IDLE ↔ PATROL ↔ ALERT → CHASE/FLEE → IDLE) driving a green NPC against a player-controlled blue rect, with personality-driven branching at the ALERT outgoing transition (aggressive routes to CHASE, coward routes to FLEE), strict on_exit → reassign → on_enter ordering in StateMachine.change_state enforced at every transition, and per-state perception checks owning their own outgoing-edge logic so adding a new state is one new class plus one add_state call rather than an edit to a centralized dispatcher — three orthogonal architectural disciplines visible per frame in the HUD: current state name color-coded by state, active personality, transition count, and a rolling 8-line state-history log showing every from→to→time row.
Instructions:
- Define
NPCState(Enum)with members IDLE/PATROL/ALERT/CHASE/FLEE. SubclassStateonce withenter()/execute(dt)/exit()hooks; subclass IdleState/PatrolState/AlertState/ChaseState/FleeState overriding the hooks with state-specific behavior plus outgoing-edge transition checks. - Build
StateMachineholding astatesdict keyed byNPCState, pluscurrent,history, andcount.change_state(new_name)MUST execute in this exact order: (a) callself.current.exit()first; (b) reassignself.current = self.states[new_name]; (c) callself.current.enter()last; (d) record the from→to→time row inhistoryand bumpcount. - In
AlertState.execute, afterself.entry_time >= 1.0, branch onself.npc.personality:'aggressive'→ CHASE,'coward'→ FLEE. The same FSM topology produces orthogonal behaviors purely from personality-as-data — no AggressiveNPC vs CowardNPC subclass split. - Each state's
execute(dt)owns its OWN outgoing transition logic. IdleState checksdistance_to_player() < 200and transitions to ALERT; ChaseState checksdistance > 300and transitions back to IDLE; etc. NO centralized tick-loop dispatcher routes transitions based on global perception state. - Pygame window 800x480, NPC color-shifts per state (gray=IDLE, blue=PATROL, yellow=ALERT, red=CHASE, magenta=FLEE). Keys 1/2 set personality aggressive/coward, I/P force IDLE/PATROL, R resets NPC position. Draw a 200-px detection-radius circle around the NPC so the IdleState/PatrolState transition trigger is visible.
- HUD shows: current state name color-coded + personality + transition count + distance-to-player + rolling 8-line state-history log with from→to→time rows so all three pillars are observable per frame.
💡 Hint
The strict on_exit → reassign → on_enter ordering matters because if you reassign FIRST and then call exit(), you call exit() on the NEW state (clobbering its just-initialized entry_time and resetting its just-allocated waypoint list); and if you call enter() before exit(), the new state's enter-side-effects run while the old state still owns self.npc's mutable attributes (orphaned timers, stuck listener registrations, wrong-self-reference bugs). Insert a print in each hook and watch the order in stdout to verify. Same lifecycle invariant as chat-54's architecture_state_machines lesson, here applied at NPC AI lifecycle scope rather than game-architecture scope — ai_state_machines is the FIRST downstream-applied case of the formal FSM pattern.
✅ Example Solution
import pygame, math, random
from enum import Enum
from typing import Optional
WIDTH, HEIGHT = 800, 480
pygame.init(); screen = pygame.display.set_mode((WIDTH, HEIGHT)); clock = pygame.time.Clock()
font = pygame.font.SysFont('monospace', 14)
class NPCState(Enum):
IDLE='idle'; PATROL='patrol'; ALERT='alert'; CHASE='chase'; FLEE='flee'
COLORS = {NPCState.IDLE:(140,140,140), NPCState.PATROL:(80,140,220),
NPCState.ALERT:(240,210,60), NPCState.CHASE:(220,60,60), NPCState.FLEE:(220,80,200)}
class State:
def __init__(self, name: NPCState, npc: 'NPC') -> None: self.name: NPCState = name; self.npc: 'NPC' = npc; self.entry_time: float = 0
def enter(self) -> None: self.entry_time = 0
def execute(self, dt: float) -> None: self.entry_time += dt
def exit(self) -> None: pass
class IdleState(State):
def __init__(self, npc: 'NPC') -> None: super().__init__(NPCState.IDLE, npc)
def enter(self) -> None: super().enter(); self.npc.vel=(0,0)
def execute(self, dt: float) -> None:
super().execute(dt)
if self.npc.distance_to_player() < 200: self.npc.fsm.change_state(NPCState.ALERT)
elif self.entry_time > 2.0: self.npc.fsm.change_state(NPCState.PATROL)
class PatrolState(State):
def __init__(self, npc: 'NPC') -> None: super().__init__(NPCState.PATROL, npc); self.waypoints: list[tuple[int, int]] = []; self.idx: int = 0
def enter(self) -> None:
super().enter()
self.waypoints=[(random.randint(60,740), random.randint(60,420)) for _ in range(3)]; self.idx=0
def execute(self, dt: float) -> None:
super().execute(dt)
if self.npc.distance_to_player() < 200: self.npc.fsm.change_state(NPCState.ALERT); return
tx,ty = self.waypoints[self.idx]
dx,dy = tx-self.npc.pos[0], ty-self.npc.pos[1]; d=math.hypot(dx,dy)
if d < 12: self.idx=(self.idx+1)%len(self.waypoints)
else: self.npc.vel=(dx/d*70, dy/d*70)
class AlertState(State):
def __init__(self, npc: 'NPC') -> None: super().__init__(NPCState.ALERT, npc)
def enter(self) -> None: super().enter(); self.npc.vel=(0,0)
def execute(self, dt: float) -> None:
super().execute(dt)
if self.entry_time >= 1.0:
if self.npc.personality == 'coward': self.npc.fsm.change_state(NPCState.FLEE)
else: self.npc.fsm.change_state(NPCState.CHASE)
class ChaseState(State):
def __init__(self, npc: 'NPC') -> None: super().__init__(NPCState.CHASE, npc)
def execute(self, dt: float) -> None:
super().execute(dt); d = self.npc.distance_to_player()
if d > 300: self.npc.fsm.change_state(NPCState.IDLE); return
dx,dy = self.npc.player[0]-self.npc.pos[0], self.npc.player[1]-self.npc.pos[1]
self.npc.vel=(dx/d*150, dy/d*150)
class FleeState(State):
def __init__(self, npc: 'NPC') -> None: super().__init__(NPCState.FLEE, npc)
def execute(self, dt: float) -> None:
super().execute(dt); d = self.npc.distance_to_player()
if d > 300: self.npc.fsm.change_state(NPCState.IDLE); return
dx,dy = self.npc.player[0]-self.npc.pos[0], self.npc.player[1]-self.npc.pos[1]
self.npc.vel=(-dx/d*180, -dy/d*180)
class StateMachine:
def __init__(self, npc: 'NPC') -> None: self.npc: 'NPC' = npc; self.states: dict[NPCState, State] = {}; self.current: Optional[State] = None; self.history: list[tuple[str, str, float]] = []; self.count: int = 0
def add_state(self, st: State) -> None: self.states[st.name]=st
def change_state(self, new_name: NPCState) -> None:
if new_name not in self.states: return
old = self.current.name if self.current else None
if self.current: self.current.exit() # 1. exit OLD
self.current = self.states[new_name] # 2. reassign
self.current.enter() # 3. enter NEW
self.count += 1
self.history.append((old.value if old else 'init', new_name.value,
round(pygame.time.get_ticks()/1000, 2)))
if len(self.history) > 8: self.history.pop(0)
def update(self, dt: float) -> None:
if self.current: self.current.execute(dt)
class NPC:
def __init__(self, x: float, y: float, personality: str = 'neutral') -> None:
self.pos: list[float] = [x, y]; self.vel: tuple[float, float] = (0, 0); self.personality: str = personality; self.player: tuple[float, float] = (0, 0)
self.fsm: StateMachine = StateMachine(self)
for cls in (IdleState, PatrolState, AlertState, ChaseState, FleeState):
self.fsm.add_state(cls(self))
self.fsm.change_state(NPCState.IDLE)
def distance_to_player(self) -> float:
return math.hypot(self.player[0]-self.pos[0], self.player[1]-self.pos[1])
def update(self, dt: float, player_pos: tuple[float, float]) -> None:
self.player = player_pos; self.fsm.update(dt)
self.pos[0] += self.vel[0]*dt; self.pos[1] += self.vel[1]*dt
player = [400, 240]; npc = NPC(120, 120, 'aggressive'); running = True
while running:
dt = clock.tick(60)/1000
for ev in pygame.event.get():
if ev.type == pygame.QUIT: running = False
elif ev.type == pygame.KEYDOWN:
if ev.key == pygame.K_1: npc.personality='aggressive'
elif ev.key == pygame.K_2: npc.personality='coward'
elif ev.key == pygame.K_i: npc.fsm.change_state(NPCState.IDLE)
elif ev.key == pygame.K_p: npc.fsm.change_state(NPCState.PATROL)
elif ev.key == pygame.K_r: npc.pos=[120,120]; npc.fsm.change_state(NPCState.IDLE)
keys = pygame.key.get_pressed()
if keys[pygame.K_w] or keys[pygame.K_UP]: player[1] -= 240*dt
if keys[pygame.K_s] or keys[pygame.K_DOWN]: player[1] += 240*dt
if keys[pygame.K_a] or keys[pygame.K_LEFT]: player[0] -= 240*dt
if keys[pygame.K_d] or keys[pygame.K_RIGHT]: player[0] += 240*dt
npc.update(dt, tuple(player))
screen.fill((30, 30, 40))
pygame.draw.circle(screen, (60, 60, 80), (int(npc.pos[0]), int(npc.pos[1])), 200, 1)
pygame.draw.rect(screen, COLORS[npc.fsm.current.name],
(npc.pos[0]-12, npc.pos[1]-12, 24, 24))
pygame.draw.rect(screen, (80, 140, 240), (player[0]-12, player[1]-12, 24, 24))
hud = [f'state: {npc.fsm.current.name.value.upper()}',
f'personality: {npc.personality}',
f'transitions: {npc.fsm.count}',
f'distance: {int(npc.distance_to_player())}',
'1/2=personality I/P=force IDLE/PATROL R=reset']
for i, line in enumerate(hud):
screen.blit(font.render(line, True, (220, 220, 220)), (10, 10 + i*16))
screen.blit(font.render('-- state history (newest at bottom) --', True, (180, 180, 180)),
(10, HEIGHT-160))
for i, (frm, to, t) in enumerate(npc.fsm.history):
screen.blit(font.render(f't={t}s {frm} -> {to}', True, (180, 220, 180)),
(10, HEIGHT-140 + i*15))
pygame.display.flip()
pygame.quit()
🎯 Quick Quiz
Question 1: In the lesson's pattern, why does IdleState.execute() itself contain the check if self.npc.can_see_player(): self.npc.change_state(NPCState.ALERT) rather than a centralized tick-loop dispatcher routing every state's transition based on a single global perception update?
Question 2: In StateMachine.change_state, why MUST the strict order be: (1) self.current.exit(), (2) self.current = self.states[new_name], (3) self.current.enter() — and never any other interleaving?
Question 3: In AlertState.execute, the same five-state FSM topology (same add_state registrations, same enter/execute/exit hook structure) produces ALERT→CHASE for personality='aggressive' and ALERT→FLEE for personality='coward' — what design principle does this externalization-of-personality-into-data demonstrate?
What's Next?
Now that you've mastered state machines, next we'll explore behavior trees for even more complex and modular AI behaviors!