3 AI and Pathfinding
John McCardle edited this page 2026-02-07 23:47:47 +00:00

AI and Pathfinding

Field of view (FOV), pathfinding, and AI systems for creating intelligent game entities.

Quick Reference

Systems: Grid-System, Entity-Management

Key Features:

  • A* pathfinding (grid.find_path() returns AStarPath object)
  • Dijkstra maps (grid.get_dijkstra_map() returns DijkstraMap object)
  • Field of view (grid.compute_fov() / grid.is_in_fov())
  • Per-entity perspective rendering

TCOD Integration: src/UIGrid.cpp libtcod bindings


Field of View (FOV)

Basic FOV

import mcrfpy

# Setup grid with transparency
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400))

# Set tile properties (walkable + transparent)
for x in range(50):
    for y in range(50):
        grid.at(x, y).transparent = True
        grid.at(x, y).walkable = True

# Add walls (opaque and unwalkable)
grid.at(10, 10).transparent = False
grid.at(10, 10).walkable = False

# Compute FOV from position with radius
grid.compute_fov((25, 25), radius=10)

# Query visibility
if grid.is_in_fov((25, 25)):
    print("Origin is visible")
if not grid.is_in_fov((0, 0)):
    print("Far corner not visible")

Per-Entity Perspective

Each entity can have its own view of the map:

player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0)
grid.entities.append(player)

# Set which entity's perspective to render
grid.perspective = player

# This affects rendering:
# - Unexplored tiles: Black (never seen)
# - Explored tiles: Dark (seen before, not visible now)
# - Visible tiles: Normal (currently in FOV)

# Clear perspective (show everything)
grid.perspective = None

FOV + Fog of War Pattern

Use a ColorLayer to implement fog of war:

# Create grid with fog layer
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400), layers=[])
fog = mcrfpy.ColorLayer(name="fog", z_index=1)
grid.add_layer(fog)

# Start fully fogged
fog.fill(mcrfpy.Color(0, 0, 0, 255))

# Setup transparency
for x in range(50):
    for y in range(50):
        grid.at(x, y).transparent = True
        grid.at(x, y).walkable = True

player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0)
grid.entities.append(player)

def update_fov():
    """Recompute FOV and update fog layer."""
    px, py = int(player.grid_x), int(player.grid_y)
    grid.compute_fov((px, py), radius=10)

    for x in range(50):
        for y in range(50):
            if grid.is_in_fov((x, y)):
                fog.set((x, y), mcrfpy.Color(0, 0, 0, 0))      # Visible
            # Explored but not visible: keep at partial opacity
            # (track explored state separately if needed)

# Initial FOV
update_fov()

# Update on movement
def on_player_move(dx, dy):
    new_x = int(player.grid_x + dx)
    new_y = int(player.grid_y + dy)
    point = grid.at(new_x, new_y)
    if point and point.walkable:
        player.grid_x = new_x
        player.grid_y = new_y
        update_fov()

Movement + FOV Input Handling

scene = mcrfpy.Scene("game")
scene.children.append(grid)
mcrfpy.current_scene = scene

move_map = {
    mcrfpy.Key.W: (0, -1),
    mcrfpy.Key.A: (-1, 0),
    mcrfpy.Key.S: (0, 1),
    mcrfpy.Key.D: (1, 0),
}

def handle_key(key, action):
    if action != mcrfpy.InputState.PRESSED:
        return
    if key in move_map:
        dx, dy = move_map[key]
        on_player_move(dx, dy)

scene.on_key = handle_key

Pathfinding

A* Pathfinding

grid.find_path() returns an AStarPath object:

grid = mcrfpy.Grid(grid_size=(30, 30), pos=(0, 0), size=(400, 400))
for x in range(30):
    for y in range(30):
        grid.at(x, y).walkable = True

# Find path between two points
path = grid.find_path((10, 10), (20, 20))

if path and len(path) > 0:
    print(f"Path has {path.remaining} steps")
    print(f"From: ({path.origin.x}, {path.origin.y})")
    print(f"To: ({path.destination.x}, {path.destination.y})")

    # Walk the path step by step
    next_step = path.walk()        # Consume and return next step
    print(f"Next: ({next_step.x}, {next_step.y})")

    # Peek without consuming
    upcoming = path.peek()         # Look at next step without consuming

AStarPath API:

Method/Property Description
path.walk() Consume and return next step (Vector)
path.peek() Look at next step without consuming
path.remaining Number of steps left
len(path) Same as remaining
path.origin Start position (Vector)
path.destination End position (Vector)

Dijkstra Maps

Multi-source distance maps for efficient AI:

# Create Dijkstra map from a single source
dm = grid.get_dijkstra_map((15, 15))

# Query distance from any cell to source
d = dm.distance((0, 0))
print(f"Distance from (0,0) to (15,15): {d}")

# Get full path from a position to the source
path_points = dm.path_from((0, 0))
print(f"Path length: {len(path_points)}")

# Get single next step toward source
next_step = dm.step_from((0, 0))
print(f"Next step: ({next_step.x}, {next_step.y})")

DijkstraMap API:

Method Description
dm.distance((x, y)) Distance from cell to source
dm.path_from((x, y)) Full path from cell to source
dm.step_from((x, y)) Single step toward source
dm.to_heightmap() Convert to HeightMap for visualization

AI Patterns

Pattern 1: Chase AI (Dijkstra)

Enemies chase player using a shared Dijkstra map:

def update_enemies(grid, player, enemies):
    # Compute Dijkstra map with player as source
    dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))

    for enemy in enemies:
        ex, ey = int(enemy.grid_x), int(enemy.grid_y)
        next_step = dm.step_from((ex, ey))
        if next_step:
            nx, ny = int(next_step.x), int(next_step.y)
            if grid.at(nx, ny).walkable:
                enemy.grid_x = nx
                enemy.grid_y = ny

Advantage: One Dijkstra map serves all enemies (O(n) compute, O(1) per enemy query).

Pattern 2: Flee AI (Inverted Dijkstra)

Enemies flee from danger by moving to higher-distance cells:

def flee_from_player(grid, player, scared_npcs):
    dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))

    for npc in scared_npcs:
        nx, ny = int(npc.grid_x), int(npc.grid_y)
        best_pos = None
        best_distance = -1

        for dx in [-1, 0, 1]:
            for dy in [-1, 0, 1]:
                if dx == 0 and dy == 0:
                    continue
                cx, cy = nx + dx, ny + dy
                if grid.at(cx, cy).walkable:
                    d = dm.distance((cx, cy))
                    if d > best_distance:
                        best_distance = d
                        best_pos = (cx, cy)

        if best_pos:
            npc.grid_x = best_pos[0]
            npc.grid_y = best_pos[1]

Pattern 3: Aggro Range with Spatial Queries

Use entities_in_radius() for aggro detection:

class Enemy:
    def __init__(self, grid, pos, aggro_range=10):
        self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=1, name="enemy")
        self.aggro_range = aggro_range
        grid.entities.append(self.entity)

    def update(self, grid):
        ex, ey = int(self.entity.grid_x), int(self.entity.grid_y)
        nearby = grid.entities_in_radius((ex, ey), self.aggro_range)

        # Filter for player entities
        for entity in nearby:
            if entity.name == "player":
                self.chase(grid, entity)
                return

    def chase(self, grid, target):
        path = grid.find_path(
            (int(self.entity.grid_x), int(self.entity.grid_y)),
            (int(target.grid_x), int(target.grid_y))
        )
        if path and len(path) > 0:
            step = path.walk()
            self.entity.grid_x = int(step.x)
            self.entity.grid_y = int(step.y)

Pattern 4: State Machine AI

Complex behaviors using states:

class StateMachineAI:
    def __init__(self, grid, pos):
        self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=3, name="ai")
        self.state = "patrol"
        self.health = 100
        self.aggro_range = 10
        self.flee_threshold = 30
        grid.entities.append(self.entity)

    def update(self, grid, player):
        if self.state == "patrol":
            # Check for player in range
            nearby = grid.entities_in_radius(
                (int(self.entity.grid_x), int(self.entity.grid_y)),
                self.aggro_range
            )
            for e in nearby:
                if e.name == "player":
                    self.state = "chase"
                    return

        elif self.state == "chase":
            if self.health < self.flee_threshold:
                self.state = "flee"
                return
            # Chase with A*
            path = grid.find_path(
                (int(self.entity.grid_x), int(self.entity.grid_y)),
                (int(player.grid_x), int(player.grid_y))
            )
            if path and len(path) > 0:
                step = path.walk()
                self.entity.grid_x = int(step.x)
                self.entity.grid_y = int(step.y)

        elif self.state == "flee":
            dm = grid.get_dijkstra_map(
                (int(player.grid_x), int(player.grid_y))
            )
            # Move away from player (maximize distance)
            # ... (see Flee AI pattern above)

Performance Considerations

FOV: Only Recompute on Move

# Don't recompute every frame
def on_player_move(dx, dy):
    # ... move player ...
    grid.compute_fov((new_x, new_y), radius=10)  # Only on move

Pathfinding: Cache Dijkstra Maps

# Compute once per turn, query many times
dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))

for enemy in enemies:
    step = dm.step_from((int(enemy.grid_x), int(enemy.grid_y)))
    # O(1) per enemy vs O(n log n) for individual A*

A* vs Dijkstra Trade-offs

Method Best For Cost
find_path() (A*) Single entity, single target O(n log n) per call
get_dijkstra_map() Many entities, one target O(n) compute, O(1) per query


Last updated: 2026-02-07