Types From Data Files
Learn to load game object types and instances from external text files
The 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 file I/O 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.
Starting Point
We'll build upon the Entity and MonsterType classes we created in the previous lesson. A complete version of that code is provided below, integrated into a minimal main() function that initializes SDL.
Files
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.pngReading Type Objects
Let's start with our serialized type objects. We'll represent our types as comma-separated values within a file called types.txt. An example collection of types might look something like this:
[output]/types.txt
Goblin,3,100,15,goblin.png,Swarm
Dragon,7,500,45,dragon.png,RangedSnipeIf you want to follow along, this file should be in the same directory as your compiled executable.
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:
src/MonsterType.h
#pragma once
#include <string>
#include <vector>
class Entity;
struct 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:
src/MonsterType.cpp
#include "MonsterType.h"
#include <SDL3/SDL.h>
#include <string>
#include <vector>
#include "Entity.h"
Entity MonsterType::CreateInstance(
int x, int y
) const {
return Entity{this, x, y};
}
Bestiary MonsterType::LoadMonsterTypes() {
std::string Base{SDL_GetBasePath()};
char* Raw{static_cast<char*>(
SDL_LoadFile((Base + "types.txt").c_str(), nullptr))
};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
// TODO: Populate the array
// (implemented in next section)
return MonsterTypes;
}Deserializing Type Objects
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,RangedSnipeTo 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-separated 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.
Splitting Strings into Tokens
Let's start by splitting a string into its 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-separated 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:
src/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;
}
}We cover this in more detail in our lesson on using std::string.
Over in our MonsterType.cpp file, let's #include our new utility function and use it to first split our string into its lines based on the line break \n delimiter, and then split each of these lines into its 6 values based on the comma , delimiter:
src/MonsterType.cpp
#include "MonsterType.h"
#include <SDL3/SDL.h>
#include <string>
#include <vector>
#include "Entity.h"
#include "Utilities.h"
Entity MonsterType::CreateInstance(
int x, int y
) const {
return Entity{this, x, y};
}
Bestiary MonsterType::LoadMonsterTypes() {
std::string Base{SDL_GetBasePath()};
char* Raw{static_cast<char*>(
SDL_LoadFile((Base + "types.txt").c_str(), nullptr))};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector<std::string> Fields{SplitString(Line, ',')};
// TODO: Create and push the type object
// (implemented in next section)
}
return MonsterTypes;
}Creating Type Objects
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 astd::string, so we can use our deserialized value directly - no conversion neededMonsterType::Level- this is anint, so we'll usestd::stoi()to convert our string to an integerMonsterType::Health- this is anintso we'll usestd::stoi()MonsterType::Damage- this is anintso we'll usestd::stoi()MonsterType::ArtFile- this is astd::stringso no conversion neededMonsterType::Style- this is a value from ourFightingStyleenum. We'll create a function to map thestd::stringto the correspondingFightingStyle.
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:
src/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:
src/MonsterType.cpp
// ...
Bestiary MonsterType::LoadMonsterTypes() {
std::string Base{SDL_GetBasePath()};
char* Raw{static_cast<char*>(
SDL_LoadFile((Base + "types.txt").c_str(), nullptr))};
std::string Data(Raw);
SDL_free(Raw);
Bestiary MonsterTypes;
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector<std::string> 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:
src/main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>
#include "MonsterType.h"
#include "Entity.h"
int main(int, char**) {
SDL_Init(0);
Bestiary Types{MonsterType::LoadMonsterTypes()};
std::cout << "Types: ";
for (const MonsterType& Type : Types) {
std::cout << Type.Name << ", ";
}
return 0;
}Types: Goblin, Dragon,Loading Dungeon Data
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:
[output]/dungeon.txt
Goblin,5,3
Goblin,5,6
Goblin,7,4
Dragon,9,9We 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 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:
src/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;
};Deserializing Dungeons
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:
src/Dungeon.h
#pragma once
#include <string>
#include <vector>
#include <SDL3/SDL.h>
#include "Entity.h"
#include "MonsterType.h"
#include "Utilities.h"
struct Dungeon {
Dungeon(const Bestiary& Types) {
std::string Base{SDL_GetBasePath()};
char* Raw{static_cast<char*>(
SDL_LoadFile((Base + "dungeon.txt").c_str(), nullptr)
)};
std::string Data(Raw);
SDL_free(Raw);
using namespace Utilities;
for (std::string& Line : SplitString(Data, '\n')) {
std::vector<std::string> Fields{SplitString(Line, ',')};
// Construct an entity and add to Enemies array (next)
}
}
std::vector<Entity> Enemies;
};This time, each entry in our Fields array has only three values but, as before, they're all std::strings. To construct an Entity, we need the following conversions:
Entity::Typeis aMonsterTypepointer. We'll need to convert thestd::stringto the appropriate pointer within our type object collection, which is theBestiaryprovided to our constructor.Entity::X_Posis anint, so we'll convert the secondstd::stringinFieldsto anintusingstd::stoi().Entity::Y_Posis also anint, so we'll usestd::stoi()on the third value inFieldstoo.
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:
src/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;
}
};Back in our Dungeon constructor, let's use this new FindType() function, alongside std::stoi() , to populate our collection of enemies:
src/Dungeon.h
#pragma once
#include <string>
#include <vector>
#include <SDL3/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<std::string> 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:
src/main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>
#include "MonsterType.h"
#include "Entity.h"
#include "Dungeon.h"
int main(int, char**) {
SDL_Init(0);
Bestiary TypeObjects{
MonsterType::LoadMonsterTypes()
};
std::cout << "Types: ";
for (const MonsterType& Type : TypeObjects) {
std::cout << Type.Name << ", ";
}
Dungeon DragonsLair{TypeObjects};
std::cout << "\nEnemies in Dungeon: \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 in Dungeon:
Goblin (5, 3)
Goblin (5, 6)
Goblin (7, 4)
Dragon (9, 9)Complete Code
A complete version of the program we created over the last two lessons is provided below:
Files
Connecting to the Application Loop
In its current form, this program simply logs out the state of our Bestiary and Dungeon:
Types: Goblin, Dragon,
Enemies in Dungeon:
Goblin (5, 3)
Goblin (5, 6)
Goblin (7, 4)
Dragon (9, 9)One way to integrate this deserialization capability into a real project is to extend our Dungeon class with usual event-handling, rendering, and ticking functions we're familiar with from earlier in the course.
Our Dungeon class would then iterate over its children and call corresponding functions on the Entity class:
// ...
class Dungeon {
public:
// ...
void Render(SDL_Surface* Surface) {
for (Entity& Enemy : Enemies) {
Enemy.Render(Surface);
}
}
void Tick() {
for (Entity& Enemy : Enemies) {
Enemy.Tick();
}
}
void HandleEvent(SDL_Event& Event) {
for (Entity& Enemy : Enemies) {
Enemy.HandleEvent(Event);
}
}
std::vector<Entity> Enemies;
};We'd then connect our Dungeon to the main application loop in the usual way by calling these functions at the appropriate times. For example:
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>
#include "Window.h"
#include "MonsterType.h"
#include "Dungeon.h"
int main(int, char**) {
SDL_Init(SDL_INIT_VIDEO);
Window GameWindow;
Bestiary TypeObjects{
MonsterType::LoadMonsterTypes()
};
Dungeon DragonsLair{TypeObjects};
SDL_Event Event;
bool IsRunning = true;
while (IsRunning) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_EVENT_QUIT) {
IsRunning = false;
}
DragonsLair.HandleEvent(Event);
}
DragonsLair.Tick();
GameWindow.Render();
DragonsLair.Render();
GameWindow.Update();
}
SDL_Quit();
return 0;
}Summary
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:
- External files (like
types.txt,dungeon.txt) can define game object characteristics. - SDL's file utilities like
SDL_LoadFile()help read data for parsing. - Parsing often involves splitting file content into smaller parts, often recursively (e.g., data into lines, and then lines into individual fields).
- String tokens need conversion to specific data types like
intusingstd::stoi(), or custom enums using helper functions or external libraries. - Loaded type objects can be stored (e.g., in a
Bestiary) and used to instantiate game entities. - This method externalizes game data, making it easier to manage and modify without requiring C++ knowledge or project recompilation.
- This idea can be extended further. Rather than our monster and dungeon state being loaded from files, our program could downloaded it from the internet instead, from a server we control. This concept is the foundation of multiplayer and "live service" games.