Lab: Enemy AI
Integrate A* pathfinding and the FSM enemy into the tile platformer — patrol, detect, path toward the player, and flee on low health.
- Integrate A* into the enemy's CHASE state so it navigates around walls
- Add a health attribute to the enemy and trigger FLEE below a threshold
- Drive enemy movement along A* waypoints using normalised vectors
This lab combines the FSM from npc-state-machine and the A* function from
astar-on-a-grid into a playable scene. The enemy patrols a tile grid,
detects the player, finds a path to them using A*, and flees when its health
drops low. Press H to deal 25 damage to the enemy.
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 lab_ai.py.
Step 1 — Tile grid helpers
Start with a compact tile map and conversion helpers. Walls are 1; open tiles are 0.
import pygame
import heapq
import sys
pygame.init()
TILE = 32
COLS = 20
ROWS = 15
W, H = COLS * TILE, ROWS * TILE
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("Enemy AI lab")
clock = pygame.time.Clock()
Vector2 = pygame.math.Vector2
# fmt: off
TILEMAP = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,1],
[1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1],
[1,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1],
[1,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,1],
[1,0,1,1,1,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1],
[1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1],
[1,0,0,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,1],
[1,0,0,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1],
[1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1],
[1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
]
# fmt: on
def tile_to_world(r, c):
"""Return the centre pixel of a tile."""
return Vector2(c * TILE + TILE // 2, r * TILE + TILE // 2)
def world_to_tile(pos):
"""Return the (row, col) of the tile containing a world position."""
c = int(pos.x // TILE)
r = int(pos.y // TILE)
r = max(0, min(ROWS - 1, r))
c = max(0, min(COLS - 1, c))
return (r, c)Step 2 — A* (from the previous lesson)
def astar(grid, start, goal):
rows = len(grid)
cols = len(grid[0])
def h(n):
return abs(n[0] - goal[0]) + abs(n[1] - goal[1])
def neighbours(r, c):
for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
nr, nc = r+dr, c+dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0:
yield (nr, nc)
open_heap = [(h(start), 0, start)]
came_from = {}
g_score = {start: 0}
while open_heap:
f, g, cur = heapq.heappop(open_heap)
if cur == goal:
path = []
while cur in came_from:
path.append(cur)
cur = came_from[cur]
path.append(start)
path.reverse()
return path
if g > g_score.get(cur, float('inf')):
continue
for nb in neighbours(*cur):
tg = g + 1
if tg < g_score.get(nb, float('inf')):
g_score[nb] = tg
came_from[nb] = cur
heapq.heappush(open_heap, (tg + h(nb), tg, nb))
return []Step 3 — Enemy with integrated A*
DETECT_RADIUS = TILE * 6
LOSE_RADIUS = TILE * 9
FLEE_RADIUS = TILE * 8
LOW_HP = 30
CHASE_SPEED = 100
FLEE_SPEED = 130
PATH_RECALC = 45 # recalculate path every N frames
class Enemy:
SIZE = 24
def __init__(self, r, c):
self.pos = tile_to_world(r, c)
self.rect = pygame.Rect(0, 0, self.SIZE, self.SIZE)
self.rect.center = (int(self.pos.x), int(self.pos.y))
self.state = "PATROL"
self.hp = 100
self.state_frames = 0
self.patrol_dir = 1
self.path = []
self.path_idx = 0
self.recalc_timer = 0
def _set_state(self, new_state):
if self.state != new_state:
self.state = new_state
self.state_frames = 0
self.path = []
self.path_idx = 0
def _recalc_path(self, player_pos):
start = world_to_tile(self.pos)
goal = world_to_tile(player_pos)
if start != goal:
self.path = astar(TILEMAP, start, goal)
self.path_idx = 0
def _move_along_path(self, dt, speed):
if not self.path or self.path_idx >= len(self.path):
return
target = tile_to_world(*self.path[self.path_idx])
to_t = target - self.pos
if to_t.length() < 6:
self.path_idx += 1
elif to_t.length() > 0:
self.pos += to_t.normalize() * speed * dt
def update(self, dt, player_pos):
self.state_frames += 1
self.recalc_timer += 1
dist = (player_pos - self.pos).length()
# Transitions (guarded by dwell minimum)
if self.state_frames >= 30:
if self.hp <= LOW_HP:
self._set_state("FLEE")
elif self.state == "PATROL" and dist < DETECT_RADIUS:
self._set_state("CHASE")
elif self.state == "CHASE" and dist > LOSE_RADIUS:
self._set_state("PATROL")
# Behaviours
if self.state == "PATROL":
self.pos.x += self.patrol_dir * 60 * dt
tc = world_to_tile(self.pos)
edge_l = TILEMAP[tc[0]][max(0, tc[1]-1)]
edge_r = TILEMAP[tc[0]][min(COLS-1, tc[1]+1)]
if edge_l == 1: self.patrol_dir = 1
if edge_r == 1: self.patrol_dir = -1
elif self.state == "CHASE":
if self.recalc_timer >= PATH_RECALC:
self._recalc_path(player_pos)
self.recalc_timer = 0
self._move_along_path(dt, CHASE_SPEED)
elif self.state == "FLEE":
# Flee: path away from player — find a tile far from the player
if self.recalc_timer >= PATH_RECALC:
pr, pc = world_to_tile(player_pos)
er, ec = world_to_tile(self.pos)
# Opposite quadrant heuristic
flee_r = min(ROWS-2, max(1, 2*er - pr))
flee_c = min(COLS-2, max(1, 2*ec - pc))
if TILEMAP[flee_r][flee_c] == 0:
self.path = astar(TILEMAP, (er,ec), (flee_r,flee_c))
self.path_idx = 0
self.recalc_timer = 0
self._move_along_path(dt, FLEE_SPEED)
self.rect.center = (int(self.pos.x), int(self.pos.y))
def draw(self, surface):
colour = {"PATROL": (200, 60, 60),
"CHASE": (220, 130, 20),
"FLEE": (80, 80, 220)}[self.state]
pygame.draw.rect(surface, colour, self.rect)
font = pygame.font.SysFont(None, 18)
label = font.render(f"{self.state} HP:{self.hp}", True, (255,255,255))
surface.blit(label, (self.rect.x, self.rect.y - 18))Step 4 — Player and main loop
class Player:
SIZE = 24
def __init__(self, r, c):
self.pos = tile_to_world(r, c)
self.rect = pygame.Rect(0, 0, self.SIZE, self.SIZE)
self.rect.center = (int(self.pos.x), int(self.pos.y))
def update(self, dt):
keys = pygame.key.get_pressed()
speed = 180
move = Vector2(0, 0)
if keys[pygame.K_LEFT]: move.x = -1
if keys[pygame.K_RIGHT]: move.x = 1
if keys[pygame.K_UP]: move.y = -1
if keys[pygame.K_DOWN]: move.y = 1
if move.length() > 0:
self.pos += move.normalize() * speed * dt
# Wall collision: push out of wall tiles
r, c = world_to_tile(self.pos)
if TILEMAP[r][c] == 1:
self.pos -= move.normalize() * speed * dt
self.rect.center = (int(self.pos.x), int(self.pos.y))
def draw(self, surface):
pygame.draw.rect(surface, (100, 200, 120), self.rect)
def draw_tiles(surface):
for r in range(ROWS):
for c in range(COLS):
colour = (60, 50, 40) if TILEMAP[r][c] == 1 else (20, 20, 35)
pygame.draw.rect(surface, colour,
pygame.Rect(c*TILE, r*TILE, TILE, TILE))
player = Player(7, 3)
enemy = Enemy(2, 2)
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 and event.key == pygame.K_h:
enemy.hp = max(0, enemy.hp - 25)
player.update(dt)
enemy.update(dt, player.pos)
draw_tiles(screen)
player.draw(screen)
enemy.draw(screen)
hud = font.render("Arrow keys: move H: damage enemy", True, (200,200,200))
screen.blit(hud, (10, H - 26))
pygame.display.flip()
pygame.quit()
sys.exit()What to try next
- Add a second enemy starting in a different room. Give each its own patrol route and independent FSM state.
- Add a line-of-sight check before entering CHASE: use DDA ray casting along the tile grid to confirm no wall tiles lie between the enemy and player.
- Combine A* navigation with the
PhysicsBodyfrom the physics module so the enemy respects gravity and can only walk on ground tiles.
A* on a grid
Implement A* pathfinding on a 2D tile grid using a heap-backed priority queue — returning an ordered list of tile coordinates.
Particle systems
A particle system is an emitter that spawns, moves, ages, and culls short-lived visual elements — explosions, smoke, sparks, and rain all follow the same pattern.