Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
The Type Object pattern introduced in the previous lesson provides a flexible way to manage variations among game entities by externalizing their common properties into data. This avoids a proliferation of C++ classes and makes game updates and content creation more accessible.
In this final lesson in the chapter, we put this theory into practice, combining it with the SDL_RWops
techniques covered previously. We will build the foundations of a system to load these type objects and their corresponding entities from external data, process that data, and use it to instantiate our C++ objects.
We'll use data serialized as text in this project. In the next chapter, we'll build a program using binary serialization, so we can establish experience in both approaches.
We'll build upon the code we created in the previous lesson. A complete version of that code is provided below:
Let's start with our serialized type objects. We'll represent our types as comma-seperated values within a file called types.txt
. An example collection of types might look something like this:
Goblin,3,100,15,goblin.png,Swarm
Dragon,7,500,45,dragon.png,RangedSnipe
If you want to follow along, this file should be in the same directory as your compiled executable.
In the previous lesson, we were demonstrating the concept of serialized type objects using the friendlier JSON format:
{
"Goblin": {
"name": "Goblin",
"level": 3,
"health": 100,
"damage": 15,
"art": "goblin.png",
"fightingStyle": "Swarm"
},
"Dragon": {
"name": "Dragon",
"level": 7,
"health": 500,
"damage": 45,
"art": "dragon.png",
"fightingStyle": "RangedSnipe"
}
}
We could use that in our implementation instead of our more basic comma-seperated rows. And, in a more complex project, perhaps we should.
However, writing the code to deserialize JSON is significantly more difficult. If we wanted to use JSON, we'd generally enlist the help of a third-party library. We have a dedicated lesson on handling JSON using a popular library created by Niels Lohmann:
In our MonsterType
struct, let's add a static
function that will read our file, and return a std::vector
of our MonsterType
type objects. We'll create a Bestiary
alias for this type:
// MonsterType.h
#pragma once
#include <string>
#include <vector>
class Entity;
class MonsterType;
using Bestiary = std::vector<MonsterType>;
enum class FightingStyle { /*...*/ };
struct MonsterType {
static Bestiary LoadMonsterTypes();
};
Let's start our implementation by loading the entire types.txt
file into a std::string
, using techniques we covered earlier in the chapter. To keep things focused, we'll skip error checking in these examples:
// MonsterType.cpp
#include <SDL.h>
#include <string>
#include <vector>
// ...
Bestiary MonsterType::LoadMonsterTypes() {
char* Raw{static_cast<char*>(
SDL_LoadFile("types.txt", nullptr))};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
// Populate the array (next)
return MonsterTypes;
}
Next, let's focus on populating the local MonsterTypes
array within our function, so we can return all the types stored in our text file. Having loaded our serialized file, our Data
string currently looks like this:
Goblin,3,100,15,goblin.png,Swarm
Dragon,7,500,45,dragon.png,RangedSnipe
To deserialize this into a collection of type objects, we'll do three things:
Firstly, we'll identify all of the line breaks in our string, and then create a substring for each line. For this example file, that will leave us with two strings:
"Goblin,3,100,15,goblin.png,Swarm"
"Dragon,7,500,45,dragon.png,RangedSnipe"
Then, for each of these strings, we create a substring for each of the comma-seperated values. Each line will result in 6 substrings which, for our goblin example, will look like this:
"Goblin"
(the name)"3"
(the level)"100"
(the health)"15"
(the damage)"goblin.png"
(the art file)"Swarm"
(the fighting style)Finally, we need to create a MonsterType
object using these values. This will involve converting the strings to the correct types that our MonsterType
struct expects.
Let's start by splitting a string into it's substrings (or "tokens") based on a delimiter. To split our string into its lines, our delimiter will be the new line character, \n
. Then, to split each of those strings into the comma-seperated values, we'll split on the comma ,
delimiter.
This string-splitting capability will be useful in multiple locations, so we'll create an inline
function for it in a new Utilities.h
file. This function uses the same techniques we covered in our earlier lesson on parsing data using a std::string
:
// Utilities.h
#pragma once
#include <string>
#include <vector>
namespace Utilities {
inline std::vector<std::string> SplitString(
const std::string& Str, char Delimiter
) {
std::vector<std::string> Tokens;
size_t StartPos{0};
size_t FindPos;
while (
(
FindPos = Str.find(Delimiter, StartPos)
) != std::string::npos
) {
Tokens.push_back(
Str.substr(StartPos, FindPos - StartPos)
);
StartPos = FindPos + 1;
}
// Don't forget the last token
if (StartPos <= Str.length()) {
Tokens.push_back(Str.substr(StartPos));
}
return Tokens;
}
}
std::getline()
Our SplitString()
function can be implemented in a slightly simpler way using the standard library's string stream utility, in conjunction with std::getline()
. Both are available within <sstream>
:
// Utilities.h
#pragma once
#include <sstream>
#include <vector>
#include <string>
namespace Utilities{
inline std::vector<std::string> SplitString(
const std::string& Str, char Delimiter
) {
std::vector<std::string> Tokens;
std::string Token;
std::istringstream TokenStream(Str);
while (std::getline(
TokenStream, Token, Delimiter
)) {
Tokens.push_back(Token);
}
return Tokens;
}
}
We don't cover standard library streams here, but we have a full chapter on them in our advanced course, including a dedicated lesson on string streams specifically:
Over in our MonsterType.cpp
file, let's #include
our new utility function and use it to first split our string into it's lines based on the line break \n
delimiter, and then split each of these lines into its 6 values based on the comma ,
delimiter:
// MonsterType.cpp
#include "Utilities.h"
// ...
Bestiary MonsterType::LoadMonsterTypes() {
char* Raw{static_cast<char*>(
SDL_LoadFile("types.txt", nullptr))};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector Fields{SplitString(Line, ',')};
// Create and push the type object
// ...
}
return MonsterTypes;
}
Within each iteration of our for
loop, our Fields
vector now has the 6 strings it needs to create the current type object - that is, each instance of MonsterType
represented by the current line we're processing.
However, each of the fields is a std::string
, so we need to convert it to the correct type expected by the MonsterType
struct. In order, the fields correspond to:
MonsterType::Name
- this is is a std::string
, so we can use our deserialized value directly - no conversion neededMonsterType::Level
- this is an int
, so we'll use std::stoi()
to convert our string to an integerMonsterType::Health
- this is an int
so we'll use std::stoi()
MonsterType::Damage
- this is an int
so we'll use std::stoi()
MonsterType::ArtFileDamage
- this is a std::string
so no conversion neededMonsterType::Style
- this is a value from our FightingStyle
enum. We'll create a function to map the std::string
to the corresponding FightingStyle
.We don't have any float
values in this example but, if we did, we'd convert the strings to the floating point value using std::stof()
.
Let's start by adding our std::string
to FightingStyle
enum mapper. This function is only needed in the MonsterType.cpp
file, so we'll add it there. We'll put it in an anonymous namespace to prevent polluting our global namespace:
// MonsterType.cpp
// ...
namespace{
FightingStyle StringToFightingStyle(
const std::string& StyleStr
) {
if (StyleStr == "BruteForce") {
return FightingStyle::BruteForce;
}
if (StyleStr == "RangedSnipe") {
return FightingStyle::RangedSnipe;
}
return FightingStyle::Swarm; // Default
}
}
// ...
Finally, in LoadMonsterTypes()
, we'll populate our MonsterTypes
array before returning it:
// MonsterType.cpp
// ...
using Bestiary = std::vector<MonsterType>;
Bestiary MonsterType::LoadMonsterTypes() {
char* Raw{static_cast<char*>(
SDL_LoadFile("types.txt", nullptr))};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector Fields{SplitString(Line, ',')};
MonsterTypes.push_back({
// Name
Fields[0],
// Level
std::stoi(Fields[1]),
// Health
std::stoi(Fields[2]),
// Damage
std::stoi(Fields[3]),
// Art File
Fields[4],
// Fighting Style
StringToFightingStyle(Fields[5])
});
}
return MonsterTypes;
}
Over in main
, let's confirm everything is working:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
int main() {
Bestiary Types{MonsterType::LoadMonsterTypes()};
std::cout << "Types: ";
for (MonsterType& Type : Types) {
std::cout << Type.Name << ", ";
}
}
Types: Goblin, Dragon,
In this section, we added a function for mapping strings to enum values. That function required us to check every possible value in the enum. This is fine for simple cases but, in more complex projects, we'd want to build a better system, or use a library to help us.
A popular choice is Magic Enum which, among other things, allows strings to be converted to the enum value with the same name. That would allow us to replace our StringToFightingStyle()
function, and any similar function we might have needed in the future, with code that looks like this:
// If Fields[5] is a string like "Swarm",
// this will return FightingStyle::Swarm
magic_enum::enum_cast<FightingStyle>(Fields[5])
Now that our type objects are being loaded successfully, let's add some data to represent our dungeon. Each monster in our dungeon will be an Entity
, whose Type
is one of the type objects we deserialized.
Our serialized dungeon data might look something like below, where every line will represent an enemy, and the commas within each line delimit the type, x position, and y position of that enemy.
We'll call this file dungeon.txt
and store it alongside our executable:
Goblin,5,3
Goblin,5,6
Goblin,7,4
Dragon,9,9
We can construct all the objects our dungeon requires, attaching the correct type object. For this example, we'll store them in a std::vector
within a Dungeon
instance.
Our Dungeon
constructor will will be responsible for populating its Enemies
array. To help with that, we'll provide a reference to our Bestiary
- the array of type objects we deserialized earlier:
// Dungeon.h
#pragma once
#include <vector>
#include "Entity.h"
#include "MonsterType.h"
struct Dungeon {
Dungeon(const Bestiary& Types) {
// Populate Enemies (next)
}
std::vector<Entity> Enemies;
};
To populate our Enemies
from the serialized data, let's start by following the same pattern as before. We'll load our dungeon.txt
file into a std::string
. We'll then use our SplitString()
utility to split that string into a vector of lines, and then each line into a vector of values:
// Dungeon.h
#pragma once
#include <string>
#include <vector>
#include <SDL.h>
#include "Entity.h"
#include "MonsterType.h"
#include "Utilities.h"
struct Dungeon {
Dungeon(const Bestiary& Types) {
char* Raw{static_cast<char*>(
SDL_LoadFile("dungeon.txt", nullptr))};
std::string Data(Raw);
SDL_free(Raw);
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector Fields{SplitString(Line, ',')};
// Construct an entity and add to Enemies array
}
}
std::vector<Entity> Enemies;
};
This time, each entry in our Fields
array has only three values but, as before, they're all std::string
s. To construct an Entity
, we need the following conversions:
Entity::Type
is a MonsterType
pointer. We'll need to convert the std::string
to the appropriate pointer within our type object collection, which is the Bestiary
provided to our constructor.Entity::X_Pos
is an int
, so we'll convert the second std::string
in Fields
to an int
using std::stoi()
.Entity::Y_Pos
is also an int
, so we'll use std::stoi()
on the third value in Fields
too.Let's start by creating a static function that will convert the type string to the corresponding MonsterType
pointer from the bestiary. The MonsterType
struct seems like the natural place for such a function:
// MonsterType.h
// ...
struct MonsterType {
// ...
static const MonsterType* FindType(
const Bestiary& Types,
const std::string& DesiredType
) {
for (const MonsterType& Type : Types) {
if (Type.Name == DesiredType) return &Type;
}
return nullptr;
}
};
When we find ourselves searching through an array for a specific object, that is often a sign that our design can be improved.
That is the case here, too - an array such as a std::vector
works, but there are other types of container more suited to finding specific objects within the collection. Examples include std::map
or std::unordered_map
.
We'll leave our bestiary as a std::vector
is for now, as we're primarily focused on getting experience with serialization, and don't want to add additional complications. We'll revisit maps later in the course.
Back in our Dungeon
constructor, let's use this new FindType()
function, alongside std::stoi()
, to populate our collection of enemies:
// Dungeon.h
#pragma once
#include <string>
#include <vector>
#include <SDL.h>
#include "Entity.h"
#include "MonsterType.h"
#include "Utilities.h"
struct Dungeon {
Dungeon(const Bestiary& Types) {
char* Raw{static_cast<char*>(
SDL_LoadFile("dungeon.txt", nullptr))};
std::string Data(Raw);
SDL_free(Raw);
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector Fields{SplitString(Line, ',')};
Enemies.emplace_back(Entity{
// Type Object
MonsterType::FindType(Types, Fields[0]),
std::stoi(Fields[1]), // X Position
std::stoi(Fields[2]), // Y Position
});
}
}
std::vector<Entity> Enemies;
};
Finally, let's create a Dungeon
and confirm everything is working:
// main.cpp
#include "MonsterType.h"
#include "Entity.h"
#include "Dungeon.h"
int main(int argc, char** argv) {
Bestiary TypeObjects{
MonsterType::LoadMonsterTypes()
};
std::cout << "Types: ";
for (const MonsterType& Type : TypeObjects) {
std::cout << Type.Name << ", ";
}
Dungeon DragonsLair{TypeObjects};
std::cout << "\nEnemies: \n";
for (const Entity& E : DragonsLair.Enemies) {
std::cout << " " << E.Type->Name
<< " (" << E.X_Pos << ", "
<< E.Y_Pos << ")\n";
}
return 0;
}
Types: Goblin, Dragon,
Enemies:
Goblin (5, 3)
Goblin (5, 6)
Goblin (7, 4)
Dragon (9, 9)
A complete version of the program we created over the last two lessons is provided below:
In this lesson, we put the Type Object pattern into action by reading MonsterType
definitions and Entity
configurations from external files. We used SDL_LoadFile()
for file input and developed string parsing logic to convert text data into C++ MonsterType
and Entity
objects, creating a data-driven foundation for game content.
Key Takeaways:
types.txt
, dungeon.txt
) can define game object characteristics.SDL_LoadFile()
help read data for parsing.int
using std::stoi()
, or custom enums using helper functions or external libraries.Bestiary
) and used to instantiate game entities.Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View CourseLearn to load game object types and instances from external text files
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