Learning Goals
3 minBy the end of this lesson you can:
- Design a family of related classes — one parent, several children.
- Override one method per child to differentiate behaviour.
- Use
super()in__init__to keep shared setup in the parent. - Write a game loop that treats every child as "a Monster" — and works regardless.
Warm-Up · Three Monsters, One Family
5 minThree monsters with different attack flavours:
- Goblin — weak but fast. 50% chance of attacking twice per turn.
- Orc — strong, plain. Just a hard hit.
- Boss — uses one of three special moves at random.
All three need HP, max_hp, attack_power, take_damage, is_alive, loot, name. That's the parent. Differentiating behaviour goes in the children.
The parent describes "what every monster has". Each child describes "what makes me different". The game code stays generic.
New Concept · Designing the Tree
14 minThe parent
import random class Monster: def __init__(self, name, hp, attack_power, loot=None): self.name = name self.hp = hp self.max_hp = hp self.attack_power = attack_power self.loot = loot def is_alive(self): return self.hp > 0 def take_damage(self, n): self.hp = max(0, self.hp - n) def attack(self, victim): """The default — a plain hit. Children may override.""" dmg = self.attack_power + random.randint(-1, 1) victim.take_damage(dmg) print(f" {self.name} hits {victim.name} for {dmg}.") def report(self): return f"{self.name} (HP {self.hp}/{self.max_hp}, ATK {self.attack_power})"
Goblin — the fast attacker
class Goblin(Monster): def __init__(self): super().__init__("Goblin", hp=30, attack_power=5, loot="gold coin") def attack(self, victim): super().attack(victim) if random.random() < 0.5: print(" (Goblin lunges again!)") super().attack(victim)
Goblin's attack extends the parent — does the parent's hit, then maybe does it again.
Orc — the heavy hitter (no override needed)
class Orc(Monster): def __init__(self): super().__init__("Orc", hp=70, attack_power=14, loot="iron axe") # uses parent's attack — no override
Orc just has different starting stats. Its attack method is the default. Sometimes inheritance means "same behaviour, different config".
Boss — special moves
class Boss(Monster): def __init__(self): super().__init__("Dragon", hp=200, attack_power=25, loot="dragon scale") def attack(self, victim): """Pure override — three different attacks.""" move = random.choice(["flame", "tail", "roar"]) if move == "flame": dmg = self.attack_power + 10 print(f" 🔥 {self.name} breathes fire! {dmg} damage.") victim.take_damage(dmg) elif move == "tail": dmg = self.attack_power print(f" 🐉 {self.name} swings its tail. {dmg} damage.") victim.take_damage(dmg) else: print(f" 🦁 {self.name} roars — {victim.name} is stunned (no effect).") # nothing happens — the stun cancels the damage
Boss replaces attack entirely. Three branches: fire, tail, roar — different damage and flavour.
The whole tree
Monster
/ | \
Goblin Orc Boss
extend inherit replace
attack attack attackThree different relationships to the parent. All three are valid. The choice depends on the design intent.
Worked Example · The Three-Monster Dungeon
12 minSave as monsters.py. Combine the Creature hierarchy from PY-L3-07 with the new monster family:
# monsters.py — Monster family + a dungeon import random class Creature: 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) class Hero(Creature): def __init__(self, name, hp=120, attack_power=14): super().__init__(name, hp, attack_power) self.inventory = [] 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}.") def pick_up(self, item): self.inventory.append(item) print(f" {self.name} picks up {item}.") class Monster(Creature): def __init__(self, name, hp, attack_power, loot=None): super().__init__(name, hp, attack_power) self.loot = loot 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}.") class Goblin(Monster): def __init__(self): super().__init__("Goblin", hp=30, attack_power=5, loot="gold coin") def attack(self, victim): super().attack(victim) if random.random() < 0.5 and victim.is_alive(): print(" (Goblin lunges again!)") super().attack(victim) class Orc(Monster): def __init__(self): super().__init__("Orc", hp=70, attack_power=14, loot="iron axe") # default attack inherited class Boss(Monster): def __init__(self): super().__init__("Dragon", hp=200, attack_power=25, loot="dragon scale") def attack(self, victim): move = random.choice(["flame", "tail", "roar"]) if move == "flame": dmg = self.attack_power + 10 print(f" 🔥 Dragon breathes fire! {dmg} damage.") victim.take_damage(dmg) elif move == "tail": dmg = self.attack_power print(f" 🐉 Dragon swings tail. {dmg} damage.") victim.take_damage(dmg) else: print(f" 🦁 Dragon roars — {victim.name} is stunned!") def duel(hero, monster): print(f"\n⚔ vs {monster.name} (HP {monster.hp})") while hero.is_alive() and monster.is_alive(): hero.attack(monster) if monster.is_alive(): monster.attack(hero) if hero.is_alive(): print(f"🏆 {hero.name} wins!") if monster.loot: hero.pick_up(monster.loot) else: print(f"💀 {hero.name} fell.") hero = Hero("Aisyah") for monster in [Goblin(), Orc(), Goblin(), Boss()]: duel(hero, monster) if not hero.is_alive(): break print(f"\n=== End of run ===") print(f"Inventory: {hero.inventory}") print(f"HP : {hero.hp}/{hero.max_hp}")
Sample output (numbers will vary)
⚔ vs Goblin (HP 30) Aisyah hits Goblin for 14. Goblin hits Aisyah for 6. (Goblin lunges again!) Goblin hits Aisyah for 4. Aisyah hits Goblin for 16. 🏆 Aisyah wins! Aisyah picks up gold coin. ⚔ vs Orc (HP 70) Aisyah hits Orc for 13. Orc hits Aisyah for 15. ... many turns ... 🏆 Aisyah wins! ⚔ vs Goblin (HP 30) ... ⚔ vs Dragon (HP 200) Aisyah hits Dragon for 14. 🦁 Dragon roars — Aisyah is stunned! Aisyah hits Dragon for 13. 🔥 Dragon breathes fire! 35 damage. ... 💀 Aisyah fell. === End of run === Inventory: ['gold coin', 'iron axe', 'gold coin'] HP : 0/120
Read the diff
Each Monster child specialises differently. Goblin extends (super + extra). Orc just inherits (no override needed). Boss replaces (entirely new attack). The duel function knows nothing about these specifics — it calls monster.attack(hero) and the right version runs because of Python's method resolution. The same code works for any monster you invent next week.
Try It Yourself
13 minAdd a Skeleton child of Monster — HP 40, ATK 10, loot "bone". Inherits attack from Monster.
Hint
class Skeleton(Monster): def __init__(self): super().__init__("Skeleton", hp=40, attack_power=10, loot="bone") duel(Hero("Aisyah"), Skeleton())
That's all — no override needed. Inheritance gives the basic attack for free.
Add Troll(Monster) with HP 90 ATK 12. Override take_damage to halve incoming damage (round down) before calling super.
Hint
class Troll(Monster): def __init__(self): super().__init__("Troll", hp=90, attack_power=12, loot="rock") def take_damage(self, n): super().take_damage(n // 2) # halve before delegating print(f" (Troll's thick hide reduces damage to {n // 2}.)") duel(Hero("Aisyah"), Troll())
Same shape as PY-L3-08 sandwich. Halve, then call super to apply the (already reduced) damage.
Modify Boss: when its HP drops below 50, it regenerates 30 HP on its next turn instead of attacking. Use the "roar" branch's style of "no damage to hero this turn".
Hint
def attack(self, victim): if self.hp < 50: regen = 30 self.hp = min(self.max_hp, self.hp + regen) print(f" ✨ Dragon heals for {regen}. HP {self.hp}/{self.max_hp}.") return # no attack this turn move = random.choice(["flame", "tail", "roar"]) # ... existing branches ...
Conditional behaviour inside the override. The Boss is now noticeably harder.
Mini-Challenge · The Random Dungeon
8 minBuild random_dungeon.py. Instead of a hard-coded sequence of monsters, generate one randomly. Use a list of classes and pick from it.
- Build a list
MONSTER_CLASSES = [Goblin, Orc, Skeleton, Troll]. (Boss appears only at the end.) - Pick 5 random monsters with
random.choice(MONSTER_CLASSES)()— note the second pair of parens that calls the class to make an instance. - After 5 monsters, the Boss appears (always last).
- The hero heals 10 HP after each fight.
- Print the survivors' inventory at the end.
Show one possible solution
# random_dungeon.py — random monster generation import random # (paste Creature, Hero, Monster, Goblin, Orc, Skeleton, Troll, Boss, duel) MONSTER_CLASSES = [Goblin, Orc, Skeleton, Troll] hero = Hero("Aisyah") for i in range(5): cls = random.choice(MONSTER_CLASSES) monster = cls() # call the class to make an instance print(f"\n--- Encounter {i + 1}: {monster.name} ---") duel(hero, monster) if not hero.is_alive(): break hero.hp = min(hero.max_hp, hero.hp + 10) if hero.is_alive(): print("\n=== FINAL BOSS ===") duel(hero, Boss()) print(f"\nFinal inventory: {hero.inventory}") print(f"Final HP : {hero.hp}/{hero.max_hp}")
Non-negotiables: a list of classes (not instances) — random.choice(MONSTER_CLASSES)() picks one class and instantly instantiates it. Python classes are values, just like numbers and strings.
Recap
3 minA monster family is a parent class plus differentiated children. Each child has one of three relationships to the parent's methods — pure inherit (no override), extend (super + extra), or replace (totally new). Pick the right one based on whether you want the parent's behaviour. The game code stays generic — it calls monster.attack() and Python figures out which version to run. Classes are first-class values; you can store them in lists and instantiate from variables.
Vocabulary Card
- family
- A parent class with multiple related children. Common shape in real systems.
- specialisation
- How each child differs from the parent — different stats, different methods, different both.
- method resolution
- The process by which Python picks which version of a method to call — child first, then parent.
- random.choice(classes)()
- Pick a class from a list, then instantiate. The second pair of parens does the construction.
Homework
4 minDesign a Pet family. Parent class with attributes name, hunger (0-10), method feed that lowers hunger by 3. Then three children:
Dog(Pet)— addstrickslist, methoddo_trick()that picks one at random.Cat(Pet)— overridesfeedto only lower hunger by 2 (cats are picky).Fish(Pet)— adds methodswim();feedonly lowers hunger by 1.
Build one of each, feed them, exercise them, print final state.
Sample · pets.py
import random class Pet: def __init__(self, name): self.name = name self.hunger = 10 def feed(self): self.hunger = max(0, self.hunger - 3) print(f"{self.name} fed. Hunger {self.hunger}/10.") class Dog(Pet): def __init__(self, name, tricks): super().__init__(name) self.tricks = tricks def do_trick(self): trick = random.choice(self.tricks) print(f"{self.name} does: {trick}!") class Cat(Pet): def feed(self): self.hunger = max(0, self.hunger - 2) print(f"{self.name} reluctantly eats. Hunger {self.hunger}/10.") class Fish(Pet): def feed(self): self.hunger = max(0, self.hunger - 1) print(f"{self.name} nibbles. Hunger {self.hunger}/10.") def swim(self): print(f"{self.name} swims around the tank.") pets = [ Dog("Rex", ["sit", "shake", "rollover"]), Cat("Whiskers"), Fish("Bubbles"), ] for p in pets: p.feed() pets[0].do_trick() pets[2].swim() print("\nFinal hunger:") for p in pets: print(f" {p.name}: {p.hunger}")
Non-negotiables: Pet parent with shared feed, three children each with a different override or extension, plus child-only methods (do_trick, swim). Pure replacement (Cat's feed) and inheritance + extra (Dog adds tricks) both present.