The biggest challenge we have when designing our program is managing how different components communicate with each other. If we manage this poorly, the complexity of our project will quickly get out of hand, making it extremely difficult to add new features or even understand what is going on.
However, if we manage this well, our project will stay understandable and easy to work with, even as we add more and more features.
Imagine we have a Player
object and a UIElement
class. The UIElement
needs to be displayed when our Player
is defeated. Let’s review some of the ways we can make this happen
The first option we have is to solve the problem as directly as possible. Our Player
class acquires a reference to the UIElement
it needs to affect, and calls a public method at the appropriate time:
class Player {
public:
void TakeDamage(int Damage) {
Health -= Damage;
if (Health <= 0) {
GameOverScreen->Show();
}
}
UIElement* GameOverScreen;
int Health;
}
This is sometimes referred to as tightly coupling our Player
class to our UI
class. It has some problems - most notably, having user interface logic in our Player
class is not expected.
Whilst quirks like this are manageable in small projects, if we continue to scatter UI code around unrelated classes, those classes will become more complex. Additionally, it will become increasingly difficult to understand how our UI works, as it is being manipulated from a wide assortment of arbitrary objects.
These problems compound further when working in a team. It is likely to be different developers or different subteams working on gameplay and UI logic, so our class will become an entanglement of UI and Player functionality that nobody fully understands.
A project that relies too much on this technique is sometimes unaffectionately compared to spaghetti.
Just as it's difficult to trace a single noodle from one end to the other in a bowl of spaghetti, it becomes challenging to follow the flow of program execution when many different parts of the code are directly calling functions in other, unrelated parts.
Eventually, understanding how changes to one part of your program might affect other parts becomes as difficult as trying to pull out a single strand of spaghetti without disturbing any other part of the bowl.
As an alternative, we can invert this connection. Instead of having our Player
class implement UI logic, we can keep our UI logic in a UI class and call Player
methods to understand the player’s current state:
class GameOverScreen : public UIElement {
public:
bool ShouldShow() {
return GetPlayer().GetHealth() <= 0;
}
}
This might seem similar to our previous example, but it better achieves encapsulation. UI logic (whether the game over screen should show) stays in a UI class, whilst player logic (how much health they have) remains in the Player
class.
Whilst this is a better design, it doesn’t solve our original problem of implementing some behavior when the player’s health reaches 0
. To do this, we’d need to check the player’s health over and over again to determine when it reaches 0
.
This is sometimes referred to as polling. The ticking mechanism we introduced earlier in this chapter gives us a way to implement this. On every iteration of our application loop, we call a Tick()
function on all of our game objects. This allows them to check the state of other objects, and update themselves if needed:
class GameOverScreen : public UIElement {
public:
void Tick() override {
if (GetPlayer().GetHealth() <= 0) {
Show();
}
}
void Show() {
// ...
}
}
However, we should be mindful of performance considerations. The more logic we have happening on each iteration of our application loop, the slower that loop will iterate. If we frequently rely on adding logic to Tick()
functions unnecessarily, our program’s performance will degrade over time as we add more features and objects.
Some functionality does require polling to work correctly, but for this example, and most other scenarios, we can find a better way.
The SDL event system we've been working with provides another approach to solving our communication problem. Just as SDL allows us to respond to keyboard and mouse events through its event queue, we can also use this same system to facilitate communication between our game objects.
Instead of objects directly calling methods on each other, we can push custom events onto SDL's event queue that other objects can then receive and handle.
Here's how we can modify our Player
class to use SDL's event system to notify other objects when an event happens:
class Player {
public:
void TakeDamage(int Damage) {
Health -= Damage;
if (Health <= 0) {
SDL_PushEvent(PLAYER_DEFEATED);
}
}
int Health;
}
With this approach, any object that is connected to SDL's event handling system can now respond to our player's defeat events coming through the event loop:
class GameOverScreen : public GameElement {
public:
void HandleEvent(SDL_Event& E) override {
if (E.type == PLAYER_DEFEATED) {
Show();
}
}
void Show() {
// ...
}
}
This design pattern prevents unnecessary coupling and typically has significantly better performance than constant polling. However, pushing events through an even system still has a performance cost that is typically excessive for simple communication between two objects.
Additionally, this also relies on an event system being available. Whilst SDL helpfully provides such a system, we’ll often be working on projects that don’t have one.
The observer pattern is a software design technique where objects, typically called observers, can sign up to be notified of events happening in some other object, typically called the subject.
An "event" in this context is simply something that can happen within the subject that might be of interest to the outside world.
If our subject is a Player
object, for example, external code might be interested in events like the player taking damage, equipping a new weapon, or leveling up.
The subject then provides a mechanism to allow external code - observers - to be notified when that event occurs.
A common mechanism for implementing this involves the observer providing the subject with something it can invoke, such as a function pointer. The subject will then store that callable, and invoke it when the event of interest happens. A callback used in this way is sometimes referred to as a delegate.
In the following example, our Player
supports a single observer, which it will notify any time it takes damage. It does this by storing a delegate in the form of a std::function<void()>
.
It stores this delegate in a variable called DamageCallback
, and provides a SetDamageDelegate()
function to allow external code to change it. Within the TakeDamage()
function, if the Player
is currently holding a delegate, it will invoke it, thereby notifying the observer that the event it was interested in has happened:
// Player.h
#pragma once
#include <functional>
class Player {
public:
using DelegateType = std::function<void()>;
void SetOnDamageObserver(DelegateType D) {
OnDamageDelegate = D;
}
void TakeDamage(int Damage) {
Health -= Damage;
if (OnDamageDelegate) OnDamageDelegate();
}
private:
int Health = 100;
DelegateType OnDamageDelegate;
};
Now, any object that wants to become an observer of the Player
simply needs to provide a delegate. The Player
will then invoke that delegate any time it takes damage:
#include <iostream>
#include "Player.h"
void LogDamage() {
std::cout << "Player has taken damage";
}
int main() {
Player Subject;
Subject.SetOnDamageObserver(LogDamage);
Subject.TakeDamage(50);
}
Player has taken damage
Like any function, delegates can receive arguments, allowing the subject to provide additional information to the observer. In the following example, we update our Player
to invoke the delegate with 3 arguments:
Player
object is invoking the delegate. This can be helpful if an observer is observing multiple Player
objects, and needs to know which one took damage.Player
tookPlayer
// Player.h
#pragma once
#include <functional>
#include <iostream>
class Player {
public:
using DelegateType = std::function<void(
Player& P, int Damage, Player& Instigator)>;
Player(const std::string& Name)
: Name{Name} {}
void SetOnDamageDelegate(DelegateType D) {
OnDamageDelegate = D;
}
void TakeDamage(int Damage,
Player& Instigator) {
if (OnDamageDelegate) {
OnDamageDelegate(
*this, Damage, Instigator
);
}
}
void Attack(Player& Target, int Damage) {
Target.TakeDamage(Damage, *this);
}
std::string Name;
private:
DelegateType OnDamageDelegate;
};
We now need to update our delegate to accept and make use of those three parameters:
// main.cpp
#include <iostream>
#include "Player.h"
void LogDamage(
Player& Subject,
int Damage,
Player& Instigator
) {
std::cout << Subject.Name << " has taken "
<< Damage << " damage from "
<< Instigator.Name << '\n';
}
int main() {
Player PlayerOne{"Player 1"};
Player PlayerTwo{"Player 2"};
PlayerOne.SetOnDamageDelegate(LogDamage);
PlayerTwo.SetOnDamageDelegate(LogDamage);
PlayerOne.Attack(PlayerTwo, 50);
PlayerTwo.Attack(PlayerOne, 25);
}
Player 2 has taken 50 damage from Player 1
Player 1 has taken 25 damage from Player 2
In previous examples, our Player
subject only supports a single observer. Typically, when we have an object that we want to be observable, we’ll want it to support any number of observers.
Rather than having our Player
store a single OnDamageDelegate
function, let’s update it to store any number of callbacks in a std::vector
.
Then, when the key event happens, our Player
will iterate over that array and notify all of its observers. A delegate that can notify multiple observers of an event is sometimes referred to as a multicast delegate:
// Player.h
#pragma once
#include <functional>
#include <iostream>
#include <vector>
class Player {
public:
using DelegateType = std::function<void()>;
void AddOnDamageDelegate(DelegateType Callback) {
MulticastDelegate.push_back(Callback);
}
void TakeDamage(int Damage) {
NotifyOnDamage();
}
private:
std::vector<DelegateType> MulticastDelegate;
void NotifyOnDamage() {
std::cout << "Took Damage - Notifying " << MulticastDelegate.size()
<< " Observer(s)\n";
for (auto& Callback : MulticastDelegate) {
Callback();
}
}
};
Below, we create a Player
subject, and add two observers to be notified when it receives damage:
// main.cpp
#include <iostream>
#include "Player.h"
void Log(const std::string& ObserverName) {
std::cout << ObserverName
<< ": Player has taken damage\n";
}
int main() {
Player Subject;
// Register two different callbacks
Subject.AddOnDamageDelegate(std::bind_front(Log, "Observer 1"));
Subject.AddOnDamageDelegate(std::bind_front(Log, "Observer 2"));
Subject.TakeDamage(50);
}
Took Damage - Notifying 2 Observer(s)
Observer 1: Player has taken damage
Observer 2: Player has taken damage
The final basic capability we typically need is a mechanism for observers to stop being notified of updates.
Earlier in the chapter, we saw how SDL manages a similar requirement. SDL_AddTimer()
returns an SDL_TimerID
- an integer that allows us to identify the timer we just added. To remove this timer, we pass its integer to SDL_RemoveTimer()
:
int TimerID{
SDL_AddTimer(Callback, 1000, nullptr)
};
// Later:
SDL_RemoveTimer(TimerID);
Let’s create a similar mechanism for our class. Our Player
class can keep track of an additional integer, representing the index of our array where the next delegate is going to be added.
Every time an observer is added, we return this index to the caller of AddOnDamageDelegate()
. That same integer can later be passed to RemoveOnDamageDelegate()
to update that index to be a nullptr
:
When iterating over our array to notify all the observers, we additionally check that the callback at that index is not a nullptr
before we invoke it:
// Player.h
#pragma once
#include <functional>
#include <iostream>
#include <unordered_map>
class Player {
public:
using DelegateType = std::function<void()>;
int AddOnDamageDelegate(DelegateType D) {
MulticastDelegate.push_back(D);
return NextObserverIndex++;
}
void RemoveOnDamageDelegate(int Index) {
std::cout << "Removing Observer at index " << Index << '\n';
if (Index >= 0 && Index < MulticastDelegate.size()) {
MulticastDelegate[Index] = nullptr;
}
}
void TakeDamage(int Damage) {
NotifyOnDamage();
}
private:
int NextObserverIndex{0};
std::vector<DelegateType> MulticastDelegate;
void NotifyOnDamage() {
std::cout << "Took Damage - Notifying " << MulticastDelegate.size()
<< " Observer(s)\n";
for (auto& Callback : MulticastDelegate) {
if (Callback) Callback();
}
}
};
Now, if an observer wants to stop observing an object, it can store the value returned by AddOnDamageDelegate()
, and later pass it to RemoveOnDamageDelegate()
when it no longer wants to be notified:
// main.cpp
#include <iostream>
#include "Player.h"
void LogDamage() {
std::cout << "Player has taken damage";
}
int main() {
Player Subject;
int ObserverId{Subject.AddOnDamageDelegate(LogDamage)};
Subject.TakeDamage(100);
Subject.RemoveOnDamageDelegate(ObserverId);
Subject.TakeDamage(100);
}
Took Damage - Notifying 1 Observer(s)
Observer: Player has taken damage
Removing Observer with Key 1
Took Damage - Notifying 0 Observer(s)
In situations where we’re adding and removing a lot of observers, the approach of setting a std::vector
element to a nullptr
may be problematic in programs that run for a long time.
Updating an element to a nullptr
doesn’t make our std::vector
any smaller, so our array never shrinks. It can only ever get larger and, if a significant proportion of its indices are null pointers, that size is an unnecessary drain on performance.
Worse, we can’t easily intervene here to delete these null pointers and make our array smaller. Doing so would mean indices returned by previous calls to AddOnDamageDelegate()
and held by observers would no longer be accurate.
Most of the time, this is not worth addressing - in normal use cases, a subject is not going to have so many observers unregistering themselves that this becomes a legitimate problem. If we think it will be a problem in our use case, we can consider storing the observers in a different container type.
A std::unordered_map
for example allows us to erase objects entirely without affecting the position of other elements. This can be done through it’s erase()
method:
// Player.h
#pragma once
#include <functional>
#include <iostream>
#include <unordered_map>
class Player {
public:
using DelegateType = std::function<void()>;
int AddOnDamageDelegate(DelegateType D) {
MulticastDelegate[NextObserverKey] = D;
return NextObserverKey++;
}
void RemoveOnDamageDelegate(int Key) {
MulticastDelegate.erase(Key);
}
private:
int NextObserverKey{0};
std::unordered_map<int, DelegateType> MulticastDelegate;
void NotifyOnDamage() {
for (auto& [Key, Callback] : MulticastDelegate) {
Callback();
}
}
};
Note, however, that there is a large tradeoff here. Iterating over a std::unordered_map
is significantly slower than iterating over a std::vector
, so we should only make a change like this if we know we’ll need it.
Any time we have two discrete objects connected to each other, we should be mindful of their lifecycles. What happens if one of the objects is destroyed before the other?
In most implementations of the observer pattern, the scenario we should be particularly concerned about is the observer being destroyed before the subject it is observing.
Without intervention, this means the subject may attempt to notify an observer that no longer exists, risking memory corruption:
// main.cpp
#include "Player.h"
#include "Subject.h"
int main() {
Player Subject;
Observer* DamageObserver{
new Observer(Subject)};
Subject.TakeDamage(100);
delete DamageObserver;
// Subject is going to notify
// DamageObserver about this, but
// DamageObserver has been deleted
Subject.TakeDamage(100);
}
To handle this, we should ensure that the observer correctly unregisters itself from its subjects before it is destroyed. The destructor is the natural place for this:
#pragma once
#include <iostream>
#include "Player.h"
class Observer {
public:
Observer(Player& Subject) : Subject{Subject} {
Key = Subject.AddOnDamageDelegate(
std::bind_front(&Observer::DamageCallback, this));
}
~Observer() {
std::cout << "Observer Destroyed - "
"Unregistering from Subject\n";
Subject.RemoveOnDamageDelegate(Key);
}
private:
void DamageCallback() {
std::cout << "A Subject Took Damage\n";
}
Player& Subject;
int Key;
};
// main.cpp
#include "Player.h"
#include "Observer.h"
int main() {
Player Subject;
Observer* DamageObserver{new Observer(Subject)};
Subject.TakeDamage(100);
delete DamageObserver;
// This is now safe
Subject.TakeDamage(100);
}
Took Damage - Notifying 1 Observer(s)
A Subject Took Damage
Observer Destroyed - Unregistering from Subject
Removing Observer with id 1
Took Damage - Notifying 0 Observer(s)
We also need to consider the possibility that the observer can outlive the subject. In most implementations, this is less problematic. It doesn’t result in memory corruption - instead, the observer will simply no longer receive notifications from a subject that doesn’t exist:
// main.cpp
#include "Player.h"
#include "Subject.h"
int main() {
Player* Subject{new Player()};
Observer DamageObserver{*Subject};
Subject->TakeDamage(25);
delete Subject;
// DamageObserver is no longer
// observing anything
// ...
}
However, our observer may still want to be notified that one of its subjects was destroyed. There are many ways we can implement this.
One of the easiest is to simply have subjects notify observers of their destruction in the exact same way they notify them of any other event. Let’s add an additional multicast delegate and associated methods to our Player
class to support this:
#pragma once
#include <functional>
#include <iostream>
#include <unordered_map>
class Player {
public:
using DelegateType = std::function<void()>;
int AddOnDeathDelegate(DelegateType Callback) {
OnDeathMulticastDelegate.push_back(Callback);
return NextDeathDelegateKey++;
}
void RemoveOnDeathDelegate(int Index) {
if (Index >= 0 && Index < OnDeathMulticastDelegate.size()) {
OnDeathMulticastDelegate[Index] = nullptr;
}
}
~Player() {
NotifyOnDeath();
}
// ...
private:
int NextDeathDelegateKey{0};
std::vector<DelegateType> OnDeathMulticastDelegate;
void NotifyOnDeath() {
std::cout << "Destroying Player - Notifying " << OnDeathMulticastDelegate.size()
<< " Observer(s)\n";
for (auto& Callback : OnDeathMulticastDelegate) {
Callback();
}
}
// ...
};
Then, we can update our observer to make use of them:
#pragma once
#include <iostream>
#include "Player.h"
class Observer {
public:
Observer(Player& Subject) : Subject{Subject} {
DamageCallbackID = Subject.AddOnDamageDelegate(
std::bind_front(&Observer::DamageCallback, this));
DeathCallbackID = Subject.AddOnDeathDelegate(
std::bind_front(&Observer::DeathCallback, this));
}
~Observer() {
if (DamageCallbackID) {
Subject.RemoveOnDamageDelegate(DamageCallbackID);
}
if (DeathCallbackID) {
Subject.RemoveOnDeathDelegate(DeathCallbackID);
}
}
private:
void DeathCallback() {
std::cout << "Subject Destroyed - will "
"no longer receive events\n";
DamageCallbackID = 0;
DeathCallbackID = 0;
}
int DamageCallbackID;
int DeathCallbackID;
// ...
};
Our Observer
is now notified when its subject is destroyed, and can react if needed:
// main.cpp
#include "Player.h"
#include "Subject.h"
int main() {
Player* Subject{new Player()};
Observer PlayerObserver{*Subject};
Subject->TakeDamage(25);
delete Subject;
}
Took Damage - Notifying 1 Observer(s)
Subject took damage
Destroying Player - Notifying 1 Observer(s)
Subject Died - will no longer receive events
Observer Destroyed - Unregistering from Subject
As with any programming task, there are multiple ways we can implement the observer pattern. The approach using delegates is popular in performance-critical contexts, as function pointers are simple constructs with minimal overhead.
To reinforce our learning, let's explore an alternative implementation of the observer pattern that uses inheritance instead of delegates. We'll create generic base classes that any class can inherit to quickly become an observer or subject.
Note that this section uses more advanced C++ features than we've covered so far, including templates and multiple inheritance. If you're not comfortable with these concepts yet, feel free to skip ahead - understanding this section isn't required for the rest of the course.
In this example, let’s imagine our events are represented by objects, in much the same way SDL’s event loop is implemented. A Player
class, for example, could notify observers when they take damage, die, or level up.
They’ll do this by sending PlayerEvent
objects to their observers, which identify the specific type of event that happened. These objects can also include arbitrary data or methods if needed:
// Player.h
#pragma once
enum class PlayerEventType {
HealthChanged, Died, LevelUp
};
struct PlayerEvent {
PlayerEventType Type;
int Value;
};
We’ll provide a class that observers can inherit from to receive such events. Player
subjects will call an OnNotify()
function on their observers, passing them a PlayerEvent
object:
// Observer.h
#pragma once
class Observer {
public:
virtual void OnNotify(const PlayerEvent& E) {}
};
We’d like observers to support multiple subject types, not just Player
objects. So, let’s replace PlayerEvent
in this example with a template parameter:
// Observer.h
#pragma once
template <typename EventType>
class Observer {
public:
virtual void OnNotify(const EventType& E) {}
};
Now, for a class to become an observer of PlayerEvent
objects, it just needs to inherit from Observer<PlayerEvent>
and override the virtual OnNotify()
function:
// UIManager.h
#pragma once
#include <iostream>
#include "Observer.h"
class UIManager : public Observer<PlayerEvent> {
public:
void OnNotify(const PlayerEvent& E) override {
if (E.Type == PlayerEventType::HealthChanged) {
std::cout << "Player Health changed to " << E.Value << '\n';
} else if (E.Type == PlayerEventType::LevelUp) {
std::cout << "Player leveled up to Level " << E.Value << '\n';
} else if (E.Type == PlayerEventType::Died) {
DisplayGameOverScreen();
}
}
private:
static void DisplayGameOverScreen() {
std::cout << "==========================\n";
std::cout << " GAME OVER \n";
std::cout << "==========================\n";
}
};
Let’s also provide a Subject
class template to allow any class to quickly inherit the capability to support observers. This class will have the same capabilities we introduced previously, with methods to add, remove, and notify observers.
Again, we don’t want this to be restricted to just supporting Observer<PlayerEvent>
objects, so we instead use the Observer<EventType>
type, where EventType
is a template parameter:
// Subject.h
#pragma once
#include <vector>
template <typename EventType>
class Subject {
public:
int AddObserver(Observer<EventType>* observer) {
Observers.push_back(observer);
return NextObserverKey++;
}
void RemoveObserver(int id) {
if (id >= 0 && id < Observers.size()) {
Observers[id] = nullptr;
}
}
protected:
void NotifyObservers(const EventType& event) {
for (auto& Obs : Observers) {
if (Obs) Obs->OnNotify(event);
}
}
private:
std::vector<Observer<EventType>*> Observers;
int NextObserverKey{0};
};
Now, for a class to gain the capability to send observers PlayerEvent
notifications, it simply needs to inherit from Subject<PlayerEvent>
:
// Player.h
#pragma once
#include "Subject.h"
enum class PlayerEventType { HealthChanged, Died, LevelUp };
struct PlayerEvent {
PlayerEventType Type;
int Value;
};
class Player : public Subject<PlayerEvent> {
// ...
};
Within our class code, we then call the inherited NotifyObservers(PlayerEvent&)
method any time we want to send an event:
// Player.h
#pragma once
#include "Subject.h"
enum class PlayerEventType { HealthChanged, Died, LevelUp };
struct PlayerEvent {
PlayerEventType Type;
int Value;
};
class Player : public Subject<PlayerEvent> {
public:
void TakeDamage(int damage) {
Health -= damage;
NotifyObservers({PlayerEventType::HealthChanged, Health});
if (Health <= 0) {
NotifyObservers({PlayerEventType::Died, 0});
}
}
void GainExperience(int exp) {
Experience += exp;
if (Experience >= 100) {
Level++;
Experience -= 100;
NotifyObservers({PlayerEventType::LevelUp, Level});
}
}
private:
int Health{100};
int Level{1};
int Experience{0};
};
Over in our main
function, let’s hook everything up and confirm it’s working:
// main.cpp
#include "UIManager.h"
#include "Player.h"
int main() {
Player PlayerOne;
UIManager UI;
int ObserverID{PlayerOne.AddObserver(&UI)};
// Simulate some game actions
PlayerOne.TakeDamage(30);
PlayerOne.GainExperience(150);
PlayerOne.TakeDamage(100);
// Remove the observer using the key
PlayerOne.RemoveObserver(ObserverID);
// UI is no longer observing
PlayerOne.GainExperience(100);
}
Player Health changed to 70
Player leveled up to Level 2
Player Health changed to -30
==========================
GAME OVER
==========================
Let’s introduce another type of observable subject to our program using this architecture. We’ll create an example NetworkManager
class, which can send NetworkEvent
objects to its observers.
Now that we have the Subject
class template, this becomes much easier - we simply inherit from Subject<NetworkEvent>
and call NotifyObservers(&NetworkEvent)
as needed:
// Network.h
#pragma once
#include <iostream>
#include "Subject.h"
enum class NetworkEventType {
Connected,
Disconnected
};
struct NetworkEvent {
NetworkEventType Type;
};
class NetworkManager : public Subject<NetworkEvent> {
public:
void Disconnect() {
std::cout << "Network Disconnected\n";
NotifyObservers({
NetworkEventType::Disconnected
});
}
};
Our UIManager
class will want to observe both the player and the network manager. Fortunately, C++ supports multiple inheritance. Our UIManager
can inherit from both Observer<PlayerEvent>
and Observer<NetworkEvent>
. The syntax looks like this:
class UIManager :
public Observer<PlayerEvent>,
public Observer<NetworkEvent> {
};
Then, we can override the virtual OnNotify()
method of both base types. These functions have different parameter lists - one receives PlayerEvent&
arguments and the other receives NetworkEvent&
arguments - so the compiler understands what we’re overriding:
// Network.h
#pragma once
#include "GameObject.h"
#include "IObserver.h"
#include "Player.h"
#include "Network.h"
class UIManager :
public Observer<PlayerEvent>,
public Observer<NetworkEvent> {
public:
void OnNotify(const NetworkEvent& E) override {
if (E.Type == NetworkEventType::Disconnected) {
std::cout << "You are offline\n";
}
}
void OnNotify(const PlayerEvent& E) override {
if (E.Type == PlayerEventType::HealthChanged) {
std::cout << "Player health changed to "
<< E.Value << '\n';
} else if (E.Type == PlayerEventType::LevelUp) {
std::cout << "Player leveled up to level "
<< E.Value << '\n';
} else if (E.Type == PlayerEventType::Died) {
DisplayGameOverScreen();
}
}
private:
void DisplayGameOverScreen() {
std::cout << "==========================\n";
std::cout << " GAME OVER \n";
std::cout << "==========================\n";
}
};
Our UIManager
objects can now observe both Player
and NetworkManager
subjects:
#include "Player.h"
#include "Network.h"
int main() {
Player PlayerOne;
UIManager UI;
NetworkManager Network;
int UIObserverKey{PlayerOne.AddObserver(&UI)};
int NetObserverKey{Network.AddObserver(&UI)};
PlayerOne.TakeDamage(30);
Network.Disconnect();
}
Player health changed to 70
Network Disconnected
You are offline
Multiple inheritance is an important capability, but it’s something we should be careful with. If we don’t approach it sensibly, our inheritance hierarchies can become incredibly complex and difficult to reason about.
To help contain this complexity, it can be helpful to self-impose some restrictions on how we use multiple inheritance. One such design pattern is to conceptually imagine our classes having (at most) one "real" base class, but as many "interfaces" as needed.
Whilst an interface is technically a base class in C++, we can imagine it having a more restrictive purpose. We inherit from an interface only to ensure compatibility with some other system that our class wants to use.
For example, for a Goblin
object to be compatible with a GameManager
system that controls things like ticking, the Goblin
class perhaps needs to inherit from an interface called GameObject
. That interface includes the virtual Tick(float DeltaTime)
function that the GameManager
system will be invoking, and that the Goblin
can override:
#include "Monster.h"
class GameObject {
public:
virtual void Tick(float DeltaTime T) {}
};
class GameManager {
public:
void Tick(float DeltaTime T) {
for (auto& Object : GameObjects) {
Object->Tick(T);
}
}
private:
std::vector<IGameObject*> GameObjects;
};
// Goblins are Monsters that can Tick
class Goblin :
public Monster, // Base Class
public GameObject // Interface
{
public:
void Tick(float DeltaTimeT) override {
// ...
}
};
To observe a Subject<Player>
object, a class must inherit from Observer<Player>
, which provides the OnNotify(const PlayerEvent&)
function that the Subject<Player>
object will be invoking.
#include "GameManager.h"
#include "IObserver.h"
#include "Player.h"
#include "Network.h"
// UIManagers are GameManagers that can observe
// Players and Networks
class UIManager :
public GameManager, // Base Class
public Observer<Player>, // Interface
public Observer<Network> // Interface
{
public:
UIManager() {
GetPlayer().AddObserver(this);
GetNetwork().AddObserver(this);
}
void OnNotify(const PlayerEvent& E) override {
// ...
}
void OnNotify(const NetworkEvent& E) override {
// ...
}
};
This conceptual separation between a base class and an interface is so common that many object-oriented programming languages include it within the syntax itself.
For example, Java identifies a base class using the extends
keyword, whilst interfaces use implements
:
public class UIManager
extends Manager
implements Observer<Player>, Observer<Network> {
// ...
}
C++ has no such distinction within the language. Instead, we typically distinguish interfaces by adopting a naming convention. The most common convention is that classes intended to be an interface will start with I
:
class UIManager :
public Manager, // Base Class
public IObserver<Player>, // Interface
public IObserver<Network> { // Interface
// ...
};
We cover multiple inheritance and the related concept of pure virtual functions in much more detail in our advanced course:
In this lesson, we've explored different approaches to managing communication between components in our programs, with a particular focus on the Observer pattern as a flexible and maintainable solution.
By implementing observers and subjects properly, we can create loosely coupled systems that are easier to understand, modify, and maintain over time. Key takeaways:
An overview of the options we have for building flexible notification systems between game components
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games