Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
In the previous lessons, we learned some techniques that allow objects in our C++ program to acopt characteristics defined in some external location. This might be a file created by a different program, a stream of data coming from the internet, or simply a text file that we can edit.
For example, we could let designers on our team or our end users add monsters to our game by editing a JSON file:
{
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
"position": { "x": 1, "y": 3 }
}
However, with this simple system, we'll soon encounter some design problems. For example, if we were to create a dungeon with some monsters, we'll end up with a lot of duplication:
[{
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
"position": { "x": 1, "y": 3 }
}, {
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
"position": { "x": 2, "y": 1 }
}, {
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
"position": { "x": 4, "y": 7 }
}]
Duplication in our serialized data is just as much of a problem as it is in our code. It makes changes quite difficult - if we need to change the health that goblins have, we have to update every copy.
Inefficient data organisation also has a runtime performance cost in a way that duplicate code often doesn't. Large blobs of data takes longer to deserialize and transfer.
We probably recognise this as a familar problem - when groups of objects have a set of similar characteristics, we can represent them as being part of the same class, or having the same type.
We could represent those shared set of characteristics as a single value, which we'll call type
:
[{
"type": "Goblin",
"position": { "x": 1, "y": 3 }
}, {
"type": "Goblin",
"position": { "x": 2, "y": 1 }
}, {
"type": "Goblin",
"position": { "x": 4, "y": 7 }
}]
Then, in the C++ code that deserializes our data, we can examine the requested type
of each object, and map it to a corresponding class
:
// Defining a class
class Goblin {
public:
Goblin(int x, int y) : PositionX{x}, PositionY{y} {}
int PositionX;
int PositionY;
std::string Name{"Goblin"};
int Level{3};
int Health{100};
int Damage{15};
float Armor{0.2};
std::string Art{"goblin.png"};
};
// Creating class instances
Goblin A{1, 3};
Goblin B{2, 1};
Goblin C{4, 7};
The tradeoff here is that, having moves values that were previously defined in data back into our C++ code, we've lost some flexibility
The problem with relying solely on C++ classes is that it ties our type definitions directly to our compiled code. This introduces several limitations:
Firstly, modifying or adding new types of monsters now requires C++ programming knowledge. A game designer or even a player who wants to mod the game can't simply tweak a configuration file; they'd need access to the source code, and the C++ knowledge to modify it.
Secondly, this approach can slow down iteration. If we want to experiment with a new monster type or adjust the stats of an existing one, we have to stop the game, change the C++ class, recompile the entire project, and then restart. This cycle can be time-consuming, especially when fine-tuning game balance or exploring creative ideas.
Finally, if our game has a vast bestiary with many unique monsters, each differing only slightly in stats or appearance, we could end up with an explosion of C++ classes. This can make the codebase harder to manage and navigate, even if many of these classes are very similar.
While the C++ type system is incredibly powerful and forms the backbone of our programs, for scenarios demanding high flexibility and external configuration of object properties, layering another system on top can be highly beneficial.
So, we want to return granular control of our objects back outside of our program where they can be easily modified, but without causing the duplicate data problem we had before. The natural idea that probably comes to us is to have those types themselves defined as data that our program deserializes.
Elsewhere in our JSON file, or in a different file, we can define the types of enemies that our program will be using. These look very similar to the classes we had in our C++ code, except now they're represented as serialized data:
{
"Goblin": {
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
},
"Dragon": {
"name": "Dragon",
"level": 12,
"health": 1300,
"damage": 75,
"armor": 0.4,
"art": "dragon.png",
}
}
We have just discovered the concept of a type object - a popular design pattern for solving this exact problem.
The Type Object pattern offers a way to define the shared characteristics of our game objects using data, rather than hardcoding everything into C++ classes.
This means we can have one general C++ class for many game entities, and their specific "type" (like being a Goblin or a Dragon) is determined by a separate "type object" they hold as a member variable.
This "type object" holds all the common properties for entities of that kind, like default health, damage, or artwork. Our individual entity instances will then store their own unique data (like current position or current health) and hold a reference to their type object.
This allows the C++ aspect of our type system to involve just two classes - one for the primary objects, which we'll call Entity
in our examples, and a second class for the type objects, which we'll call MonsterType
.
First, let's define our simple MonsterType
struct, which we'll use later to instantiate our type objects:
// MonsterType.h
#pragma once
#include <string>
struct MonsterType {
std::string Name;
int Level;
// Shared maximum health for this type
int MaxHealth;
int Damage;
std::string ArtFile;
};
Next, our Entity
class will be quite generic. It stores its unique properties (like position and current health) and holds a reference to its MonsterType
:
// Entity.h
#pragma once
#include "MonsterType.h"
class Entity {
public:
Entity(const MonsterType& Type, int x, int y)
: Type{Type}, X_Pos{x}, Y_Pos{y},
CurrentHealth{Type.MaxHealth} {}
// Member variables
const MonsterType& Type; // See note below
// Instance-specific values
int X_Pos;
int Y_Pos;
int CurrentHealth;
};
In the previous example, we've called the member variable storing the type object simply Type
. However, the word "type" can become a bit overloaded when discussing systems like this. When we talk about an entity's "type", we could be referring to its C++ class (e.g., Entity
) or the specific type object it references (e.g., the MonsterType
instance stored in its Type
variable).
Usually, the meaning is clear from the context. However, to avoid ambiguity, it's sometimes helpful to use a more specific term for the data-driven type that the type object represents.
Common alternatives for the concept embodied by the type object include "metatype", "kind", "prototype", or "archetype".
Depending on the specific context in which type objects are being used, even more descriptive names might be suitable. For example, given our main C++ class is representing Monster
objects in this example, alternative terms for their type object might be things like Breed
or Species
.
We'll just stick with Type
in this lesson.
Now, let's see how to use our two classes this in main
:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
int main() {
MonsterType GoblinType{
"Goblin", // Name
3, // Level
100, // MaxHealth
15, // Damage
"goblin.png" // ArtFile
};
MonsterType DragonType{
"Dragon", // Name
12, // Level
1300, // MaxHealth
75, // Damage
"dragon.png" // ArtFile
};
// Create Entity instances, providing
// their type and position
Entity GoblinA{GoblinType, 10, 20};
Entity GoblinB{GoblinType, 15, 25};
Entity BigDragon{DragonType, 50, 50};
}
The Type Object pattern offers several significant advantages, particularly in game development:
GoblinScout
, GoblinWarrior
, GoblinShaman
), you can have a single Entity
class and differentiate them through their type objects. This keeps the C++ class hierarchy cleaner and more manageable.But fundamentally, the core advantage of type object systems is the flexibility - we can build it however we want, to meet the exact requirements of the program we're designing.
Breed
. We'll implement this later in the lesson.But that's also the main disadvantage - the need to build and maintain this stuff ourselves.
If we need inheritance-like behaviour for our class, that's easy. The C++ type system already has built inheritance for us and, to access it, we just need to add : public SomeClass
to our definition:
class BigGoblin : public Goblin {
public:
BigGoblin() {
// Update the inherited members
Name = "Big Goblin";
Health = 200;
}
};
But if we wanted similar capabilities for our type object system, we'd need to build that ourselves:
{
"Goblin": {
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"armor": 0.2,
"art": "goblin.png",
},
"BigGoblin": {
// We'd need to write the C++ to implement this
"Parent": "Goblin",
"name": "Big Goblin",
"health": 200,
}
}
Writing the code to make this work would take some effort, and it's not a one-time cost. It leaves us with more code we need to maintain, more complexity in our project, and more things that can go wrong.
As always, we should aim to keep things as simple as possible, and only add complexity when we need it, or when we're relatively certain that the benefits will outweigh the costs.
There are a couple of common ways we can instantiate our Entity
objects and associate them with their MonsterType
.
The most straightforward method is to pass the MonsterType
object (or a reference/pointer to it) directly into the Entity
's constructor. This is the approach we've used so far:
// main.cpp
#include <iostream>
#include "MonsterType.h"
#include "Entity.h"
int main() {
MonsterType GoblinType{
"Goblin", 3, 100, 15, "goblin.png"
};
// Entity constructor takes the MonsterType
Entity GoblinInstance{GoblinType, 5, 10};
std::cout << "Created a "
<< GoblinInstance.Type.Name
<< " at (" << GoblinInstance.X_Pos
<< ", " << GoblinInstance.Y_Pos
<< ") with " << GoblinInstance.CurrentHealth
<< " HP.\n";
}
Created a Goblin at (5, 10) with 100 HP.
This approach is clear and follows familiar C++ practices for dependency injection. The Entity
explicitly declares its need for a MonsterType
.
An alternative is to have the MonsterType
object itself be responsible for creating instances of entities that conform to its type. We can add a CreateInstance()
or Construct()
method to the MonsterType
struct:
// MonsterType.h
#pragma once
#include <string>
// Forward declaration for Entity to avoid circular
// dependency if Entity.h also includes MonsterType.h
class Entity;
struct MonsterType {
std::string Name;
int Level;
int MaxHealth;
int Damage;
std::string ArtFile;
// Factory method
Entity CreateInstance(int x, int y) const;
};
// MonsterType.cpp
#include "MonsterType.h"
#include "Entity.h"
Entity MonsterType::CreateInstance(
int x, int y
) const {
return Entity{*this, x, y};
}
// main.cpp
#include <iostream>
#include "MonsterType.h"
#include "Entity.h"
int main(int argc, char** argv) {
MonsterType GoblinType{
"Goblin", 3, 100, 15, "goblin.png"
};
Entity GoblinInstance{GoblinType, 5, 10};
Entity GoblinInstance{
GoblinType.CreateInstance(5, 10)};
std::cout << "Created a "
<< GoblinInstance.Type.Name
<< " at (" << GoblinInstance.X_Pos
<< ", " << GoblinInstance.Y_Pos
<< ") with " << GoblinInstance.CurrentHealth
<< " HP.\n";
return 0;
}
Created a Goblin at (5, 10) with 100 HP.
This factory method approach can be useful if the creation logic is complex or if the MonsterType
needs to perform some setup on the Entity
that goes beyond simple member initialization. It also keeps the creation knowledge localized within the MonsterType
.
Both methods are valid, and the choice depends on the specific requirements of your project, and your preferred coding style.
One significant advantage of the traditional C++ class system is how easily it allows us to define distinct behaviors for different types of objects - particularly when we use virtual
functions and polymorphism. For example, a Goblin
class could have a different Attack()
method implementation than a Dragon
class.
When we move towards a data-driven Type Object system, defining complex, unique behaviors purely through data becomes more challenging. Unless we integrate a scripting system (like Lua, which we discussed earlier in this chapter) that our type objects can reference or embed, we can't directly write arbitrary "code" in our data files.
However, a practical middle-ground approach is to add data fields to our type objects that influence behavior, which is then interpreted by the C++ Entity
class. The behavior itself is still implemented in C++, but it can be parameterized or selected based on data from the type object.
For instance, we could add a FightingStyle
property to our MonsterType
data:
{
"Goblin": {
"name": "Goblin",
"health": 100,
"art": "goblin.png",
"fightingStyle": "Swarm"
},
"Ogre": {
"name": "Ogre",
"health": 500,
"art": "ogre.png",
"fightingStyle": "BruteForce"
}
}
Our MonsterType
struct in C++ would then include this new field:
// MonsterType.h
#pragma once
#include <string>
class Entity;
enum class FightingStyle {
Swarm, BruteForce, RangedSnipe
};
struct MonsterType {
std::string Name;
int Level;
int MaxHealth;
int Damage;
std::string ArtFile;
FightingStyle Style;
Entity CreateInstance(int x, int y) const;
};
And our Entity
class can have a method, say PerformCombatAction()
, that uses this FightingStyle
property to decide what to do:
// Entity.h
#pragma once
#include <iostream>
#include "MonsterType.h"
class Entity {
public:
Entity(const MonsterType& Type, int x, int y)
: Type{Type}, X_Pos{x}, Y_Pos{y},
CurrentHealth{Type.MaxHealth} {}
const MonsterType& Type;
int X_Pos;
int Y_Pos;
int CurrentHealth;
void PerformCombatAction() const {
std::cout << Type.Name << " considers its "
"action...\n";
using enum FightingStyle;
if (Type.Style == Swarm) {
std::cout << " It attempts to overwhelm "
"with numbers!\n";
} else if (Type.Style == BruteForce) {
std::cout << " It charges with brute "
"force!\n";
} else if (Type.Style == RangedSnipe) {
std::cout << " It looks for a sniping "
"opportunity!\n";
} else {
std::cout << " It attacks normally.\n";
}
}
};
In our main function:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
int main(int argc, char** argv) {
using enum FightingStyle;
MonsterType GoblinType{
"Goblin", 3, 100, 15, "goblin.png", Swarm
};
MonsterType OgreType{
"Ogre", 5, 300, 25, "ogre.png", BruteForce
};
Entity Goblin{
GoblinType.CreateInstance(5, 10)};
Entity Ogre{OgreType.CreateInstance(10, 6)};
Goblin.PerformCombatAction();
Ogre.PerformCombatAction();
return 0;
}
Goblin considers its action...
It attempts to overwhelm with numbers!
Ogre considers its action...
It charges with brute force!
This way, designers can change the FightingStyle
in the data to alter how entities behave, without touching C++ code. The range of behaviors is still defined in C++, but their selection and parameterization can be data-driven.
This approach provides a good balance between flexibility and the complexity of implementing a full scripting system. We could extend this further with more data fields, like a Spellbook
array that lists the combat abilities the type has access to, or MovementPattern
to represent how the type moves.
One of the defining capabilities of type objects is that they give us the ability to change an object's type at runtime.
C++ doesn't let us change the class
of an object at run time, but it does let us change what is stored in a member variable, and a type object is just another variable. In the following example, we've added a SetType()
function to our Entity
class.
We've also changed the Type
member from being a MonsterType
reference to a MonsterType
pointer, as references cannot be updated to point to other objects. The logic we write may also want to consider the the possibility the object having no type at all - that is, Type
being a nullptr
:
// Entity.h
#pragma once
#include <iostream>
#include "MonsterType.h"
class Entity {
public:
// Constructor now takes a pointer to MonsterType
Entity(const MonsterType* Type, int x, int y)
: Type{Type}, X_Pos{x}, Y_Pos{y},
// Added null check and switched from
// the . to -> operator
CurrentHealth{Type ? Type->MaxHealth : 0}
{}
// Changed type to pointer
const MonsterType* Type;
void ChangeType(const MonsterType* NewType) {
if (!NewType) return; // Or handle error
std::cout << X_Pos << "," << Y_Pos
<< ": " << Type->Name
<< " is transforming into a "
<< NewType->Name << "!\n";
Type = NewType;
// Adjust health based on the new type.
// For example, cap current health at new
// max health, or fully heal to new max health.
// Let's set it to the new type's max health
// for this example.
CurrentHealth = Type->MaxHealth;
}
int X_Pos;
int Y_Pos;
int CurrentHealth;
};
// MonsterType.cpp
#include "MonsterType.h"
#include "Entity.h"
Entity MonsterType::CreateInstance(
int x, int y
) const {
// Change argument from reference to pointer
return Entity{*this, x, y};
return Entity{this, x, y};
}
We can now dynamically change our object's type at run time:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
int main(int argc, char** argv) {
using enum FightingStyle;
MonsterType GoblinType{
"Goblin", 3, 100, 15, "goblin.png", Swarm
};
MonsterType DragonType{
"Dragon", 7, 500, 45, "dragon.png", RangedSnipe
};
Entity Goblin{
GoblinType.CreateInstance(5, 10)};
Goblin.ChangeType(&DragonType);
return 0;
}
5,10: Goblin is transforming into a Dragon!
In type object systems, it's generally the case that changing this value will have signicant impacts on the behaviour of the object. Naturally, the extent of this will depend on how we've designed our program.
In the following example, our object initially renders as a goblin (which we've simulated through some logging) and, after a type change, it renders itself as a dragon:
// Entity.h
#pragma once
#include <iostream>
#include "MonsterType.h"
class Entity {
public:
void Render() {
if (!Type) {
return; // Or handle error
}
std::cout << X_Pos << "," << Y_Pos
<< ": Rendering " << Type->Name
<< " using art file: " << Type->ArtFile
<< "\n";
}
};
Let's see this in action:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
int main(int argc, char** argv) {
using enum FightingStyle;
MonsterType GoblinType{
"Goblin", 3, 100, 15, "goblin.png", Swarm
};
MonsterType DragonType{
"Dragon", 7, 500, 45, "dragon.png",
RangedSnipe
};
Entity Enemy{
GoblinType.CreateInstance(5, 10)};
Enemy.Render();
Enemy.ChangeType(&DragonType);
Enemy.Render();
return 0;
}
5,10: Rendering Goblin using art file: goblin.png
5,10: Goblin is transforming into a Dragon!
5,10: Rendering Dragon using art file: dragon.png
Even if the program we're making has no user-facing requirement for objects to change type, having this capability can still be useful for our behind-the-scenes implementation.
In more complex programs, we tend to avoid excessive creation and deletion of objects, as creating a complex object like a typical game entity can be quite expensive. Additionally, the constant churn of object deletion and creation over the lifetime of our program's execution can cause the system's memory to get fragmented, further degrading performance.
Instead of deleting objects, we often prefer to hide and disable them in some way, thereby allowing us to reuse those objects again later. In some older games, this was literally implemented as moving the object behind a wall, or under the floor, so the player can't see it.
Later in our game, when we need an object of the same class, we just pick one of those disabled objects, bring it back to life, and move it to wherever it's needed. This technique is commonly called object pooling. We implement it by having a fixed-size collection (or "pool") of available game objects neatly organized in memory. When we need a "new" game object somewhere in our world, we can repurpose an object from that pool rather than create a new one.
A flexible type object systems naturally lends itself to this technique. If all our game objects have the exact same class - like Entity
- then object pooling and memory optimization in general is much easier.
For example, a player might kill a goblin early in the dungeon, open a treasure chest in the middle, then fight a dragon at the end. But, behind the scenes, the goblin, treasure chest, and dragon may have been the exact same object. It was simply recycled and given a different type object between each encounter.
Later in the course, we'll introduce the entity-component-system (ECS) design pattern. This gives us yet more options for significantly changing the behaviour of our objects at run time.
A complete version of the files we created in this lesson is available below:
This lesson introduced the Type Object design pattern as a way to define an object's characteristics using external data rather than hardcoding them into C++ classes. We explored how this pattern separates an entity's fixed class from its variable, data-defined traits, leading to more flexible and maintainable game designs.
By using type objects, we can empower designers and even players to modify game content without recompiling code.
Key Takeaways:
Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View CourseLearn to create flexible game entities using the Type Object pattern for data-driven design.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View Course