Learning Goals
3 minBy the end of this lesson you can:
- Design a Hero class with several attributes and several methods.
- Build a turn-based duel loop using two instances.
- Add an XP/level system with a method that triggers automatically when XP crosses a threshold.
- Spot how class methods on TWO instances (
hero.attack(monster)) form interaction.
Warm-Up · The Two-Object Pattern
5 minCombat involves two characters — attacker and victim. The Pythonic way is one method on the attacker that takes the victim as a parameter:
hero.attack(monster) # hero attacks monster monster.attack(hero) # monster attacks back
Inside the method, self is the attacker and the parameter is the victim. The method modifies both:
def attack(self, victim): damage = self.attack_power victim.take_damage(damage)
That's the whole interaction pattern. Two objects, one method, mutual state change.
Methods don't have to act on one object. Pass another instance as an argument and you have interaction — the foundation of every game with NPCs, every multiplayer system, every UI with mouse events.
New Concept · The Hero Spec
14 minThe attributes
name str "Aisyah" hp int current health max_hp int cap for healing attack_power int damage per hit inventory list items the hero carries xp int experience points, starts at 0 level int starts at 1
The methods
is_alive(self) True if hp > 0 take_damage(self, n) hp -= n, clamped at 0 heal(self, n) hp += n, clamped at max_hp attack(self, victim) victim takes self.attack_power damage pick_up(self, item) inventory.append gain_xp(self, n) increase XP, level up if threshold reached _level_up(self) private-ish helper (we'll learn about _ in L3-10) report(self) multi-line status banner
Levelling formula
The classic: each level requires level × 100 XP. After levelling up, max_hp grows by 20, attack_power by 2, and HP refills to full.
def gain_xp(self, n): self.xp += n while self.xp >= self.level * 100: self.xp -= self.level * 100 self._level_up() def _level_up(self): self.level += 1 self.max_hp += 20 self.attack_power += 2 self.hp = self.max_hp print(f" ✨ {self.name} leveled up to {self.level}!")
The while handles multi-level jumps — gaining 500 XP at level 1 levels you twice. The leading underscore in _level_up signals "private helper, don't call from outside" — formalised in PY-L3-10.
The duel loop
def duel(hero, monster): while hero.is_alive() and monster.is_alive(): hero.attack(monster) if not monster.is_alive(): break monster.attack(hero) return hero if hero.is_alive() else monster
Two-object turn-based combat in 6 lines. The function operates on the instances; the rules live in the methods.
Worked Example · The First Duel
12 minSave as dungeon.py:
# dungeon.py — Hero class + a duel import random class Hero: def __init__(self, name, hp=100, attack_power=12): self.name = name self.hp = hp self.max_hp = hp self.attack_power = attack_power self.inventory = [] self.xp = 0 self.level = 1 def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def heal(self, n): self.hp = min(self.max_hp, self.hp + n) def attack(self, victim): dmg = self.attack_power + random.randint(-2, 2) victim.take_damage(dmg) print(f" {self.name} hits {victim.name} for {dmg}. " f"({victim.name} HP {victim.hp}/{victim.max_hp})") def pick_up(self, item): self.inventory.append(item) print(f" {self.name} picked up {item}.") def gain_xp(self, n): self.xp += n while self.xp >= self.level * 100: self.xp -= self.level * 100 self._level_up() def _level_up(self): self.level += 1 self.max_hp += 20 self.attack_power += 2 self.hp = self.max_hp print(f" ✨ {self.name} leveled up to {self.level}!") def report(self): bar = "█" * (self.hp * 10 // self.max_hp) + "░" * (10 - self.hp * 10 // self.max_hp) return (f"{self.name} (Lv {self.level}) " f"HP {self.hp}/{self.max_hp} [{bar}] " f"ATK {self.attack_power}") # Monster as a simpler class (we'll formalise this in PY-L3-09) class Monster: def __init__(self, name, hp, attack_power): self.name = name; self.hp = hp; self.max_hp = hp self.attack_power = attack_power def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def attack(self, victim): dmg = self.attack_power + random.randint(-1, 1) victim.take_damage(dmg) print(f" {self.name} hits {victim.name} for {dmg}. " f"({victim.name} HP {victim.hp}/{victim.max_hp})") def duel(hero, monster): print(f"\n⚔ {hero.report()}\n vs {monster.name} (HP {monster.hp})\n") turn = 1 while hero.is_alive() and monster.is_alive(): print(f"-- Turn {turn} --") hero.attack(monster) if monster.is_alive(): monster.attack(hero) turn += 1 if hero.is_alive(): print(f"\n🏆 {hero.name} wins!") hero.gain_xp(80) hero.pick_up("gold coin") else: print(f"\n💀 {monster.name} wins. {hero.name} fell in the dungeon.") # Play aisyah = Hero("Aisyah", hp=80, attack_power=14) goblin = Monster("Goblin", hp=40, attack_power=8) duel(aisyah, goblin) print("\n" + aisyah.report()) print("Inventory:", aisyah.inventory)
Sample output (numbers will vary)
⚔ Aisyah (Lv 1) HP 80/80 [██████████] ATK 14 vs Goblin (HP 40) -- Turn 1 -- Aisyah hits Goblin for 13. (Goblin HP 27/40) Goblin hits Aisyah for 9. (Aisyah HP 71/80) -- Turn 2 -- Aisyah hits Goblin for 16. (Goblin HP 11/40) Goblin hits Aisyah for 8. (Aisyah HP 63/80) -- Turn 3 -- Aisyah hits Goblin for 14. (Goblin HP 0/40) 🏆 Aisyah wins! Aisyah picked up gold coin. Aisyah (Lv 1) HP 63/80 [████████░░] ATK 14 Inventory: ['gold coin']
Read the diff
Every interaction is method-on-instance. hero.attack(goblin) calls hero's attack method, passing goblin as the victim. goblin.attack(hero) is the same shape with the roles swapped. The duel function knows nothing about HP arithmetic or damage — it just orchestrates turns. The HP bar uses string multiplication from Level 1. The split between Hero and Monster classes is intentional; tomorrow's lesson is about reusing code between them with inheritance.
Try It Yourself
13 minOutside any duel, call aisyah.gain_xp(250). Confirm she levels up twice (250 = 100 + 150, the second level wants 200 so it doesn't quite reach Lv 3 — adjust the formula or the XP to see exactly).
Hint
a = Hero("Aisyah") a.gain_xp(300) # 100 + 200 = exactly 2 levels print(a.report()) # Aisyah (Lv 3)
Add a method use_potion(self) that removes one "potion" from inventory and heals 30 HP. Print a warning if there are no potions.
Hint
def use_potion(self): if "potion" not in self.inventory: print(f" {self.name} has no potion!") return self.inventory.remove("potion") self.heal(30) print(f" {self.name} drinks a potion. HP {self.hp}/{self.max_hp}.") aisyah.pick_up("potion") aisyah.take_damage(50) aisyah.use_potion()
Methods can read and modify multiple attributes in one go — inventory + hp here. That's encapsulation: outside callers don't need to know the internals.
10% of the time, an attack does double damage. Print "CRIT!" before the message.
Hint
def attack(self, victim): dmg = self.attack_power + random.randint(-2, 2) if random.random() < 0.10: dmg *= 2 print(" CRIT!") victim.take_damage(dmg) print(f" {self.name} hits {victim.name} for {dmg}.")
Run the duel a few times — you'll see crits roughly every 10 turns. The 10% is just random.random() < 0.1, exactly the pattern from PY-L2-18.
Mini-Challenge · The Dungeon Crawl
8 minBuild dungeon_crawl.py. One hero fights through a list of three increasingly tough monsters. Between fights, the hero gains XP and loot.
- 3 Monsters: Goblin (HP 40, ATK 8), Orc (HP 80, ATK 14), Dragon (HP 200, ATK 25).
- After each victory: hero gains XP (40, 80, 200), picks up loot (gold coin, silver key, dragon scale).
- Between fights, hero heals 30 HP (rest at camp).
- Print the hero's status between fights.
- If the hero falls, print "Game over".
Show one possible solution
# dungeon_crawl.py — three-monster run # (paste Hero + Monster + duel from dungeon.py) aisyah = Hero("Aisyah", hp=100, attack_power=14) dungeon = [ (Monster("Goblin", 40, 8), 40, "gold coin"), (Monster("Orc", 80, 14), 80, "silver key"), (Monster("Dragon", 200, 25), 200, "dragon scale"), ] for monster, xp_reward, loot in dungeon: duel(aisyah, monster) if not aisyah.is_alive(): print(f"💀 Game over at {monster.name}.") break aisyah.gain_xp(xp_reward) aisyah.pick_up(loot) aisyah.heal(30) print(aisyah.report()) print() else: print(f"\n🏆 Dungeon cleared!") print(aisyah.report()) print("Loot:", aisyah.inventory)
Non-negotiables: a list of monsters as tuples (monster, xp, loot), a for-loop driving the duels, a heal between fights, and the for/else trick to detect "survived the whole dungeon". for/else from PY-L1-12 — the else runs only when the loop didn't break.
Recap
3 minThe Hero class is everything from the last five lessons in one place. __init__ sets up attributes. Methods read and write self. Methods that take another instance as a parameter handle interactions like combat. A duel loop calls those methods turn after turn. An XP system mutates the hero's state automatically when thresholds are crossed. Tomorrow we'll address the elephant in the room — Hero and Monster share almost all their code — with inheritance.
Vocabulary Card
- two-object method
- A method that takes another instance as a parameter and mutates both. Combat, transfer, follow.
- orchestrator
- A function (like
duel) that drives interactions without knowing the internals. - derived stat
max_hpcomputed fromhpat construction. Internal consistency.- _private helper
- A method starting with
_— convention for "don't call from outside". Formalised in PY-L3-10.
Homework
4 minAdd three features to your Hero class:
- Class — accept
hero_class(str: "warrior", "mage", "rogue"). Print it inreport. - Class-based starting stats — warriors start with more HP, mages with more attack. Use an
ifin__init__. - Combat log — append every hit to
self.log(a list of dicts with turn, target, damage). Add ashow_log()method.
Sample · key changes
def __init__(self, name, hero_class="warrior"): self.name = name self.hero_class = hero_class if hero_class == "warrior": self.hp, self.attack_power = 120, 12 elif hero_class == "mage": self.hp, self.attack_power = 70, 20 elif hero_class == "rogue": self.hp, self.attack_power = 90, 16 else: self.hp, self.attack_power = 100, 14 self.max_hp = self.hp self.inventory = [] self.xp = 0 self.level = 1 self.log = [] def attack(self, victim): dmg = self.attack_power + random.randint(-2, 2) victim.take_damage(dmg) self.log.append({"target": victim.name, "dmg": dmg}) def show_log(self): for i, entry in enumerate(self.log, 1): print(f" {i}. hit {entry['target']} for {entry['dmg']}")
Non-negotiables: class-based stats in __init__, a log list, and a method that prints it. The log gives the player a debrief — a feature shippable games have and toy games don't.