Basic Enemy AI
Using A* pathfinding to create enemies that chase and attack the player.
Overview
McRogueFace provides built-in A* pathfinding through entity.path_to(x, y) (returns a plain list of (x, y) tuples) and grid.find_path((start_x, start_y), (end_x, end_y)) (returns an AStarPath object, or None if unreachable). AStarPath supports len(), truthiness, and iteration, plus peek()/walk() to inspect or consume the next step — it does not support path[0] indexing. This recipe shows how to use these for enemy AI behaviors.
Entity tile position is read/written through grid_x/grid_y (or grid_pos) — entity.x/entity.y are pixel coordinates relative to the grid, not tile coordinates, so they are not used here.
Basic Chase AI
import mcrfpy
def enemy_ai(enemy, player, grid):
"""Simple AI: chase the player and attack if adjacent."""
# Get positions (tile coordinates, not pixel x/y)
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
# Check if adjacent (can attack)
distance = abs(ex - px) + abs(ey - py)
if distance == 1:
# Attack the player
attack(enemy, player)
return
# Not adjacent - try to path toward player
path = enemy.path_to(px, py)
if path and len(path) > 0:
# Move to first step in path
next_x, next_y = path[0]
# Check if tile is walkable and not occupied
if is_walkable(grid, next_x, next_y) and not is_occupied(next_x, next_y):
enemy.grid_x = next_x
enemy.grid_y = next_y
AI with Line of Sight
Only chase when the player is visible:
def enemy_ai_with_sight(enemy, player, grid, sight_range=8):
"""Chase player only when in sight range."""
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
# Calculate distance
distance = abs(ex - px) + abs(ey - py)
# Check if player is in sight range
if distance > sight_range:
# Player not visible - wander or idle
wander(enemy, grid)
return
# Optional: Check line of sight (no walls blocking)
if not has_line_of_sight(grid, ex, ey, px, py):
wander(enemy, grid)
return
# Player is visible - chase or attack
if distance == 1:
attack(enemy, player)
else:
path = enemy.path_to(px, py)
if path:
next_x, next_y = path[0]
if is_walkable(grid, next_x, next_y):
enemy.grid_x = next_x
enemy.grid_y = next_y
def has_line_of_sight(grid, x1, y1, x2, y2):
"""Simple line of sight check using Bresenham's line."""
dx = abs(x2 - x1)
dy = abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx - dy
x, y = x1, y1
while True:
if x == x2 and y == y2:
return True
# Check if current tile blocks sight
point = grid.at(x, y)
if not point.transparent and (x != x1 or y != y1):
return False
e2 = 2 * err
if e2 > -dy:
err -= dy
x += sx
if e2 < dx:
err += dx
y += sy
return True
Wandering Behavior
Random movement for when enemies don’t see the player:
import random
def wander(enemy, grid):
"""Move randomly to an adjacent walkable tile."""
ex, ey = enemy.grid_x, enemy.grid_y
# Get valid adjacent tiles
directions = [(0, -1), (0, 1), (-1, 0), (1, 0)]
random.shuffle(directions)
for dx, dy in directions:
new_x, new_y = ex + dx, ey + dy
if is_walkable(grid, new_x, new_y) and not is_occupied(new_x, new_y):
enemy.grid_x = new_x
enemy.grid_y = new_y
return
# No valid moves - stay in place
def is_walkable(grid, x, y):
"""Check if a tile can be walked on."""
grid_w, grid_h = grid.grid_size
if x < 0 or x >= grid_w or y < 0 or y >= grid_h:
return False
return grid.at(x, y).walkable
def is_occupied(x, y, entities=None):
"""Check if a tile is occupied by another entity."""
if entities is None:
return False
for entity in entities:
if entity.grid_x == x and entity.grid_y == y:
return True
return False
Multiple AI Behaviors
Different enemy types with different behaviors:
class AIBehavior:
"""Base class for AI behaviors."""
def execute(self, enemy, player, grid, entities):
raise NotImplementedError
class ChaseAI(AIBehavior):
"""Always chase the player."""
def execute(self, enemy, player, grid, entities):
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
if abs(ex - px) + abs(ey - py) == 1:
return ("attack", player)
path = enemy.path_to(px, py)
if path:
return ("move", path[0])
return ("wait", None)
class CowardAI(AIBehavior):
"""Run away from the player."""
def execute(self, enemy, player, grid, entities):
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
# Find direction away from player
dx = 1 if ex > px else -1 if ex < px else 0
dy = 1 if ey > py else -1 if ey < py else 0
# Try to move away
for new_x, new_y in [(ex + dx, ey), (ex, ey + dy), (ex + dx, ey + dy)]:
if is_walkable(grid, new_x, new_y) and not is_occupied(new_x, new_y, entities):
return ("move", (new_x, new_y))
return ("wait", None)
class PatrolAI(AIBehavior):
"""Patrol between waypoints, chase if player spotted."""
def __init__(self, waypoints):
self.waypoints = waypoints
self.current_waypoint = 0
self.alert = False
self.sight_range = 6
def execute(self, enemy, player, grid, entities):
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
distance = abs(ex - px) + abs(ey - py)
# Check for player
if distance <= self.sight_range:
self.alert = True
if self.alert:
# Chase behavior
if distance == 1:
return ("attack", player)
path = enemy.path_to(px, py)
if path:
return ("move", path[0])
return ("wait", None)
# Patrol behavior
if not self.waypoints:
return ("wait", None)
target = self.waypoints[self.current_waypoint]
if (ex, ey) == target:
# Reached waypoint, go to next
self.current_waypoint = (self.current_waypoint + 1) % len(self.waypoints)
target = self.waypoints[self.current_waypoint]
# Path to waypoint. grid.find_path() takes (start, end) tuples and
# returns an AStarPath (or None) -- use peek() for the next step,
# not path[0] (AStarPath doesn't support indexing).
path = grid.find_path((ex, ey), target)
if path:
return ("move", path.peek().int)
return ("wait", None)
class HunterAI(AIBehavior):
"""Wait until player is close, then chase aggressively."""
def __init__(self, trigger_range=4, chase_range=12):
self.trigger_range = trigger_range
self.chase_range = chase_range
self.hunting = False
def execute(self, enemy, player, grid, entities):
ex, ey = enemy.grid_x, enemy.grid_y
px, py = player.grid_x, player.grid_y
distance = abs(ex - px) + abs(ey - py)
# Trigger hunting mode
if distance <= self.trigger_range:
self.hunting = True
# Stop hunting if player escapes
if distance > self.chase_range:
self.hunting = False
if not self.hunting:
return ("wait", None)
# Hunting - chase aggressively
if distance == 1:
return ("attack", player)
path = enemy.path_to(px, py)
if path:
return ("move", path[0])
return ("wait", None)
Processing AI Actions
Execute the actions returned by AI behaviors:
def process_ai_action(enemy, action, target, player, grid):
"""Execute an AI action."""
if action == "attack":
attack(enemy, target)
elif action == "move":
new_x, new_y = target
# Move with animation
move_with_animation(enemy, new_x, new_y)
elif action == "wait":
pass # Do nothing
def run_all_enemy_ai(enemies, player, grid):
"""Run AI for all enemies."""
all_entities = [player] + [e.entity for e in enemies]
for enemy_obj in enemies:
if enemy_obj.hp <= 0:
continue
behavior = enemy_obj.ai
action, target = behavior.execute(
enemy_obj.entity,
player,
grid,
all_entities
)
process_ai_action(enemy_obj.entity, action, target, player, grid)
Enemy Class with AI
Putting it together:
class Enemy:
"""Enemy with health, AI, and entity representation."""
def __init__(self, grid, x, y, texture, sprite_index, ai_behavior):
self.entity = mcrfpy.Entity((x, y), texture, sprite_index)
grid.entities.append(self.entity)
self.grid = grid
self.hp = 10
self.max_hp = 10
self.attack_power = 3
self.defense = 1
self.ai = ai_behavior
def take_turn(self, player, all_entities):
"""Execute AI and take action."""
if self.hp <= 0:
return
action, target = self.ai.execute(
self.entity, player, self.grid, all_entities
)
if action == "attack":
damage = max(0, self.attack_power - target.defense)
target.hp -= damage
elif action == "move":
new_x, new_y = target
self.entity.grid_x = new_x
self.entity.grid_y = new_y
def destroy(self):
"""Remove from game."""
self.entity.die()
# Usage
patrol_ai = PatrolAI([(5, 5), (15, 5), (15, 15), (5, 15)])
guard = Enemy(grid, 5, 5, enemy_texture, 0, patrol_ai)
hunter_ai = HunterAI(trigger_range=3, chase_range=10)
lurker = Enemy(grid, 20, 20, enemy_texture, 1, hunter_ai)
Tips
-
Pathfinding Performance: Cache paths and only recalculate when needed (player moves, obstacles change)
-
Collision with Other Enemies: Check
is_occupied()before moving to prevent stacking - Diagonal Movement: The default pathfinding may use diagonals. Filter path steps if you want 4-directional only:
# Filter to cardinal directions only path = [p for p in path if abs(p[0] - ex) + abs(p[1] - ey) == 1] - Alert Radius: When one enemy spots the player, alert nearby enemies:
def alert_nearby(x, y, radius, enemies): for enemy in enemies: dist = abs(enemy.entity.grid_x - x) + abs(enemy.entity.grid_y - y) if dist <= radius and hasattr(enemy.ai, 'alert'): enemy.ai.alert = True - Blocked Paths: If
path_to()returns empty list, the target is unreachable. Handle gracefully.
See Also
- Turn System - When enemies take their turns
- Melee Combat - Attack calculations
- Animated Movement - Smooth AI movement