Code of the Day
AdvancedGame Design Patterns

The observer pattern

Publisher/subscriber decouples systems so an achievement engine, a sound manager, and a UI can all react to game events without the player or enemy knowing they exist.

Game DevAdvanced6 min read
By the end of this lesson you will be able to:
  • Explain the observer pattern as publisher/subscriber without direct coupling
  • Identify where games benefit from it — achievement systems, UI updates, audio triggers
  • Contrast observer with direct method calls and articulate the trade-off

As a game grows, systems that were once independent start needing to react to each other. Killing an enemy should: update the score, trigger a sound, check achievement progress, and spawn a particle explosion. Writing all four calls directly inside the enemy's die() method creates a tangled web of dependencies — the Enemy class now imports SoundManager, AchievementSystem, ParticleEmitter, and ScoreTracker. Changing any one of those systems means touching Enemy.

The observer pattern untangles this by introducing an intermediary.

How it works

There are two roles:

Publishers (also called subjects or observables) raise events when something noteworthy happens. They do not know who is listening.

Subscribers (also called observers or listeners) register interest in a particular event and are called whenever it is raised.

A minimal event bus:

from collections import defaultdict

class EventBus:
    def __init__(self):
        self._subscribers = defaultdict(list)

    def subscribe(self, event_type, callback):
        self._subscribers[event_type].append(callback)

    def publish(self, event_type, **data):
        for callback in self._subscribers[event_type]:
            callback(**data)


bus = EventBus()

The enemy raises an event when it dies:

class Enemy:
    def die(self):
        self.alive = False
        bus.publish("ENEMY_KILLED", pos=self.pos, enemy_type=self.kind)

The achievement system, sound manager, and particle emitter all subscribe independently:

def on_enemy_killed(pos, enemy_type):
    achievements.check("FIRST_KILL")

def on_enemy_killed_sound(pos, enemy_type):
    sounds.play("enemy_die")

bus.subscribe("ENEMY_KILLED", on_enemy_killed)
bus.subscribe("ENEMY_KILLED", on_enemy_killed_sound)

The Enemy class does not import achievements, sounds, or anything else. It fires one event and its responsibility ends.

Where games use it

Achievement systems — subscribe to a wide range of events (ENEMY_KILLED, LEVEL_COMPLETE, ITEM_COLLECTED) and check progress conditions internally. Adding a new achievement never touches any other system.

UI / HUD updates — the score display subscribes to SCORE_CHANGED rather than being polled every frame. The event carries the new value.

Audio triggers — a sound manager subscribes to gameplay events (PLAYER_JUMP, PROJECTILE_FIRED, BOSS_PHASE_CHANGE) and plays the appropriate clip. Tuning sound without touching gameplay code.

Analytics / telemetry — subscribe to events in a debug build to log player behaviour without any conditional code in the game systems.

Comparison with direct calls

Direct callObserver
CouplingEnemy imports every listenerEnemy imports only the bus
Adding a listenerModify Enemy.die()bus.subscribe(...) anywhere
Order of callsExplicitDetermined by subscription order
DebuggingFollow call stack normallyTrace via the bus

Direct calls are simpler for small games. The observer shines when more than two systems need to react to the same event, or when you want to add listeners from editor plugins or mods.

Keep event data plain and serialisable — tuples, dicts, and scalars rather than live object references. This makes events easy to log, replay, and inspect, and prevents subscribers from holding references that outlive the published object.

Where to go next

Next: component architecture — replacing deep inheritance hierarchies with a composition-based entity model that scales to complex games.

Finished reading? Mark it complete to track your progress.

On this page