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.
grid.compute_fov()ColorLayer to visualize visibility statestransparent property for wallsClassic 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.
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!")
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.
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:
grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)
This calculates which tiles are visible from the player’s position:
player_x, player_y: The observer’s positionFOV_RADIUS: How far the player can seemcrfpy.FOV.SHADOW: The shadowcasting algorithm (recommended)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.
# 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).
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.
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:
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.
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.
The shadowcasting algorithm works by:
Shadows cast by walls
\ /
\ /
Wall -> X <- Wall
/|\
/ | \
/ | \
Player
Walls cast shadows that block tiles behind them from view.
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.
FOV_RADIUS to 4 or 12 and observe the differencemcrfpy.Color(40, 40, 40, 180) for a gray tint instead of bluewalkable=False, transparent=TrueCreate 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
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)
transparent: FOV will not work correctly if walls have transparent=Truez_index is positive, the color layer may cover entitiesThe current implementation iterates over all tiles when updating FOV. For larger maps, you could optimize by:
For typical roguelike map sizes (up to 100x100), the simple approach works fine.
In Part 5, we will add enemies to the dungeon. You will learn:
Continue to Part 5: Placing Enemies
The complete code is shown above. Key additions from Part 3:
grid.add_layer("color", z_index=-1)explored 2D array with mark_explored() and is_explored()grid.compute_fov() and grid.is_in_fov()COLOR_VISIBLE, COLOR_DISCOVERED, COLOR_UNKNOWN