Lesson 24: RPG Systems
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
Enumstatus so its reward is paid exactly once. - Save and load all of it as JSON, turning Enums into their
.valueand 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.
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.
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.
💾 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.
- Run the starter. Talk to the Elder (E), accept the quest (1, then 1), and walk east to pick up the herbs. (≈ 3 min)
- Make
attack()anddefense()add the bonuses of every equipped item. ATK in the HUD should become 6 with the walking stick. (≈ 5 min) - Write the
whileloop ingain_xp(), so XP levels you up (possibly several times). (≈ 8 min) - Finish
turn_in(): only in theREADYstate, take the herbs, give the XP and the reward items, and move toDONE. 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) - Open the inventory (I) and equip the Iron Sword and Leather Vest with the number keys. (≈ 3 min)
- In
to_save()andfrom_save(), save and restore the equipment and quest status using.value,ItemKind(...)andQuestStatus(...). 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:
- Draw the dialogue graph for a second NPC in the village (a blacksmith, a guard). What action would one of its replies trigger?
- Why is computing
attack()from the equipment safer than storing a bonus number? Describe a bug the stored version could cause. - 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
Enumstatus; paying the reward moves toDONE, so it happens once. - JSON can't store Enums: save
.value, load withKind(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 isadd_item()plus a subtraction. - Simple combat: add a slime with its own
Stats; pressing F next to it dealsmax(1, hero.attack() - 0)damage and it hits back withmax(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
ITEMSintoitems.jsonand 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.