McRogueFace

Part 6: Combat System

In Part 5, we populated our dungeon with enemies. Now we will make them dangerous. When the player bumps into an enemy, combat ensues. Swords clash, HP drops, and only the strongest survive.

What You Will Learn

Combat Design

Our combat system follows classic roguelike conventions:

Concept Implementation
Attack trigger Move into an occupied tile
Damage formula max(0, attacker.attack - defender.defense)
Death condition HP reaches 0
Turn order Player acts, then enemies

This creates tactical decisions: do you engage or flee?

The Complete Code

Create a file called part_06_combat.py:

"""McRogueFace Tutorial - Part 6: Combat System

Implement bump-to-attack combat with HP, damage, and death.
"""
import mcrfpy
import random
from dataclasses import dataclass
from typing import Optional

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

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

# Enemy sprites
SPRITE_GOBLIN = 103  # 'g'
SPRITE_ORC = 111     # 'o'
SPRITE_TROLL = 116   # 't'

# Grid dimensions
GRID_WIDTH = 50
GRID_HEIGHT = 35

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

# Enemy spawn parameters
MAX_ENEMIES_PER_ROOM = 3

# FOV settings
FOV_RADIUS = 8

# Visibility colors
COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0)
COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180)
COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255)

# Message log settings
MAX_MESSAGES = 5

# =============================================================================
# Fighter Component
# =============================================================================

@dataclass
class Fighter:
    """Combat stats for an entity."""
    hp: int
    max_hp: int
    attack: int
    defense: int
    name: str
    is_player: bool = False

    @property
    def is_alive(self) -> bool:
        """Check if this fighter is still alive."""
        return self.hp > 0

    def take_damage(self, amount: int) -> int:
        """Apply damage and return actual damage taken."""
        actual_damage = min(self.hp, amount)
        self.hp -= actual_damage
        return actual_damage

    def heal(self, amount: int) -> int:
        """Heal and return actual amount healed."""
        actual_heal = min(self.max_hp - self.hp, amount)
        self.hp += actual_heal
        return actual_heal

# =============================================================================
# Enemy Templates
# =============================================================================

ENEMY_TEMPLATES = {
    "goblin": {
        "sprite": SPRITE_GOBLIN,
        "hp": 6,
        "attack": 3,
        "defense": 0
    },
    "orc": {
        "sprite": SPRITE_ORC,
        "hp": 10,
        "attack": 4,
        "defense": 1
    },
    "troll": {
        "sprite": SPRITE_TROLL,
        "hp": 16,
        "attack": 6,
        "defense": 2
    }
}

# =============================================================================
# Global State
# =============================================================================

# Entity data storage
entity_data: dict[mcrfpy.Entity, Fighter] = {}

# Global references
player: Optional[mcrfpy.Entity] = None
grid: Optional[mcrfpy.Grid] = None
fov_layer = None
texture: Optional[mcrfpy.Texture] = None

# Game state
game_over: bool = False

# Message log
messages: list[tuple[str, mcrfpy.Color]] = []

# =============================================================================
# Room Class
# =============================================================================

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
# =============================================================================

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

# =============================================================================
# Message Log
# =============================================================================

def add_message(text: str, color: mcrfpy.Color = None) -> None:
    """Add a message to the log.

    Args:
        text: The message text
        color: Optional color (defaults to white)
    """
    if color is None:
        color = mcrfpy.Color(255, 255, 255)

    messages.append((text, color))

    # Keep only the most recent messages
    while len(messages) > MAX_MESSAGES:
        messages.pop(0)

    # Update the message display
    update_message_display()

def update_message_display() -> None:
    """Update the message log UI."""
    if message_log_caption is None:
        return

    # Combine messages into a single string
    lines = []
    for text, color in messages:
        lines.append(text)

    message_log_caption.text = "\n".join(lines)

def clear_messages() -> None:
    """Clear all messages."""
    global messages
    messages = []
    update_message_display()

# =============================================================================
# Dungeon Generation
# =============================================================================

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

def carve_room(target_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 = target_grid.at(x, y)
                cell.tilesprite = SPRITE_FLOOR
                cell.walkable = True
                cell.transparent = True

def carve_tunnel_horizontal(target_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 = target_grid.at(x, y)
            cell.tilesprite = SPRITE_FLOOR
            cell.walkable = True
            cell.transparent = True

def carve_tunnel_vertical(target_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 = target_grid.at(x, y)
            cell.tilesprite = SPRITE_FLOOR
            cell.walkable = True
            cell.transparent = True

def carve_l_tunnel(
    target_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(target_grid, x1, x2, y1)
        carve_tunnel_vertical(target_grid, y1, y2, x2)
    else:
        carve_tunnel_vertical(target_grid, y1, y2, x1)
        carve_tunnel_horizontal(target_grid, x1, x2, y2)

# =============================================================================
# Entity Management
# =============================================================================

def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity:
    """Spawn an enemy at the given position."""
    template = ENEMY_TEMPLATES[enemy_type]

    enemy = mcrfpy.Entity(
        grid_pos=(x, y),
        texture=tex,
        sprite_index=template["sprite"]
    )
    enemy.visible = False

    target_grid.entities.append(enemy)

    # Create Fighter component for this enemy
    entity_data[enemy] = Fighter(
        hp=template["hp"],
        max_hp=template["hp"],
        attack=template["attack"],
        defense=template["defense"],
        name=enemy_type.capitalize(),
        is_player=False
    )

    return enemy

def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None:
    """Spawn random enemies in a room."""
    num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM)

    for _ in range(num_enemies):
        inner_x, inner_y = room.inner
        x = random.randint(inner_x.start, inner_x.stop - 1)
        y = random.randint(inner_y.start, inner_y.stop - 1)

        if get_entity_at(target_grid, x, y) is not None:
            continue

        roll = random.random()
        if roll < 0.6:
            enemy_type = "goblin"
        elif roll < 0.9:
            enemy_type = "orc"
        else:
            enemy_type = "troll"

        spawn_enemy(target_grid, x, y, enemy_type, tex)

def get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]:
    """Get any entity at the given position."""
    for entity in target_grid.entities:
        if int(entity.x) == x and int(entity.y) == y:
            # Check if this entity is alive (or is a non-Fighter entity)
            if entity in entity_data:
                if entity_data[entity].is_alive:
                    return entity
            else:
                return entity
    return None

def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]:
    """Get any living entity that blocks movement at the given position."""
    for entity in target_grid.entities:
        if entity == exclude:
            continue
        if int(entity.x) == x and int(entity.y) == y:
            if entity in entity_data and entity_data[entity].is_alive:
                return entity
    return None

def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
    """Remove an entity from the grid and data storage."""
    # Find and remove from grid
    for i, e in enumerate(target_grid.entities):
        if e == entity:
            target_grid.entities.remove(i)
            break

    # Remove from entity data
    if entity in entity_data:
        del entity_data[entity]

def clear_enemies(target_grid: mcrfpy.Grid) -> None:
    """Remove all enemies from the grid."""
    enemies_to_remove = []

    for entity in target_grid.entities:
        if entity in entity_data and not entity_data[entity].is_player:
            enemies_to_remove.append(entity)

    for enemy in enemies_to_remove:
        remove_entity(target_grid, enemy)

# =============================================================================
# Combat System
# =============================================================================

def calculate_damage(attacker: Fighter, defender: Fighter) -> int:
    """Calculate damage dealt from attacker to defender.

    Args:
        attacker: The attacking Fighter
        defender: The defending Fighter

    Returns:
        The amount of damage to deal (minimum 0)
    """
    damage = max(0, attacker.attack - defender.defense)
    return damage

def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None:
    """Execute an attack from one entity to another.

    Args:
        attacker_entity: The entity performing the attack
        defender_entity: The entity being attacked
    """
    global game_over

    attacker = entity_data.get(attacker_entity)
    defender = entity_data.get(defender_entity)

    if attacker is None or defender is None:
        return

    # Calculate and apply damage
    damage = calculate_damage(attacker, defender)
    defender.take_damage(damage)

    # Generate combat message
    if damage > 0:
        if attacker.is_player:
            add_message(
                f"You hit the {defender.name} for {damage} damage!",
                mcrfpy.Color(200, 200, 200)
            )
        else:
            add_message(
                f"The {attacker.name} hits you for {damage} damage!",
                mcrfpy.Color(255, 150, 150)
            )
    else:
        if attacker.is_player:
            add_message(
                f"You hit the {defender.name} but deal no damage.",
                mcrfpy.Color(150, 150, 150)
            )
        else:
            add_message(
                f"The {attacker.name} hits you but deals no damage.",
                mcrfpy.Color(150, 150, 200)
            )

    # Check for death
    if not defender.is_alive:
        handle_death(defender_entity, defender)

def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None:
    """Handle the death of an entity.

    Args:
        entity: The entity that died
        fighter: The Fighter component of the dead entity
    """
    global game_over, grid

    if fighter.is_player:
        # Player death
        add_message("You have died!", mcrfpy.Color(255, 50, 50))
        add_message("Press R to restart or Escape to quit.", mcrfpy.Color(200, 200, 200))
        game_over = True

        # Change player sprite to corpse
        entity.sprite_index = SPRITE_CORPSE
    else:
        # Enemy death
        add_message(f"The {fighter.name} dies!", mcrfpy.Color(100, 255, 100))

        # Replace with corpse
        entity.sprite_index = SPRITE_CORPSE

        # Mark as dead (hp is already 0)
        # Remove blocking but keep visual corpse
        # Actually remove the entity and its data
        remove_entity(grid, entity)

    # Update HP display
    update_hp_display()

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

def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
    """Update visibility of all entities based on FOV."""
    global player

    for entity in target_grid.entities:
        if entity == player:
            entity.visible = True
            continue

        ex, ey = int(entity.x), int(entity.y)
        entity.visible = target_grid.is_in_fov(ex, ey)

def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None:
    """Update the field of view visualization."""
    target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW)

    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            if target_grid.is_in_fov(x, y):
                mark_explored(x, y)
                target_fov_layer.set(x, y, COLOR_VISIBLE)
            elif is_explored(x, y):
                target_fov_layer.set(x, y, COLOR_DISCOVERED)
            else:
                target_fov_layer.set(x, y, COLOR_UNKNOWN)

    update_entity_visibility(target_grid)

# =============================================================================
# Movement and Actions
# =============================================================================

def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> 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

    if not target_grid.at(x, y).walkable:
        return False

    blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover)
    if blocker is not None:
        return False

    return True

def try_move_or_attack(dx: int, dy: int) -> None:
    """Attempt to move the player or attack if blocked by enemy.

    Args:
        dx: Change in X position (-1, 0, or 1)
        dy: Change in Y position (-1, 0, or 1)
    """
    global player, grid, fov_layer, game_over

    if game_over:
        return

    px, py = int(player.x), int(player.y)
    target_x = px + dx
    target_y = py + dy

    # Check bounds
    if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT:
        return

    # Check for blocking entity
    blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player)

    if blocker is not None:
        # Attack the blocking entity
        perform_attack(player, blocker)
        # After player attacks, enemies take their turn
        enemy_turn()
    elif grid.at(target_x, target_y).walkable:
        # Move to the empty tile
        player.x = target_x
        player.y = target_y
        pos_display.text = f"Position: ({target_x}, {target_y})"

        # Update FOV after movement
        update_fov(grid, fov_layer, target_x, target_y)

        # Enemies take their turn after player moves
        enemy_turn()

    # Update HP display
    update_hp_display()

# =============================================================================
# Enemy AI
# =============================================================================

def enemy_turn() -> None:
    """Execute enemy actions."""
    global player, grid, game_over

    if game_over:
        return

    player_x, player_y = int(player.x), int(player.y)

    # Collect enemies that can act
    enemies = []
    for entity in grid.entities:
        if entity == player:
            continue
        if entity in entity_data and entity_data[entity].is_alive:
            enemies.append(entity)

    for enemy in enemies:
        fighter = entity_data.get(enemy)
        if fighter is None or not fighter.is_alive:
            continue

        ex, ey = int(enemy.x), int(enemy.y)

        # Only act if in player's FOV (aware of player)
        if not grid.is_in_fov(ex, ey):
            continue

        # Check if adjacent to player
        dx = player_x - ex
        dy = player_y - ey

        if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0):
            # Adjacent - attack!
            perform_attack(enemy, player)
        else:
            # Not adjacent - try to move toward player
            move_toward_player(enemy, ex, ey, player_x, player_y)

def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None:
    """Move an enemy one step toward the player.

    Uses simple greedy movement - not true pathfinding.
    """
    global grid

    # Calculate direction to player
    dx = 0
    dy = 0

    if px < ex:
        dx = -1
    elif px > ex:
        dx = 1

    if py < ey:
        dy = -1
    elif py > ey:
        dy = 1

    # Try to move in the desired direction
    # First try the combined direction
    new_x = ex + dx
    new_y = ey + dy

    if can_move_to(grid, new_x, new_y, enemy):
        enemy.x = new_x
        enemy.y = new_y
    elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy):
        # Try horizontal only
        enemy.x = ex + dx
    elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy):
        # Try vertical only
        enemy.y = ey + dy
    # If all fail, enemy stays in place

# =============================================================================
# UI Updates
# =============================================================================

def update_hp_display() -> None:
    """Update the HP display in the UI."""
    global player

    if hp_display is None or player is None:
        return

    if player in entity_data:
        fighter = entity_data[player]
        hp_display.text = f"HP: {fighter.hp}/{fighter.max_hp}"

        # Color based on health percentage
        hp_percent = fighter.hp / fighter.max_hp
        if hp_percent > 0.6:
            hp_display.fill_color = mcrfpy.Color(100, 255, 100)
        elif hp_percent > 0.3:
            hp_display.fill_color = mcrfpy.Color(255, 255, 100)
        else:
            hp_display.fill_color = mcrfpy.Color(255, 100, 100)

# =============================================================================
# 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, 480),
    grid_size=(GRID_WIDTH, GRID_HEIGHT),
    texture=texture,
    zoom=1.0
)

# Generate initial dungeon structure
fill_with_walls(grid)
init_explored()

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)

# Get player starting position
if rooms:
    player_start_x, player_start_y = rooms[0].center
else:
    player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2

# Add FOV layer
fov_layer = grid.add_layer("color", z_index=-1)
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)

# Create player Fighter component
entity_data[player] = Fighter(
    hp=30,
    max_hp=30,
    attack=5,
    defense=2,
    name="Player",
    is_player=True
)

# Spawn enemies in all rooms except the first
for i, room in enumerate(rooms):
    if i == 0:
        continue
    spawn_enemies_in_room(grid, room, texture)

# 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 6: Combat System"
)
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/Attack | R: Restart | Escape: Quit"
)
instructions.fill_color = mcrfpy.Color(180, 180, 180)
instructions.font_size = 16
scene.children.append(instructions)

# Position display
pos_display = mcrfpy.Caption(
    pos=(50, 580),
    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)

# HP display
hp_display = mcrfpy.Caption(
    pos=(300, 580),
    text="HP: 30/30"
)
hp_display.fill_color = mcrfpy.Color(100, 255, 100)
hp_display.font_size = 16
scene.children.append(hp_display)

# Message log (positioned below the grid)
message_log_caption = mcrfpy.Caption(
    pos=(50, 610),
    text=""
)
message_log_caption.fill_color = mcrfpy.Color(200, 200, 200)
message_log_caption.font_size = 14
scene.children.append(message_log_caption)

# Initial message
add_message("Welcome to the dungeon! Find and defeat the enemies.", mcrfpy.Color(100, 100, 255))

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

def restart_game() -> None:
    """Restart the game with a new dungeon."""
    global player, grid, fov_layer, game_over, entity_data, rooms

    game_over = False

    # Clear all entities and data
    entity_data.clear()

    # Remove all entities from grid
    while len(grid.entities) > 0:
        grid.entities.remove(0)

    # Regenerate dungeon
    fill_with_walls(grid)
    init_explored()
    clear_messages()

    rooms = []

    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)

    # Get new player starting position
    if rooms:
        new_x, new_y = rooms[0].center
    else:
        new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2

    # Recreate player
    player = mcrfpy.Entity(
        grid_pos=(new_x, new_y),
        texture=texture,
        sprite_index=SPRITE_PLAYER
    )
    grid.entities.append(player)

    entity_data[player] = Fighter(
        hp=30,
        max_hp=30,
        attack=5,
        defense=2,
        name="Player",
        is_player=True
    )

    # Spawn enemies
    for i, room in enumerate(rooms):
        if i == 0:
            continue
        spawn_enemies_in_room(grid, room, texture)

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

    # Update displays
    update_fov(grid, fov_layer, new_x, new_y)
    pos_display.text = f"Position: ({new_x}, {new_y})"
    update_hp_display()

    add_message("A new adventure begins!", mcrfpy.Color(100, 100, 255))

def handle_keys(key: str, action: str) -> None:
    """Handle keyboard input."""
    global game_over

    if action != "start":
        return

    # Handle restart
    if key == "R":
        restart_game()
        return

    if key == "Escape":
        mcrfpy.exit()
        return

    # Ignore other input if game is over
    if game_over:
        return

    # Movement and attack
    if key == "W" or key == "Up":
        try_move_or_attack(0, -1)
    elif key == "S" or key == "Down":
        try_move_or_attack(0, 1)
    elif key == "A" or key == "Left":
        try_move_or_attack(-1, 0)
    elif key == "D" or key == "Right":
        try_move_or_attack(1, 0)

scene.on_key = handle_keys

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

scene.activate()
print("Part 6 loaded! Combat is now active. Good luck!")

Understanding the Code

The Fighter Dataclass

@dataclass
class Fighter:
    hp: int
    max_hp: int
    attack: int
    defense: int
    name: str
    is_player: bool = False

    @property
    def is_alive(self) -> bool:
        return self.hp > 0

    def take_damage(self, amount: int) -> int:
        actual_damage = min(self.hp, amount)
        self.hp -= actual_damage
        return actual_damage

Using a dataclass provides:

Damage Calculation

def calculate_damage(attacker: Fighter, defender: Fighter) -> int:
    damage = max(0, attacker.attack - defender.defense)
    return damage

The formula attack - defense (minimum 0) creates meaningful stat differences:

This simple formula encourages tactical thinking about which enemies to engage.

The Attack Flow

def perform_attack(attacker_entity, defender_entity):
    attacker = entity_data.get(attacker_entity)
    defender = entity_data.get(defender_entity)

    # Calculate and apply damage
    damage = calculate_damage(attacker, defender)
    defender.take_damage(damage)

    # Generate message
    if damage > 0:
        add_message(f"You hit the {defender.name} for {damage} damage!")
    else:
        add_message(f"You hit the {defender.name} but deal no damage.")

    # Check for death
    if not defender.is_alive:
        handle_death(defender_entity, defender)

Each attack:

  1. Calculates damage using stats
  2. Applies damage to defender
  3. Reports the result
  4. Checks for and handles death

Death Handling

def handle_death(entity, fighter):
    global game_over, grid

    if fighter.is_player:
        add_message("You have died!", mcrfpy.Color(255, 50, 50))
        game_over = True
        entity.sprite_index = SPRITE_CORPSE
    else:
        add_message(f"The {fighter.name} dies!", mcrfpy.Color(100, 255, 100))
        remove_entity(grid, entity)

Player death:

Enemy death:

Bump-to-Attack

def try_move_or_attack(dx, dy):
    target_x = px + dx
    target_y = py + dy

    blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player)

    if blocker is not None:
        # Attack!
        perform_attack(player, blocker)
        enemy_turn()
    elif grid.at(target_x, target_y).walkable:
        # Move
        player.x = target_x
        player.y = target_y
        update_fov(...)
        enemy_turn()

The unified move-or-attack function:

  1. Checks if an enemy blocks the target tile
  2. If yes: attack instead of move
  3. If no: move normally
  4. Either way: enemies get their turn

Enemy AI

def enemy_turn():
    for enemy in enemies:
        # Only act if visible (aware of player)
        if not grid.is_in_fov(ex, ey):
            continue

        # If adjacent, attack
        if abs(dx) <= 1 and abs(dy) <= 1:
            perform_attack(enemy, player)
        else:
            # Otherwise, move toward player
            move_toward_player(enemy, ...)

Enemies:

  1. Only act when in player’s FOV (they are “aware”)
  2. Attack if adjacent
  3. Otherwise move toward player

The movement is simple greedy pathfinding - enemies move in the direction of the player when possible.

Message Log

messages: list[tuple[str, mcrfpy.Color]] = []

def add_message(text: str, color: mcrfpy.Color = None) -> None:
    messages.append((text, color))

    while len(messages) > MAX_MESSAGES:
        messages.pop(0)

    update_message_display()

The message log:

Color-coded messages help players scan for important information:

HP Display

def update_hp_display():
    fighter = entity_data[player]
    hp_display.text = f"HP: {fighter.hp}/{fighter.max_hp}"

    hp_percent = fighter.hp / fighter.max_hp
    if hp_percent > 0.6:
        hp_display.fill_color = mcrfpy.Color(100, 255, 100)  # Green
    elif hp_percent > 0.3:
        hp_display.fill_color = mcrfpy.Color(255, 255, 100)  # Yellow
    else:
        hp_display.fill_color = mcrfpy.Color(255, 100, 100)  # Red

The HP display changes color based on health percentage, providing at-a-glance status.

Game Over State

game_over: bool = False

def handle_keys(key, action):
    if game_over:
        if key == "R":
            restart_game()
        return  # Ignore other input

    # Normal gameplay...

When game_over is True:

Combat Balance

The default stats create distinct enemy threat levels:

Entity HP Attack Defense Threat
Player 30 5 2 -
Goblin 6 3 0 Low
Orc 10 4 1 Medium
Troll 16 6 2 High

With these stats:

This means trolls are genuinely dangerous and should be approached carefully.

Try This

  1. Add healing: Implement a rest mechanic that heals 1 HP per turn when no enemies are nearby

  2. Critical hits: Add a 10% chance for double damage:
    if random.random() < 0.1:
        damage *= 2
        add_message("Critical hit!", mcrfpy.Color(255, 255, 0))
    
  3. Death drops: Make enemies drop gold or items when killed

  4. Combat log colors: Different colors for different damage amounts

  5. Damage numbers: Show floating damage numbers at the combat location

Challenge: Fleeing Enemies

Make low-HP enemies run away:

def enemy_turn():
    for enemy in enemies:
        fighter = entity_data[enemy]

        # Flee at low HP
        if fighter.hp < fighter.max_hp * 0.3:
            flee_from_player(enemy)
            continue

        # Normal AI...

Challenge: Experience Points

Add XP and leveling:

@dataclass
class Fighter:
    # ... existing fields ...
    xp: int = 0
    level: int = 1

    def gain_xp(self, amount: int) -> bool:
        """Returns True if leveled up."""
        self.xp += amount
        if self.xp >= self.level * 100:
            self.level_up()
            return True
        return False

    def level_up(self):
        self.level += 1
        self.max_hp += 5
        self.hp = self.max_hp
        self.attack += 1

Common Mistakes

  1. Forgetting to check is_alive: Always verify an entity is alive before allowing actions

  2. Not updating displays: Call update_hp_display() after any HP change

  3. Enemy acting when dead: Check is_alive at the start of enemy turn

  4. Attacking after death: Check game_over before processing player input

  5. Memory leaks: Remove dead entities from both grid.entities and entity_data

  6. Turn order bugs: Be consistent about when enemies act (after player move OR attack)

The Turn System

Our simple turn system:

Player presses key
  |
  v
Is movement? -----> Is tile blocked by enemy?
  |                   |
  | No                | Yes
  v                   v
Move player         Attack enemy
  |                   |
  v                   v
Update FOV          Check death
  |                   |
  +-------+-----------+
          |
          v
    Enemy turn
          |
          v
    For each enemy:
      - If visible and adjacent: attack
      - If visible and not adjacent: move toward player
          |
          v
    Update displays

This creates a classic roguelike feel where each player action triggers enemy responses.

What is Next

Congratulations! You now have a working combat roguelike. In Part 7, we will build a polished user interface. You will learn:

Continue to Part 7: User Interface


Complete Code Reference

The complete code is shown above. Key components: