Build a Game Character Combat System
Build a text-based combat system using Python OOP patterns in Google Colab.
Introduction
30 Second Summary
Every video game character has stats, inventory, and combat abilities that interact in complex ways. Behind the scenes, these systems are organized using object-oriented programming patterns that keep code flexible and maintainable as the game grows.
In this project, you will build a text-based game character and combat system in Python that exercises OOP concepts beyond basic inheritance. You will use abstract base classes, composition, and the strategy pattern to create characters that equip weapons, manage inventories, and swap combat behaviors at runtime.
What You'll Build
A complete game character and combat system running in a Google Colab notebook, where Warriors, Mages, and Rangers fight using swappable combat strategies.
By the end of this project, you'll have:
- A working character hierarchy using abstract base classes, with concrete Warrior, Mage, and Ranger subclasses that each implement unique attack logic.
- A composition-based inventory system that characters own rather than inherit from, demonstrating the difference between "has-a" and "is-a" relationships.
- A strategy pattern implementation for runtime-swappable combat behaviors (Aggressive, Defensive, Balanced) with a turn-based battle function.
- Secret Mission: An observer pattern that logs combat events to a battle history using Python's Protocol for structural typing.
Are there any prerequisites?
You should be comfortable with basic Python (functions, classes, and simple inheritance). You also need a Google account to access Google Colab, which provides a free Python 3.12 runtime with no installation required.
Before We Start
Before We Start...
Set Up Google Colab
Before building the game character system, you need a working Python environment with access to the standard library modules that power OOP design patterns.
Google Colab provides a free, browser-based Python notebook that requires no local installation. In this step, you will create a notebook and verify that the runtime supports every feature the project uses.
In this step, get ready to:
- Create a new notebook in Google Colab.
- Confirm the Python version is 3.12+.
- Verify all standard library imports run without errors.
Open Google Colab and create a notebook
- Open your browser and navigate to colab.research.google.com.
- Sign in with your Google account if prompted.
- Click New notebook to create a blank notebook.
- Click the notebook title at the top left (it defaults to something like Untitled0.ipynb).
- Rename it to oop_combat_system.ipynb.
What is Google Colab?
Google Colab is a free, cloud-hosted Jupyter notebook environment. It gives you a full Python runtime in the browser with no setup required beyond a Google account.
Each notebook is made up of cells. You write code in a cell and press the play button (or Shift+Enter) to execute it. Output appears directly below the cell.
Verify the Python version
This project uses Python features introduced in version 3.10+ (union type syntax, frozen dataclasses with full support). Colab's runtime currently ships Python 3.12, so a quick version check confirms you are good to go.
- In the first cell of your notebook, check the Python version by running this code:
import sys
print(f"Python {sys.version}")
What does this code do?
The sys module provides access to interpreter-level information. sys.version returns the full version string of the Python runtime powering your notebook.
After running the cell, check your output below.
✔️ I see Python 3.12 or higher
Your Colab runtime is up to date. You have everything you need to use frozen dataclasses, union type syntax, and Protocol.
ⓧ I see an older version
Colab's hosted runtime should already be Python 3.12+. If you see an older version, your notebook may be connected to a stale runtime session.
- Click Runtime in the top menu bar.
- Click Disconnect and delete runtime.
- Re-run the version check cell. Colab will automatically connect a fresh runtime.
If you still see an older version after reconnecting, try creating a brand new notebook at colab.research.google.com and running the check there.
Import the standard library modules
The project uses several standard library modules for abstract classes, dataclasses, enums, structural typing, and randomness. Importing them now confirms they are all available before you start building.
- Add a new cell below your version check, then paste and run this import block:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
What do these imports provide?
- ABC and abstractmethod let you define abstract base classes that enforce method contracts on subclasses.
- dataclass and field auto-generate boilerplate for data-holding classes (__init__, __repr__, __eq__).
- Enum and auto create named constants that are safer than raw strings or integers.
- Protocol enables structural subtyping, which you will use in the Secret Mission.
- random provides randomness for combat calculations like critical hits.
- Confirm the cell runs without any error output. A successful import produces no output at all.
Seeing an ImportError?
All five imports are from Python's standard library and require no pip install. If you see an error, double check for typos in the module or symbol names (e.g. abstractmethod is one word, not two).
help me debug an ImportError in my Google Colab notebook
✔️ Awesome, I've got everything!
Great. Double check you have both cells in your notebook: the version check and the imports.
ⓧ I'd like to double check the full code
Here is the complete import cell your notebook should contain:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
Your environment is ready and every module is confirmed working. Next up, you will start modelling game items using dataclasses and enums.
Model Items and Weapon Stats
Your notebook is set up and all imports are confirmed working. Now you can start building the data layer that powers every item in your game.
Every game character needs items like weapons, armor, and potions. Each weapon has stats that should never accidentally change mid-battle. Python's dataclasses and enums give you clean data containers with built-in safety features, so you can define items once and trust they stay consistent.
In this step, get ready to:
- Define item types with an enum and weapon stats with a frozen dataclass.
- Build an Item dataclass with auto-generated methods.
- Create item instances and verify their representations and equality.
Define the ItemType enum and frozen WeaponStats dataclass
Your game needs a way to categorize items (is this a weapon, armor, or a potion?) and store weapon attributes like damage and speed. An enum restricts item types to a fixed set of valid options. A frozen dataclass stores stats as an immutable bundle that cannot be modified after creation.
- In your Colab notebook, add a new code cell below your imports cell.
- Define the ItemType enum and WeaponStats frozen dataclass by pasting this code:
# Define the types of items in our game
class ItemType(Enum):
WEAPON = auto()
ARMOR = auto()
POTION = auto()
# Weapon stats are frozen - once created, they cannot be changed
@dataclass(frozen=True)
class WeaponStats:
damage: int
speed: int
crit_chance: float
What does this code do?
ItemType(Enum) creates a fixed set of valid item categories. Each member (WEAPON, ARMOR, POTION) gets an auto-generated integer value. This prevents typos like "wepon" from sneaking into your data.
@dataclass(frozen=True) on WeaponStats makes instances immutable. Any attempt to change a field after creation raises a FrozenInstanceError. This protects weapon stats from being accidentally modified during combat.
- Run the cell by pressing Shift+Enter.
- Add another new cell below and paste this code to test the frozen behavior:
# Test that WeaponStats prints cleanly
sword_stats = WeaponStats(damage=10, speed=5, crit_chance=0.2)
print(sword_stats)
# Attempt to modify a frozen field - this should raise an error
try:
sword_stats.damage = 15
except FrozenInstanceError as e:
print(f"FrozenInstanceError: {e}")
What should I see?
The first print shows a clean representation: WeaponStats(damage=10, speed=5, crit_chance=0.2). The try/except block catches the FrozenInstanceError and prints a message confirming the field cannot be reassigned.
- Run the cell with Shift+Enter.
- Confirm you see the clean WeaponStats(...) output followed by the FrozenInstanceError message.
Not seeing the FrozenInstanceError?
Make sure you ran the cell that defines WeaponStats before running the test cell. Colab cells must be executed in order for classes to be available.
If you see NameError: name 'FrozenInstanceError' is not defined, add from dataclasses import FrozenInstanceError to your imports cell and re-run it.
Help me debug my frozen dataclass test
Create the Item dataclass
Now you need a container that brings together an item's name, type, weight, and optional weapon stats. A regular dataclass (not frozen) works here because items might change ownership or location, but their definition should still auto-generate useful methods.
- Add a new cell below your test cell and paste this code:
# An item combines a name, type, weight, and optional weapon stats
@dataclass
class Item:
name: str
item_type: ItemType
weight: float
stats: WeaponStats | None = None
What does this code do?
The @dataclass decorator auto-generates __init__, __repr__, and __eq__ methods based on the fields you define. You get a constructor, readable print output, and value-based equality comparison for free.
The stats: WeaponStats | None = None field uses Python's union syntax to say "this can be a WeaponStats object or None." A potion or armor piece has no weapon stats, so it stays None.
- Run the cell with Shift+Enter.
- Add a new cell and paste this code to test the auto-generated methods:
# Dataclasses auto-generate __init__, __repr__, and __eq__
item1 = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
item2 = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
# __repr__ gives a clean printable representation
print(item1)
# __eq__ compares field values, not object identity
print(f"item1 == item2: {item1 == item2}")
print(f"item1 is item2: {item1 is item2}")
What should I see?
The print(item1) line outputs Item(name='Iron Sword', item_type=<ItemType.WEAPON: 1>, weight=5.0, stats=WeaponStats(...)). The equality check shows True because dataclasses compare by field values, not memory address. The identity check (is) shows False because they are two separate objects.
- Run the cell with Shift+Enter.
- Confirm that item1 == item2 prints True and item1 is item2 prints False.
Seeing a NameError?
If you see NameError: name 'Item' is not defined, you need to run the cell that defines the Item class before running the test cell. In Colab, click into the Item cell and press Shift+Enter, then re-run your test cell.
Help me fix my Item dataclass cell
Create item instances and print them
With your data types defined, you can now create the actual game items your characters will use. Each weapon gets a name, a type, a weight, and a WeaponStats bundle.
- Add a new cell and paste this code to create your game items:
# Create game items with weapon stats
sword = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
staff = Item("Oak Staff", ItemType.WEAPON, 3.0, WeaponStats(damage=10, speed=5, crit_chance=0.05))
bow = Item("Longbow", ItemType.WEAPON, 2.0, WeaponStats(damage=15, speed=7, crit_chance=0.25))
# Print each item to see the auto-generated __repr__
print(sword)
print(staff)
print(bow)
What does this code do?
Each Item(...) call uses the auto-generated __init__ from the @dataclass decorator. You pass field values in the order they were declared and Python constructs the object automatically.
Notice how each weapon has different stat profiles. The sword hits hard but slow. The staff has lower damage but faster speed. The bow has the highest crit chance. These differences will drive distinct combat behaviors later.
- Run the cell with Shift+Enter.
- Confirm you see three clean Item(...) representations with their names, types, weights, and stats.
When should I use a dataclass vs a regular class?
Use a dataclass when your object is primarily a data container with little behavior. Items and stats are perfect examples. They store values, compare by those values, and print themselves clearly.
Use a regular class when your object has complex state, private attributes, and many methods that change over time. In the next step, you will build a Character class that manages HP, equips weapons, and executes attacks. That kind of behavior-heavy object fits a regular class.
Items printing without stats?
If stats=None shows up in your output, make sure you passed a WeaponStats(...) object as the fourth argument. The stats field defaults to None when omitted.
Help me check my Item instances
✔️ Awesome, I've got everything!
Great. Double check you have saved all your cells. You should have your imports cell at the top, followed by the ItemType + WeaponStats cell, the Item cell, and your test cells.
ⓧ I'd like to double check the full code
Here is the full cumulative code your notebook should contain across its definition cells (not counting your test/demo cells):
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
# --- Step 2: Dataclasses and Enums ---
class ItemType(Enum):
WEAPON = auto()
ARMOR = auto()
POTION = auto()
@dataclass(frozen=True)
class WeaponStats:
damage: int
speed: int
crit_chance: float
@dataclass
class Item:
name: str
item_type: ItemType
weight: float
stats: WeaponStats | None = None
Your data layer is locked in. You have type-safe item categories, immutable weapon stats, and clean data containers with built-in equality. Next up, you will build the Character class hierarchy using abstract base classes and inheritance.
Build the Character Hierarchy
You have a clean data model for items and weapons. The next problem: who actually uses these items? You need characters with shared behavior (taking damage, equipping weapons) but completely different fighting styles.
Abstract Base Classes solve this by defining a contract. Every character subclass must implement specific methods, and Python prevents anyone from creating a half-baked generic character that doesn't know how to fight.
In this step, get ready to:
- Define an abstract Character class with shared behavior and required methods.
- Create three concrete character types: Warrior, Mage, and Ranger.
- Demonstrate encapsulation through HP clamping and property access.
Define the Character abstract base class
An abstract base class defines a contract. Subclasses must implement every method marked @abstractmethod, or Python refuses to instantiate them.
- Create a new code cell below your Step 2 cells. Add a section comment and the full Character class by pasting this code:
# --- Step 3: Abstract Base Classes and Inheritance ---
class Character(ABC):
def __init__(self, name: str, max_hp: int) -> None:
self.name = name
self.max_hp = max_hp
self._current_hp = max_hp
self.inventory = Inventory(capacity=50.0)
self._equipped_weapon: Item | None = None
self.combat_strategy: CombatStrategy = BalancedStrategy()
@property
def hp(self) -> int:
return self._current_hp
@hp.setter
def hp(self, value: int) -> None:
# Clamp HP between 0 and max_hp
self._current_hp = max(0, min(value, self.max_hp))
@property
def is_alive(self) -> bool:
return self._current_hp > 0
def take_damage(self, amount: int) -> None:
# Uses the hp setter, which enforces clamping
self.hp = self.hp - amount
def equip_weapon(self, weapon_name: str) -> bool:
# Search inventory for a matching weapon
for item in self.inventory._items:
if item.name == weapon_name and item.item_type == ItemType.WEAPON:
self._equipped_weapon = item
return True
return False
def set_strategy(self, strategy: "CombatStrategy") -> None:
self.combat_strategy = strategy
@abstractmethod
def attack(self, target: "Character") -> int:
...
@abstractmethod
def special_ability(self) -> str:
...
What does this code do?
- Character(ABC) inherits from ABC, marking it as an abstract class that cannot be instantiated directly.
- _current_hp uses the underscore convention to signal "don't touch this directly." The hp property provides controlled access with a clamping setter that keeps values between 0 and max_hp.
- attack() and special_ability() are abstract. Every subclass must provide its own implementation or Python raises a TypeError.
- Inventory and CombatStrategy are referenced here but defined in later steps. The cell won't run yet, and that's fine.
Why use a property instead of a plain attribute?
A plain attribute like self.hp = -999 would silently corrupt your game state. The property setter intercepts every assignment and clamps it to a valid range. External code reads and writes character.hp naturally, but the invariant (0 to max) is always enforced behind the scenes.
- Do not run this cell yet. It references classes that you will build in Steps 4 and 5.
Create the Warrior, Mage, and Ranger subclasses
Each subclass inherits all of Character's shared behavior but provides unique fighting logic. This is polymorphism in action. You call character.attack(target) on any character type and get type-specific behavior without knowing which class is behind it.
- Create a new cell directly below the Character class. Add the Warrior subclass:
class Warrior(Character):
def __init__(self, name: str) -> None:
# Warriors have the highest HP pool
super().__init__(name, max_hp=150)
def attack(self, target: "Character") -> int:
base_damage = 15
# Bonus damage from equipped weapon
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Shield Bash! Stuns the enemy for 1 turn."
What does this code do?
- super().__init__(name, max_hp=150) calls the parent's __init__ with a fixed max HP of 150. The Warrior only exposes name to the caller.
- attack() starts with a base damage of 15 and adds weapon damage if one is equipped. Straightforward melee logic.
- In the same cell, add the Mage subclass below Warrior:
class Mage(Character):
def __init__(self, name: str) -> None:
# Mages are fragile with low HP
super().__init__(name, max_hp=80)
def attack(self, target: "Character") -> int:
base_damage = 8
spell_multiplier = 2.5
# Spell multiplier amplifies all damage
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return int(base_damage * spell_multiplier)
def special_ability(self) -> str:
return f"{self.name} casts Fireball! Deals area damage."
What does this code do?
The Mage has only 80 HP but compensates with a spell_multiplier of 2.5 applied to all damage. The int() call rounds down the result since damage must be a whole number.
- Still in the same cell, add the Ranger subclass below Mage:
class Ranger(Character):
def __init__(self, name: str) -> None:
# Rangers sit between Warrior and Mage in durability
super().__init__(name, max_hp=100)
def attack(self, target: "Character") -> int:
base_damage = 12
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
# Crit chance from weapon speed determines double damage
if random.random() < self._equipped_weapon.stats.crit_chance:
base_damage = int(base_damage * 2)
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Evasion! Dodges the next attack."
What does this code do?
The Ranger's attack uses the weapon's crit_chance field from your WeaponStats dataclass. If random.random() rolls below the crit threshold, damage doubles. This gives the Ranger a high-risk, high-reward playstyle.
- Do not run this cell yet. It depends on the Character cell above, which depends on classes from later steps.
Why do subclasses only take name?
Each subclass hardcodes its own max_hp (150, 80, 100). This is a design choice: a Warrior is always a tanky character. Callers write Warrior("Thorin") without needing to know internal balance numbers.
Test the abstract class and encapsulation
You now have a full class hierarchy on paper. To verify it actually works, you need to run a test cell with temporary placeholder stubs for the classes you will build later.
- Create a new cell below your subclass definitions. Paste this test code that includes temporary stubs and three demonstrations:
# --- Temporary stubs (will be replaced in Steps 4 and 5) ---
class Inventory:
def __init__(self, capacity: float) -> None:
self._items: list[Item] = []
self.capacity = capacity
class CombatStrategy(ABC):
@abstractmethod
def execute(self, attacker, defender) -> int:
...
class BalancedStrategy(CombatStrategy):
def execute(self, attacker, defender) -> int:
return attacker.attack(defender)
# --- Test 1: Cannot instantiate Character directly ---
try:
broken = Character("Nobody", 100)
except TypeError as e:
print(f"Test 1 PASSED: {e}")
# --- Test 2: Subclasses instantiate fine ---
warrior = Warrior("Thorin")
print(f"\nTest 2 PASSED: {warrior.name} has {warrior.hp}/{warrior.max_hp} HP")
# --- Test 3: HP clamping ---
warrior.hp = -50
print(f"\nTest 3a: Set hp = -50, actual hp = {warrior.hp} (clamped to 0)")
warrior.hp = 9999
print(f"Test 3b: Set hp = 9999, actual hp = {warrior.hp} (clamped to {warrior.max_hp})")
print(f"Test 3c: is_alive = {warrior.is_alive} (should be True since hp was clamped to max)")
What do these tests prove?
- Test 1 proves the ABC contract works. Python's metaclass machinery raises TypeError before __init__ even runs.
- Test 2 proves Warrior correctly implements the abstract contract. Instantiation succeeds.
- Test 3 proves encapsulation. Setting hp = -50 results in hp == 0. Setting hp = 9999 results in hp == 150 (Warrior's max). The property setter enforces bounds automatically.
- Run the test cell by clicking the play button or pressing Shift+Enter.
- You should see all three tests pass with output confirming TypeError, correct HP values, and clamping behavior.
Seeing a NameError instead?
- Make sure you ran the Step 2 cells first (the ones with ItemType, WeaponStats, and Item). Colab runs cells independently.
- Check that the imports cell at the very top (with from abc import ABC, abstractmethod) has been run. Without it, ABC is undefined.
- Confirm you pasted the temporary stubs (Inventory, CombatStrategy, BalancedStrategy) at the top of this test cell, not in a separate cell below.
Help me debug a NameError in my Colab character hierarchy test cell.
✔️ Awesome, I've got everything!
Great. Make sure you keep this test cell for reference. The temporary stubs will be replaced by full implementations in the next two steps.
ⓧ I'd like to double check the full code
Here is the complete notebook code up to this point (excluding the temporary test cell). Your Character cell and subclass cell should match this:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
# --- Step 2: Dataclasses and Enums ---
class ItemType(Enum):
WEAPON = auto()
ARMOR = auto()
POTION = auto()
@dataclass(frozen=True)
class WeaponStats:
damage: int
speed: int
crit_chance: float
@dataclass
class Item:
name: str
item_type: ItemType
weight: float
stats: WeaponStats | None = None
# --- Step 3: Abstract Base Classes and Inheritance ---
class Character(ABC):
def __init__(self, name: str, max_hp: int) -> None:
self.name = name
self.max_hp = max_hp
self._current_hp = max_hp
self.inventory = Inventory(capacity=50.0)
self._equipped_weapon: Item | None = None
self.combat_strategy: CombatStrategy = BalancedStrategy()
@property
def hp(self) -> int:
return self._current_hp
@hp.setter
def hp(self, value: int) -> None:
self._current_hp = max(0, min(value, self.max_hp))
@property
def is_alive(self) -> bool:
return self._current_hp > 0
def take_damage(self, amount: int) -> None:
self.hp = self.hp - amount
def equip_weapon(self, weapon_name: str) -> bool:
for item in self.inventory._items:
if item.name == weapon_name and item.item_type == ItemType.WEAPON:
self._equipped_weapon = item
return True
return False
def set_strategy(self, strategy: "CombatStrategy") -> None:
self.combat_strategy = strategy
@abstractmethod
def attack(self, target: "Character") -> int:
...
@abstractmethod
def special_ability(self) -> str:
...
class Warrior(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=150)
def attack(self, target: "Character") -> int:
base_damage = 15
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Shield Bash! Stuns the enemy for 1 turn."
class Mage(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=80)
def attack(self, target: "Character") -> int:
base_damage = 8
spell_multiplier = 2.5
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return int(base_damage * spell_multiplier)
def special_ability(self) -> str:
return f"{self.name} casts Fireball! Deals area damage."
class Ranger(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=100)
def attack(self, target: "Character") -> int:
base_damage = 12
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
if random.random() < self._equipped_weapon.stats.crit_chance:
base_damage = int(base_damage * 2)
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Evasion! Dodges the next attack."
Your character hierarchy enforces a clean contract. No one can create a generic character that doesn't know how to fight. Next up, you will build a composition-based inventory system so characters can carry, manage, and equip items.
Build an Inventory System
Your characters can take damage and have abstract abilities, but they have no way to carry items or equip weapons yet.
In this step, you will build an Inventory class that exists as a standalone object. A Character will have an Inventory (composition), rather than being an Inventory (inheritance). This is one of the most important distinctions in object-oriented design.
In this step, get ready to:
- Define an Inventory class with weight-based capacity management.
- Connect the Inventory to your Character hierarchy via composition.
- Demonstrate that Inventory works both with Characters and as a standalone object.
Create the Inventory class
The Inventory manages a private list of items and enforces a weight limit. It is completely independent of Character. You could attach it to a player, a treasure chest, or a shop.
- Click between your Step 2 dataclass cells and your Step 3 Character cell to reveal the + Code button.
- Click + Code to insert a new code cell above your Character class definition.
- Paste the following Inventory class into the new cell:
# --- Step 4: Composition - Inventory ---
class Inventory:
def __init__(self, capacity: float) -> None:
# Private list holds the actual Item objects
self._items: list[Item] = []
# Maximum total weight this inventory can hold
self.capacity = capacity
@property
def total_weight(self) -> float:
# Sum up the weight of every item currently stored
return sum(item.weight for item in self._items)
def add_item(self, item: Item) -> bool:
# Reject the item if it would exceed capacity
if self.total_weight + item.weight > self.capacity:
return False
self._items.append(item)
return True
def remove_item(self, name: str) -> Item | None:
# Search by name and pop from the list if found
for i, item in enumerate(self._items):
if item.name == name:
return self._items.pop(i)
return None
def list_items(self) -> list[str]:
# Return just the names for easy display
return [item.name for item in self._items]
What does this code do?
The _items list is private (prefixed with an underscore). External code should interact through add_item() and remove_item() rather than manipulating the list directly.
The add_item() method returns a boolean. This is a deliberate design choice. Instead of raising an exception when the inventory is full, callers get a simple True/False signal they can act on.
The total_weight property recalculates on every call. For a game with few items per character, this is perfectly efficient.
- Run the cell by pressing Shift+Enter.
- The cell should execute without errors. No output is expected since you are only defining the class.
Seeing a NameError for Item?
Make sure your Step 2 cell (the one defining ItemType, WeaponStats, and Item) was already run in this session. Colab cells must be executed in order for later cells to use earlier definitions.
Help me fix a NameError when defining my Inventory class in Colab.
Understand how Character uses Inventory via composition
Your Character class already references Inventory in its __init__ method. Now that the class exists, those references resolve.
- Look at your Character cell and find this line inside __init__:
self.inventory = Inventory(capacity=50.0)
This single line is composition in action. Every Character creates and owns an Inventory instance with a 50-unit weight limit.
- Now find the equip_weapon method in the same Character cell:
def equip_weapon(self, weapon_name: str) -> bool:
for item in self.inventory._items:
if item.name == weapon_name and item.item_type == ItemType.WEAPON:
self._equipped_weapon = item
return True
return False
The Character delegates item storage to its Inventory. It searches the inventory for a matching weapon and stores a reference in _equipped_weapon.
Why composition instead of inheritance?
Inheritance says "a Warrior IS an Inventory." That makes no sense. Composition says "a Warrior HAS an Inventory." The relationship is ownership, not identity.
Composition also means Inventory is reusable. You can attach it to a treasure chest, a shop, or an NPC without touching the Character hierarchy at all.
- Re-run your Character cell (and the Warrior, Mage, Ranger cells if they are separate) by pressing Shift+Enter on each.
- All cells should execute without NameError now that Inventory is defined above them.
Still seeing a NameError for Inventory?
Colab executes cells top to bottom when you use Runtime > Run all. Make sure your Inventory cell sits physically above your Character cell in the notebook. You can drag cells up or down using the cell's drag handle on the left edge.
Help me fix cell ordering so Inventory is defined before Character.
Test composition in action
Time to prove that composition works. You will create items, add them to a Warrior's inventory, hit the capacity limit, equip a weapon, and then show that Inventory works on its own.
- Add a new code cell at the bottom of your notebook by clicking + Code below your last cell.
- Paste this demo code to test the full composition system:
# --- Test: Composition in action ---
# Create items with different weights
sword = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
heavy_armor = Item("Heavy Plate", ItemType.ARMOR, 48.0)
# Create a warrior and add items
warrior = Warrior("Thorin")
print(f"Added sword: {warrior.inventory.add_item(sword)}")
print(f"Added armor: {warrior.inventory.add_item(heavy_armor)}")
print(f"Inventory: {warrior.inventory.list_items()}")
print(f"Total weight: {warrior.inventory.total_weight} / {warrior.inventory.capacity}")
# Equip the sword and check attack damage
dummy = Warrior("Target Dummy")
print(f"\nAttack WITHOUT weapon: 15 (base damage)")
warrior.equip_weapon("Iron Sword")
print(f"Attack WITH Iron Sword: {warrior.attack(dummy)}")
# Inventory works standalone - no Character needed
treasure_chest = Inventory(capacity=100.0)
treasure_chest.add_item(Item("Gold Ring", ItemType.ARMOR, 0.1))
treasure_chest.add_item(Item("Magic Staff", ItemType.WEAPON, 3.0, WeaponStats(damage=15, speed=4, crit_chance=0.15)))
print(f"\nTreasure chest contents: {treasure_chest.list_items()}")
What should I see?
- Adding the sword (5.0 weight) succeeds with True.
- Adding the heavy armor (48.0 weight) fails with False because 5.0 + 48.0 = 53.0 exceeds the 50.0 capacity.
- The Warrior's base attack is 15. With the Iron Sword equipped (+20 damage), it becomes 35.
- The treasure chest shows two items, proving Inventory works independently of any Character.
- Run the cell by pressing Shift+Enter.
- Confirm that the armor addition returns False, the equipped weapon boosts damage to 35, and the treasure chest lists its items independently.
Seeing unexpected output?
If add_item returns True for both items, check that your capacity is set to 50.0 in the Inventory class (not a larger value). If the attack value does not change after equipping, make sure your Warrior's attack() method checks self._equipped_weapon and adds its stats.damage.
Help me debug why my Inventory capacity check or weapon equip is not working.
✔️ Awesome, I've got everything!
Great. Double check that you have saved your notebook with all cells in place.
ⓧ I'd like to double check the full code
Here is the complete code your notebook should contain up to this point (excluding the test/demo cell):
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
# --- Step 2: Dataclasses and Enums ---
class ItemType(Enum):
WEAPON = auto()
ARMOR = auto()
POTION = auto()
@dataclass(frozen=True)
class WeaponStats:
damage: int
speed: int
crit_chance: float
@dataclass
class Item:
name: str
item_type: ItemType
weight: float
stats: WeaponStats | None = None
# --- Step 3: Abstract Base Classes and Inheritance ---
class Character(ABC):
def __init__(self, name: str, max_hp: int) -> None:
self.name = name
self.max_hp = max_hp
self._current_hp = max_hp
self.inventory = Inventory(capacity=50.0)
self._equipped_weapon: Item | None = None
self.combat_strategy: CombatStrategy = BalancedStrategy()
@property
def hp(self) -> int:
return self._current_hp
@hp.setter
def hp(self, value: int) -> None:
self._current_hp = max(0, min(value, self.max_hp))
@property
def is_alive(self) -> bool:
return self._current_hp > 0
def take_damage(self, amount: int) -> None:
self.hp = self.hp - amount
def equip_weapon(self, weapon_name: str) -> bool:
for item in self.inventory._items:
if item.name == weapon_name and item.item_type == ItemType.WEAPON:
self._equipped_weapon = item
return True
return False
def set_strategy(self, strategy: "CombatStrategy") -> None:
self.combat_strategy = strategy
@abstractmethod
def attack(self, target: "Character") -> int:
...
@abstractmethod
def special_ability(self) -> str:
...
class Warrior(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=150)
def attack(self, target: "Character") -> int:
base_damage = 15
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Shield Bash! Stuns the enemy for 1 turn."
class Mage(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=80)
def attack(self, target: "Character") -> int:
base_damage = 8
spell_multiplier = 2.5
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return int(base_damage * spell_multiplier)
def special_ability(self) -> str:
return f"{self.name} casts Fireball! Deals area damage."
class Ranger(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=100)
def attack(self, target: "Character") -> int:
base_damage = 12
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
if random.random() < self._equipped_weapon.stats.crit_chance:
base_damage = int(base_damage * 2)
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Evasion! Dodges the next attack."
# --- Step 4: Composition - Inventory ---
class Inventory:
def __init__(self, capacity: float) -> None:
self._items: list[Item] = []
self.capacity = capacity
@property
def total_weight(self) -> float:
return sum(item.weight for item in self._items)
def add_item(self, item: Item) -> bool:
if self.total_weight + item.weight > self.capacity:
return False
self._items.append(item)
return True
def remove_item(self, name: str) -> Item | None:
for i, item in enumerate(self._items):
if item.name == name:
return self._items.pop(i)
return None
def list_items(self) -> list[str]:
return [item.name for item in self._items]
Your characters now carry items and equip weapons that change their combat power. Next up, you will build swappable combat strategies so characters can change their fighting style mid-battle.
Implement Swappable Combat Strategies
Your characters can equip weapons and deal damage, but they always fight the same way. A Warrior with an Iron Sword attacks with the same intensity whether they're at full health or nearly dead.
In this step, you'll implement the strategy pattern to give characters swappable combat behaviors. A character can fight aggressively early on, then switch to a defensive style when their HP drops. The behavior changes at runtime without modifying the character class itself.
In this step, get ready to:
- Define an abstract CombatStrategy class with three concrete implementations.
- Connect strategies to your existing Character class for runtime behavior swapping.
- Build a turn-based battle function and run a full combat simulation.
Define the CombatStrategy abstract class
The strategy pattern separates what a character does (attack) from how they do it (aggressively, defensively, or balanced). Each "how" is its own class with a single execute() method.
- In your Colab notebook, create a new cell below your Inventory class. Add the strategy pattern classes by pasting this code:
# --- Step 5: Strategy Pattern ---
class CombatStrategy(ABC):
@abstractmethod
def execute(self, attacker: Character, defender: Character) -> int:
...
class AggressiveStrategy(CombatStrategy):
# 1.5x damage multiplier - hit hard, risk more
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return int(raw_damage * 1.5)
class DefensiveStrategy(CombatStrategy):
# 0.6x damage multiplier - play it safe
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return int(raw_damage * 0.6)
class BalancedStrategy(CombatStrategy):
# 1.0x multiplier - no modification to raw damage
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return raw_damage
What does this code do?
CombatStrategy is an abstract base class with one abstract method: execute(). Any concrete strategy must implement this method, guaranteeing a consistent interface.
Each concrete strategy calls attacker.attack(defender) to get the raw damage (which factors in the equipped weapon), then applies its own multiplier. The strategy doesn't care whether the attacker is a Warrior, Mage, or Ranger.
- Run the cell to confirm no errors appear.
Seeing a NameError?
If you see NameError: name 'Character' is not defined, your strategy cell is running before the Character class cell. Move it below the Character class cell, or re-run all cells from the top using Runtime > Run all.
help me fix a NameError in my strategy pattern cell
Connect strategies to your characters
Your Character class already has the plumbing for strategies. In the earlier step, you added two pieces to Character.__init__ and the class body:
self.combat_strategy: CombatStrategy = BalancedStrategy()
def set_strategy(self, strategy: "CombatStrategy") -> None:
self.combat_strategy = strategy
What does this code do?
Every character starts with a BalancedStrategy by default. The set_strategy() method lets you swap to a different strategy at any point during gameplay.
This is the strategy pattern in action. Instead of embedding combat logic inside each character subclass and overriding it, you compose the character with a behavior object that can be changed at runtime.
Why strategies instead of method overriding?
You could put aggressive/defensive logic directly inside each attack() method with conditionals. But that locks the behavior into the class hierarchy. Adding a new behavior means editing every subclass.
With the strategy pattern, you add a new behavior by creating one new class. No existing code changes. A character can also switch behaviors mid-battle without any conditionals inside attack().
Build the battle function and run a combat simulation
Now you have all the pieces: characters, weapons, inventories, and strategies. Time to wire them together into a turn-based combat loop that demonstrates strategy swapping at runtime.
- Create a new cell below your strategy classes. Add the battle function by pasting this code:
def battle(char1: Character, char2: Character) -> str:
turn = 1
while char1.is_alive and char2.is_alive:
print(f"--- Turn {turn} ---")
# Character 1 attacks using their current strategy
damage1 = char1.combat_strategy.execute(char1, char2)
char2.take_damage(damage1)
print(f"{char1.name} deals {damage1} damage to {char2.name} (HP: {char2.hp}/{char2.max_hp})")
if not char2.is_alive:
print(f"\n{char2.name} has been defeated! {char1.name} wins!")
return char1.name
# Character 2 attacks using their current strategy
damage2 = char2.combat_strategy.execute(char2, char1)
char1.take_damage(damage2)
print(f"{char2.name} deals {damage2} damage to {char1.name} (HP: {char1.hp}/{char1.max_hp})")
if not char1.is_alive:
print(f"\n{char1.name} has been defeated! {char2.name} wins!")
return char2.name
# Auto-switch to defensive when HP drops below 50%
if char1.hp < char1.max_hp * 0.5:
char1.set_strategy(DefensiveStrategy())
if char2.hp < char2.max_hp * 0.5:
char2.set_strategy(DefensiveStrategy())
turn += 1
return "Draw"
What does this code do?
- The while loop runs until one character's is_alive returns False.
- Each turn, both characters delegate their attack to combat_strategy.execute(). The strategy handles the damage multiplier.
- After both attacks, the function checks if either character's HP dropped below 50% of their max. If so, it auto-swaps them to DefensiveStrategy() to simulate self-preservation.
- The function returns the winner's name as a string.
- Run the cell to confirm no errors. The function is defined but not called yet.
- Create one more cell below. Add the demo that creates characters, equips weapons, assigns strategies, and runs the battle:
# --- Main Demo ---
# Create weapons
sword = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
staff = Item("Oak Staff", ItemType.WEAPON, 3.0, WeaponStats(damage=10, speed=5, crit_chance=0.05))
# Set up a Warrior with aggressive strategy
warrior = Warrior("Thorin")
warrior.inventory.add_item(sword)
warrior.equip_weapon("Iron Sword")
warrior.set_strategy(AggressiveStrategy())
# Set up a Mage with balanced strategy
mage = Mage("Gandalf")
mage.inventory.add_item(staff)
mage.equip_weapon("Oak Staff")
mage.set_strategy(BalancedStrategy())
# Show starting state
print(f"{warrior.name}: HP={warrior.hp}, Weapon={warrior._equipped_weapon.name if warrior._equipped_weapon else 'None'}")
print(f"{mage.name}: HP={mage.hp}, Weapon={mage._equipped_weapon.name if mage._equipped_weapon else 'None'}")
print()
# Run the battle
winner = battle(warrior, mage)
print(f"\nVictor: {winner}")
What does this code do?
- Creates two weapon items (Iron Sword and Oak Staff) using the Item and WeaponStats dataclasses from Step 2.
- Sets up Thorin (Warrior) with AggressiveStrategy and Gandalf (Mage) with BalancedStrategy.
- Prints starting HP and equipped weapon, then calls battle() to simulate turn-by-turn combat.
- Run the cell. You should see turn-by-turn output showing damage dealt, HP remaining, and a final winner declaration.
Notice how the damage numbers drop partway through the battle. That's the strategy swap happening. When a character's HP drops below 50%, they automatically switch to DefensiveStrategy and deal less damage per turn.
Battle output looks wrong or throws an error?
- If you see AttributeError: 'NoneType' object has no attribute 'execute', make sure your Character cell includes self.combat_strategy: CombatStrategy = BalancedStrategy() in __init__.
- If damage values never change, confirm the strategy-swap logic checks char1.hp < char1.max_hp * 0.5 (not char1.hp < 50).
- If the battle runs forever, make sure take_damage() actually reduces HP. Check that your hp setter uses max(0, min(value, self.max_hp)).
help me debug my battle function
✔️ Awesome, I've got everything!
Great. Make sure all your cells are saved.
ⓧ I'd like to double check the full code
Here is the full combined code up to this point. Compare it against your notebook cells:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Protocol
import random
# --- Step 2: Dataclasses and Enums ---
class ItemType(Enum):
WEAPON = auto()
ARMOR = auto()
POTION = auto()
@dataclass(frozen=True)
class WeaponStats:
damage: int
speed: int
crit_chance: float
@dataclass
class Item:
name: str
item_type: ItemType
weight: float
stats: WeaponStats | None = None
# --- Step 3: Abstract Base Classes and Inheritance ---
class Character(ABC):
def __init__(self, name: str, max_hp: int) -> None:
self.name = name
self.max_hp = max_hp
self._current_hp = max_hp
self.inventory = Inventory(capacity=50.0)
self._equipped_weapon: Item | None = None
self.combat_strategy: CombatStrategy = BalancedStrategy()
@property
def hp(self) -> int:
return self._current_hp
@hp.setter
def hp(self, value: int) -> None:
self._current_hp = max(0, min(value, self.max_hp))
@property
def is_alive(self) -> bool:
return self._current_hp > 0
def take_damage(self, amount: int) -> None:
self.hp = self.hp - amount
def equip_weapon(self, weapon_name: str) -> bool:
for item in self.inventory._items:
if item.name == weapon_name and item.item_type == ItemType.WEAPON:
self._equipped_weapon = item
return True
return False
def set_strategy(self, strategy: "CombatStrategy") -> None:
self.combat_strategy = strategy
@abstractmethod
def attack(self, target: "Character") -> int:
...
@abstractmethod
def special_ability(self) -> str:
...
class Warrior(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=150)
def attack(self, target: "Character") -> int:
base_damage = 15
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Shield Bash! Stuns the enemy for 1 turn."
class Mage(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=80)
def attack(self, target: "Character") -> int:
base_damage = 8
spell_multiplier = 2.5
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
return int(base_damage * spell_multiplier)
def special_ability(self) -> str:
return f"{self.name} casts Fireball! Deals area damage."
class Ranger(Character):
def __init__(self, name: str) -> None:
super().__init__(name, max_hp=100)
def attack(self, target: "Character") -> int:
base_damage = 12
if self._equipped_weapon and self._equipped_weapon.stats:
base_damage += self._equipped_weapon.stats.damage
if random.random() < self._equipped_weapon.stats.crit_chance:
base_damage = int(base_damage * 2)
return base_damage
def special_ability(self) -> str:
return f"{self.name} uses Evasion! Dodges the next attack."
# --- Step 4: Composition - Inventory ---
class Inventory:
def __init__(self, capacity: float) -> None:
self._items: list[Item] = []
self.capacity = capacity
@property
def total_weight(self) -> float:
return sum(item.weight for item in self._items)
def add_item(self, item: Item) -> bool:
if self.total_weight + item.weight > self.capacity:
return False
self._items.append(item)
return True
def remove_item(self, name: str) -> Item | None:
for i, item in enumerate(self._items):
if item.name == name:
return self._items.pop(i)
return None
def list_items(self) -> list[str]:
return [item.name for item in self._items]
# --- Step 5: Strategy Pattern ---
class CombatStrategy(ABC):
@abstractmethod
def execute(self, attacker: Character, defender: Character) -> int:
...
class AggressiveStrategy(CombatStrategy):
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return int(raw_damage * 1.5)
class DefensiveStrategy(CombatStrategy):
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return int(raw_damage * 0.6)
class BalancedStrategy(CombatStrategy):
def execute(self, attacker: Character, defender: Character) -> int:
raw_damage = attacker.attack(defender)
return raw_damage
def battle(char1: Character, char2: Character) -> str:
turn = 1
while char1.is_alive and char2.is_alive:
print(f"--- Turn {turn} ---")
damage1 = char1.combat_strategy.execute(char1, char2)
char2.take_damage(damage1)
print(f"{char1.name} deals {damage1} damage to {char2.name} (HP: {char2.hp}/{char2.max_hp})")
if not char2.is_alive:
print(f"\n{char2.name} has been defeated! {char1.name} wins!")
return char1.name
damage2 = char2.combat_strategy.execute(char2, char1)
char1.take_damage(damage2)
print(f"{char2.name} deals {damage2} damage to {char1.name} (HP: {char1.hp}/{char1.max_hp})")
if not char1.is_alive:
print(f"\n{char1.name} has been defeated! {char2.name} wins!")
return char2.name
if char1.hp < char1.max_hp * 0.5:
char1.set_strategy(DefensiveStrategy())
if char2.hp < char2.max_hp * 0.5:
char2.set_strategy(DefensiveStrategy())
turn += 1
return "Draw"
# --- Main Demo ---
if __name__ == "__main__":
sword = Item("Iron Sword", ItemType.WEAPON, 5.0, WeaponStats(damage=20, speed=3, crit_chance=0.1))
staff = Item("Oak Staff", ItemType.WEAPON, 3.0, WeaponStats(damage=10, speed=5, crit_chance=0.05))
warrior = Warrior("Thorin")
warrior.inventory.add_item(sword)
warrior.equip_weapon("Iron Sword")
warrior.set_strategy(AggressiveStrategy())
mage = Mage("Gandalf")
mage.inventory.add_item(staff)
mage.equip_weapon("Oak Staff")
mage.set_strategy(BalancedStrategy())
print(f"{warrior.name}: HP={warrior.hp}, Weapon={warrior._equipped_weapon.name if warrior._equipped_weapon else 'None'}")
print(f"{mage.name}: HP={mage.hp}, Weapon={mage._equipped_weapon.name if mage._equipped_weapon else 'None'}")
print()
winner = battle(warrior, mage)
print(f"\nVictor: {winner}")
Your full OOP system is alive. Characters wield weapons, manage inventories, and fight with swappable strategies. Next up, you'll extend this system with an observer pattern in the Secret Mission.
Secret mission
Add an Observer Pattern for Battle Logging
Your battle system works, but you have no record of what happened after the fight ends. Can you add an Observer pattern that logs every combat event to a battle history, without modifying any of your existing character or strategy classes?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely in Google Colab with no paid services, so there are no ongoing costs.
Resources you used:
- Google Colab notebook oop_combat_system.ipynb (stored in your Google Drive).
Keep everything running
No action needed. Choose this if you plan to revisit the notebook, add new OOP patterns, or adapt it for teaching.
- Your notebook stays in Google Drive and is accessible any time you open Google Colab.
- Colab automatically disconnects idle runtimes after a period of inactivity, so no compute resources are consumed while you are away.
Pause - I'll come back to this later
Shut down the Colab runtime to free up memory. Your notebook file stays safe in Google Drive.
- In your Colab notebook, click Runtime in the top menu bar.
- Click Disconnect and delete runtime.
- Your code and outputs remain saved in the notebook. Next time you open it, click the play button on any cell to start a fresh runtime.
Delete - I don't want to use this again
Remove the notebook entirely from your Google Drive.
- Open Google Drive at drive.google.com.
- Navigate to the Colab Notebooks folder (or wherever your notebook was saved).
- Right-click oop_combat_system.ipynb and select Move to trash.
- To permanently delete it, open Trash in the left sidebar and click Empty trash.
Nice Work!
Nice Work!
You just built a complete game character and combat system that exercises every major OOP concept beyond basic inheritance.
You've learned how to:
- Build a complete game character system using dataclasses, enums, abstract base classes, inheritance, polymorphism, composition, encapsulation, and the strategy pattern.
- Demonstrate why each OOP concept exists by solving real design problems that surface naturally as the system grows.
- Implement the strategy pattern with runtime-swappable combat behaviors that change how characters fight mid-battle.
- (Secret Mission) Add an observer pattern using Python's Protocol for structural typing, showing an alternative to ABC-based interfaces.
Ready to quiz yourself?