Skip to main content

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:

A finite state machine for an enemy AI with four states arranged in a square: Idle (top-left), Patrol (top-right), Chase (bottom-right), and Attack (bottom-left). A start dot points into Idle. Transitions cycle clockwise: Idle to Patrol on a timer, Patrol to Chase when the player is spotted, Chase to Attack when in attack range, and Attack back to Idle when the target is down.
A four-state enemy AI cycling Idle → Patrol → Chase → Attack → Idle. Each transition fires when its trigger condition is met; real NPCs typically add more transitions for fleeing, recovery, and edge cases.
graph TD A["Finite State Machine"] --> B["States"] A --> C["Transitions"] A --> D["Advanced FSM"] B --> E["Idle"] B --> F["Patrol"] B --> G["Alert/Chase"] B --> H["Attack"] C --> I["Conditions"] C --> J["Events"] C --> K["Timers"] D --> L["Hierarchical"] D --> M["Concurrent"] D --> N["Pushdown Automata"]
A top-down diagram of an NPC at the apex of a 60-degree amber vision cone bounded by a dashed amber arc at vision range. A teal target inside the cone is labelled A, visible. A red target behind the NPC is labelled B, hidden because it falls outside the vision angle. A gray target along the facing axis but beyond the dashed arc is labelled C, out of range. The half-angle of 30 degrees is marked between the facing axis and the upper cone edge.
An NPC's field of view is the intersection of an angle (the cone) and a range (the radius). The state machine's 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

An NPC state machine cycling through Idle, Patrol, Chase, and Attack states.
An NPC state machine cycling through Idle → Patrol → Chase → Attack. The interactive simulation lets you trigger transitions and step through frames; this diagram shows the full state cycle that drives the agent.

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

Key Takeaways

🏋️‍♂️ 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:

  1. Define NPCState(Enum) with members IDLE/PATROL/ALERT/CHASE/FLEE. Subclass State once with enter()/execute(dt)/exit() hooks; subclass IdleState/PatrolState/AlertState/ChaseState/FleeState overriding the hooks with state-specific behavior plus outgoing-edge transition checks.
  2. Build StateMachine holding a states dict keyed by NPCState, plus current, history, and count. change_state(new_name) MUST execute in this exact order: (a) call self.current.exit() first; (b) reassign self.current = self.states[new_name]; (c) call self.current.enter() last; (d) record the from→to→time row in history and bump count.
  3. In AlertState.execute, after self.entry_time >= 1.0, branch on self.npc.personality: 'aggressive' → CHASE, 'coward' → FLEE. The same FSM topology produces orthogonal behaviors purely from personality-as-data — no AggressiveNPC vs CowardNPC subclass split.
  4. Each state's execute(dt) owns its OWN outgoing transition logic. IdleState checks distance_to_player() < 200 and transitions to ALERT; ChaseState checks distance > 300 and transitions back to IDLE; etc. NO centralized tick-loop dispatcher routes transitions based on global perception state.
  5. 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.
  6. 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!