McRogueFace

Part 4: Field of View

In Part 3, we generated procedural dungeons. Now we will add field of view (FOV) so players can only see what is nearby. This creates the classic roguelike “fog of war” where unexplored areas are hidden and explored areas fade into memory.

What You Will Learn

Three Visibility States

Classic roguelike FOV uses three states:

State Description Visual
Unknown Never seen by the player Completely black
Discovered Previously seen but not currently visible Dimmed/grayed
Visible Currently in line of sight Full brightness

This creates a sense of exploration while letting players remember where they have been.

The Complete Code

Create a file called part_04_fov.py:

"""McRogueFace Tutorial - Part 4: Field of View

Implement fog of war with visible, explored, and unknown tiles.
"""
import mcrfpy
import random

# =============================================================================
# Constants
# =============================================================================

# Sprite indices for CP437 tileset
SPRITE_WALL = 35    # '#' - wall
SPRITE_FLOOR = 46   # '.' - floor
SPRITE_PLAYER = 64  # '@' - player

# Grid dimensions
GRID_WIDTH = 50
GRID_HEIGHT = 35

# Room generation parameters
ROOM_MIN_SIZE = 6
ROOM_MAX_SIZE = 12
MAX_ROOMS = 8

# FOV settings
FOV_RADIUS = 8

# Visibility colors (applied as overlays)
COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0)           # Fully transparent - show tile
COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180)     # Dark blue tint - dimmed
COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255)         # Solid black - hidden

# =============================================================================
# Room Class (from Part 3)
# =============================================================================

class RectangularRoom:
    """A rectangular room with its position and size."""

    def __init__(self, x: int, y: int, width: int, height: int):
        self.x1 = x
        self.y1 = y
        self.x2 = x + width
        self.y2 = y + height

    @property
    def center(self) -> tuple[int, int]:
        center_x = (self.x1 + self.x2) // 2
        center_y = (self.y1 + self.y2) // 2
        return center_x, center_y

    @property
    def inner(self) -> tuple[slice, slice]:
        return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2)

    def intersects(self, other: "RectangularRoom") -> bool:
        return (
            self.x1 <= other.x2 and
            self.x2 >= other.x1 and
            self.y1 <= other.y2 and
            self.y2 >= other.y1
        )

# =============================================================================
# Exploration Tracking
# =============================================================================

# Track which tiles have been discovered (seen at least once)
explored: list[list[bool]] = []

def init_explored() -> None:
    """Initialize the explored array to all False."""
    global explored
    explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]

def mark_explored(x: int, y: int) -> None:
    """Mark a tile as explored."""
    if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
        explored[y][x] = True

def is_explored(x: int, y: int) -> bool:
    """Check if a tile has been explored."""
    if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
        return explored[y][x]
    return False

# =============================================================================
# Dungeon Generation (from Part 3, with transparent property)
# =============================================================================

def fill_with_walls(grid: mcrfpy.Grid) -> None:
    """Fill the entire grid with wall tiles."""
    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            cell = grid.at(x, y)
            cell.tilesprite = SPRITE_WALL
            cell.walkable = False
            cell.transparent = False  # Walls block line of sight

def carve_room(grid: mcrfpy.Grid, room: RectangularRoom) -> None:
    """Carve out a room by setting its inner tiles to floor."""
    inner_x, inner_y = room.inner
    for y in range(inner_y.start, inner_y.stop):
        for x in range(inner_x.start, inner_x.stop):
            if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
                cell = grid.at(x, y)
                cell.tilesprite = SPRITE_FLOOR
                cell.walkable = True
                cell.transparent = True  # Floors allow line of sight

def carve_tunnel_horizontal(grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None:
    """Carve a horizontal tunnel."""
    for x in range(min(x1, x2), max(x1, x2) + 1):
        if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
            cell = grid.at(x, y)
            cell.tilesprite = SPRITE_FLOOR
            cell.walkable = True
            cell.transparent = True

def carve_tunnel_vertical(grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None:
    """Carve a vertical tunnel."""
    for y in range(min(y1, y2), max(y1, y2) + 1):
        if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
            cell = grid.at(x, y)
            cell.tilesprite = SPRITE_FLOOR
            cell.walkable = True
            cell.transparent = True

def carve_l_tunnel(
    grid: mcrfpy.Grid,
    start: tuple[int, int],
    end: tuple[int, int]
) -> None:
    """Carve an L-shaped tunnel between two points."""
    x1, y1 = start
    x2, y2 = end

    if random.random() < 0.5:
        carve_tunnel_horizontal(grid, x1, x2, y1)
        carve_tunnel_vertical(grid, y1, y2, x2)
    else:
        carve_tunnel_vertical(grid, y1, y2, x1)
        carve_tunnel_horizontal(grid, x1, x2, y2)

def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]:
    """Generate a dungeon with rooms and tunnels."""
    fill_with_walls(grid)
    init_explored()  # Reset exploration when generating new dungeon

    rooms: list[RectangularRoom] = []

    for _ in range(MAX_ROOMS):
        room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
        room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
        x = random.randint(1, GRID_WIDTH - room_width - 2)
        y = random.randint(1, GRID_HEIGHT - room_height - 2)

        new_room = RectangularRoom(x, y, room_width, room_height)

        overlaps = False
        for other_room in rooms:
            if new_room.intersects(other_room):
                overlaps = True
                break

        if overlaps:
            continue

        carve_room(grid, new_room)
        if rooms:
            carve_l_tunnel(grid, new_room.center, rooms[-1].center)
        rooms.append(new_room)

    if rooms:
        return rooms[0].center
    return GRID_WIDTH // 2, GRID_HEIGHT // 2

# =============================================================================
# Field of View
# =============================================================================

def update_fov(grid: mcrfpy.Grid, fov_layer, player_x: int, player_y: int) -> None:
    """Update the field of view visualization.

    Args:
        grid: The game grid
        fov_layer: The ColorLayer for FOV visualization
        player_x: Player's X position
        player_y: Player's Y position
    """
    # Compute FOV from player position
    grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)

    # Update each tile's visibility
    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            if grid.is_in_fov(x, y):
                # Currently visible - mark as explored and show clearly
                mark_explored(x, y)
                fov_layer.set(x, y, COLOR_VISIBLE)
            elif is_explored(x, y):
                # Previously seen but not currently visible - show dimmed
                fov_layer.set(x, y, COLOR_DISCOVERED)
            else:
                # Never seen - hide completely
                fov_layer.set(x, y, COLOR_UNKNOWN)

# =============================================================================
# Collision Detection
# =============================================================================

def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool:
    """Check if a position is valid for movement."""
    if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT:
        return False
    return grid.at(x, y).walkable

# =============================================================================
# Game Setup
# =============================================================================

# Create the scene
scene = mcrfpy.Scene("game")

# Load texture
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)

# Create the grid
grid = mcrfpy.Grid(
    pos=(50, 80),
    size=(800, 560),
    grid_size=(GRID_WIDTH, GRID_HEIGHT),
    texture=texture,
    zoom=1.0
)

# Generate the dungeon
player_start_x, player_start_y = generate_dungeon(grid)

# Add a color layer for FOV visualization (below entities)
fov_layer = grid.add_layer("color", z_index=-1)

# Initialize the FOV layer to all black (unknown)
for y in range(GRID_HEIGHT):
    for x in range(GRID_WIDTH):
        fov_layer.set(x, y, COLOR_UNKNOWN)

# Create the player
player = mcrfpy.Entity(
    grid_pos=(player_start_x, player_start_y),
    texture=texture,
    sprite_index=SPRITE_PLAYER
)
grid.entities.append(player)

# Calculate initial FOV
update_fov(grid, fov_layer, player_start_x, player_start_y)

# Add grid to scene
scene.children.append(grid)

# =============================================================================
# UI Elements
# =============================================================================

title = mcrfpy.Caption(
    pos=(50, 15),
    text="Part 4: Field of View"
)
title.fill_color = mcrfpy.Color(255, 255, 255)
title.font_size = 24
scene.children.append(title)

instructions = mcrfpy.Caption(
    pos=(50, 50),
    text="WASD/Arrows: Move | R: Regenerate | Escape: Quit"
)
instructions.fill_color = mcrfpy.Color(180, 180, 180)
instructions.font_size = 16
scene.children.append(instructions)

pos_display = mcrfpy.Caption(
    pos=(50, 660),
    text=f"Position: ({int(player.x)}, {int(player.y)})"
)
pos_display.fill_color = mcrfpy.Color(200, 200, 100)
pos_display.font_size = 16
scene.children.append(pos_display)

fov_display = mcrfpy.Caption(
    pos=(400, 660),
    text=f"FOV Radius: {FOV_RADIUS}"
)
fov_display.fill_color = mcrfpy.Color(100, 200, 100)
fov_display.font_size = 16
scene.children.append(fov_display)

# =============================================================================
# Input Handling
# =============================================================================

def regenerate_dungeon() -> None:
    """Generate a new dungeon and reposition the player."""
    new_x, new_y = generate_dungeon(grid)
    player.x = new_x
    player.y = new_y

    # Reset FOV layer to unknown
    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            fov_layer.set(x, y, COLOR_UNKNOWN)

    # Calculate new FOV
    update_fov(grid, fov_layer, new_x, new_y)
    pos_display.text = f"Position: ({new_x}, {new_y})"

def handle_keys(key: str, action: str) -> None:
    """Handle keyboard input."""
    if action != "start":
        return

    px, py = int(player.x), int(player.y)
    new_x, new_y = px, py

    if key == "W" or key == "Up":
        new_y -= 1
    elif key == "S" or key == "Down":
        new_y += 1
    elif key == "A" or key == "Left":
        new_x -= 1
    elif key == "D" or key == "Right":
        new_x += 1
    elif key == "R":
        regenerate_dungeon()
        return
    elif key == "Escape":
        mcrfpy.exit()
        return
    else:
        return

    if can_move_to(grid, new_x, new_y):
        player.x = new_x
        player.y = new_y
        pos_display.text = f"Position: ({new_x}, {new_y})"

        # Update FOV after movement
        update_fov(grid, fov_layer, new_x, new_y)

scene.on_key = handle_keys

# =============================================================================
# Start the Game
# =============================================================================

scene.activate()
print("Part 4 loaded! Explore the dungeon - watch the fog of war!")

Understanding the Code

The ColorLayer

fov_layer = grid.add_layer("color", z_index=-1)

A ColorLayer is a grid-sized overlay that tints each cell. The z_index=-1 places it below entities, so the player remains visible even in darkness.

Setting Cell Colors

fov_layer.set(x, y, COLOR_VISIBLE)    # Clear overlay - show tile
fov_layer.set(x, y, COLOR_DISCOVERED) # Dark tint - dimmed
fov_layer.set(x, y, COLOR_UNKNOWN)    # Solid black - hidden

The color’s alpha channel controls visibility:

Computing FOV

grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)

This calculates which tiles are visible from the player’s position:

Checking Visibility

if grid.is_in_fov(x, y):
    # This tile is currently visible

After calling compute_fov(), use is_in_fov(x, y) to check if a specific tile is visible.

The Transparent Property

# Walls block line of sight
cell.transparent = False

# Floors allow line of sight
cell.transparent = True

The FOV algorithm uses the transparent property to determine what blocks vision. Walls should block sight (transparent=False), while floors allow it (transparent=True).

Tracking Exploration

explored: list[list[bool]] = []

def mark_explored(x: int, y: int) -> None:
    if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
        explored[y][x] = True

We maintain a 2D array tracking which tiles have ever been seen. Once a tile is visible, we mark it as explored. Explored tiles remain dimly visible even when out of FOV.

The Update Loop

def update_fov(grid, fov_layer, player_x, player_y):
    # 1. Compute what is visible
    grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)

    # 2. Update each tile's visual state
    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            if grid.is_in_fov(x, y):
                mark_explored(x, y)
                fov_layer.set(x, y, COLOR_VISIBLE)
            elif is_explored(x, y):
                fov_layer.set(x, y, COLOR_DISCOVERED)
            else:
                fov_layer.set(x, y, COLOR_UNKNOWN)

This runs every time the player moves:

  1. Compute new FOV from player position
  2. For each tile, determine its visibility state
  3. Update the color layer accordingly

Movement Triggers FOV Update

if can_move_to(grid, new_x, new_y):
    player.x = new_x
    player.y = new_y
    update_fov(grid, fov_layer, new_x, new_y)  # Recalculate!

FOV must be recalculated whenever the player moves. This is essential for the fog of war to update correctly.

FOV Algorithms

McRogueFace supports multiple FOV algorithms:

Algorithm Description
mcrfpy.FOV.BASIC Simple raycasting - fast but less accurate
mcrfpy.FOV.SHADOW Recursive shadowcasting - natural shadows, recommended

The SHADOW algorithm produces more realistic shadows around corners and is the standard choice for roguelikes.

How Shadowcasting Works

The shadowcasting algorithm works by:

  1. Dividing the area around the player into 8 octants
  2. For each octant, scanning outward row by row
  3. Tracking “shadow” angles cast by walls
  4. Marking tiles as visible unless they fall within a shadow
     Shadows cast by walls
          \   /
           \ /
    Wall -> X <- Wall
           /|\
          / | \
         /  |  \
        Player

Walls cast shadows that block tiles behind them from view.

Visibility Colors

The three colors create a clear visual distinction:

COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0)        # Transparent
COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180)  # Dark blue tint
COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255)      # Solid black

The dark blue tint for discovered tiles suggests “memory” - the player remembers the layout but cannot see current details.

Try This

  1. Adjust FOV radius: Change FOV_RADIUS to 4 or 12 and observe the difference
  2. Different discovery color: Try mcrfpy.Color(40, 40, 40, 180) for a gray tint instead of blue
  3. Torchlight effect: Add slight random variation to the visible area radius
  4. Light sources: Create a second FOV calculation from a torch position
  5. Transparent walls: Add glass walls with walkable=False, transparent=True

Challenge: Dynamic Lighting

Create flickering torchlight by varying the FOV radius:

import math

base_radius = 8
time_counter = 0

def update_lighting():
    global time_counter
    time_counter += 1
    # Vary radius between 7 and 9
    flicker = math.sin(time_counter * 0.3) * 0.5 + 0.5
    current_radius = int(base_radius - 1 + flicker * 2)
    update_fov(grid, fov_layer, int(player.x), int(player.y), current_radius)

mcrfpy.setTimer("lighting", update_lighting, 100)  # Update every 100ms

Challenge: Enemy Visibility

Make enemies only visible when in FOV (we will add enemies in Part 5, but here is a preview):

def update_entity_visibility():
    for entity in grid.entities:
        if entity == player:
            continue
        ex, ey = int(entity.x), int(entity.y)
        # Hide entities outside FOV
        entity.visible = grid.is_in_fov(ex, ey)

Common Mistakes

  1. Forgetting to set transparent: FOV will not work correctly if walls have transparent=True
  2. Not updating on movement: FOV must be recalculated after every player move
  3. Wrong layer z_index: If z_index is positive, the color layer may cover entities
  4. Forgetting to reset on regeneration: When generating a new dungeon, reset the explored array and FOV layer

Performance Note

The current implementation iterates over all tiles when updating FOV. For larger maps, you could optimize by:

  1. Only updating tiles near the player
  2. Caching the previous FOV and only updating changed tiles
  3. Using dirty rectangles to limit the update area

For typical roguelike map sizes (up to 100x100), the simple approach works fine.

What is Next

In Part 5, we will add enemies to the dungeon. You will learn:

Continue to Part 5: Placing Enemies


Complete Code Reference

The complete code is shown above. Key additions from Part 3: