Skip to main content

Lesson 24: RPG Systems

  • Module 12: Genre Studio
  • Lesson 24 of 27
  • ⏱️ About 1 h 45 min (instruction + lab)

Role-playing games feel huge, but most of their machinery is a few small systems that all talk to one character: stats that grow, a bag of items, conversations that branch, and quests that reward you. In this lesson you build each of those systems, one stage at a time, and tie them together in a playable village with a save file that remembers everything.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Model character stats with a dataclass and handle level-ups, including several levels from one big reward.
  • Build an item database, an inventory and equipment slots, and compute attack and defense from equipment so they can never drift.
  • Store a branching conversation as a dialogue graph and walk it with numbered choices.
  • Track a quest with an Enum status so its reward is paid exactly once.
  • Save and load all of it as JSON, turning Enums into their .value and restoring equipment and quests.

Project: Village Quest, a small RPG scene where you accept the Elder's quest, gather herbs, level up from the reward, and save your progress.

In This Lesson

🗺️ An RPG Is a Web of Systems

Think of a tabletop role-playing game. You have a character sheet with your stats, a list of what you carry, a game master who plays the villagers, and a notebook of quests. None of those is complicated on its own; the fun comes from how they feed each other. A quest gives you experience, experience raises your level, a higher level makes the next fight easier, and the fight drops loot that makes you stronger again.

A character in the center, showing HP, MP, level, XP, attack and defense, surrounded by four systems: combat reads attack and defense, inventory and items boost stats through gear, quests and NPCs grant XP and items, and leveling turns XP into stronger stats.
Every system reads and writes the same character. Those shared numbers are how quests, items, leveling and combat feed each other.

This lesson builds four of those systems (stats, items, dialogue and quests) plus the save file that holds them. Combat gets a pointer in Going Further, because it is just more reads of the same stats. Each system uses Python you already know: dataclasses and asdict from the Saving & Loading lesson, Enum from the Game States & Scenes lesson, and the dialogue box and cached fonts from the UI & HUD lesson.

💡 Why this matters

These patterns reach far beyond RPGs. An item database keyed by id, a graph of conversation nodes, a status that only moves forward, and a save file built from plain data show up in almost every game with progression, from farming sims to shooters with unlockable gear.

📊 Stats and Levels

A character's stats are a bundle of numbers that always travel together, which is exactly what a dataclass is for:

@dataclass
class Stats:
    level: int = 1
    xp: int = 0
    max_hp: int = 30
    hp: int = 30
    strength: int = 5

Type hints like int are notes for you and your editor. Python does not check them while the game runs: Stats(level="three") is created without complaint. Only a static checker such as mypy, run separately, would flag it.

How much experience does each level need? A common shape is a curve that grows by a fixed factor, so later levels take longer. Write it as a pure function of the level, so you can ask about any level at any time and rebalance the whole curve by changing two numbers:

def xp_to_next(level):
    """XP needed to go from `level` to the next one: 100, 150, 225, ..."""
    return int(100 * 1.5 ** (level - 1))

When XP arrives, a big reward might be enough for more than one level. Using while instead of if handles that: it keeps leveling up as long as there is enough XP, and the leftover carries into the next level.

def gain_xp(self, amount):
    """Add XP; a big reward can raise several levels. Returns levels gained."""
    self.stats.xp += amount
    gained = 0
    while self.stats.xp >= xp_to_next(self.stats.level):
        self.stats.xp -= xp_to_next(self.stats.level)
        self.stats.level += 1
        self.stats.max_hp += 5
        self.stats.hp = self.stats.max_hp
        self.stats.strength += 1
        gained += 1
    return gained

🔮 Predict, then run

A level 1 hero with 0 XP gains 260 XP. What level are they, and how much XP is left? Work it out with xp_to_next() (100 for level 1, 150 for level 2), then check it in the exercise. (Level 3 with 10 XP: 260 − 100 − 150 = 10.)

🎒 Items, Inventory and Equipment

A shop in a real village doesn't write out a full description of the sword on every receipt; it writes the product code. Do the same: keep one item database, a dictionary from a short id to the item's details, and let inventories and saves store only the id.

class ItemKind(Enum):
    WEAPON = "weapon"
    ARMOR = "armor"
    POTION = "potion"
    QUEST = "quest"


@dataclass
class Item:
    name: str
    kind: ItemKind
    attack: int = 0
    defense: int = 0
    heal: int = 0


# The item database. Inventories and saves store only the id ("sword").
ITEMS = {
    "herb": Item("Moon Herb", ItemKind.QUEST),
    "potion": Item("Potion", ItemKind.POTION, heal=30),
    "stick": Item("Walking Stick", ItemKind.WEAPON, attack=1),
    "sword": Item("Iron Sword", ItemKind.WEAPON, attack=4),
    "vest": Item("Leather Vest", ItemKind.ARMOR, defense=2),
}

Each item has its own kind, so a reward list like ["sword", "vest", "potion"] gives a weapon, a piece of armor and a potion, not three weapons. The hero's inventory is a dictionary from id to count, and equipment is a dictionary from slot to the id worn there (or None):

class Hero:
    def __init__(self):
        self.stats = Stats()
        self.inventory = {"stick": 1, "potion": 1}          # item id -> how many
        self.equipped = {ItemKind.WEAPON: "stick", ItemKind.ARMOR: None}

    # Derived numbers are computed from the equipment every time, so they can never drift.
    def attack(self):
        return self.stats.strength + sum(ITEMS[i].attack for i in self.equipped.values() if i)

    def defense(self):
        return sum(ITEMS[i].defense for i in self.equipped.values() if i)

    def equip(self, item_id):
        kind = ITEMS[item_id].kind
        if kind not in self.equipped or item_id not in self.inventory:
            return False
        self.equipped[kind] = item_id
        return True

Why compute attack() every time instead of storing a "bonus" number that you add to on equip and subtract from on unequip? Because a stored total must be updated perfectly on every path: equip, unequip, selling the item, losing it, loading a save. Miss one and the number drifts forever. Summing a couple of equipped items costs next to nothing, and it is always right. equip() also refuses things you don't own and things that aren't wearable, like a potion.

✅ Growth Mindset: Build It in Stages

An RPG is a lot of code, and looking at the whole exercise solution at once can feel overwhelming. That's normal, and it's also not how anyone writes one. Get stats working and print them. Then add items and print the inventory. Then dialogue. Each stage is small enough to test by itself, and every working stage is real progress. If a stage fights you, you haven't failed; you've found the next thing to learn.

💬 Dialogue as a Graph

A branching conversation is a graph: each node is something the character says plus the replies you can pick, and each reply points to the next node. Stored as data, adding a branch means adding a node, not rewriting if/elif chains in your drawing code.

graph TD S["start: Our healer is out of Moon Herbs."] -->|"Can I help?"| O["offer: Bring me 3 herbs."] S -->|"Sorry, I'm busy."| X(("end")) O -->|"I'll do it. (accept)"| X O -->|"What's in it for me?"| R["reward: My old sword and vest."] O -->|"Maybe later."| X R -->|"Deal! (accept)"| X R -->|"No thanks."| X
DIALOGUE = {
    "start": {"text": "Elder: Traveler! Our healer is out of Moon Herbs.",
              "choices": [{"say": "Can I help?", "next": "offer", "action": None},
                          {"say": "Sorry, I'm busy.", "next": None, "action": None}]},
    "offer": {"text": "Elder: Bring me 3 herbs from the meadow to the east.",
              "choices": [{"say": "I'll do it.", "next": None, "action": "accept"},
                          {"say": "What's in it for me?", "next": "reward", "action": None},
                          {"say": "Maybe later.", "next": None, "action": None}]},
    # ... "reward", "waiting", "ready" and "done" follow the same shape
}

A choice has three parts: what you say, the next node id (None ends the conversation), and an optional action name that the game carries out, such as "accept" or "turn_in". The game remembers only the current node id; the dialogue box from the UI & HUD lesson draws that node's text and numbered replies, and pressing 1, 2 or 3 picks one:

def choose(self, index):
    choices = DIALOGUE[self.node]["choices"]
    if index >= len(choices):
        return
    choice = choices[index]
    if choice["action"] == "accept" and self.quest.status is QuestStatus.NOT_STARTED:
        self.quest.status = QuestStatus.ACTIVE
        self.quest.update(self.hero)   # maybe we already carry the herbs
        self.message = "Quest started: Moon Herbs."
    elif choice["action"] == "turn_in" and self.quest.turn_in(self.hero):
        self.message = f"Quest complete! +{self.quest.reward_xp} XP"
    self.node = choice["next"]

Where a conversation starts depends on the quest: a stranger hears "start", a hero with the herbs hears "ready". A small dictionary, FIRST_NODE, maps each quest status to its first node. Because the whole graph is data, one short test can check that every next points to a node that exists, catching a typo like "ofer" before a player ever finds it.

📜 Quests That Pay Once

A quest moves through a few stages, and only forward. An Enum makes those stages explicit:

class QuestStatus(Enum):
    NOT_STARTED = "not_started"
    ACTIVE = "active"
    READY = "ready"                        # has the items, not handed in yet
    DONE = "done"                          # rewarded: never again


@dataclass
class Quest:
    title: str
    item_id: str
    needed: int
    reward_xp: int
    reward_items: list
    status: QuestStatus = QuestStatus.NOT_STARTED

    def update(self, hero):
        if self.status is QuestStatus.ACTIVE and hero.inventory.get(self.item_id, 0) >= self.needed:
            self.status = QuestStatus.READY

    def turn_in(self, hero):
        """Pay the reward exactly once."""
        if self.status is not QuestStatus.READY:
            return False
        hero.remove_item(self.item_id, self.needed)
        hero.gain_xp(self.reward_xp)
        for item_id in self.reward_items:
            hero.add_item(item_id)
        self.status = QuestStatus.DONE
        return True

The guard at the top of turn_in() is the whole trick. A quest system that only checks "does the hero have 3 herbs?" pays out again every time the check runs, or every time the player talks to the Elder. Here the reward is paid only in the READY state, and paying it moves the quest to DONE, where no path leads back.

stateDiagram-v2 [*] --> NOT_STARTED NOT_STARTED --> ACTIVE: accept in dialogue ACTIVE --> READY: carrying 3 herbs READY --> DONE: turn_in() pays once DONE --> [*]

💾 Saving the Adventure

The Saving & Loading lesson gave you the pattern: turn the game state into plain dictionaries and lists, write JSON to a temporary file, then swap it in with os.replace() so a crash mid-save can't destroy the old file. RPG state adds one wrinkle: Enums. json doesn't know what to do with them. This short program shows the problem and the fix:

import json
from dataclasses import dataclass, asdict
from enum import Enum


class QuestStatus(Enum):
    ACTIVE = "active"
    DONE = "done"


@dataclass
class Stats:
    level: int = 1
    xp: int = 0


status = QuestStatus.ACTIVE
try:
    json.dumps({"quest": status})
except TypeError as error:
    print("TypeError:", error)

text = json.dumps({"quest": status.value, "stats": asdict(Stats(level=3, xp=40))})
print(text)

data = json.loads(text)
print(QuestStatus(data["quest"]), Stats(**data["stats"]))

It prints:

TypeError: Object of type QuestStatus is not JSON serializable
{"quest": "active", "stats": {"level": 3, "xp": 40}}
QuestStatus.ACTIVE Stats(level=3, xp=40)

So: save status.value (a plain string), and load it back with QuestStatus(the_string). The same goes for the equipment dictionary, whose keys are ItemKind members. A complete save also needs everything that makes up the adventure, not just the stats: the inventory, what is equipped, each quest's status, the position, and which herbs have already been picked.

def to_save(hero, quest, pos, herbs):
    """Turn the game state into plain JSON types. Enums become their .value."""
    return {"version": SAVE_VERSION,
            "stats": asdict(hero.stats),
            "inventory": dict(hero.inventory),
            "equipped": {kind.value: item_id for kind, item_id in hero.equipped.items()},
            "quest": quest.status.value,
            "pos": [pos.x, pos.y],
            "herbs": [[h.x, h.y] for h in herbs]}


def from_save(data):
    """Rebuild (hero, quest, pos, herbs) from a save dict, including equipment and quest."""
    if data.get("version") != SAVE_VERSION:
        raise ValueError(f"unknown save version: {data.get('version')}")
    hero = Hero()
    hero.stats = Stats(**data["stats"])
    hero.inventory = {k: int(v) for k, v in data["inventory"].items() if k in ITEMS}
    hero.equipped = {ItemKind(kind): item_id for kind, item_id in data["equipped"].items()}
    quest = make_quest()
    quest.status = QuestStatus(data["quest"])
    pos = pygame.Vector2(data["pos"])
    herbs = [pygame.Vector2(h) for h in data["herbs"]]
    return hero, quest, pos, herbs

Three details from the Saving & Loading lesson carry over. The version number lets a future version of your game recognize and migrate old saves. Unknown item ids are skipped instead of crashing the load. And the save lives next to the game with Path(__file__).parent / "village_save.json", not in a folder that only exists on one operating system, like /tmp.

One more Python fact worth knowing when you read save data: data["quest"] raises KeyError if the key is missing, while data.get("quest") returns None. Use [ ] when a missing key is a real error you want to hear about, and .get() with a default when the key is optional.

🏋️ Practice Exercise: Village Quest

Objective: finish a small RPG scene where you accept the Elder's quest, gather three herbs, get rewarded exactly once, level up, equip your new sword and save your progress.

Time: about 45 minutes. Starter file: village_quest_starter.py (your instructor has it). Movement, drawing, the dialogue box and the inventory panel already work. Right now equipment adds nothing, XP never levels you up, the quest pays nothing, and saves forget your equipment and quest.

  1. Run the starter. Talk to the Elder (E), accept the quest (1, then 1), and walk east to pick up the herbs. (≈ 3 min)
  2. Make attack() and defense() add the bonuses of every equipped item. ATK in the HUD should become 6 with the walking stick. (≈ 5 min)
  3. Write the while loop in gain_xp(), so XP levels you up (possibly several times). (≈ 8 min)
  4. Finish turn_in(): only in the READY state, take the herbs, give the XP and the reward items, and move to DONE. Hand in the herbs and check that you jump to level 3 (250 XP is 100 for level 2 plus 150 for level 3). Then talk to the Elder again and make sure nothing more is paid. (≈ 10 min)
  5. Open the inventory (I) and equip the Iron Sword and Leather Vest with the number keys. (≈ 3 min)
  6. In to_save() and from_save(), save and restore the equipment and quest status using .value, ItemKind(...) and QuestStatus(...). Press F5, walk somewhere, press F9, and check that you are back with the same gear and quest. (≈ 12 min)

You are done when:

  • swapping the walking stick (+1) for the sword (+4) raises ATK by 3, and the vest raises DEF by 2;
  • turning in the quest gives 250 XP once (exactly enough for level 3), a sword, a vest and a potion;
  • talking to the Elder again after the reward pays nothing more;
  • F5 then F9 restores your level, items, equipment, quest status, position and the herbs still on the ground.
💡 Hint

If ATK doesn't change after equipping, remember that equipped holds ids, so look up ITEMS[item_id].attack, and skip slots that are None. If F5 crashes with TypeError: Object of type ... is not JSON serializable, some Enum is still in the save: find it and add .value.

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.

"""Village Quest: Intermediate Lesson 24 practice exercise (solution).

A tiny RPG slice built from four systems: stats with levels, an inventory
with equipment, a dialogue graph and a quest, all saved to JSON.
Move with the arrow keys or WASD. E talks to the Elder (stand next to her),
1-3 pick a reply. Walk over herbs to pick them up. I opens the inventory,
where 1-9 equips or uses an item. F5 saves, F9 loads.
"""
import json
import os
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path

import pygame


WIDTH, HEIGHT = 800, 500
SPEED = 200.0                              # px/s
TALK_RANGE = 70.0
SAVE_PATH = Path(__file__).parent / "village_save.json"
SAVE_VERSION = 1
TEXT_COLOR = (235, 235, 235)
MOVE_KEYS = {pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), pygame.K_UP: (0, -1),
             pygame.K_DOWN: (0, 1), pygame.K_a: (-1, 0), pygame.K_d: (1, 0),
             pygame.K_w: (0, -1), pygame.K_s: (0, 1)}


class ItemKind(Enum):
    WEAPON = "weapon"
    ARMOR = "armor"
    POTION = "potion"
    QUEST = "quest"


class QuestStatus(Enum):
    NOT_STARTED = "not_started"
    ACTIVE = "active"
    READY = "ready"                        # has the items, not handed in yet
    DONE = "done"                          # rewarded: never again


@dataclass
class Item:
    name: str
    kind: ItemKind
    attack: int = 0
    defense: int = 0
    heal: int = 0


# The item database. Inventories and saves store only the id ("sword").
ITEMS = {
    "herb": Item("Moon Herb", ItemKind.QUEST),
    "potion": Item("Potion", ItemKind.POTION, heal=30),
    "stick": Item("Walking Stick", ItemKind.WEAPON, attack=1),
    "sword": Item("Iron Sword", ItemKind.WEAPON, attack=4),
    "vest": Item("Leather Vest", ItemKind.ARMOR, defense=2),
}


def xp_to_next(level):
    """XP needed to go from `level` to the next one: 100, 150, 225, ..."""
    return int(100 * 1.5 ** (level - 1))


@dataclass
class Stats:
    level: int = 1
    xp: int = 0
    max_hp: int = 30
    hp: int = 30
    strength: int = 5


class Hero:
    def __init__(self):
        self.stats = Stats()
        self.inventory = {"stick": 1, "potion": 1}          # item id -> how many
        self.equipped = {ItemKind.WEAPON: "stick", ItemKind.ARMOR: None}

    # Derived numbers are computed from the equipment every time, so they can never drift.
    def attack(self):
        return self.stats.strength + sum(ITEMS[i].attack for i in self.equipped.values() if i)

    def defense(self):
        return sum(ITEMS[i].defense for i in self.equipped.values() if i)

    def add_item(self, item_id, count=1):
        self.inventory[item_id] = self.inventory.get(item_id, 0) + count

    def remove_item(self, item_id, count=1):
        if self.inventory.get(item_id, 0) < count:
            return False
        self.inventory[item_id] -= count
        if self.inventory[item_id] == 0:
            del self.inventory[item_id]
            for slot, equipped in self.equipped.items():
                if equipped == item_id:
                    self.equipped[slot] = None
        return True

    def equip(self, item_id):
        kind = ITEMS[item_id].kind
        if kind not in self.equipped or item_id not in self.inventory:
            return False
        self.equipped[kind] = item_id
        return True

    def use(self, item_id):
        item = ITEMS[item_id]
        if item.kind is not ItemKind.POTION or self.stats.hp == self.stats.max_hp:
            return False
        self.remove_item(item_id)
        self.stats.hp = min(self.stats.max_hp, self.stats.hp + item.heal)
        return True

    def gain_xp(self, amount):
        """Add XP; a big reward can raise several levels. Returns levels gained."""
        self.stats.xp += amount
        gained = 0
        while self.stats.xp >= xp_to_next(self.stats.level):
            self.stats.xp -= xp_to_next(self.stats.level)
            self.stats.level += 1
            self.stats.max_hp += 5
            self.stats.hp = self.stats.max_hp
            self.stats.strength += 1
            gained += 1
        return gained


@dataclass
class Quest:
    title: str
    item_id: str
    needed: int
    reward_xp: int
    reward_items: list
    status: QuestStatus = QuestStatus.NOT_STARTED

    def update(self, hero):
        if self.status is QuestStatus.ACTIVE and hero.inventory.get(self.item_id, 0) >= self.needed:
            self.status = QuestStatus.READY

    def turn_in(self, hero):
        """Pay the reward exactly once."""
        if self.status is not QuestStatus.READY:
            return False
        hero.remove_item(self.item_id, self.needed)
        hero.gain_xp(self.reward_xp)
        for item_id in self.reward_items:
            hero.add_item(item_id)
        self.status = QuestStatus.DONE
        return True


def make_quest():
    return Quest("Moon Herbs", "herb", 3, 250, ["sword", "vest", "potion"])


# The dialogue graph: node id -> text and choices. A choice names the next node
# (None ends the talk) and an optional action for the game to run.
DIALOGUE = {
    "start": {"text": "Elder: Traveler! Our healer is out of Moon Herbs.",
              "choices": [{"say": "Can I help?", "next": "offer", "action": None},
                          {"say": "Sorry, I'm busy.", "next": None, "action": None}]},
    "offer": {"text": "Elder: Bring me 3 herbs from the meadow to the east.",
              "choices": [{"say": "I'll do it.", "next": None, "action": "accept"},
                          {"say": "What's in it for me?", "next": "reward", "action": None},
                          {"say": "Maybe later.", "next": None, "action": None}]},
    "reward": {"text": "Elder: My old sword and vest, and a potion.",
               "choices": [{"say": "Deal!", "next": None, "action": "accept"},
                           {"say": "No thanks.", "next": None, "action": None}]},
    "waiting": {"text": "Elder: The herbs glow near the pond.",
                "choices": [{"say": "On my way.", "next": None, "action": None}]},
    "ready": {"text": "Elder: You found them all! These are yours.",
              "choices": [{"say": "Thank you!", "next": "done", "action": "turn_in"}]},
    "done": {"text": "Elder: The village owes you. Safe travels.",
             "choices": [{"say": "Goodbye.", "next": None, "action": None}]},
}
FIRST_NODE = {QuestStatus.NOT_STARTED: "start", QuestStatus.ACTIVE: "waiting",
              QuestStatus.READY: "ready", QuestStatus.DONE: "done"}


def to_save(hero, quest, pos, herbs):
    """Turn the game state into plain JSON types. Enums become their .value."""
    return {"version": SAVE_VERSION,
            "stats": asdict(hero.stats),
            "inventory": dict(hero.inventory),
            "equipped": {kind.value: item_id for kind, item_id in hero.equipped.items()},
            "quest": quest.status.value,
            "pos": [pos.x, pos.y],
            "herbs": [[h.x, h.y] for h in herbs]}


def from_save(data):
    """Rebuild (hero, quest, pos, herbs) from a save dict, including equipment and quest."""
    if data.get("version") != SAVE_VERSION:
        raise ValueError(f"unknown save version: {data.get('version')}")
    hero = Hero()
    hero.stats = Stats(**data["stats"])
    hero.inventory = {k: int(v) for k, v in data["inventory"].items() if k in ITEMS}
    hero.equipped = {ItemKind(kind): item_id for kind, item_id in data["equipped"].items()}
    quest = make_quest()
    quest.status = QuestStatus(data["quest"])
    pos = pygame.Vector2(data["pos"])
    herbs = [pygame.Vector2(h) for h in data["herbs"]]
    return hero, quest, pos, herbs


def save_game(data, path=SAVE_PATH):
    temp = path.with_suffix(".tmp")        # write a temp file, then swap it in
    with open(temp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    os.replace(temp, path)


def load_game(path=SAVE_PATH):
    with open(path, encoding="utf-8") as f:
        return from_save(json.load(f))


class Village:
    def __init__(self):
        self.hero = Hero()
        self.quest = make_quest()
        self.pos = pygame.Vector2(250, 250)
        self.elder = pygame.Vector2(200, 250)
        self.herbs = [pygame.Vector2(620, 150), pygame.Vector2(690, 270), pygame.Vector2(600, 390)]
        self.node = None                   # current dialogue node id, None = not talking
        self.show_inventory = False
        self.message = "Talk to the Elder (E)."

    def talk(self):
        if self.node is None and self.pos.distance_to(self.elder) <= TALK_RANGE:
            self.node = FIRST_NODE[self.quest.status]

    def choose(self, index):
        choices = DIALOGUE[self.node]["choices"]
        if index >= len(choices):
            return
        choice = choices[index]
        if choice["action"] == "accept" and self.quest.status is QuestStatus.NOT_STARTED:
            self.quest.status = QuestStatus.ACTIVE
            self.quest.update(self.hero)   # maybe we already carry the herbs
            self.message = "Quest started: Moon Herbs."
        elif choice["action"] == "turn_in" and self.quest.turn_in(self.hero):
            self.message = f"Quest complete! +{self.quest.reward_xp} XP"
        self.node = choice["next"]

    def use_slot(self, index):
        items = sorted(self.hero.inventory)
        if index < len(items):
            item_id = items[index]
            if self.hero.equip(item_id) or self.hero.use(item_id):
                self.message = f"Used {ITEMS[item_id].name}."

    def update(self, move, dt):
        if self.node is not None or move.length_squared() == 0:
            return
        self.pos += move.normalize() * SPEED * dt
        self.pos.x = max(14, min(WIDTH - 14, self.pos.x))
        self.pos.y = max(14, min(HEIGHT - 14, self.pos.y))
        for herb in list(self.herbs):
            if self.pos.distance_to(herb) < 24:
                self.herbs.remove(herb)
                self.hero.add_item("herb")
                self.message = "Picked a Moon Herb."
        self.quest.update(self.hero)

    def draw(self, screen, font):
        screen.fill((34, 70, 48))
        pygame.draw.circle(screen, (60, 110, 170), (660, 270), 60)             # the pond
        for herb in self.herbs:
            pygame.draw.circle(screen, (200, 240, 255), herb, 8)
        pygame.draw.circle(screen, (210, 180, 240), self.elder, 16)
        body = pygame.FRect(0, 0, 26, 26)
        body.center = self.pos
        pygame.draw.rect(screen, (250, 210, 120), body, border_radius=6)

        s = self.hero.stats
        hud = [f"Level {s.level}   XP {s.xp}/{xp_to_next(s.level)}   HP {s.hp}/{s.max_hp}",
               f"ATK {self.hero.attack()}   DEF {self.hero.defense()}   "
               f"Quest: {self.quest.title} ({self.quest.status.value})",
               self.message]
        for i, line in enumerate(hud):
            screen.blit(font.render(line, True, TEXT_COLOR), (12, 10 + i * 24))

        if self.show_inventory:
            panel = pygame.Rect(470, 90, 310, 200)
            pygame.draw.rect(screen, (24, 26, 40), panel, border_radius=8)
            screen.blit(font.render("Inventory (1-9 equip/use)", True, TEXT_COLOR), (484, 100))
            for i, item_id in enumerate(sorted(self.hero.inventory)):
                mark = " [E]" if item_id in self.hero.equipped.values() else ""
                line = f"{i + 1}. {ITEMS[item_id].name} x{self.hero.inventory[item_id]}{mark}"
                screen.blit(font.render(line, True, TEXT_COLOR), (484, 130 + i * 24))

        if self.node is not None:
            box = pygame.Rect(20, HEIGHT - 150, WIDTH - 40, 130)
            pygame.draw.rect(screen, (20, 22, 34), box, border_radius=10)
            pygame.draw.rect(screen, (200, 200, 220), box, 2, border_radius=10)
            node = DIALOGUE[self.node]
            screen.blit(font.render(node["text"], True, TEXT_COLOR), (box.x + 16, box.y + 14))
            for i, choice in enumerate(node["choices"]):
                screen.blit(font.render(f"{i + 1}) {choice['say']}", True, (255, 225, 140)),
                            (box.x + 30, box.y + 46 + i * 24))


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Village Quest")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 26)
    village = Village()
    held = set()

    running = True
    while running:
        dt = clock.tick(60) / 1000
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYUP:
                held.discard(event.key)
            elif event.type == pygame.KEYDOWN:
                held.add(event.key)
                number = event.key - pygame.K_1          # 0 for the 1 key, 1 for 2, ...
                if village.node is not None and 0 <= number <= 2:
                    village.choose(number)
                elif village.show_inventory and 0 <= number <= 8:
                    village.use_slot(number)
                elif event.key == pygame.K_e:
                    village.talk()
                elif event.key == pygame.K_i:
                    village.show_inventory = not village.show_inventory
                elif event.key == pygame.K_F5:
                    save_game(to_save(village.hero, village.quest, village.pos, village.herbs))
                    village.message = "Game saved."
                elif event.key == pygame.K_F9 and SAVE_PATH.exists():
                    village.hero, village.quest, village.pos, village.herbs = load_game()
                    village.node = None
                    village.message = "Game loaded."

        move = pygame.Vector2()
        for key in held:
            if key in MOVE_KEYS:
                move += MOVE_KEYS[key]
        village.update(move, dt)
        village.draw(screen, font)
        pygame.display.flip()

    pygame.quit()
    s = village.hero.stats
    print(f"Level {s.level}, quest {village.quest.status.value}, herbs carried "
          f"{village.hero.inventory.get('herb', 0)}. {village.message}")


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

✍️ This lesson's prompts:

  1. Draw the dialogue graph for a second NPC in the village (a blacksmith, a guard). What action would one of its replies trigger?
  2. Why is computing attack() from the equipment safer than storing a bonus number? Describe a bug the stored version could cause.
  3. Which system (stats, items, dialogue, quests, saves) would you most like to extend in your own game, and how?

📝 Summary

You built an RPG the way RPGs are really built: as small systems around one character. Stats became a dataclass with a pure XP curve and a while loop for multi-level rewards. Items live in one database keyed by id, the inventory counts ids, and attack and defense are computed from what's equipped so they can't drift. Conversations became a data graph of nodes and choices, a quest status Enum made sure the reward is paid exactly once, and a versioned JSON save stores every Enum as its .value and brings back the equipment and quest along with the stats.

🎓 Key Takeaways

  • Keep one item database keyed by id; inventories, rewards and saves store only ids.
  • Compute derived stats (attack, defense) from equipment when you need them, instead of maintaining a running total.
  • Use while xp >= xp_to_next(level) so one reward can raise several levels.
  • A dialogue graph is a dict of nodes; each choice names the next node and an optional action.
  • Quests move forward through an Enum status; paying the reward moves to DONE, so it happens once.
  • JSON can't store Enums: save .value, load with Kind(value), and save everything the player would miss.

🔭 Looking Ahead

Next is the Tower Defense Capstone, a three-hour build that brings together particles, UI, pathfinding, juice and everything else from this course into one complete game.

❓ Common Questions

Should the item database live in a JSON file instead of the code?

For a bigger game, yes: designers can then add items without touching Python. Load the file once at startup and build the ITEMS dictionary from it, turning each "kind" string into an ItemKind. The rest of the code doesn't change, because it only ever looks items up by id.

What happens if I rename an Enum member after players have saved?

Saves store the .value strings, not the member names. You can rename ItemKind.ARMOR to ItemKind.BODY_ARMOR safely as long as its value stays "armor". Changing a value breaks old saves unless you add a migration step, as in the Saving & Loading lesson.

How would I add a second quest?

Keep quests in a dictionary keyed by an id, save {quest_id: status.value} for all of them, and give each quest-giving NPC its own FIRST_NODE mapping. The Quest class itself doesn't need to change.

Why does the dialogue freeze the player?

While self.node is set, update() returns early, and the number keys go to the dialogue instead of the inventory. Most RPGs do the same, so a player can't wander off mid-sentence or trigger two things with one key.

Where do combat and enemies fit in?

They read the same numbers. A simple turn-based attack could be damage = max(1, attacker.attack() - defender.defense()), and an enemy can be a small class with its own Stats. Winning a fight is then just another source of XP and items for the systems you built today.

🎯 Quick Quiz

Question 1: A level 1 hero (0 XP) gains 260 XP. With xp_to_next() giving 100 for level 1 and 150 for level 2, what is the result?

Question 2: Why does Hero.attack() add up the equipped items every time instead of keeping a stored bonus?

Question 3: What does json.dumps({"quest": QuestStatus.ACTIVE}) do?

Question 4: What stops the Elder's quest from paying its reward twice?

Question 5: In the dialogue graph, what does a choice with "next": None do?

🌟 Going Further

  • A shop: add a merchant NPC with a dialogue node that sells potions for gold. Gold is one more field on Stats, and buying is add_item() plus a subtraction.
  • Simple combat: add a slime with its own Stats; pressing F next to it deals max(1, hero.attack() - 0) damage and it hits back with max(1, 3 - hero.defense()). Defeating it grants 40 XP.
  • Conditional replies: give a choice a "requires": "sword" field and only show it when the hero carries that item.
  • Items from a file: move ITEMS into items.json and load it at startup.
  • Read the docs: enum, dataclasses and json.
  • Coming up in Game Dev III: Advanced: Robust Saves: Integrity, Versioning, Security protects save files like this one against corruption and tampering.