Part 12: Experience and Leveling

In Part 11, we added multiple dungeon levels with scaling difficulty. Now we will implement an experience and leveling system so the player can grow stronger as they defeat enemies. This creates a satisfying progression loop that rewards combat.

What You Will Learn

  • Tracking experience points (XP)
  • Level up thresholds and calculations
  • Stat increases on level up
  • Enemy XP values by type
  • XP bar in the UI
  • Level up notifications

Experience System Design

Experience provides character progression:

Component Purpose
XP Points Currency earned from kills
Player Level Measure of character power
XP Threshold XP needed to reach next level
Stat Gains Improvements on level up

We will use a simple formula: XP needed = level * 100.

The Complete Code

Create a file called part_12_experience.py:

"""McRogueFace Tutorial - Part 12: Experience and Leveling

Add XP system and character progression.
"""
import mcrfpy
import random
import json
import os
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum

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

# Sprite indices for CP437 tileset
SPRITE_WALL = 35    # '#' - wall
SPRITE_FLOOR = 46   # '.' - floor
SPRITE_PLAYER = 64  # '@' - player
SPRITE_CORPSE = 37  # '%' - remains
SPRITE_POTION = 173 # Potion sprite
SPRITE_CURSOR = 88  # 'X' - targeting cursor
SPRITE_STAIRS_DOWN = 62  # '>' - stairs down

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

# Grid dimensions
GRID_WIDTH = 50
GRID_HEIGHT = 30

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

# FOV and targeting settings
FOV_RADIUS = 8
RANGED_ATTACK_RANGE = 6
RANGED_ATTACK_DAMAGE = 4

# Save file location
SAVE_FILE = "savegame.json"

# XP values for enemies
ENEMY_XP_VALUES = {
    "goblin": 35,
    "orc": 50,
    "troll": 100
}

# 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 colors
COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200)
COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150)
COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50)
COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100)
COLOR_HEAL = mcrfpy.Color(100, 255, 100)
COLOR_PICKUP = mcrfpy.Color(100, 200, 255)
COLOR_INFO = mcrfpy.Color(100, 100, 255)
COLOR_WARNING = mcrfpy.Color(255, 200, 50)
COLOR_INVALID = mcrfpy.Color(255, 100, 100)
COLOR_RANGED = mcrfpy.Color(255, 255, 100)
COLOR_SAVE = mcrfpy.Color(100, 255, 200)
COLOR_DESCEND = mcrfpy.Color(200, 200, 255)
COLOR_LEVEL_UP = mcrfpy.Color(255, 255, 100)
COLOR_XP = mcrfpy.Color(200, 200, 100)

# UI Layout constants
UI_TOP_HEIGHT = 60
UI_BOTTOM_HEIGHT = 150
GAME_AREA_Y = UI_TOP_HEIGHT
GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT

# =============================================================================
# Game Modes
# =============================================================================

class GameMode(Enum):
    NORMAL = "normal"
    TARGETING = "targeting"

# =============================================================================
# Fighter Component with XP
# =============================================================================

@dataclass
class Fighter:
    """Combat stats for an entity with experience system."""
    hp: int
    max_hp: int
    attack: int
    defense: int
    name: str
    is_player: bool = False
    xp: int = 0
    level: int = 1

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

    @property
    def xp_to_next_level(self) -> int:
        """Calculate XP needed to reach the next level."""
        return self.level * 100

    @property
    def xp_progress(self) -> float:
        """Get XP progress as a percentage (0.0 to 1.0)."""
        return self.xp / self.xp_to_next_level if self.xp_to_next_level > 0 else 0

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

    def heal(self, amount: int) -> int:
        actual_heal = min(self.max_hp - self.hp, amount)
        self.hp += actual_heal
        return actual_heal

    def gain_xp(self, amount: int) -> bool:
        """Add XP and check for level up.

        Args:
            amount: XP to add

        Returns:
            True if leveled up, False otherwise
        """
        self.xp += amount

        if self.xp >= self.xp_to_next_level:
            self.level_up()
            return True

        return False

    def level_up(self) -> None:
        """Level up the character, increasing stats."""
        # Subtract XP cost (excess carries over)
        self.xp -= self.xp_to_next_level

        self.level += 1

        # Stat increases
        hp_increase = 5
        attack_increase = 1
        defense_increase = 1 if self.level % 3 == 0 else 0  # Every 3rd level

        self.max_hp += hp_increase
        self.hp = self.max_hp  # Full heal on level up!
        self.attack += attack_increase
        self.defense += defense_increase

    def to_dict(self) -> dict:
        return {
            "hp": self.hp,
            "max_hp": self.max_hp,
            "attack": self.attack,
            "defense": self.defense,
            "name": self.name,
            "is_player": self.is_player,
            "xp": self.xp,
            "level": self.level
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Fighter":
        return cls(
            hp=data["hp"],
            max_hp=data["max_hp"],
            attack=data["attack"],
            defense=data["defense"],
            name=data["name"],
            is_player=data.get("is_player", False),
            xp=data.get("xp", 0),
            level=data.get("level", 1)
        )

# =============================================================================
# Item Component
# =============================================================================

@dataclass
class Item:
    """Data for an item that can be picked up and used."""
    name: str
    item_type: str
    heal_amount: int = 0

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "item_type": self.item_type,
            "heal_amount": self.heal_amount
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Item":
        return cls(
            name=data["name"],
            item_type=data["item_type"],
            heal_amount=data.get("heal_amount", 0)
        )

# =============================================================================
# Inventory System
# =============================================================================

@dataclass
class Inventory:
    """Container for items the player is carrying."""
    capacity: int = 10
    items: list = field(default_factory=list)

    def add(self, item: Item) -> bool:
        if len(self.items) >= self.capacity:
            return False
        self.items.append(item)
        return True

    def remove(self, index: int) -> Optional[Item]:
        if 0 <= index < len(self.items):
            return self.items.pop(index)
        return None

    def get(self, index: int) -> Optional[Item]:
        if 0 <= index < len(self.items):
            return self.items[index]
        return None

    def is_full(self) -> bool:
        return len(self.items) >= self.capacity

    def count(self) -> int:
        return len(self.items)

    def to_dict(self) -> dict:
        return {
            "capacity": self.capacity,
            "items": [item.to_dict() for item in self.items]
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Inventory":
        inv = cls(capacity=data.get("capacity", 10))
        inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])]
        return inv

# =============================================================================
# Templates
# =============================================================================

ITEM_TEMPLATES = {
    "health_potion": {
        "name": "Health Potion",
        "sprite": SPRITE_POTION,
        "item_type": "health_potion",
        "heal_amount": 10
    },
    "greater_health_potion": {
        "name": "Greater Health Potion",
        "sprite": SPRITE_POTION,
        "item_type": "health_potion",
        "heal_amount": 20
    }
}

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
    }
}

# =============================================================================
# Difficulty Scaling
# =============================================================================

def get_max_enemies_per_room(level: int) -> int:
    return min(2 + level, 6)

def get_max_items_per_room(level: int) -> int:
    return min(1 + level // 2, 3)

def get_enemy_weights(level: int) -> list[tuple[str, float]]:
    if level <= 2:
        return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)]
    elif level <= 4:
        return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)]
    else:
        return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)]

def get_item_weights(level: int) -> list[tuple[str, float]]:
    if level <= 2:
        return [("health_potion", 1.0)]
    else:
        return [("health_potion", 0.7), ("greater_health_potion", 1.0)]

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

class MessageLog:
    """A message log that displays recent game messages with colors."""

    def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6):
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.max_messages = max_messages
        self.messages: list[tuple[str, mcrfpy.Color]] = []
        self.captions: list[mcrfpy.Caption] = []

        self.frame = mcrfpy.Frame(
            pos=(x, y),
            size=(width, height)
        )
        self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200)
        self.frame.outline = 2
        self.frame.outline_color = mcrfpy.Color(80, 80, 100)

        line_height = 20
        for i in range(max_messages):
            caption = mcrfpy.Caption(
                pos=(x + 10, y + 5 + i * line_height),
                text=""
            )
            caption.font_size = 14
            caption.fill_color = mcrfpy.Color(200, 200, 200)
            self.captions.append(caption)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.frame)
        for caption in self.captions:
            scene.children.append(caption)

    def add(self, text: str, color: mcrfpy.Color = None) -> None:
        if color is None:
            color = mcrfpy.Color(200, 200, 200)

        self.messages.append((text, color))

        while len(self.messages) > self.max_messages:
            self.messages.pop(0)

        self._refresh()

    def _refresh(self) -> None:
        for i, caption in enumerate(self.captions):
            if i < len(self.messages):
                text, color = self.messages[i]
                caption.text = text
                caption.fill_color = color
            else:
                caption.text = ""

    def clear(self) -> None:
        self.messages.clear()
        self._refresh()

# =============================================================================
# Health Bar System
# =============================================================================

class HealthBar:
    """A visual health bar using nested frames."""

    def __init__(self, x: int, y: int, width: int, height: int):
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.max_hp = 30
        self.current_hp = 30

        self.bg_frame = mcrfpy.Frame(
            pos=(x, y),
            size=(width, height)
        )
        self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0)
        self.bg_frame.outline = 2
        self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150)

        self.fg_frame = mcrfpy.Frame(
            pos=(x + 2, y + 2),
            size=(width - 4, height - 4)
        )
        self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0)
        self.fg_frame.outline = 0

        self.label = mcrfpy.Caption(
            pos=(x + 5, y + 2),
            text=f"HP: {self.current_hp}/{self.max_hp}"
        )
        self.label.font_size = 16
        self.label.fill_color = mcrfpy.Color(255, 255, 255)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.bg_frame)
        scene.children.append(self.fg_frame)
        scene.children.append(self.label)

    def update(self, current_hp: int, max_hp: int) -> None:
        self.current_hp = current_hp
        self.max_hp = max_hp

        percent = max(0, current_hp / max_hp) if max_hp > 0 else 0

        inner_width = self.width - 4
        self.fg_frame.resize(int(inner_width * percent), self.height - 4)

        self.label.text = f"HP: {current_hp}/{max_hp}"

        if percent > 0.6:
            self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0)
        elif percent > 0.3:
            self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0)
        else:
            self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0)

# =============================================================================
# XP Bar System
# =============================================================================

class XPBar:
    """A visual XP bar showing progress to next level."""

    def __init__(self, x: int, y: int, width: int, height: int):
        self.x = x
        self.y = y
        self.width = width
        self.height = height

        self.bg_frame = mcrfpy.Frame(
            pos=(x, y),
            size=(width, height)
        )
        self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80)
        self.bg_frame.outline = 2
        self.bg_frame.outline_color = mcrfpy.Color(100, 100, 150)

        self.fg_frame = mcrfpy.Frame(
            pos=(x + 2, y + 2),
            size=(0, height - 4)
        )
        self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50)
        self.fg_frame.outline = 0

        self.label = mcrfpy.Caption(
            pos=(x + 5, y + 1),
            text="Level 1 | XP: 0/100"
        )
        self.label.font_size = 14
        self.label.fill_color = mcrfpy.Color(255, 255, 255)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.bg_frame)
        scene.children.append(self.fg_frame)
        scene.children.append(self.label)

    def update(self, level: int, xp: int, xp_to_next: int) -> None:
        """Update the XP bar display.

        Args:
            level: Current player level
            xp: Current XP
            xp_to_next: XP needed to reach next level
        """
        percent = xp / xp_to_next if xp_to_next > 0 else 0
        percent = min(1.0, max(0.0, percent))

        inner_width = self.width - 4
        self.fg_frame.resize(int(inner_width * percent), self.height - 4)

        self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}"

# =============================================================================
# Inventory Panel
# =============================================================================

class InventoryPanel:
    """A panel displaying the player's inventory."""

    def __init__(self, x: int, y: int, width: int, height: int):
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.captions: list[mcrfpy.Caption] = []

        self.frame = mcrfpy.Frame(
            pos=(x, y),
            size=(width, height)
        )
        self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200)
        self.frame.outline = 2
        self.frame.outline_color = mcrfpy.Color(80, 80, 100)

        self.title = mcrfpy.Caption(
            pos=(x + 10, y + 5),
            text="Inventory (G:pickup, 1-5:use)"
        )
        self.title.font_size = 14
        self.title.fill_color = mcrfpy.Color(200, 200, 255)

        for i in range(5):
            caption = mcrfpy.Caption(
                pos=(x + 10, y + 25 + i * 18),
                text=""
            )
            caption.font_size = 13
            caption.fill_color = mcrfpy.Color(180, 180, 180)
            self.captions.append(caption)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.frame)
        scene.children.append(self.title)
        for caption in self.captions:
            scene.children.append(caption)

    def update(self, inventory: Inventory) -> None:
        for i, caption in enumerate(self.captions):
            if i < len(inventory.items):
                item = inventory.items[i]
                caption.text = f"{i+1}. {item.name}"
                caption.fill_color = mcrfpy.Color(180, 180, 180)
            else:
                caption.text = f"{i+1}. ---"
                caption.fill_color = mcrfpy.Color(80, 80, 80)

# =============================================================================
# Stats Panel
# =============================================================================

class StatsPanel:
    """A panel displaying player stats."""

    def __init__(self, x: int, y: int, width: int, height: int):
        self.x = x
        self.y = y
        self.width = width
        self.height = height

        self.frame = mcrfpy.Frame(
            pos=(x, y),
            size=(width, height)
        )
        self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200)
        self.frame.outline = 2
        self.frame.outline_color = mcrfpy.Color(80, 80, 100)

        self.title = mcrfpy.Caption(
            pos=(x + 10, y + 5),
            text="Stats"
        )
        self.title.font_size = 14
        self.title.fill_color = mcrfpy.Color(200, 200, 255)

        self.attack_label = mcrfpy.Caption(
            pos=(x + 10, y + 25),
            text="Attack: 5"
        )
        self.attack_label.font_size = 13
        self.attack_label.fill_color = mcrfpy.Color(255, 150, 150)

        self.defense_label = mcrfpy.Caption(
            pos=(x + 10, y + 43),
            text="Defense: 2"
        )
        self.defense_label.font_size = 13
        self.defense_label.fill_color = mcrfpy.Color(150, 150, 255)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.frame)
        scene.children.append(self.title)
        scene.children.append(self.attack_label)
        scene.children.append(self.defense_label)

    def update(self, attack: int, defense: int) -> None:
        self.attack_label.text = f"Attack: {attack}"
        self.defense_label.text = f"Defense: {defense}"

# =============================================================================
# Level Display
# =============================================================================

class LevelDisplay:
    """Displays current dungeon level."""

    def __init__(self, x: int, y: int):
        self.caption = mcrfpy.Caption(
            pos=(x, y),
            text="Dungeon Level: 1"
        )
        self.caption.font_size = 16
        self.caption.fill_color = mcrfpy.Color(200, 200, 255)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.caption)

    def update(self, level: int) -> None:
        self.caption.text = f"Dungeon: {level}"

# =============================================================================
# Mode Display
# =============================================================================

class ModeDisplay:
    """Displays the current game mode."""

    def __init__(self, x: int, y: int):
        self.caption = mcrfpy.Caption(
            pos=(x, y),
            text="[NORMAL MODE]"
        )
        self.caption.font_size = 16
        self.caption.fill_color = mcrfpy.Color(100, 255, 100)

    def add_to_scene(self, scene: mcrfpy.Scene) -> None:
        scene.children.append(self.caption)

    def update(self, mode: GameMode) -> None:
        if mode == GameMode.NORMAL:
            self.caption.text = "[NORMAL] F:Ranged | >:Descend"
            self.caption.fill_color = mcrfpy.Color(100, 255, 100)
        elif mode == GameMode.TARGETING:
            self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel"
            self.caption.fill_color = mcrfpy.Color(255, 255, 100)

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

entity_data: dict[mcrfpy.Entity, Fighter] = {}
item_data: dict[mcrfpy.Entity, Item] = {}

player: Optional[mcrfpy.Entity] = None
player_inventory: Optional[Inventory] = None
grid: Optional[mcrfpy.Grid] = None
fov_layer = None
texture: Optional[mcrfpy.Texture] = None
game_over: bool = False
dungeon_level: int = 1
stairs_position: tuple[int, int] = (0, 0)

game_mode: GameMode = GameMode.NORMAL
target_cursor: Optional[mcrfpy.Entity] = None
target_x: int = 0
target_y: int = 0

# UI components
message_log: Optional[MessageLog] = None
health_bar: Optional[HealthBar] = None
xp_bar: Optional[XPBar] = None
inventory_panel: Optional[InventoryPanel] = None
stats_panel: Optional[StatsPanel] = None
mode_display: Optional[ModeDisplay] = None
level_display: Optional[LevelDisplay] = None

# =============================================================================
# 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:
    global explored
    explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]

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

def is_explored(x: int, y: int) -> bool:
    if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
        return explored[y][x]
    return False

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

def fill_with_walls(target_grid: mcrfpy.Grid) -> None:
    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:
    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:
    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:
    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:
    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)

def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None:
    global stairs_position
    cell = target_grid.at(x, y)
    cell.tilesprite = SPRITE_STAIRS_DOWN
    cell.walkable = True
    cell.transparent = True
    stairs_position = (x, y)

def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]:
    global stairs_position
    fill_with_walls(target_grid)

    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(target_grid, new_room)

        if rooms:
            carve_l_tunnel(target_grid, new_room.center, rooms[-1].center)

        rooms.append(new_room)

    if rooms:
        stairs_x, stairs_y = rooms[-1].center
        place_stairs(target_grid, stairs_x, stairs_y)

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

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

def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity:
    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)

    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_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity:
    template = ITEM_TEMPLATES[item_type]

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

    target_grid.entities.append(item_entity)

    item_data[item_entity] = Item(
        name=template["name"],
        item_type=template["item_type"],
        heal_amount=template.get("heal_amount", 0)
    )

    return item_entity

def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None:
    floor_tiles = []
    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            cell = target_grid.at(x, y)
            if cell.walkable and cell.tilesprite == SPRITE_FLOOR:
                floor_tiles.append((x, y))

    max_enemies = get_max_enemies_per_room(level) * 3
    enemy_weights = get_enemy_weights(level)

    for _ in range(max_enemies):
        if not floor_tiles:
            break

        x, y = random.choice(floor_tiles)

        if (x, y) == (int(player.grid_x), int(player.grid_y)):
            continue
        if (x, y) == stairs_position:
            continue
        if is_position_occupied(target_grid, x, y):
            continue

        roll = random.random()
        enemy_type = "goblin"
        for etype, threshold in enemy_weights:
            if roll < threshold:
                enemy_type = etype
                break

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

    max_items = get_max_items_per_room(level) * 2
    item_weights = get_item_weights(level)

    for _ in range(max_items):
        if not floor_tiles:
            break

        x, y = random.choice(floor_tiles)

        if (x, y) == (int(player.grid_x), int(player.grid_y)):
            continue
        if (x, y) == stairs_position:
            continue
        if is_position_occupied(target_grid, x, y):
            continue

        roll = random.random()
        item_type = "health_potion"
        for itype, threshold in item_weights:
            if roll < threshold:
                item_type = itype
                break

        spawn_item(target_grid, x, y, item_type, tex)

def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool:
    for entity in target_grid.entities:
        if int(entity.grid_x) == x and int(entity.grid_y) == y:
            return True
    return False

def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]:
    for entity in target_grid.entities:
        if entity in item_data:
            if int(entity.grid_x) == x and int(entity.grid_y) == y:
                return entity
    return None

def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]:
    for entity in target_grid.entities:
        if entity == exclude:
            continue
        if int(entity.grid_x) == x and int(entity.grid_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:
    for e in target_grid.entities:
        if e == entity:
            target_grid.entities.remove(e)
            break
    if entity in entity_data:
        del entity_data[entity]

def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
    for e in target_grid.entities:
        if e == entity:
            target_grid.entities.remove(e)
            break
    if entity in item_data:
        del item_data[entity]

def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None:
    global entity_data, item_data

    entities_to_remove = []
    for entity in target_grid.entities:
        if entity in entity_data and entity_data[entity].is_player:
            continue
        entities_to_remove.append(entity)

    for entity in entities_to_remove:
        if entity in entity_data:
            del entity_data[entity]
        if entity in item_data:
            del item_data[entity]

        for e in target_grid.entities:
            if e == entity:
                target_grid.entities.remove(e)
                break

# =============================================================================
# XP and Level Up
# =============================================================================

def award_xp(enemy_name: str) -> None:
    """Award XP for killing an enemy.

    Args:
        enemy_name: Name of the defeated enemy
    """
    global player

    fighter = entity_data.get(player)
    if fighter is None:
        return

    enemy_type = enemy_name.lower()
    xp_amount = ENEMY_XP_VALUES.get(enemy_type, 35)

    leveled_up = fighter.gain_xp(xp_amount)

    if leveled_up:
        message_log.add(
            f"You gained {xp_amount} XP and reached level {fighter.level}!",
            COLOR_LEVEL_UP
        )
        message_log.add(
            f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!",
            COLOR_LEVEL_UP
        )
    else:
        message_log.add(f"You gain {xp_amount} XP.", COLOR_XP)

    update_ui()

# =============================================================================
# Level Transition
# =============================================================================

def descend_stairs() -> bool:
    global player, dungeon_level, grid, fov_layer, stairs_position

    px, py = int(player.grid_x), int(player.grid_y)

    if (px, py) != stairs_position:
        message_log.add("There are no stairs here.", COLOR_INVALID)
        return False

    dungeon_level += 1

    clear_entities_except_player(grid)

    init_explored()
    player_start = generate_dungeon(grid, dungeon_level)

    player.grid_pos = player_start

    spawn_entities_for_level(grid, texture, dungeon_level)

    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            fov_layer.set((x, y), COLOR_UNKNOWN)

    update_fov(grid, fov_layer, player_start[0], player_start[1])

    message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND)
    level_display.update(dungeon_level)
    update_ui()

    return True

# =============================================================================
# Save/Load System
# =============================================================================

def save_game() -> bool:
    global player, player_inventory, grid, explored, dungeon_level, stairs_position

    try:
        tiles = []
        for y in range(GRID_HEIGHT):
            row = []
            for x in range(GRID_WIDTH):
                cell = grid.at(x, y)
                row.append({
                    "tilesprite": cell.tilesprite,
                    "walkable": cell.walkable,
                    "transparent": cell.transparent
                })
            tiles.append(row)

        enemies = []
        for entity in grid.entities:
            if entity == player:
                continue
            if entity in entity_data:
                fighter = entity_data[entity]
                enemies.append({
                    "x": int(entity.grid_x),
                    "y": int(entity.grid_y),
                    "type": fighter.name.lower(),
                    "fighter": fighter.to_dict()
                })

        items_on_ground = []
        for entity in grid.entities:
            if entity in item_data:
                item = item_data[entity]
                items_on_ground.append({
                    "x": int(entity.grid_x),
                    "y": int(entity.grid_y),
                    "item": item.to_dict()
                })

        save_data = {
            "version": 3,
            "dungeon_level": dungeon_level,
            "stairs_position": list(stairs_position),
            "player": {
                "x": int(player.grid_x),
                "y": int(player.grid_y),
                "fighter": entity_data[player].to_dict(),
                "inventory": player_inventory.to_dict()
            },
            "tiles": tiles,
            "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)],
            "enemies": enemies,
            "items": items_on_ground
        }

        with open(SAVE_FILE, "w") as f:
            json.dump(save_data, f, indent=2)

        message_log.add("Game saved successfully!", COLOR_SAVE)
        return True

    except Exception as e:
        message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID)
        return False

def load_game() -> bool:
    global player, player_inventory, grid, explored, dungeon_level
    global entity_data, item_data, fov_layer, game_over, stairs_position

    if not os.path.exists(SAVE_FILE):
        return False

    try:
        with open(SAVE_FILE, "r") as f:
            save_data = json.load(f)

        entity_data.clear()
        item_data.clear()

        while len(grid.entities) > 0:
            grid.entities.pop()

        dungeon_level = save_data.get("dungeon_level", 1)
        stairs_position = tuple(save_data.get("stairs_position", [0, 0]))

        tiles = save_data["tiles"]
        for y in range(GRID_HEIGHT):
            for x in range(GRID_WIDTH):
                cell = grid.at(x, y)
                tile_data = tiles[y][x]
                cell.tilesprite = tile_data["tilesprite"]
                cell.walkable = tile_data["walkable"]
                cell.transparent = tile_data["transparent"]

        global explored
        explored_data = save_data["explored"]
        explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)]

        player_data = save_data["player"]
        player = mcrfpy.Entity(
            grid_pos=(player_data["x"], player_data["y"]),
            texture=texture,
            sprite_index=SPRITE_PLAYER
        )
        grid.entities.append(player)

        entity_data[player] = Fighter.from_dict(player_data["fighter"])
        player_inventory = Inventory.from_dict(player_data["inventory"])

        for enemy_data in save_data.get("enemies", []):
            enemy_type = enemy_data["type"]
            template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"])

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

            grid.entities.append(enemy)
            entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"])

        for item_entry in save_data.get("items", []):
            item_type = item_entry["item"]["item_type"]
            template = ITEM_TEMPLATES.get(item_type, ITEM_TEMPLATES["health_potion"])

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

            grid.entities.append(item_entity)
            item_data[item_entity] = Item.from_dict(item_entry["item"])

        for y in range(GRID_HEIGHT):
            for x in range(GRID_WIDTH):
                fov_layer.set((x, y), COLOR_UNKNOWN)

        update_fov(grid, fov_layer, int(player.grid_x), int(player.grid_y))

        game_over = False

        message_log.add("Game loaded successfully!", COLOR_SAVE)
        level_display.update(dungeon_level)
        update_ui()
        return True

    except Exception as e:
        message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID)
        return False

def delete_save() -> bool:
    try:
        if os.path.exists(SAVE_FILE):
            os.remove(SAVE_FILE)
        return True
    except Exception:
        return False

def has_save_file() -> bool:
    return os.path.exists(SAVE_FILE)

# =============================================================================
# Targeting System
# =============================================================================

def enter_targeting_mode() -> None:
    global game_mode, target_cursor, target_x, target_y, player, grid, texture

    target_x = int(player.grid_x)
    target_y = int(player.grid_y)

    target_cursor = mcrfpy.Entity(
        grid_pos=(target_x, target_y),
        texture=texture,
        sprite_index=SPRITE_CURSOR
    )
    grid.entities.append(target_cursor)

    game_mode = GameMode.TARGETING

    message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO)
    mode_display.update(game_mode)

def exit_targeting_mode() -> None:
    global game_mode, target_cursor, grid

    if target_cursor is not None:
        for e in grid.entities:
            if e == target_cursor:
                grid.entities.remove(e)
                break
        target_cursor = None

    game_mode = GameMode.NORMAL
    mode_display.update(game_mode)

def move_cursor(dx: int, dy: int) -> None:
    global target_x, target_y, target_cursor, grid, player

    new_x = target_x + dx
    new_y = target_y + dy

    if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
        return

    if not grid.is_in_fov(new_x, new_y):
        message_log.add("You cannot see that location.", COLOR_INVALID)
        return

    player_x, player_y = int(player.grid_x), int(player.grid_y)
    distance = abs(new_x - player_x) + abs(new_y - player_y)
    if distance > RANGED_ATTACK_RANGE:
        message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING)
        return

    target_x = new_x
    target_y = new_y
    target_cursor.x = target_x
    target_cursor.y = target_y

    enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player)
    if enemy and enemy in entity_data:
        fighter = entity_data[enemy]
        message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED)

def confirm_target() -> None:
    global game_mode, target_x, target_y, player, grid

    if target_x == int(player.grid_x) and target_y == int(player.grid_y):
        message_log.add("You cannot target yourself!", COLOR_INVALID)
        return

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

    if target_enemy is None or target_enemy not in entity_data:
        message_log.add("No valid target at that location.", COLOR_INVALID)
        return

    perform_ranged_attack(target_enemy)
    exit_targeting_mode()
    enemy_turn()
    update_ui()

def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None:
    global player, game_over

    defender = entity_data.get(target_entity)
    attacker = entity_data.get(player)

    if defender is None or attacker is None:
        return

    damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2)

    defender.take_damage(damage)

    message_log.add(
        f"Your ranged attack hits the {defender.name} for {damage} damage!",
        COLOR_RANGED
    )

    if not defender.is_alive:
        handle_death(target_entity, defender)

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

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

def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None:
    global game_over

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

    if attacker is None or defender is None:
        return

    damage = calculate_damage(attacker, defender)
    defender.take_damage(damage)

    if damage > 0:
        if attacker.is_player:
            message_log.add(
                f"You hit the {defender.name} for {damage} damage!",
                COLOR_PLAYER_ATTACK
            )
        else:
            message_log.add(
                f"The {attacker.name} hits you for {damage} damage!",
                COLOR_ENEMY_ATTACK
            )
    else:
        if attacker.is_player:
            message_log.add(
                f"You hit the {defender.name} but deal no damage.",
                mcrfpy.Color(150, 150, 150)
            )
        else:
            message_log.add(
                f"The {attacker.name} hits but deals no damage.",
                mcrfpy.Color(150, 150, 200)
            )

    if not defender.is_alive:
        handle_death(defender_entity, defender)

    update_ui()

def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None:
    global game_over, grid

    if fighter.is_player:
        message_log.add("You have died!", COLOR_PLAYER_DEATH)
        message_log.add("Press R to restart or Escape to quit.", COLOR_INFO)
        game_over = True
        entity.sprite_index = SPRITE_CORPSE
        delete_save()
    else:
        message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH)
        # Award XP before removing the entity
        award_xp(fighter.name)
        remove_entity(grid, entity)

# =============================================================================
# Item Actions
# =============================================================================

def pickup_item() -> bool:
    global player, player_inventory, grid

    px, py = int(player.grid_x), int(player.grid_y)
    item_entity = get_item_at(grid, px, py)

    if item_entity is None:
        message_log.add("There is nothing to pick up here.", COLOR_INVALID)
        return False

    if player_inventory.is_full():
        message_log.add("Your inventory is full!", COLOR_WARNING)
        return False

    item = item_data.get(item_entity)
    if item is None:
        return False

    player_inventory.add(item)
    remove_item_entity(grid, item_entity)

    message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP)

    update_ui()
    return True

def use_item(index: int) -> bool:
    global player, player_inventory

    item = player_inventory.get(index)
    if item is None:
        message_log.add("Invalid item selection.", COLOR_INVALID)
        return False

    if item.item_type == "health_potion":
        fighter = entity_data.get(player)
        if fighter is None:
            return False

        if fighter.hp >= fighter.max_hp:
            message_log.add("You are already at full health!", COLOR_WARNING)
            return False

        actual_heal = fighter.heal(item.heal_amount)
        player_inventory.remove(index)

        message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL)

        update_ui()
        return True

    message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID)
    return False

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

def update_entity_visibility(target_grid: mcrfpy.Grid) -> None:
    global player, target_cursor

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

        if entity == target_cursor:
            entity.visible = True
            continue

        ex, ey = int(entity.grid_x), int(entity.grid_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:
    target_grid.compute_fov((player_x, player_y), radius=FOV_RADIUS, algorithm=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:
    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:
    global player, grid, fov_layer, game_over

    if game_over:
        return

    px, py = int(player.grid_x), int(player.grid_y)
    new_target_x = px + dx
    new_target_y = py + dy

    if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT:
        return

    blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player)

    if blocker is not None:
        perform_attack(player, blocker)
        enemy_turn()
    elif grid.at(new_target_x, new_target_y).walkable:
        player.grid_x = new_target_x
        player.grid_y = new_target_y
        update_fov(grid, fov_layer, new_target_x, new_target_y)
        enemy_turn()

    update_ui()

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

def enemy_turn() -> None:
    global player, grid, game_over

    if game_over:
        return

    player_x, player_y = int(player.grid_x), int(player.grid_y)

    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)

        if not grid.is_in_fov(ex, ey):
            continue

        dx = player_x - ex
        dy = player_y - ey

        if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0):
            perform_attack(enemy, player)
        else:
            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:
    global grid

    dx = 0
    dy = 0

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

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

    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):
        enemy.x = ex + dx
    elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy):
        enemy.y = ey + dy

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

def update_ui() -> None:
    global player, health_bar, xp_bar, inventory_panel, stats_panel, player_inventory

    if player in entity_data:
        fighter = entity_data[player]
        health_bar.update(fighter.hp, fighter.max_hp)
        xp_bar.update(fighter.level, fighter.xp, fighter.xp_to_next_level)
        stats_panel.update(fighter.attack, fighter.defense)

    if player_inventory:
        inventory_panel.update(player_inventory)

# =============================================================================
# New Game Generation
# =============================================================================

def generate_new_game() -> None:
    global player, player_inventory, grid, fov_layer, game_over
    global entity_data, item_data, dungeon_level, game_mode

    game_over = False
    game_mode = GameMode.NORMAL
    dungeon_level = 1

    entity_data.clear()
    item_data.clear()

    while len(grid.entities) > 0:
        grid.entities.pop()

    init_explored()
    message_log.clear()

    player_start = generate_dungeon(grid, dungeon_level)

    player = mcrfpy.Entity(
        grid_pos=(player_start[0], player_start[1]),
        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,
        xp=0,
        level=1
    )

    player_inventory = Inventory(capacity=10)

    spawn_entities_for_level(grid, texture, dungeon_level)

    for y in range(GRID_HEIGHT):
        for x in range(GRID_WIDTH):
            fov_layer.set((x, y), COLOR_UNKNOWN)

    update_fov(grid, fov_layer, player_start[0], player_start[1])

    mode_display.update(game_mode)
    level_display.update(dungeon_level)
    update_ui()

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

scene = mcrfpy.Scene("game")
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)

grid = mcrfpy.Grid(
    pos=(20, GAME_AREA_Y),
    size=(700, GAME_AREA_HEIGHT - 20),
    grid_size=(GRID_WIDTH, GRID_HEIGHT),
    texture=texture,
    zoom=1.0
)

fov_layer = mcrfpy.ColorLayer(z_index=-1)
grid.add_layer(fov_layer)
for y in range(GRID_HEIGHT):
    for x in range(GRID_WIDTH):
        fov_layer.set((x, y), COLOR_UNKNOWN)

scene.children.append(grid)

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

title = mcrfpy.Caption(
    pos=(20, 10),
    text="Part 12: Experience System"
)
title.fill_color = mcrfpy.Color(255, 255, 255)
title.font_size = 24
scene.children.append(title)

instructions = mcrfpy.Caption(
    pos=(300, 15),
    text="WASD:Move | >:Descend | F:Ranged | G:Pickup"
)
instructions.fill_color = mcrfpy.Color(180, 180, 180)
instructions.font_size = 14
scene.children.append(instructions)

health_bar = HealthBar(x=730, y=10, width=280, height=25)
health_bar.add_to_scene(scene)

xp_bar = XPBar(x=730, y=40, width=280, height=20)
xp_bar.add_to_scene(scene)

level_display = LevelDisplay(x=860, y=65)
level_display.add_to_scene(scene)

mode_display = ModeDisplay(x=20, y=40)
mode_display.add_to_scene(scene)

stats_panel = StatsPanel(x=730, y=GAME_AREA_Y, width=140, height=70)
stats_panel.add_to_scene(scene)

inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y + 75, width=280, height=120)
inventory_panel.add_to_scene(scene)

message_log = MessageLog(
    x=20,
    y=768 - UI_BOTTOM_HEIGHT + 10,
    width=990,
    height=UI_BOTTOM_HEIGHT - 20,
    max_messages=6
)
message_log.add_to_scene(scene)

# =============================================================================
# Initialize Game
# =============================================================================

init_explored()

if has_save_file():
    message_log.add("Found saved game. Loading...", COLOR_INFO)
    if not load_game():
        message_log.add("Failed to load. Starting new game.", COLOR_WARNING)
        generate_new_game()
        message_log.add("Welcome to the dungeon!", COLOR_INFO)
else:
    generate_new_game()
    message_log.add("Welcome to the dungeon!", COLOR_INFO)
    message_log.add("Kill enemies to gain XP and level up!", COLOR_INFO)

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

def handle_keys(key: mcrfpy.Key, action: mcrfpy.InputState) -> None:
    global game_over, game_mode

    if action != mcrfpy.InputState.PRESSED:
        return

    if key == mcrfpy.Key.R:
        delete_save()
        generate_new_game()
        message_log.add("A new adventure begins!", COLOR_INFO)
        return

    if key == mcrfpy.Key.Escape:
        if game_mode == GameMode.TARGETING:
            exit_targeting_mode()
            message_log.add("Targeting cancelled.", COLOR_INFO)
            return
        else:
            if not game_over:
                save_game()
            mcrfpy.exit()
            return

    if key == mcrfpy.Key.Period and game_mode == GameMode.NORMAL and not game_over:
        # Check for shift to descend
        descend_stairs()
        return

    if game_over:
        return

    if game_mode == GameMode.TARGETING:
        handle_targeting_input(key)
    else:
        handle_normal_input(key)

ITEM_KEYS = {
    mcrfpy.Key.Num1: 0,
    mcrfpy.Key.Num2: 1,
    mcrfpy.Key.Num3: 2,
    mcrfpy.Key.Num4: 3,
    mcrfpy.Key.Num5: 4,
}

def handle_normal_input(key: mcrfpy.Key) -> None:
    if key == mcrfpy.Key.W or key == mcrfpy.Key.Up:
        try_move_or_attack(0, -1)
    elif key == mcrfpy.Key.S or key == mcrfpy.Key.Down:
        try_move_or_attack(0, 1)
    elif key == mcrfpy.Key.A or key == mcrfpy.Key.Left:
        try_move_or_attack(-1, 0)
    elif key == mcrfpy.Key.D or key == mcrfpy.Key.Right:
        try_move_or_attack(1, 0)
    elif key == mcrfpy.Key.F:
        enter_targeting_mode()
    elif key == mcrfpy.Key.G or key == mcrfpy.Key.Comma:
        pickup_item()
    elif key in ITEM_KEYS:
        index = ITEM_KEYS[key]
        if use_item(index):
            enemy_turn()
            update_ui()

def handle_targeting_input(key: mcrfpy.Key) -> None:
    if key == mcrfpy.Key.Up or key == mcrfpy.Key.W:
        move_cursor(0, -1)
    elif key == mcrfpy.Key.Down or key == mcrfpy.Key.S:
        move_cursor(0, 1)
    elif key == mcrfpy.Key.Left or key == mcrfpy.Key.A:
        move_cursor(-1, 0)
    elif key == mcrfpy.Key.Right or key == mcrfpy.Key.D:
        move_cursor(1, 0)
    elif key == mcrfpy.Key.Enter or key == mcrfpy.Key.Space:
        confirm_target()

scene.on_key = handle_keys

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

scene.activate()
print("Part 12 loaded! Kill enemies to gain XP and level up!")

Understanding the Code

Fighter with XP Fields

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

    @property
    def xp_to_next_level(self) -> int:
        """Calculate XP needed to reach the next level."""
        return self.level * 100

    @property
    def xp_progress(self) -> float:
        """Get XP progress as a percentage (0.0 to 1.0)."""
        return self.xp / self.xp_to_next_level

The Fighter class now tracks:

  • xp: Current experience points
  • level: Character level (starts at 1)
  • xp_to_next_level: Computed threshold for next level

Gaining XP and Leveling Up

def gain_xp(self, amount: int) -> bool:
    """Add XP and check for level up."""
    self.xp += amount

    if self.xp >= self.xp_to_next_level:
        self.level_up()
        return True

    return False

def level_up(self) -> None:
    """Level up the character, increasing stats."""
    # Subtract XP cost (excess carries over)
    self.xp -= self.xp_to_next_level

    self.level += 1

    # Stat increases
    hp_increase = 5
    attack_increase = 1
    defense_increase = 1 if self.level % 3 == 0 else 0

    self.max_hp += hp_increase
    self.hp = self.max_hp  # Full heal on level up!
    self.attack += attack_increase
    self.defense += defense_increase

Level up mechanics:

  • XP carries over (if you need 100 XP and have 120, you keep 20)
  • HP increases by 5 and fully heals
  • Attack increases by 1 every level
  • Defense increases by 1 every 3rd level

Enemy XP Values

ENEMY_XP_VALUES = {
    "goblin": 35,
    "orc": 50,
    "troll": 100
}

Stronger enemies give more XP, rewarding the player for taking on tougher fights.

Awarding XP on Kill

def award_xp(enemy_name: str) -> None:
    """Award XP for killing an enemy."""
    fighter = entity_data.get(player)
    if fighter is None:
        return

    enemy_type = enemy_name.lower()
    xp_amount = ENEMY_XP_VALUES.get(enemy_type, 35)

    leveled_up = fighter.gain_xp(xp_amount)

    if leveled_up:
        message_log.add(
            f"You gained {xp_amount} XP and reached level {fighter.level}!",
            COLOR_LEVEL_UP
        )
        message_log.add(
            f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!",
            COLOR_LEVEL_UP
        )
    else:
        message_log.add(f"You gain {xp_amount} XP.", COLOR_XP)

def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None:
    if fighter.is_player:
        # Player death handling...
    else:
        message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH)
        # Award XP before removing the entity
        award_xp(fighter.name)
        remove_entity(grid, entity)

XP is awarded when an enemy dies, before removing them from the game.

XP Bar UI

class XPBar:
    def __init__(self, x: int, y: int, width: int, height: int):
        self.bg_frame = mcrfpy.Frame(...)
        self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80)

        self.fg_frame = mcrfpy.Frame(...)
        self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50)

        self.label = mcrfpy.Caption(text="Level 1 | XP: 0/100")

    def update(self, level: int, xp: int, xp_to_next: int) -> None:
        percent = xp / xp_to_next if xp_to_next > 0 else 0
        percent = min(1.0, max(0.0, percent))

        inner_width = self.width - 4
        self.fg_frame.resize(int(inner_width * percent), self.height - 4)

        self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}"

The XP bar shows:

  • Current player level
  • XP progress toward next level
  • Visual bar that fills as you gain XP

Stats Panel

class StatsPanel:
    def __init__(self, x: int, y: int, width: int, height: int):
        self.attack_label = mcrfpy.Caption(text="Attack: 5")
        self.defense_label = mcrfpy.Caption(text="Defense: 2")

    def update(self, attack: int, defense: int) -> None:
        self.attack_label.text = f"Attack: {attack}"
        self.defense_label.text = f"Defense: {defense}"

Shows current attack and defense values, which change as you level up.

Serializing XP Data

def to_dict(self) -> dict:
    return {
        "hp": self.hp,
        "max_hp": self.max_hp,
        "attack": self.attack,
        "defense": self.defense,
        "name": self.name,
        "is_player": self.is_player,
        "xp": self.xp,        # New field
        "level": self.level   # New field
    }

@classmethod
def from_dict(cls, data: dict) -> "Fighter":
    return cls(
        # ... other fields ...
        xp=data.get("xp", 0),      # Default to 0
        level=data.get("level", 1) # Default to 1
    )

XP and level are saved and loaded properly. Old saves without these fields get sensible defaults.

Level Progression Table

Level XP Needed Total XP HP Attack Defense
1 100 0 30 5 2
2 200 100 35 6 2
3 300 300 40 7 3
4 400 600 45 8 3
5 500 1000 50 9 3
6 600 1500 55 10 4

Try This

  1. Exponential XP curve: Make higher levels require more XP:
    @property
    def xp_to_next_level(self) -> int:
        return int(100 * (1.5 ** (self.level - 1)))
    
  2. Stat choice on level up: Let player choose their stat increase:
    class GameMode(Enum):
        NORMAL = "normal"
        TARGETING = "targeting"
        LEVEL_UP = "level_up"  # New mode
    
    def show_level_up_menu():
        game_mode = GameMode.LEVEL_UP
        message_log.add("Level up! Press 1:+HP, 2:+Attack, 3:+Defense")
    
  3. XP from exploration: Award XP for discovering new tiles

  4. Level-based enemy scaling: Enemies get stronger as you level:
    def get_enemy_stats(base_stats, player_level):
        scaling = 1 + (player_level - 1) * 0.1
        return {
            "hp": int(base_stats["hp"] * scaling),
            "attack": int(base_stats["attack"] * scaling)
        }
    
  5. Skills or abilities: Unlock new abilities at certain levels

Challenge: Skill Points

Add a skill point system:

@dataclass
class Fighter:
    # ... existing fields ...
    skill_points: int = 0

    def level_up(self):
        self.level += 1
        self.skill_points += 1  # Get a skill point each level
        # Don't auto-increase stats

def spend_skill_point(stat: str) -> bool:
    fighter = entity_data[player]
    if fighter.skill_points <= 0:
        return False

    fighter.skill_points -= 1

    if stat == "hp":
        fighter.max_hp += 5
        fighter.hp += 5
    elif stat == "attack":
        fighter.attack += 1
    elif stat == "defense":
        fighter.defense += 1

    return True

Common Mistakes

  1. Forgetting to save XP: Update serialization when adding new fields

  2. XP awarded after removal: Award XP before removing the dead entity

  3. Integer division: Use proper math for XP calculations

  4. Not updating UI: Call update_ui() after XP changes

  5. Level 0 bugs: Make sure level starts at 1, not 0

  6. Missing default values: Use .get() with defaults when loading old saves

XP Flow Diagram

Enemy Dies
    |
    v
handle_death()
    |
    +-- Log death message
    |
    +-- award_xp(enemy_name)
    |       |
    |       +-- Get XP value from ENEMY_XP_VALUES
    |       +-- fighter.gain_xp(amount)
    |       |       |
    |       |       +-- Add XP
    |       |       +-- Check threshold
    |       |       +-- If >= threshold: level_up()
    |       |               |
    |       |               +-- Subtract XP cost
    |       |               +-- Increase level
    |       |               +-- Boost stats
    |       |               +-- Full heal
    |       |
    |       +-- Log XP gain (and level up if applicable)
    |
    +-- remove_entity()
    |
    +-- update_ui()

What is Next

In Part 13, we will add equipment - weapons and armor that provide stat bonuses. This is the final part of the tutorial where you will learn:

  • Equipment slots (weapon, armor)
  • Items with stat bonuses
  • Equipping and unequipping items
  • Modified damage calculations

Continue to Part 13: Equipment System


Complete Code Reference

Key additions from Part 11:

  • Fighter.xp/level: New fields for experience tracking
  • Fighter.xp_to_next_level: Property for threshold calculation
  • Fighter.gain_xp(): Method to add XP and check for level up
  • Fighter.level_up(): Method to increase stats on level up
  • ENEMY_XP_VALUES: XP rewards by enemy type
  • award_xp(): Function to grant XP on enemy death
  • XPBar: UI component showing XP progress
  • StatsPanel: UI component showing attack/defense
  • Updated serialization: Save/load XP and level