Lesson 19: NPC State Machines
A guard who patrols, spots you, gives chase, loses you and goes looking where you were last seen feels alive, and it is built from a handful of simple states. In this lesson you give an NPC a state machine that is easy to extend, fails loudly when something is missing, and behaves the same at any frame rate.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Build an NPC state machine with an
Enumof state names, one class per state and a machine that checks every state is registered. - Build perception checks for range, a vision cone and line of sight, and handle the zero-distance case safely.
- Explain why timers must be chosen once and counted down with
dt, instead of rolling dice every frame. - Use hysteresis and any-state transitions to stop flickering and to handle emergencies like fleeing.
- Debug NPC behavior by drawing its state, vision cone and a transition log.
Project: a Guard NPC with seven states that patrols, chases, attacks, searches, investigates noises and flees.
In This Lesson
🎭 States Are Behaviors
Picture a museum guard. Most of the night they stand around or walk a route. If they spot someone, they give chase; up close, they grab. If the intruder slips away, they search the last place they saw them, then give up and go back to their route. A strange noise sends them to check it out. Each of those is a state: one behavior with its own rules for what to do and when to switch.
You met this pattern in the Game States & Scenes lesson, where the whole game switched between Menu, Play and Pause. Here each NPC gets its own small state machine. Only one state is active at a time, and each state decides when to hand over to another.
Our guard has seven states. Every arrow below is a transition some state's code can make:
Drawing this diagram before coding is worth it. Every state named in an arrow must exist in your code. If one is missing, a careless state machine lets the guard freeze the first time it tries to use it; the machine you build next refuses to start instead.
🧩 The State Machine
The state names come from an Enum (see the Python sidebar in the Game States & Scenes lesson), so a typo like GuardState.CHASSE is an error instead of a silent string mismatch. Each state is a small class with three methods, just like the scenes you built before:
enter(guard)runs once when the state starts: reset timers, pick a target.update(guard, dt)runs every frame and returns the next state, orNoneto stay.exit(guard)runs once when the state ends: tidy up.
Returning the next state, instead of switching from inside update(), means a state never gets swapped out halfway through its own code, and every change goes through one method that can log it. The machine also refuses to start if any state in the Enum has no class registered for it. Here is a complete program you can run in the terminal; it simulates ten seconds of a three-state guard at 60 frames per second:
import random
from enum import Enum, auto
class GuardState(Enum):
IDLE = auto()
PATROL = auto()
CHASE = auto()
class State:
def enter(self, guard):
pass
def update(self, guard, dt):
return None # None means "stay in this state"
def exit(self, guard):
pass
class Idle(State):
def enter(self, guard):
guard.timer = guard.rng.uniform(1.0, 2.0) # chosen ONCE, on entry
def update(self, guard, dt):
if guard.sees_player:
return GuardState.CHASE
guard.timer -= dt
if guard.timer <= 0:
return GuardState.PATROL
return None
class Patrol(State):
def enter(self, guard):
guard.timer = 3.0
def update(self, guard, dt):
if guard.sees_player:
return GuardState.CHASE
guard.timer -= dt
if guard.timer <= 0:
return GuardState.IDLE
return None
class Chase(State):
def update(self, guard, dt):
if not guard.sees_player:
return GuardState.PATROL
return None
class StateMachine:
def __init__(self, owner, states, start):
missing = [name.name for name in GuardState if name not in states]
if missing: # fail loudly, at startup
raise ValueError(f"No state registered for: {', '.join(missing)}")
self.owner = owner
self.states = states
self.name = start
self.log = []
self.states[start].enter(owner)
def change(self, name):
if name not in self.states:
raise KeyError(f"State {name} is not registered")
self.states[self.name].exit(self.owner) # 1. leave the old state
self.log.append(f"{self.name.name}->{name.name}")
self.name = name
self.states[name].enter(self.owner) # 2. enter the new one
def update(self, dt):
next_name = self.states[self.name].update(self.owner, dt)
if next_name is not None and next_name != self.name:
self.change(next_name)
class Guard:
def __init__(self, seed=1):
self.rng = random.Random(seed) # this guard's own dice
self.timer = 0.0
self.sees_player = False
self.fsm = StateMachine(self, {GuardState.IDLE: Idle(), GuardState.PATROL: Patrol(),
GuardState.CHASE: Chase()}, GuardState.IDLE)
guard = Guard()
for frame in range(600): # ten seconds at 60 FPS
guard.sees_player = 300 <= frame < 360 # the player walks past from 5 s to 6 s
guard.fsm.update(1 / 60)
print(" ".join(guard.fsm.log))
Run it, then try two experiments. Delete GuardState.CHASE: Chase() from the dictionary: the program stops immediately with No state registered for: CHASE, instead of running for five seconds and then leaving the guard stuck. Then change the seed: the idle times change, but the same seed always gives the same run, which makes AI bugs repeatable.
💡 Why this matters
Adding a behavior is now one new class, one Enum member and one dictionary entry, and the startup check tells you if you forgot one of the three. Failing loudly on the first frame beats a guard that quietly freezes during a playtest an hour later.
👁️ Seeing and Hearing
A guard sees the player when three things are all true: the player is close enough, inside the vision cone, and not behind a wall.
def can_see(eye, facing, target, walls, view_range=230, half_angle=35):
"""True if target is in range, inside the vision cone and not behind a wall."""
offset = target - eye
dist_sq = offset.length_squared()
if dist_sq == 0:
return True # standing right on top of the guard
if dist_sq > view_range * view_range:
return False
if facing.dot(offset.normalize()) < math.cos(math.radians(half_angle)):
return False
return not any(wall.clipline(eye, target) for wall in walls)
- Range. Comparing squared lengths skips a square root and gives the same answer.
- Cone.
facingis a unit vector. The dot product of two unit vectors is the cosine of the angle between them, so "within 35° of facing" is the same as "dot product at leastcos(35°)". - Walls.
Rect.clipline(start, end)returns the part of the line inside the rectangle, or an empty tuple if the line misses it. Any non-empty result means a wall blocks the view. - Zero distance.
normalize()on a zero-length vector raisesValueError: Can't normalize Vector of length zero. When the player stands exactly on the guard there is no direction at all, so answer before normalizing.
Hearing is simpler: a noise at a position is heard if it is within the hearing range, walls or not. The game sets world.noise for one frame when something makes a sound.
Sense once per frame, before the state runs, and store the results on the guard (guard.sees_player, guard.heard). The states only read them. That keeps every state honest (they all see the same world) and does the wall checks only once. States still need the world for a few things, such as the player's position to chase, so from here on every state's method is update(self, guard, dt, world) and the machine's is update(self, dt, world): the same design as the three-state program, with one more argument passed along.
🚶 Moving Without Surprises
Almost every state moves the guard toward something: a waypoint, the player, a noise. One helper does it safely:
def move_toward(pos, target, speed, dt):
"""Return (new_pos, arrived). Never overshoots, never divides by zero."""
offset = target - pos
dist = offset.length()
step = speed * dt
if dist <= step:
return pygame.Vector2(target), True
return pos + offset / dist * step, False
If the target is closer than this frame's step, the guard lands exactly on it and reports that it arrived. That also covers a distance of zero, so there is no division by zero. Without that check, a fast guard jitters back and forth across its waypoint and never "arrives".
Two more rules keep the guard inside the level. Clamp its position to the world after every move, so a fleeing guard can't run off the edge of the map:
def clamp_to_world(pos):
pos.x = max(GUARD_RADIUS, min(WIDTH - GUARD_RADIUS, pos.x))
pos.y = max(GUARD_RADIUS, min(HEIGHT - GUARD_RADIUS, pos.y))
And move one axis at a time against walls, the per-axis collision recipe from the Intro course: move x, push out of any wall, then move y and push out again. With that in place, a guard chasing you straight into a wall slides along it or gets stuck behind it, which is exactly the problem the next lesson, A* Pathfinding, solves.
⏱️ Timers, Flicker and Emergencies
Choose once, count down with dt
A tempting way to make idling "random" is to roll the dice every frame:
if guard.rng.random() < 0.01: # a 1% chance, EVERY FRAME: frame-rate dependent!
return GuardState.PATROL
That ends the idle after 100 frames on average, which is about 1.7 seconds at 60 FPS but only about 0.7 seconds at 144 FPS. The guard's personality now depends on the player's monitor. Instead, pick the duration once in enter() and count it down in seconds:
class Idle(State):
def enter(self, guard):
guard.timer = guard.rng.uniform(1.0, 2.0) # picked ONCE, on entry
def update(self, guard, dt, world):
guard.timer -= dt
if guard.timer <= 0:
return GuardState.PATROL
return None
Each guard gets its own random.Random(seed), as in the Randomness for Games lesson, so a run can be repeated exactly while you debug.
Hysteresis: two thresholds, no flicker
If ATTACK starts when the player is within 40 px and stops as soon as they are 40.1 px away, a player standing right on the line makes the guard flip between CHASE and ATTACK every frame. The fix is hysteresis: switch in at one distance and out at a larger one. Our guard starts attacking at 40 px and only goes back to chasing beyond 60 px (1.5 × the attack range). Thermostats work the same way.
Any-state transitions
Some rules apply no matter what the guard is doing. "Badly hurt and can see the player: flee" should win over patrolling, chasing and attacking alike. Rather than copying that check into six states, check it once in the guard's update, before the current state runs:
def update(self, dt, world):
# 1. Sense once per frame; the states only read these results.
self.sees_player = can_see(self.pos, self.facing, world.player, world.walls)
self.heard = None
if world.noise is not None and self.pos.distance_to(world.noise) <= HEARING_RANGE:
self.heard = pygame.Vector2(world.noise)
# 2. A transition that applies in ANY state.
if self.hp <= FLEE_HP and self.sees_player and self.fsm.name != GuardState.FLEE:
self.fsm.change(GuardState.FLEE)
# 3. Let the current state act and pick the next state.
self.fsm.update(dt, world)
States that "look for" something (SEARCH and INVESTIGATE) also get a give-up timer. Without it, a guard trying to reach a spot behind a wall would push against the wall forever.
✅ Growth Mindset: Weird AI Is Information
Your guard will do strange things: spin in place, stare at a wall, flicker between two states. That is not proof you are bad at AI; it is the AI telling you exactly which rule is missing. Game developers treat odd NPC behavior as a bug report written by the game itself. Ask "which state is it in, and which condition keeps it there?", and the fix is usually one line.
🔍 Watching the Guard Think
You can't fix what you can't see. Three cheap debug drawings make an NPC's mind visible:
- The state name above its head, and a color per state.
- The vision cone, drawn on a
SRCALPHAoverlay so it is see-through, turning red while the guard sees the player. - A transition log: the last few
OLD->NEWentries fromfsm.log, printed in a corner.
The demo below runs the same seven-state guard. The blue player walks a loop by itself; press the buttons to make a noise or hurt the guard, and watch the log.
When a behavior looks wrong, pause, read the state and the last transition, and ask which condition in that state's update() should have fired. Seeding the guard's random number generator means you can replay the exact same situation until it is fixed.
🏋️ Practice Exercise: Guard NPC
Objective: finish a guard that patrols, chases, attacks, searches, investigates noises and flees, without ever freezing, crashing or leaving the map.
Time: about 40 minutes. Starter file: guard_npc_starter.py (your instructor has it). The world, drawing and most states already work. Each step below names what to change, and the starter marks each spot with a numbered to-do comment (the numbers are labels, not step numbers).
- Run the starter and walk around (arrows or WASD). If you ever stood exactly on the guard,
can_see()would crash withValueError: Can't normalize Vector of length zero; positions are floats, so it rarely happens by walking, butpygame.Vector2(0, 0).normalize()at the Python prompt shows the error. Add the zero-distance check tocan_see(). (≈ 4 min) - Make
move_toward()land exactly on the target when this frame's step would overshoot. (≈ 4 min) - Replace Idle's per-frame dice roll with a duration picked once in
enter()and counted down withdt. (≈ 7 min) - Press N near the guard: the terminal says
State GuardState.INVESTIGATE not foundand the guard ignores it. MakeStateMachinecheck everyGuardStateat startup and raise errors instead of printing. Run it: now it refuses to start. (≈ 8 min) - Register the missing INVESTIGATE and FLEE states. (≈ 3 min)
- Add the any-state flee rule to
Guard.update(). Test: press K three times, then step into the guard's view. (≈ 6 min) - Play hide and seek: get spotted, break line of sight behind a wall, and watch the guard search, then give up. (≈ 8 min)
You are done when:
- the program prints
7 of 7 states registeredat startup, and removing any state from the dictionary makes it stop with an error naming it; - standing on the guard never crashes, and the guard never leaves the window;
- the guard chases when it sees you, attacks up close without flickering, searches after losing you and investigates noises;
- a guard with 25 HP runs away as soon as it sees you;
- closing the window prints the list of transitions.
💡 Hint
For the startup check, loop over the Enum itself: for name in GuardState: visits every member, so you can collect the ones that are not keys of states. If the guard idles for a strange length of time, print guard.timer in Idle.enter(): it should be set once, between 1 and 2, and then only go down.
✅ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.
"""Guard NPC: Intermediate Lesson 19 practice exercise (solution).
A guard patrols between waypoints. It chases you when you step into its
vision cone, attacks up close, searches where it last saw you, investigates
noises and runs away when it is badly hurt.
Keys: arrows or WASD move, N makes a noise, K hurts the guard, R resets.
"""
import math
import random
from enum import Enum, auto
import pygame
WIDTH, HEIGHT = 960, 540
GUARD_RADIUS = 14
PLAYER_SPEED = 200 # pixels per second
VIEW_RANGE = 230 # pixels
VIEW_HALF_ANGLE = 35 # degrees either side of the facing direction
HEARING_RANGE = 400
ATTACK_RANGE = 40
FLEE_HP = 30
WALLS = [pygame.Rect(380, 120, 40, 200), pygame.Rect(600, 330, 220, 36)]
WAYPOINTS = [pygame.Vector2(120, 100), pygame.Vector2(300, 440), pygame.Vector2(860, 440),
pygame.Vector2(860, 100)]
class GuardState(Enum):
IDLE = auto()
PATROL = auto()
CHASE = auto()
ATTACK = auto()
SEARCH = auto()
INVESTIGATE = auto()
FLEE = auto()
def can_see(eye, facing, target, walls, view_range=VIEW_RANGE, half_angle=VIEW_HALF_ANGLE):
"""True if target is in range, inside the vision cone and not behind a wall."""
offset = target - eye
dist_sq = offset.length_squared()
if dist_sq == 0:
return True # standing right on top of the guard
if dist_sq > view_range * view_range:
return False
if facing.dot(offset.normalize()) < math.cos(math.radians(half_angle)):
return False
return not any(wall.clipline(eye, target) for wall in walls)
def move_toward(pos, target, speed, dt):
"""Return (new_pos, arrived). Never overshoots, never divides by zero."""
offset = target - pos
dist = offset.length()
step = speed * dt
if dist <= step:
return pygame.Vector2(target), True
return pos + offset / dist * step, False
def slide(pos, delta, walls, radius=GUARD_RADIUS):
"""Move pos by delta one axis at a time, stopping at walls (the Intro collision recipe)."""
box = pygame.FRect(0, 0, radius * 2, radius * 2)
pos.x += delta.x
box.center = pos
for wall in walls:
if box.colliderect(wall):
if delta.x > 0:
box.right = wall.left
elif delta.x < 0:
box.left = wall.right
pos.x = box.centerx
pos.y += delta.y
box.center = pos
for wall in walls:
if box.colliderect(wall):
if delta.y > 0:
box.bottom = wall.top
elif delta.y < 0:
box.top = wall.bottom
pos.y = box.centery
def clamp_to_world(pos):
pos.x = max(GUARD_RADIUS, min(WIDTH - GUARD_RADIUS, pos.x))
pos.y = max(GUARD_RADIUS, min(HEIGHT - GUARD_RADIUS, pos.y))
class World:
"""Everything the guard can sense."""
def __init__(self):
self.player = pygame.Vector2(700, 200)
self.player_hp = 100
self.walls = WALLS
self.noise = None # position of a noise made this frame
# ---------------------------------------------------------------- the states
# Each state's update() returns the NEXT state, or None to stay put.
class State:
def enter(self, guard):
pass
def update(self, guard, dt, world):
return None
def exit(self, guard):
pass
class Idle(State):
def enter(self, guard):
guard.timer = guard.rng.uniform(1.0, 2.0) # picked ONCE, on entry
def update(self, guard, dt, world):
if guard.sees_player:
return GuardState.CHASE
if guard.heard is not None:
return GuardState.INVESTIGATE
guard.facing.rotate_ip(60 * dt) # look around
guard.timer -= dt
if guard.timer <= 0:
return GuardState.PATROL
return None
class Patrol(State):
def update(self, guard, dt, world):
if guard.sees_player:
return GuardState.CHASE
if guard.heard is not None:
return GuardState.INVESTIGATE
if guard.walk_to(WAYPOINTS[guard.waypoint], 80, dt):
guard.waypoint = (guard.waypoint + 1) % len(WAYPOINTS)
return GuardState.IDLE # pause at every waypoint
return None
class Chase(State):
def update(self, guard, dt, world):
if not guard.sees_player:
return GuardState.SEARCH
guard.last_seen = pygame.Vector2(world.player)
if guard.pos.distance_to(world.player) <= ATTACK_RANGE:
return GuardState.ATTACK
guard.walk_to(world.player, 150, dt)
return None
class Attack(State):
def enter(self, guard):
guard.timer = 0.3 # short wind-up before the first hit
def update(self, guard, dt, world):
if guard.pos.distance_to(world.player) > ATTACK_RANGE * 1.5: # a little slack: no flicker
return GuardState.CHASE
guard.timer -= dt
if guard.timer <= 0:
world.player_hp = max(0, world.player_hp - 10)
guard.timer = 0.8 # seconds between hits
return None
class Search(State):
def enter(self, guard):
guard.timer = 5.0 # give up after 5 seconds
def update(self, guard, dt, world):
if guard.sees_player:
return GuardState.CHASE
if guard.last_seen is None or guard.walk_to(guard.last_seen, 110, dt):
guard.facing.rotate_ip(120 * dt) # arrived: look around
guard.timer -= dt
if guard.timer <= 0:
return GuardState.PATROL
return None
class Investigate(State):
def enter(self, guard):
guard.noise_target = guard.heard
guard.timer = 4.0 # give up after 4 seconds
def update(self, guard, dt, world):
if guard.sees_player:
return GuardState.CHASE
if guard.heard is not None:
guard.noise_target = guard.heard # a newer noise wins
guard.timer = 4.0
if guard.walk_to(guard.noise_target, 100, dt):
guard.facing.rotate_ip(90 * dt) # arrived: look around
guard.timer -= dt
if guard.timer <= 0:
return GuardState.PATROL
return None
class Flee(State):
def enter(self, guard):
guard.timer = 3.0
def update(self, guard, dt, world):
away = guard.pos - world.player
if away.length_squared() > 0: # zero-length vectors can't be normalized
guard.facing = away.normalize()
slide(guard.pos, guard.facing * 170 * dt, world.walls)
clamp_to_world(guard.pos)
guard.timer -= dt
if guard.timer <= 0 and not guard.sees_player:
return GuardState.IDLE
return None
class StateMachine:
def __init__(self, owner, states, start):
missing = [name.name for name in GuardState if name not in states]
if missing: # fail loudly, at startup
raise ValueError(f"No state registered for: {', '.join(missing)}")
self.owner = owner
self.states = states
self.name = start
self.log = []
self.states[start].enter(owner)
def change(self, name):
if name not in self.states:
raise KeyError(f"State {name} is not registered")
self.states[self.name].exit(self.owner)
self.log.append(f"{self.name.name}->{name.name}")
self.name = name
self.states[name].enter(self.owner)
def update(self, dt, world):
next_name = self.states[self.name].update(self.owner, dt, world)
if next_name is not None and next_name != self.name:
self.change(next_name)
class Guard:
def __init__(self, seed=7):
self.rng = random.Random(seed)
self.pos = pygame.Vector2(WAYPOINTS[0])
self.facing = pygame.Vector2(1, 0)
self.hp = 100
self.timer = 0.0
self.waypoint = 1
self.last_seen = None
self.noise_target = None
self.sees_player = False
self.heard = None
self.fsm = StateMachine(self, {
GuardState.IDLE: Idle(), GuardState.PATROL: Patrol(), GuardState.CHASE: Chase(),
GuardState.ATTACK: Attack(), GuardState.SEARCH: Search(),
GuardState.INVESTIGATE: Investigate(), GuardState.FLEE: Flee(),
}, GuardState.IDLE)
def walk_to(self, target, speed, dt):
"""Face and walk toward target. Returns True once it has arrived."""
offset = target - self.pos
if offset.length_squared() > 0:
self.facing = offset.normalize()
new_pos, arrived = move_toward(self.pos, target, speed, dt)
slide(self.pos, new_pos - self.pos, WALLS) # walls stop the guard (it can get stuck!)
clamp_to_world(self.pos)
return arrived and self.pos.distance_squared_to(target) < 1
def update(self, dt, world):
# 1. Sense once per frame; the states only read these results.
self.sees_player = can_see(self.pos, self.facing, world.player, world.walls)
self.heard = None
if world.noise is not None and self.pos.distance_to(world.noise) <= HEARING_RANGE:
self.heard = pygame.Vector2(world.noise)
# 2. A transition that applies in ANY state.
if self.hp <= FLEE_HP and self.sees_player and self.fsm.name != GuardState.FLEE:
self.fsm.change(GuardState.FLEE)
# 3. Let the current state act and pick the next state.
self.fsm.update(dt, world)
STATE_COLORS = {GuardState.IDLE: (150, 150, 160), GuardState.PATROL: (90, 150, 230),
GuardState.CHASE: (230, 80, 70), GuardState.ATTACK: (255, 40, 40),
GuardState.SEARCH: (240, 200, 70), GuardState.INVESTIGATE: (240, 150, 60),
GuardState.FLEE: (200, 90, 220)}
def cone_points(guard, steps=12):
points = [guard.pos]
for i in range(steps + 1):
angle = -VIEW_HALF_ANGLE + 2 * VIEW_HALF_ANGLE * i / steps
points.append(guard.pos + guard.facing.rotate(angle) * VIEW_RANGE)
return points
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Guard NPC")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 22)
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) # for the see-through cone
world = World()
guard = Guard()
held = set()
print(f"{len(guard.fsm.states)} of {len(GuardState)} states registered")
running = True
while running:
dt = clock.tick(60) / 1000
world.noise = None
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYUP:
held.discard(event.key)
elif event.type == pygame.KEYDOWN:
held.add(event.key)
if event.key == pygame.K_n:
world.noise = pygame.Vector2(world.player)
elif event.key == pygame.K_k:
guard.hp = max(0, guard.hp - 25)
elif event.key == pygame.K_r:
world, guard = World(), Guard()
move = pygame.Vector2(
(pygame.K_RIGHT in held or pygame.K_d in held) - (pygame.K_LEFT in held or pygame.K_a in held),
(pygame.K_DOWN in held or pygame.K_s in held) - (pygame.K_UP in held or pygame.K_w in held))
if move.length_squared() > 0:
slide(world.player, move.normalize() * PLAYER_SPEED * dt, world.walls, 12)
clamp_to_world(world.player)
guard.update(dt, world)
screen.fill((26, 30, 40))
overlay.fill((0, 0, 0, 0))
cone_color = (255, 90, 80, 70) if guard.sees_player else (255, 255, 180, 45)
pygame.draw.polygon(overlay, cone_color, cone_points(guard))
screen.blit(overlay, (0, 0))
for wall in world.walls:
pygame.draw.rect(screen, (100, 106, 124), wall)
for point in WAYPOINTS:
pygame.draw.circle(screen, (60, 80, 120), point, 5)
pygame.draw.circle(screen, (80, 200, 255), world.player, 12)
pygame.draw.circle(screen, STATE_COLORS[guard.fsm.name], guard.pos, GUARD_RADIUS)
pygame.draw.line(screen, (255, 255, 255), guard.pos, guard.pos + guard.facing * 22, 2)
label = font.render(guard.fsm.name.name, True, (255, 255, 255))
screen.blit(label, label.get_rect(midbottom=guard.pos - (0, 20)))
lines = [f"Guard HP {guard.hp} Player HP {world.player_hp}",
"Move: arrows/WASD N noise K hurt guard R reset"]
lines += guard.fsm.log[-6:]
for i, line in enumerate(lines):
screen.blit(font.render(line, True, (220, 220, 230)), (10, 10 + i * 20))
pygame.display.flip()
pygame.quit()
print("Transitions:", ", ".join(guard.fsm.log) if guard.fsm.log else "none")
print("Final state:", guard.fsm.name.name)
if __name__ == "__main__":
main()
📓 Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
✍️ This lesson's prompts:
- Draw the state diagram for an NPC from a game you know (a shopkeeper, a ghost, a pet). Which transitions would be easy to forget?
- Describe the strangest thing your guard did today and the rule that fixed it.
- Where else could hysteresis stop flickering in a game: a door, music, a camera?
📝 Summary
You turned a guard's behavior into seven named states, each a small class that returns the next state, and a machine that refuses to start if any state is missing. The guard senses once per frame (range, vision cone with a dot product, walls with clipline, and hearing), handles the zero-distance case, and moves with a helper that never overshoots and a clamp that keeps it in the world. Timers are chosen once and counted down in seconds, hysteresis stops flicker, and an any-state rule sends a hurt guard running. Debug drawings made all of it visible.
🎓 Key Takeaways
- One state per behavior;
update()returns the next state, and the machine does the switching. - Check at startup that every state is registered, and raise an error instead of printing.
- Guard against zero-length vectors before
normalize(), and clamp NPCs to the world. - Pick random durations once on
enter()and count them down withdt; never roll dice every frame. - Use two thresholds (hysteresis) for states that switch on a distance.
- Draw the state, the vision cone and a transition log while you develop.
🔭 Looking Ahead
Your guard walks straight at its targets and gets stuck behind walls. Next, in A* Pathfinding, you give it a map-reading brain that finds the cheapest route around walls and across rough ground.
❓ Common Questions
Why classes for states instead of one big if/elif on the state name?
For two or three states, an if/elif chain is fine. As behaviors grow, each state's timers and rules end up tangled in one long function. Separate classes keep each behavior in its own place, and enter()/exit() give you a natural spot for setup and cleanup.
Where should transition checks go: in the states or in the machine?
Rules that belong to one state (a chasing guard losing sight of you) go in that state's update(). Rules that apply everywhere (fleeing when badly hurt) go in one place before the state runs. That way, each rule is written exactly once.
How do I make different kinds of NPCs, like a coward and a brute?
Keep the same states and change the data: speeds, vision range, flee threshold, or which state follows a sighting. A coward might flee at 80 HP instead of 30. Personalities made from numbers are much easier to tune than a separate class per NPC type.
My NPC spins in circles at its waypoint. Why?
It probably never "arrives": each step overshoots the waypoint, then the next step comes back. Use the move_toward() helper, which lands exactly on the target when it is closer than one step.
Is a state machine enough for complicated enemies?
For most small games, yes. When an NPC has dozens of states and many shared transitions, the diagram becomes hard to follow, and developers reach for other structures, such as behavior trees.
🎯 Quick Quiz
Question 1: Using this lesson's StateMachine, you add SEARCH to the Enum but forget to register a class for it. What happens?
Question 2: Why pick the idle duration once in enter() instead of ending idle with a 1% chance each frame?
Question 3: The player stands exactly on the guard, so offset is Vector2(0, 0). What does offset.normalize() do?
Question 4: The guard starts attacking within 40 px but only goes back to chasing beyond 60 px. Why the gap?
Question 5: What does facing.dot(offset.normalize()) >= math.cos(math.radians(35)) test?
🌟 Going Further
- Alert your friends: when one guard starts chasing, give every guard within 250 px a noise at the player's position, so they come to investigate.
- A suspicion meter: instead of chasing on the first frame the player is seen, fill a 0–1 meter while they stay in view (faster when closer) and chase at 1. Draw it as a small bar above the guard.
- Personalities as data: make a
COWARDand aBRUTEdictionary of numbers (speed, vision range, flee HP) and pass one to each guard. - Read the docs: Python enum and pygame-ce's pygame.math.Vector2 (
dot,rotate,angle_to). - Coming up in Game Dev III: Advanced: Behavior Trees and Utility AI, two ways to organize NPCs whose behavior outgrows a state machine.