Loading Saved Levels
Complete the save/load cycle by implementing level deserialization using SDL_RWops and actor factories.
We've learned how to save our level designs, translating the in-memory state of actors and grid settings into a binary file using SDL's file I/O capabilities. Now, we need the ability to bring those saved designs back to life in the editor.
This lesson focuses entirely on deserialization - the process of reading the binary data and reconstructing the original objects. We'll implement the Level::Load()
function, carefully using SDL_ReadU8()
, SDL_ReadLE32()
, and other SDL_RWops
functions to read data in the sequence it was saved.
A major focus will be the Factory Pattern. Since we only store a type identifier (like 1
for BlueBlock
), we need a way to invoke the correct constructor. We'll achieve this by:
- Adding static
Construct()
methods to actor subclasses. - Storing pointers to these methods (using
std::function
). - Using the type identifier read from the file to select and call the right factory function.
This ensures our loading logic is flexible and can handle different actor types correctly.
By the end of this lesson, we'll have completed our level editor project!

Level Deserialization
With the serialization logic in place, we can now turn our attention to the reverse process: deserialization. The Level::Load()
function is where we'll read the binary data from a .bin file and reconstruct the level state within the editor.
The first step mirrors saving: we need to open the file. We construct the filename using the LoadedLevel
number and attempt to open it using SDL_RWFromFile()
, this time with the mode "rb" for reading in binary.
If the file doesn't exist or cannot be opened, SDL_RWFromFile()
returns nullptr
, which we check for. Before reading any data, we also call Actors.clear()
to remove any existing actors from the level currently loaded in the editor.
// Editor/Source/Level.cpp
// ...
void Level::Load() {
Actors.clear();
std::string FileName{std::format(
"Assets/Level{}.bin", LoadedLevel
)};
SDL_RWops* Handle{SDL_RWFromFile(
FileName.c_str(), "rb")};
if (!Handle) {
CheckSDLError("Loading Level");
return;
}
// TODO: Deserialize stuff
SDL_RWclose(Handle);
}
Our deserializing code must closely align with our serialization code. We must deserialize our objects respecting the size, order, and endianness in which they were serialized.
Our serialized data has three Uint8
values representing the version, grid width and grid height in that order, followed by a little-endian Uint32
representing the number of actors in the level.
Let's deserialize them in that order, and log out what we found:
// Editor/Source/Level.cpp
// ...
void Level::Load() {
Uint8 FileVersion{SDL_ReadU8(Handle)};
if (FileVersion != VERSION) {
// This file is from a different version of
// our software - react as needed
}
Uint8 GridWidth{SDL_ReadU8(Handle)};
Uint8 GridHeight{SDL_ReadU8(Handle)};
Uint32 ActorCount{SDL_ReadLE32(Handle)};
std::cout << std::format( "Loading a version "
"{} level ({}x{}) with {} actors\n",
FileVersion, GridWidth,
GridHeight, ActorCount
);
// TODO: Load Actors
SDL_RWclose(Handle);
}
Currently, our loading code exists but isn't called automatically when the editor starts. To make the editor load Level 1 immediately on launch, we can call Load()
directly from the Level
class's constructor.
Since the LoadedLevel
member defaults to 1
, adding Load()
inside the constructor body will trigger the loading sequence for "Assets/Level1.bin" as soon as the Level
object is created within the Scene
.
// Editor/Level.h
// ...
namespace Editor {
// ...
class Level {
public:
Level(Scene& ParentScene)
: ParentScene{ParentScene} {
Load();
}
// ...
};
}
If a "Level1.bin" file exists from a previous save, its data should now be read and logged.
Loading a version 1 level (13x6) with 2 actors
Actor Factories
To load our actors, let's use some of the techniques we covered in our lesson on function pointers. Each specific Actor
subtype that can exist in our serialized data will have a function that is responsible for deserializing that data, constructing the object based on that data, and returning a pointer to it.
The natural place to define these functions is alongside the types themselves, so we'll add them as a static functions to our Actor
subtypes, like BlueBlock
and GreenBlock
.
Let's start with BlueBlock
. To construct a BlueBlock
, we need two things:
- A reference to the parent
Scene
. OurLevel
'sLoad()
function can pass this when it's calling our factory function. - An
SDL_Rect
representing its position and size. The position (x
,y
) can be calculated from the grid row and grid column values in the serialized data. The size (w
,h
) is the same for allBlueBlock
instances, and is currently stored as staticWIDTH
andHEIGHT
values.
All of our block types will need to calculate this SDL_Rect
, so let's add a helper function to the Actor
base class that they can all use:
// Editor/Actor.h
// ...
namespace Editor {
class Scene;
class Actor {
public:
// ...
static SDL_Rect GeneratePositionRectangle(
SDL_RWops* Handle, int Width, int Height
) {
using namespace Config::Editor;
Uint8 GridRow{SDL_ReadU8(Handle)};
Uint8 GridCol{SDL_ReadU8(Handle)};
SDL_Rect R{0, 0, Width, Height};
R.x = GridCol * HORIZONTAL_GRID_SNAP;
R.y = GridRow * VERTICAL_GRID_SNAP;
return R;
}
// ...
};
}
Back in BlueBlock
, let's add our static
Construct()
function:
// Editor/Blocks.h
// ...
namespace Editor{
class BlueBlock : public Actor {
public:
// ...
static std::unique_ptr<Actor> Construct(
SDL_RWops* Handle,
Scene& ParentScene
) {
return std::make_unique<BlueBlock>(
ParentScene,
GeneratePositionRectangle(
Handle, WIDTH, HEIGHT));
}
};
}
Deserializing Green Blocks
GreenBlock::Construct()
will be similar - we'll also just grab the contrived SomeNumber
and SomeArray
data from the file and apply it to the object we create:
// Editor/Blocks.h
// ...
namespace Editor{
// ...
class GreenBlock : public Actor {
public:
// ...
static std::unique_ptr<Actor> Construct(
SDL_RWops* Handle,
Scene& ParentScene
) {
auto NewActor{std::make_unique<GreenBlock>(
ParentScene,
GeneratePositionRectangle(
Handle, WIDTH, HEIGHT))};
NewActor->SomeNumber = SDL_ReadLE16(Handle);
Uint32 ArraySize{SDL_ReadLE32(Handle)};
NewActor->SomeArray.resize(ArraySize);
for (int i{0}; i < ArraySize; ++i) {
NewActor->SomeArray[i] = SDL_ReadLE32(Handle);
}
return NewActor;
}
};
}
Using the Factories
Back in Level::Load()
, let's use these factories to recreate our full level. We'll store our loaders as std::function
wrappers. We only need them in our Level.cpp
file, so let's add them in an anonymous namespace in that file, above our Load()
function:
// Editor/Source/Level.cpp
// ...
#include <functional>
// ...
namespace{
using namespace Config;
using ActorLoaderFunc = std::function<
std::unique_ptr<Actor>(SDL_RWops*, Scene&)>;
std::vector<ActorLoaderFunc> ActorLoaders{
{}, // Index 0
BlueBlock::Construct, // Index 1
GreenBlock::Construct, // Index 2
};
}
// ...
The effect of putting ActorLoaders
in an anonymous namespace is that it can now only be accessed from within this same source file, Level.cpp
. This prevents us from polluting our global scope with symbols that are only relevant to one area of our program.
The indices of this ActorLoaders
array corresponds to the numeric actor types in our serialized data. As a reminder, our ActorType
enum is set up like this:
enum class ActorType : Uint8 {
Actor = 0,
BlueBlock = 1,
GreenBlock = 2,
};
So, we store the loader for BlueBlock
at index 1, and GreenBlock
at index 2. Our program doesn't allow basic Actor
instances to be added to the level, so we'll never need to deserialize those. As such, we just store an empty std::functional
at the base Actor
index (0
).
All that's left to do is update our Load()
function to loop ActorCount
times, grab the type of each actor, call the corresponding std::functional
to create it, and then add it to the level:
// Editor/Source/Level.cpp
// ...
void Level::Load() {
for (size_t i{0}; i < ActorCount; ++i) {
Uint8 ActorType{SDL_ReadU8(Handle)};
auto Loader{ActorLoaders[ActorType]};
if (Loader) {
ActorPtr NewActor{Loader(Handle, ParentScene)};
NewActor->SetLocation(ActorLocation::Level);
AddToLevel(std::move(NewActor));
} else {
std::cout << "Error: No Loader for Actor "
"Type " << ActorType << '\n';
}
}
SDL_RWclose(Handle);
}
With all our plumbing in place, we can just add new actors to our editor as needed. In the Complete Code section below, we've added RedBlock
, OrangeBlock
, and YellowBlock
actors by following the exact same pattern.
- Add our new types to the
ActorType
enum - Add new classes that inherit from
Actor
- Add our necessary functions -
Construct()
,Clone()
,GetActorType()
, and the constructor. This can be done manually of using our preprocessor macro if we created one - Add an instance of our new type to the
ActorMenu
.
With that, our program is now be complete! We can create levels which get saved in the Assets/
directory alongside our program executable. We can also load those levels later, and see them restored in our Editor
for us to modify.

Complete Code
Complete versions of the files we changed in this lesson are provided below:
The remaining project files, which were not changed in this lesson, are available below:
Summary
In this final part of the editor's core implementation, we focused on deserialization - loading the saved level data back into the editor. We implemented the Level::Load()
method, using SDL_RWops
to read the binary file created in the previous lesson.
We emphasized matching the read operations (SDL_ReadU8()
, SDL_ReadLE32()
, etc) to the write operations used during serialization. To handle reconstructing actors of unknown types, we introduced static factory methods (Construct
) within each actor class. These factories read the necessary data from the file handle and returned a unique_ptr<Actor>
. A std::vector<std::function>
was used to map the serialized actor type ID to the corresponding factory function.
Key Takeaways:
- Loading involves opening files with "rb" mode using
SDL_RWFromFile()
. - Error check the result of
SDL_RWFromFile()
. - Read data in the exact sequence and format it was written.
- Static factory methods provide a clean way to handle object construction during deserialization.
std::function
allows storing pointers to these static methods for dynamic dispatch.- Remember to convert grid coordinates back to rendering coordinates when setting actor positions.
Exponents and cmath
Understand the math foundations needed for game programming in C++, including power and root functions.