Case Study: How OOP Powers Video Games
This case study examines how object-oriented programming is used in game development. The code examples are simplified but reflect real patterns used in professional game engines and indie game development.
The Problem: Why Games Need OOP
Imagine building a 2D game with a player character, 20 enemies, 50 collectible items, 10 projectiles flying across the screen, and 15 platforms to jump on. At any moment, any of these things could interact with any other: a projectile hits an enemy, the player collects a coin, an enemy walks off a platform.
With procedural programming, you'd need separate variables for each entity and explicit logic for every possible interaction. By the time you have 100 game objects, the code is unmaintainable.
OOP solves this elegantly. Each game entity is an object. Each object knows its own position, size, and state, and knows how to update itself, draw itself, and check for collisions. The game loop just says: "everybody update, everybody draw."
The Game Object Pattern
At the heart of most game engines is a base class that every game entity shares:
class GameObject:
"""Base class for all objects in the game world."""
def __init__(self, x, y, width, height):
self.x = x
self.y = y
self.width = width
self.height = height
self.active = True # False = removed from the game
def update(self):
"""Update state each frame. Override in subclasses."""
pass
def render(self):
"""Draw the object. Override in subclasses."""
pass
def get_bounds(self):
"""Get bounding rectangle for collision detection."""
return (self.x, self.y, self.x + self.width, self.y + self.height)
def collides_with(self, other):
"""Check if this object's bounding box overlaps another's."""
ax1, ay1, ax2, ay2 = self.get_bounds()
bx1, by1, bx2, by2 = other.get_bounds()
return (ax1 < bx2 and ax2 > bx1 and ay1 < by2 and ay2 > by1)
def __str__(self):
return f"{type(self).__name__} at ({self.x}, {self.y})"
Every game object — player, enemy, coin, projectile — inherits from this base class (you'll learn about inheritance in Chapter 15). The key insight: collides_with works between any two game objects, because they all share the same interface for position and size.
Building Specific Game Objects
The Player
class Player(GameObject):
"""The player character."""
def __init__(self, x, y):
super().__init__(x, y, width=32, height=48)
self.speed = 5
self.health = 100
self.score = 0
self.inventory = []
self.facing = "right"
def move(self, dx, dy):
"""Move the player by the given offset."""
self.x += dx * self.speed
self.y += dy * self.speed
if dx > 0:
self.facing = "right"
elif dx < 0:
self.facing = "left"
def take_damage(self, amount):
"""Reduce health. Returns True if player is defeated."""
self.health = max(0, self.health - amount)
return self.health == 0
def collect(self, item):
"""Pick up a collectible item."""
self.score += item.value
self.inventory.append(item.name)
item.active = False # Remove from the game world
print(f"Collected {item.name}! Score: {self.score}")
def update(self):
"""Called every frame."""
# In a real game, this would handle gravity, animation, etc.
pass
def render(self):
"""Draw the player."""
direction = ">" if self.facing == "right" else "<"
print(f" Player {direction} at ({self.x}, {self.y}) "
f"HP:{self.health} Score:{self.score}")
def __str__(self):
return (f"Player at ({self.x}, {self.y}) — "
f"HP: {self.health}, Score: {self.score}")
Enemies
class Enemy(GameObject):
"""A basic enemy that patrols back and forth."""
def __init__(self, x, y, name, damage=10, patrol_range=100):
super().__init__(x, y, width=32, height=32)
self.name = name
self.damage = damage
self.health = 50
self._start_x = x
self._patrol_range = patrol_range
self._direction = 1 # 1 = right, -1 = left
self._speed = 2
def update(self):
"""Patrol back and forth."""
self.x += self._speed * self._direction
if abs(self.x - self._start_x) >= self._patrol_range:
self._direction *= -1 # Reverse direction
def take_hit(self, damage):
"""Take damage. Returns True if defeated."""
self.health = max(0, self.health - damage)
if self.health == 0:
self.active = False
return True
return False
def render(self):
"""Draw the enemy."""
arrow = ">" if self._direction == 1 else "<"
print(f" Enemy '{self.name}' {arrow} at ({self.x}, {self.y}) "
f"HP:{self.health}")
def __str__(self):
return f"Enemy '{self.name}' at ({self.x}, {self.y}) HP:{self.health}"
Collectible Items
class Collectible(GameObject):
"""An item the player can pick up."""
def __init__(self, x, y, name, value):
super().__init__(x, y, width=16, height=16)
self.name = name
self.value = value
def render(self):
"""Draw the collectible."""
if self.active:
print(f" [{self.name}] at ({self.x}, {self.y}) "
f"worth {self.value} pts")
def __str__(self):
return f"{self.name} ({self.value} pts) at ({self.x}, {self.y})"
The Game Loop
The game loop is where OOP shows its power. Instead of writing separate update and collision logic for every type of object, you iterate over a single list:
class GameWorld:
"""Manages all game objects and the main loop."""
def __init__(self):
self.objects = []
self.player = None
def add(self, game_object):
"""Add an object to the world."""
self.objects.append(game_object)
if isinstance(game_object, Player):
self.player = game_object
def update(self):
"""Update all active objects and check collisions."""
# Update each object
for obj in self.objects:
if obj.active:
obj.update()
# Check player collisions
if self.player and self.player.active:
for obj in self.objects:
if obj is self.player or not obj.active:
continue
if self.player.collides_with(obj):
self._handle_collision(self.player, obj)
# Remove inactive objects
self.objects = [obj for obj in self.objects if obj.active]
def _handle_collision(self, player, other):
"""Handle a collision between the player and another object."""
if isinstance(other, Collectible):
player.collect(other)
elif isinstance(other, Enemy):
defeated = player.take_damage(other.damage)
if defeated:
print("GAME OVER!")
def render(self):
"""Draw all active objects."""
print("\n--- Game World ---")
for obj in self.objects:
if obj.active:
obj.render()
print(f" Objects in world: {len(self.objects)}")
def simulate(self, frames):
"""Run the game for a number of frames."""
for frame in range(frames):
print(f"\n=== Frame {frame + 1} ===")
self.update()
self.render()
Running a Simulation
# Set up the world
world = GameWorld()
# Create objects
player = Player(100, 200)
world.add(player)
world.add(Enemy(200, 200, "Goblin", damage=15, patrol_range=50))
world.add(Enemy(400, 200, "Skeleton", damage=20, patrol_range=80))
world.add(Collectible(150, 200, "Gold Coin", 50))
world.add(Collectible(300, 200, "Health Potion", 25))
# Move player toward first coin
player.move(10, 0) # Move right
# Simulate 3 frames
world.simulate(3)
This outputs the game state for each frame, showing objects moving, collisions being detected, and items being collected. The exact output depends on positions and speeds, but the structure is clear: every object updates itself, the world checks for collisions, and the render pass draws everything.
Why OOP Makes Games Manageable
1. Adding New Entity Types Is Easy
Want to add a power-up that gives the player invincibility? Create a PowerUp class that extends Collectible. Want flying enemies? Create FlyingEnemy that extends Enemy with vertical movement. The existing code doesn't need to change.
2. The Game Loop Doesn't Know (or Care) About Specifics
The update() and render() calls in the game loop work on any GameObject. Whether the list contains 5 objects or 500, the loop looks the same. This is the power of polymorphism — different objects responding to the same method call in their own way (Chapter 15 explores this in depth).
3. Each Object Manages Its Own Complexity
The enemy's patrol logic is inside the Enemy class. The player's inventory is inside the Player class. The coin's value is inside the Collectible class. No single function needs to understand everything.
4. Collision Detection Is Generic
The collides_with method works between any two game objects because it only depends on position and size — attributes every GameObject has. A bullet can collide with an enemy, a player can collide with a coin, an enemy can collide with a wall. The collision code is written once.
Real-World Examples
Pygame
Pygame, one of the most popular Python game libraries, uses a Sprite class that's structurally identical to our GameObject. The pygame.sprite.Group class is similar to our GameWorld — it manages collections of sprites and can check for collisions between groups.
Unity
Unity, one of the most widely used professional game engines, takes this further with an "Entity-Component System" (ECS). Instead of putting all behavior in one class hierarchy, each game object is composed of components (a Transform component for position, a Renderer component for drawing, a Collider component for physics). Each component is itself an object. This is OOP at scale — objects containing objects, each with a focused responsibility.
Godot
Godot, an open-source game engine, uses a node-based scene system where everything is an object in a tree structure. A player character might be a CharacterBody2D node containing a Sprite2D node, a CollisionShape2D node, and an AnimationPlayer node. Each node is an object with its own attributes and methods.
Exercises Based on This Case Study
- Add a
Projectileclass that moves in a straight line and damages enemies on contact. Give the player ashoot()method. - Add a
Platformclass that the player can stand on (objects below it can't fall through). - Create a
Bossenemy with higher health, a special attack pattern, and a loot drop when defeated. - Add a scoring system that tracks the player's high score across multiple game sessions (hint: use a class attribute or a separate
ScoreManagerclass). - How would you modify the design to support multiplayer — two players in the same world? What changes to
GameWorldare needed?