RPG Systems Implementation (continued)

A central card labelled Character lists shared stats — HP, MP, Level, XP, Attack and Defense. Four system cards surround it, each joined to the centre by a labelled line: Combat (reads ATK/DEF), Inventory and Items (gear boosts stats), Quests and NPCs (grant XP and items), and Leveling (XP raises stats). Every system reads and writes the same character stats.
An RPG can feel like a dozen unrelated features, but they’re really one web of systems around a single character. Combat, inventory, quests, dialogue, and leveling all read and write the same stats — HP, XP, level, attack — and those shared numbers are the common language that lets them feed one another: a quest grants XP, XP raises your level, a higher level wins the next fight, the fight drops loot that boosts stats again. As you read the systems code below, notice how each one keeps circling back to the character’s stats.
        response2.add_action('give_quest', 'main_1')
        
        self.npcs.append({
            'name': 'Village Elder',
            'x': 200,
            'y': 200,
            'dialogue': root
        })
    
    def use_item(self, item: Item):
        """Use consumable item"""
        if item.item_type == ItemType.CONSUMABLE:
            if item.effect == 'heal':
                heal_amount = item.value or 50
                self.character.hp = min(self.character.max_hp,
                                       self.character.hp + heal_amount)
                return True
            elif item.effect == 'mana':
                mana_amount = item.value or 30
                self.character.mp = min(self.character.max_mp,
                                       self.character.mp + mana_amount)
                return True
        return False
    
    def perform_attack(self, attacker: Character, target: Dict) -> int:
        """Perform combat attack"""
        # Base damage calculation
        base_damage = attacker.get_attack_power()
        defense = target.get('defense', 0)
        
        # Random variation
        damage_variance = random.randint(-5, 5)
        damage = max(1, base_damage - defense + damage_variance)
        
        # Critical hit check
        if random.random() * 100 < attacker.get_crit_chance():
            damage *= 2
            print(f"Critical hit! {damage} damage!")
        
        # Apply damage
        target['hp'] -= damage
        
        return damage
    
    def update_quest_progress(self, quest_type: str, amount: int = 1):
        """Update quest progress"""
        for quest in self.quests:
            if quest.completed:
                continue
                
            for i, obj in enumerate(quest.objectives):
                if quest_type in obj['text'].lower():
                    quest.update_progress(i, amount)
                    
                    if quest.is_complete():
                        self.complete_quest(quest)
    
    def complete_quest(self, quest: Quest):
        """Complete a quest and give rewards"""
        if quest.rewards['exp']:
            self.character.gain_experience(quest.rewards['exp'])
        
        if quest.rewards['gold']:
            self.character.gold += quest.rewards['gold']
        
        for item_name in quest.rewards.get('items', []):
            # Create and add reward items
            item = Item(item_name, ItemType.WEAPON, "🎁")
            self.inventory.add_item(item)
        
        print(f"Quest completed: {quest.name}")
    
    def save_game(self, filename: str = "savegame.json"):
        """Save game state"""
        save_data = {
            'character': asdict(self.character),
            'inventory': {
                'slots': [asdict(item) if item else None 
                         for item in self.inventory.slots],
                'equipment': {
                    k: asdict(v) if v else None 
                    for k, v in self.inventory.equipment.items()
                }
            },
            'quests': [
                {
                    'id': q.id,
                    'objectives': q.objectives,
                    'completed': q.completed
                } for q in self.quests
            ]
        }
        
        with open(filename, 'w') as f:
            json.dump(save_data, f, indent=2)
        
        print(f"Game saved to {filename}")
    
    def load_game(self, filename: str = "savegame.json"):
        """Load game state"""
        try:
            with open(filename, 'r') as f:
                save_data = json.load(f)
            
            # Restore character
            char_data = save_data['character']
            self.character = Character(
                char_data['name'],
                CharacterClass(char_data['char_class'])
            )
            for key, value in char_data.items():
                if hasattr(self.character, key):
                    setattr(self.character, key, value)
            
            # Restore inventory
            self.inventory = Inventory()
            for i, item_data in enumerate(save_data['inventory']['slots']):
                if item_data:
                    item = Item(
                        item_data['name'],
                        ItemType(item_data['item_type']),
                        item_data.get('icon', '📦'),
                        item_data.get('quantity', 1)
                    )
                    self.inventory.slots[i] = item
            
            print(f"Game loaded from {filename}")
            
        except FileNotFoundError:
            print(f"Save file {filename} not found")
    
    def render_ui(self):
        """Render game UI"""
        # Character info
        info_text = [
            f"Name: {self.character.name}",
            f"Class: {self.character.char_class.value}",
            f"Level: {self.character.level}",
            f"HP: {int(self.character.hp)}/{self.character.max_hp}",
            f"MP: {int(self.character.mp)}/{self.character.max_mp}",
            f"Gold: {self.character.gold}",
            f"XP: {self.character.experience}/{self.character.exp_to_next}"
        ]
        
        font = pygame.font.Font(None, 24)
        y_offset = 10
        for text in info_text:
            text_surface = font.render(text, True, (255, 255, 255))
            self.screen.blit(text_surface, (10, y_offset))
            y_offset += 25
        
        # Inventory display
        if self.show_inventory:
            self.render_inventory()
        
        # Quest display
        if self.show_quests:
            self.render_quests()
    
    def render_inventory(self):
        """Render inventory UI"""
        # Background
        inv_surface = pygame.Surface((600, 400))
        inv_surface.fill((50, 50, 50))
        inv_surface.set_alpha(240)
        self.screen.blit(inv_surface, (100, 100))
        
        # Title
        font = pygame.font.Font(None, 32)
        title = font.render("Inventory", True, (255, 255, 255))
        self.screen.blit(title, (350, 110))
        
        # Grid
        slot_size = 50
        margin = 5
        cols = 10
        
        for i, item in enumerate(self.inventory.slots):
            row = i // cols
            col = i % cols
            x = 110 + col * (slot_size + margin)
            y = 150 + row * (slot_size + margin)
            
            # Slot background
            pygame.draw.rect(self.screen, (100, 100, 100),
                           (x, y, slot_size, slot_size), 2)
            
            if item:
                # Item icon (simplified)
                font = pygame.font.Font(None, 36)
                icon = font.render(item.icon, True, (255, 255, 255))
                self.screen.blit(icon, (x + 10, y + 10))
                
                # Quantity
                if item.quantity > 1:
                    font = pygame.font.Font(None, 16)
                    qty = font.render(str(item.quantity), True, (255, 255, 0))
                    self.screen.blit(qty, (x + 35, y + 35))
    
    def render_quests(self):
        """Render quest log"""
        # Background
        quest_surface = pygame.Surface((400, 300))
        quest_surface.fill((50, 50, 50))
        quest_surface.set_alpha(240)
        self.screen.blit(quest_surface, (200, 150))
        
        # Title
        font = pygame.font.Font(None, 32)
        title = font.render("Quest Log", True, (255, 255, 255))
        self.screen.blit(title, (350, 160))
        
        # Quests
        y_offset = 200
        for quest in self.quests:
            if quest.completed:
                continue
            
            # Quest name
            font = pygame.font.Font(None, 24)
            name_text = font.render(quest.name, True, (255, 215, 0))
            self.screen.blit(name_text, (220, y_offset))
            y_offset += 25
            
            # Objectives
            font = pygame.font.Font(None, 20)
            for obj in quest.objectives:
                status = "✓" if obj['completed'] else "○"
                obj_text = f"{status} {obj['text']} ({obj['current']}/{obj['required']})"
                text_surface = font.render(obj_text, True, (200, 200, 200))
                self.screen.blit(text_surface, (240, y_offset))
                y_offset += 20
            
            y_offset += 10
    
    def run(self):
        """Main game loop"""
        running = True
        
        while running:
            dt = self.clock.tick(60) / 1000.0  # Delta time in seconds
            
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
                
                elif event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_i:
                        self.show_inventory = not self.show_inventory
                    elif event.key == pygame.K_q:
                        self.show_quests = not self.show_quests
                    elif event.key == pygame.K_c:
                        self.show_character = not self.show_character
                    elif event.key == pygame.K_s and pygame.key.get_mods() & pygame.KMOD_CTRL:
                        self.save_game()
                    elif event.key == pygame.K_l and pygame.key.get_mods() & pygame.KMOD_CTRL:
                        self.load_game()
            
            # Update
            # Regeneration
            if self.character.hp < self.character.max_hp:
                self.character.hp += 0.1 * dt
            if self.character.mp < self.character.max_mp:
                self.character.mp += 0.2 * dt
            
            # Render
            self.screen.fill((34, 139, 34))  # Forest green background
            
            # Draw game world
            # (simplified - would include tile map, entities, etc.)
            
            # Draw UI
            self.render_ui()
            
            pygame.display.flip()
        
        pygame.quit()

# Skill Tree System
class Skill:
    """Individual skill in skill tree"""
    
    def __init__(self, skill_id: str, name: str, max_level: int = 5):
        self.id = skill_id
        self.name = name
        self.level = 0
        self.max_level = max_level
        self.cost = 1
        self.requirements = []
        self.effects = {}
    
    def can_learn(self, character: Character, skill_tree: 'SkillTree') -> bool:
        """Check if skill can be learned"""
        if self.level >= self.max_level:
            return False
        
        if character.skill_points < self.cost:
            return False
        
        # Check requirements
        for req in self.requirements:
            req_skill = skill_tree.get_skill(req['skill_id'])
            if not req_skill or req_skill.level < req['level']:
                return False
        
        return True
    
    def learn(self, character: Character):
        """Learn or upgrade skill"""
        if character.skill_points >= self.cost:
            self.level += 1
            character.skill_points -= self.cost
            
            # Apply effects
            for stat, value in self.effects.items():
                if hasattr(character, stat):
                    current = getattr(character, stat)
                    setattr(character, stat, current + value)
            
            return True
        return False

class SkillTree:
    """Skill tree system"""
    
    def __init__(self):
        self.skills = {}
        self.create_default_skills()
    
    def create_default_skills(self):
        """Create default skill trees"""
        # Warrior skills
        power_strike = Skill('power_strike', 'Power Strike')
        power_strike.effects = {'strength': 2}
        self.skills['power_strike'] = power_strike
        
        iron_skin = Skill('iron_skin', 'Iron Skin')
        iron_skin.effects = {'vitality': 3, 'max_hp': 10}
        self.skills['iron_skin'] = iron_skin
        
        berserker = Skill('berserker', 'Berserker Rage', max_level=3)
        berserker.cost = 2
        berserker.requirements = [{'skill_id': 'power_strike', 'level': 3}]
        berserker.effects = {'strength': 5}
        self.skills['berserker'] = berserker
        
        # Mage skills
        fireball = Skill('fireball', 'Fireball')
        fireball.effects = {'intelligence': 2}
        self.skills['fireball'] = fireball
        
        mana_shield = Skill('mana_shield', 'Mana Shield')
        mana_shield.effects = {'max_mp': 20}
        self.skills['mana_shield'] = mana_shield
        
        teleport = Skill('teleport', 'Teleport', max_level=1)
        teleport.cost = 3
        teleport.requirements = [{'skill_id': 'fireball', 'level': 3}]
        self.skills['teleport'] = teleport
    
    def get_skill(self, skill_id: str) -> Optional[Skill]:
        """Get skill by ID"""
        return self.skills.get(skill_id)
    
    def get_available_skills(self, character: Character) -> List[Skill]:
        """Get skills available to learn"""
        available = []
        for skill in self.skills.values():
            if skill.can_learn(character, self):
                available.append(skill)
        return available

# Crafting System
class Recipe:
    """Crafting recipe"""
    
    def __init__(self, result: Item, ingredients: List[Tuple[str, int]]):
        self.result = result
        self.ingredients = ingredients  # List of (item_name, quantity) tuples
    
    def can_craft(self, inventory: Inventory) -> bool:
        """Check if recipe can be crafted"""
        for ingredient_name, required_qty in self.ingredients:
            found_qty = 0
            for item in inventory.slots:
                if item and item.name == ingredient_name:
                    found_qty += item.quantity
            
            if found_qty < required_qty:
                return False
        
        return True
    
    def craft(self, inventory: Inventory) -> bool:
        """Craft the item"""
        if not self.can_craft(inventory):
            return False
        
        # Remove ingredients
        for ingredient_name, required_qty in self.ingredients:
            remaining = required_qty
            for i, item in enumerate(inventory.slots):
                if item and item.name == ingredient_name:
                    if item.quantity <= remaining:
                        remaining -= item.quantity
                        inventory.slots[i] = None
                    else:
                        item.quantity -= remaining
                        remaining = 0
                    
                    if remaining == 0:
                        break
        
        # Add crafted item
        inventory.add_item(self.result)
        return True

# Trading System
class Shop:
    """Shop/merchant system"""
    
    def __init__(self, name: str):
        self.name = name
        self.inventory = []
        self.buy_multiplier = 1.0  # Price multiplier when buying
        self.sell_multiplier = 0.5  # Price multiplier when selling
    
    def add_item(self, item: Item, stock: int = -1):
        """Add item to shop (-1 for infinite stock)"""
        self.inventory.append({
            'item': item,
            'stock': stock,
            'price': item.price
        })
    
    def buy_item(self, item_index: int, character: Character, 
                player_inventory: Inventory) -> bool:
        """Player buys item from shop"""
        if 0 <= item_index < len(self.inventory):
            shop_item = self.inventory[item_index]
            price = int(shop_item['price'] * self.buy_multiplier)
            
            if character.gold >= price:
                if shop_item['stock'] != 0:  # Check stock
                    if player_inventory.add_item(shop_item['item']):
                        character.gold -= price
                        if shop_item['stock'] > 0:
                            shop_item['stock'] -= 1
                        return True
        
        return False
    
    def sell_item(self, item: Item, character: Character) -> bool:
        """Player sells item to shop"""
        price = int(item.price * self.sell_multiplier)
        character.gold += price
        return True

if __name__ == "__main__":
    game = RPGGame()
    game.run()

Best Practices

⚡ RPG System Best Practices

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Three Pillars of Python RPG Architecture — Enum-Roundtrip Identity + Dataclass-asdict Save Format + Declarative Skill-Prerequisite Records in One Pygame Window

Objective: Build a single ~80-line pygame program that simultaneously demonstrates the three orthogonal Python-specific architectural levers this lesson exercises. (a) Enum-roundtrip-through-save at CHARACTER-CLASS-AND-ITEM-TYPE IDENTITY scope where class CharacterClass(Enum) and class ItemType(Enum) enumerate valid identities at design-time so a typo ItemType.CONSUM raises AttributeError at the import statement of the typo-containing module, and the CharacterClass(char_data['char_class']) reconstructor at load time raises ValueError if the saved string does not match a declared member — making save-file format drift a LOAD-TIME exception rather than silent runtime corruption. (b) Dataclass-asdict serialization as schema-IS-the-class at SAVE-FORMAT scope where asdict(self.character) walks the @dataclass-decorated Character's fields recursively producing a JSON-serializable dict whose keys EXACTLY match the field names — adding a new field to Character automatically appears in saves with zero changes to save_game. (c) Skill-prerequisites-as-declarative-data-records at SKILL-TREE-VALIDATION scope where Skill.requirements = [{'skill_id': 'power_strike'}] is data the can_learn() method iterates through, so adding a new prerequisite is a single requirements.append({...}) call with zero changes to can_learn()'s control flow — Open/Closed Principle satisfaction. Three labeled regions visible per frame on an 800×480 window.

Instructions:

  1. Define class CharacterClass(Enum) with WARRIOR / MAGE / ROGUE members assigned string values, and class ItemType(Enum) with WEAPON / POTION members assigned string values.
  2. Define @dataclass Character (name, char_class: CharacterClass, level, strength, intelligence) and Item (name, item_type: ItemType, value), and @dataclass Skill (skill_id, name, requirements: List[Dict] = field(default_factory=list)).
  3. Build the SKILLS dict with five entries — power_strike (no reqs), iron_skin (req power_strike), berserker (reqs power_strike + iron_skin), fireball (no reqs), teleport (req fireball) — each Skill carrying its prerequisites as a List[Dict] of {skill_id} records.
  4. Implement can_learn(skill, learned) that returns True iff every requirement's skill_id is in the learned set, iterating over skill.requirements as data.
  5. Implement save(char, items, learned, path) that uses asdict(char) and overrides char_class to char.char_class.value for JSON-roundtrippability, doing the same with each Item's item_type; persist the learned set as a sorted list.
  6. Implement load(path) that reads the JSON, reconstructs CharacterClass(cd['char_class']) and ItemType(i['item_type']) (raising ValueError on drift), and rebuilds Character and Item dataclass instances via Character(**cd) / Item(**i).
  7. Bind keys S=save, L=load, 1=learn power_strike, 2=learn fireball, 3=learn iron_skin (only if can_learn returns True); update a status string on each event capturing which architectural lever just fired as visible feedback.
  8. Render three regions per frame: LEFT panel (x=10, y=10) shows the SKILL TREE with each skill's reqs=[...] data record visualized in a color encoding learned / learnable / blocked status; TOP-RIGHT panel (x=380, y=10) shows the CHARACTER with both char_class.name (static) and char_class.value (the round-trip string); BOTTOM panel (x=10, y=320) shows the SAVE FORMAT json.dumps preview demonstrating the asdict-walked field set + Enum.value extraction.
💡 Hint

The three axes are three independent state-shape choices: Enum-with-string-value-roundtrip moves identity-validation from runtime to design-time AND to load-time (catching both source typos at import and save-file drift at load); dataclass-asdict makes the field declaration the single source of truth for the save format (any field rename breaks saves loudly at exactly the renamed field rather than silently constructing a half-loaded character); requirements-as-data-records lets skill_tree growth happen at construction time with zero validator changes (adding berserker with two prerequisites is a single Skill('berserker', 'Berserker', [{'skill_id': 'power_strike'}, {'skill_id': 'iron_skin'}]) call). Per-frame renderer reads are O(1) field accesses (char.char_class.name / char.char_class.value / asdict(char)); maintenance cost lives in the write paths (Enum-from-string at load time, asdict overrides for non-default-serializable Enum fields, requirements.append for skill graph growth). The classic failure mode for axis (a) is silently constructing invalid characters on load when an Enum member was renamed or removed; for axis (b) is hand-rolled save dicts drifting out of sync with the dataclass field set; for axis (c) is scattered if-elif prerequisite chains in can_learn() forcing every new skill to add a new branch.

✅ Example Solution
import pygame, json
from dataclasses import dataclass, asdict, field
from enum import Enum
from typing import List, Dict

class CharacterClass(Enum):
    WARRIOR = 'warrior'
    MAGE = 'mage'
    ROGUE = 'rogue'

class ItemType(Enum):
    WEAPON = 'weapon'
    POTION = 'potion'

@dataclass
class Item:
    name: str
    item_type: ItemType
    value: int

@dataclass
class Character:
    name: str
    char_class: CharacterClass
    level: int = 1
    strength: int = 10
    intelligence: int = 10

@dataclass
class Skill:
    skill_id: str
    name: str
    requirements: List[Dict] = field(default_factory=list)

SKILLS = {
    'power_strike': Skill('power_strike', 'Power Strike'),
    'iron_skin': Skill('iron_skin', 'Iron Skin', [{'skill_id': 'power_strike'}]),
    'berserker': Skill('berserker', 'Berserker', [{'skill_id': 'power_strike'}, {'skill_id': 'iron_skin'}]),
    'fireball': Skill('fireball', 'Fireball'),
    'teleport': Skill('teleport', 'Teleport', [{'skill_id': 'fireball'}]),
}

def can_learn(skill, learned):
    return all(req['skill_id'] in learned for req in skill.requirements)

def save(char, items, learned, path):
    data = {
        'character': {**asdict(char), 'char_class': char.char_class.value},
        'items': [{**asdict(i), 'item_type': i.item_type.value} for i in items],
        'learned': sorted(learned),
    }
    with open(path, 'w') as f:
        json.dump(data, f)

def load(path):
    with open(path) as f:
        data = json.load(f)
    cd = data['character']
    cd['char_class'] = CharacterClass(cd['char_class'])
    char = Character(**cd)
    items = [Item(**{**i, 'item_type': ItemType(i['item_type'])}) for i in data['items']]
    return char, items, set(data['learned'])

pygame.init()
screen = pygame.display.set_mode((800, 480))
font = pygame.font.Font(None, 18)
clock = pygame.time.Clock()
char = Character('Aria', CharacterClass.WARRIOR)
items = [Item('Sword', ItemType.WEAPON, 50), Item('Potion', ItemType.POTION, 10)]
learned = {'power_strike'}
status = 'S=save  L=load  1=power_strike  2=fireball  3=iron_skin'
running = True
while running:
    for e in pygame.event.get():
        if e.type == pygame.QUIT: running = False
        elif e.type == pygame.KEYDOWN:
            if e.key == pygame.K_s:
                save(char, items, learned, '/tmp/rpg.json')
                status = 'Saved (asdict walks @dataclass fields, Enum.value extracted)'
            elif e.key == pygame.K_l:
                try:
                    char, items, learned = load('/tmp/rpg.json')
                    status = 'Loaded (Enum-roundtrip OK, dataclass fields populated)'
                except (FileNotFoundError, ValueError) as ex:
                    status = f'Load failed: {ex}'
            elif e.key in (pygame.K_1, pygame.K_2, pygame.K_3):
                sid = {pygame.K_1: 'power_strike', pygame.K_2: 'fireball', pygame.K_3: 'iron_skin'}[e.key]
                if can_learn(SKILLS[sid], learned):
                    learned.add(sid)
                    status = f'Learned {sid} (prereqs satisfied per declarative requirements list)'
                else:
                    reqs = ', '.join(r['skill_id'] for r in SKILLS[sid].requirements)
                    status = f'Cannot learn {sid}: needs [{reqs}]'
    screen.fill((20, 20, 30))
    screen.blit(font.render('SKILL TREE (declarative requirements as data records):', True, (255, 255, 0)), (10, 10))
    y = 40
    for sid, skill in SKILLS.items():
        ok = can_learn(skill, learned)
        color = (0, 255, 0) if sid in learned else ((255, 255, 255) if ok else (110, 110, 110))
        reqs = ', '.join(r['skill_id'] for r in skill.requirements) or 'none'
        screen.blit(font.render(f'{skill.name:14s}  reqs=[{reqs}]', True, color), (20, y))
        y += 22
    screen.blit(font.render('CHARACTER (Enum-typed identity, .name vs .value):', True, (255, 255, 0)), (380, 10))
    screen.blit(font.render(f'name={char.name}  class.name={char.char_class.name}  class.value={char.char_class.value!r}', True, (255, 255, 255)), (380, 35))
    screen.blit(font.render(f'level={char.level}  STR={char.strength}  INT={char.intelligence}', True, (255, 255, 255)), (380, 55))
    screen.blit(font.render('ITEMS:', True, (255, 255, 0)), (380, 85))
    iy = 105
    for it in items:
        screen.blit(font.render(f'  {it.name}  type.name={it.item_type.name}  value={it.value}', True, (255, 255, 255)), (380, iy))
        iy += 20
    screen.blit(font.render('SAVE FORMAT (asdict walks @dataclass fields; Enum -> .value -> JSON string -> Enum on load):', True, (255, 255, 0)), (10, 320))
    sample = {'character': {**asdict(char), 'char_class': char.char_class.value}}
    payload = json.dumps(sample, separators=(',', ':'))
    screen.blit(font.render(payload[:100], True, (200, 200, 200)), (10, 345))
    screen.blit(font.render(payload[100:200], True, (200, 200, 200)), (10, 365))
    screen.blit(font.render(f'STATUS: {status}', True, (0, 255, 255)), (10, 410))
    screen.blit(font.render('(Enum-from-string raises ValueError on drift; asdict tracks dataclass fields; requirements iterated as data)', True, (180, 180, 180)), (10, 440))
    pygame.display.flip()
    clock.tick(60)
pygame.quit()

🎯 Quick Quiz

Question 1: Why does the save/load roundtrip use CharacterClass(char_data['char_class']) to reconstruct the character class on load, rather than storing and restoring the raw string?

Question 2: What architectural property does using asdict(self.character) in save_game() provide that hand-rolled {'name': self.character.name, 'level': self.character.level, ...} would not?

Question 3: Why does Skill.can_learn() iterate over self.requirements (a List[Dict] of {skill_id, level} records) rather than checking prerequisites with hardcoded if-elif chains keyed on self.id?

What's Next?

Now that you've mastered RPG systems, next we'll explore strategy game mechanics!