Code of the Day
AdvancedGame AI

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.

Lab · optionalGame DevAdvanced40 min
Recommended first
By the end of this lesson you will be able to:
  • 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 PhysicsBody from the physics module so the enemy respects gravity and can only walk on ground tiles.
Finished reading? Mark it complete to track your progress.

On this page