Saving Your Editor Levels
Implement footer buttons and binary serialization to save and load your custom game levels to disk.
In the previous lessons, we've built the core mechanics of our level editor: creating a window, managing actors, implementing drag-and-drop with grid snapping, and handling selection and deletion. However, any level we design currently vanishes the moment we close the application.
This lesson tackles persistence. We'll add controls to the editor's footer buttons to save the current level layout to a file and buttons to load previously saved levels. This involves defining new custom SDL events to signal these actions.
We'll then implement binary serialization using SDL's SDL_RWops
structure and related functions (SDL_WriteU8()
, SDL_WriteLE32()
, etc.).
We'll carefully design a binary format for our level files, deciding what data needs saving (version, grid size, actor types, positions) and how to represent it efficiently.
Finally, we'll implement the Save()
logic within our Level
class, writing the editor's state to a file according to our defined format. We'll also extend actors with a virtual Serialize()
method, allowing different actor types to save their specific data, preparing the groundwork for loading in the next lesson.
We'll assume our project contains 3 levels, but feel free to add more.

Let's begin by adding the user interface elements for saving and loading. We'll place several buttons in the footer area below the main level canvas. These buttons will trigger different actions related to level management.
To communicate these actions within our application, we'll rely on custom SDL events, just like we did for actor dragging.
We need distinct event types for loading a specific level, saving the current level, and initiating a "save and play" sequence.
We'll define these new event types in our Config.h
file, within the UserEvents
namespace, using SDL_RegisterEvents()
.
We register LOAD_LEVEL
, SAVE_LEVEL
, and SAVE_AND_PLAY_LEVEL
. as the primary events we need to react to for serialization (in this lesson) and deserialization (in the next lesson) actions.
We'll also add LEVEL_EDIT
to signal when a change occurs, and LAUNCH_LEVEL
which the editor will eventually use to signal the game (once built) to start playing a specific level.
// Config.h
// ...
namespace UserEvents{
#ifdef WITH_EDITOR
inline Uint32 ACTOR_DRAG{SDL_RegisterEvents(1)};
inline Uint32 LOAD_LEVEL{SDL_RegisterEvents(1)};
inline Uint32 SAVE_LEVEL{SDL_RegisterEvents(1)};
inline Uint32 SAVE_AND_PLAY_LEVEL{
SDL_RegisterEvents(1)};
inline Uint32 LEVEL_EDIT{SDL_RegisterEvents(1)};
#endif
inline Uint32 LAUNCH_LEVEL{
SDL_RegisterEvents(1)};
}
// ...
Load Level Button
Let's implement the buttons that allow the user to choose which level file to work with. We'll define a LoadLevelButton
class in Editor/EditorButtons.h
, inheriting from Button
.
An essential piece of information for this button is the level number it represents. The constructor will take this number as an argument and store it privately.
In the constructor's initializer list, we call the base Button
constructor. The button's label is set using std::to_string(Level)
. Its position (Rect
) is calculated to place it correctly in the footer row, spaced according to its level number.
To provide visual feedback about the initially loaded level, we add logic to the LoadLevelButton
constructor: if the button is being created for level 1
, its state is immediately set to Active
as later, when our program first launches, we'll make it load level 1 automatically.
All other load buttons start in the default Normal
state.
// Editor/EditorButtons.h
#pragma once;
#include "Button.h"
namespace Editor{
using namespace Config::Editor;
class LoadLevelButton : public Button {
public:
constexpr static int WIDTH{40};
LoadLevelButton(Scene& ParentScene, int Level)
: Level{Level},
Button {
ParentScene,
std::to_string(Level),
{
PADDING * Level + WIDTH * (Level-1),
LEVEL_HEIGHT + PADDING,
WIDTH, 30
}
}
{
if (Level == 1) {
SetState(ButtonState::Active);
}
}
private:
int Level;
};
}
When a LoadLevelButton
is clicked, it needs to signal the rest of the application that a request to load its associated level has been made. We achieve this by overriding the virtual HandleLeftClick()
function.
Inside HandleLeftClick()
, we create an SDL_Event
struct, setting its type
to our custom UserEvents::LOAD_LEVEL
. Now, we need to communicate which level was requested.
As we've seen, the SDL_UserEvent
structure within SDL_Event
has several fields we can use, including data1
, data2
, and code
. We've mostly been using data1
and data2
, as they're void*
pointers suitable for passing any arbitrary data we need. Our other option, code
is a simple Sint32
(signed 32-bit integer).
Since our Level
member is just an integer, using code
is simpler and avoids dealing with void*
casting. We'll copy the button's Level
member to E.user.code
and then push the event onto the queue using SDL_PushEvent()
.
// Editor/EditorButtons.h
// ...
namespace Editor{
using namespace Config::Editor;
class LoadLevelButton : public Button {
public:
// ...
void HandleLeftClick() override {
SDL_Event E{UserEvents::LOAD_LEVEL};
E.user.code = Level;
SDL_PushEvent(&E);
}
// ...
};
}
A LoadLevelButton
needs to visually reflect which level is currently active. This requires reacting to LOAD_LEVEL
events pushed by any button. We'll override the HandleEvent()
method for this.
The first line calls Button::HandleEvent(E)
to ensure the base class handles the basic motion and clicking actions correcntly. Then, we check if E.type
is UserEvents::LOAD_LEVEL
.
If a load event is detected, we need to determine if this button should become active. We compare the event's payload, E.user.code
, which holds the level number being loaded, against this button's own Level
member.
If they're ther same, it means this button represents the level being loaded, so we call update our state to ButtonState::Active
. Otherwise, this button represents an inactive level, and we'll revert our state to ButtonState::Normal
to ensure we're not highlighted.
// Editor/EditorButtons.h
// ...
namespace Editor{
using namespace Config::Editor;
class LoadLevelButton : public Button {
public:
// ...
void HandleEvent(const SDL_Event& E) {
Button::HandleEvent(E);
if (E.type == UserEvents::LOAD_LEVEL) {
if (E.user.code == Level) {
SetState(ButtonState::Active);
} else {
SetState(ButtonState::Normal);
}
}
}
// ...
}
We're updating this state in the HandleEvent()
function rather than HandleLeftClick()
because we'll have multiple LoadLevelButton
instances in our scene. When any one of them is clicked, we want all of them to react and update their state accordingly.
Play Level Button
Next, we'll implement the PlayLevelButton
. This button's purpose is to initiate a save operation followed by a signal to start the game with the saved level.
In its overridden HandleLeftClick()
method, we simply construct an SDL_Event
with the type SAVE_AND_PLAY_LEVEL
and push it onto the event queue. The logic to perform the save and then push the LAUNCH_LEVEL
event will reside in the Level
class's event handling later.
// Editor/EditorButtons.h
// ...
namespace Editor{
// ...
class PlayLevelButton : public Button {
public:
constexpr static int WIDTH{70};
PlayLevelButton(Scene& ParentScene)
: Button {
ParentScene,
"PLAY",
{
LEVEL_WIDTH - WIDTH - PADDING,
LEVEL_HEIGHT + PADDING,
WIDTH, 30
}
}
{}
void HandleLeftClick() override {
using namespace UserEvents;
SDL_Event E{SAVE_AND_PLAY_LEVEL};
SDL_PushEvent(&E);
}
};
}
Save Level Button
Now for the SaveLevelButton
. A key usability feature is to only enable saving when changes have been made. We don't want the user clicking "Save" unnecessarily.
To implement this, the SaveLevelButton
constructor sets the button's initial state to Disabled
right after setting up the base class properties like text and position. It will be enabled later when a level edit occurs.
// Editor/EditorButtons.h
// ...
namespace Editor{
// ...
class SaveLevelButton : public Button {
public:
constexpr static int WIDTH{70};
SaveLevelButton(Scene& ParentScene)
: Button {
ParentScene,
"SAVE",
{
LEVEL_WIDTH - WIDTH
- PlayLevelButton::WIDTH - PADDING * 2,
LEVEL_HEIGHT + PADDING,
WIDTH, 30
}
}
{
SetState(ButtonState::Disabled);
}
// ...
};
}
The SaveLevelButton
needs to dynamically change its state based on whether the level has been modified. It does this in its HandleEvent()
function (again, calling the base class version first).
It listens for our new event type, LEVEL_EDIT
. We'll later modify the Level
class later to push this event whenever an actor is added, moved, or deleted.
When LEVEL_EDIT
is detected, the SaveLevelButton
enables itself by setting its state to Normal
.
Conversely, saving or loading implies the current state is persisted, so there's nothing new to save immediately afterward. The button listens for LOAD_LEVEL
, SAVE_LEVEL
, and SAVE_AND_PLAY_LEVEL
events. Upon detecting any of these, it sets its state back to Disabled
.
// Editor/EditorButtons.h
// ...
namespace Editor{
// ...
class SaveLevelButton : public Button {
public:
// ...
void HandleEvent(const SDL_Event& E) {
Button::HandleEvent(E);
using namespace UserEvents;
if (E.type == LEVEL_EDIT) {
SetState(ButtonState::Normal);
} else if (
E.type == LOAD_LEVEL ||
E.type == SAVE_LEVEL ||
E.type == SAVE_AND_PLAY_LEVEL
) {
SetState(ButtonState::Disabled);
}
}
};
}
Finally, when our SaveLevelButton
is clicked, we'll push a SAVE_LEVEL
event, which will trigger our serialization once we add it:
// Editor/EditorButtons.h
// ...
namespace Editor{
// ...
class SaveLevelButton : public Button {
public:
// ...
void HandleLeftClick() override {
SDL_Event E{UserEvents::SAVE_LEVEL};
SDL_PushEvent(&E);
}
};
}
Now that we've defined our specialized button classes (LoadLevelButton
, SaveLevelButton
, PlayLevelButton
), we need to create instances of them within our Editor::Scene
.
We add member variables for three LoadLevelButtons
(for levels 1, 2, and 3), one SaveLevelButton
, and one PlayLevelButton
to the private section of the Scene
class.
We then ensure these buttons participate in the application loop by calling their respective HandleEvent()
, Tick()
, and Render()
methods from within the corresponding Scene
methods.
// Editor/Scene.h
// ...
#include "EditorButtons.h"
namespace Editor{
class Scene {
public:
// ...
void HandleEvent(const SDL_Event& E) {
ActorShelf.HandleEvent(E);
TooltipWindow.HandleEvent(E);
CurrentLevel.HandleEvent(E);
LoadButton1.HandleEvent(E);
LoadButton2.HandleEvent(E);
LoadButton3.HandleEvent(E);
SaveButton.HandleEvent(E);
PlayButton.HandleEvent(E);
}
void Tick(float DeltaTime) {
ActorShelf.Tick(DeltaTime);
TooltipWindow.Tick(DeltaTime);
CurrentLevel.Tick(DeltaTime);
LoadButton1.Tick(DeltaTime);
LoadButton2.Tick(DeltaTime);
LoadButton3.Tick(DeltaTime);
SaveButton.Tick(DeltaTime);
PlayButton.Tick(DeltaTime);
}
void Render(SDL_Surface* Surface) {
ActorShelf.Render(Surface);
TooltipWindow.Render();
CurrentLevel.Render(Surface);
LoadButton1.Render(Surface);
LoadButton2.Render(Surface);
LoadButton3.Render(Surface);
SaveButton.Render(Surface);
PlayButton.Render(Surface);
}
// ...
private:
// ...
LoadLevelButton LoadButton1{*this, 1};
LoadLevelButton LoadButton2{*this, 2};
LoadLevelButton LoadButton3{*this, 3};
SaveLevelButton SaveButton{*this};
PlayLevelButton PlayButton{*this};
};
}
Our buttons are now pushing events, but nothing is reacting to them yet. The Level
class is the logical place to handle saving and loading operations, as it manages the actor data.
Let's add placeholder member functions Load()
, Save()
, and SaveAndPlay()
to the Level
class definition in Editor/Level.h
. We also add an integer member LoadedLevel
, initialized to 1
, to keep track of which level file is currently active in the editor.
// Editor/Level.h
// ...
namespace Editor {
// ...
class Level {
public:
// ...
void Load();
void Save();
void SaveAndPlay();
private:
// ...
int LoadedLevel{1};
};
}
Now we connect the events to these new functions within Level::HandleEvent()
in Level.cpp
. We add else if
clauses to check for our new event types: LOAD_LEVEL
, SAVE_LEVEL
, and SAVE_AND_PLAY_LEVEL
.
When LOAD_LEVEL
is detected, we update the LoadedLevel
member with the level number from the event's user.code
field, and then call the Load()
function. When SAVE_LEVEL
is detected, we simply call Save()
.
For SAVE_AND_PLAY_LEVEL
, we call SaveAndPlay()
. Inside SaveAndPlay()
, we first call Save()
to ensure the level is persisted, and then we push another custom event, UserEvents::LAUNCH_LEVEL
.
This new event signals that the game itself should launch, loading the level specified by LoadedLevel
(attached via E.user.code
).
// Editor/Source/Level.cpp
// ...
void Level::HandleEvent(const SDL_Event& E) {
using namespace UserEvents;
else if (E.type == LOAD_LEVEL) {
LoadedLevel = E.user.code;
Load();
} else if (E.type == SAVE_LEVEL) {
Save();
} else if (E.type == SAVE_AND_PLAY_LEVEL) {
SaveAndPlay();
}
}
// ...
void Level::Load() {
// Coming soon...
}
void Level::Save() {
// Coming soon...
}
void Level::SaveAndPlay() {
Save();
SDL_Event E{UserEvents::LAUNCH_LEVEL};
E.user.code = LoadedLevel;
SDL_PushEvent(&E);
}
Our SaveLevelButton
needs to know when to become enabled. This happens whenever the user modifies the level by adding, moving, or deleting an actor. The Level::HandleDrop()
function is called whenever an actor is successfully dropped (either cloned or moved).
Therefore, at the end of HandleDrop()
, after the actor has been positioned or added, we push a UserEvents::LEVEL_EDIT
event. This signals to the SaveLevelButton
(and potentially other UI elements in the future) that the level state has changed and might need saving.
// Editor/Source/Level.cpp
// ...
void Level::HandleDrop(Actor* DragActor) {
SDL_Event E{UserEvents::LEVEL_EDIT};
SDL_PushEvent(&E);
}
Serialization Strategy
Let's work on serializing our level, such that we can store it on our hard drive. We need to decide on two things:
- What we need to serialize
- What format we should serialize it in
What to Serialize
It's technically the case that the core functionality of being able to load a level we previous saved only requires us to serialize the actors in our level. Specifically, for each actor, we need to remember it's type (eg, BlueBlock
) and position within the level.
However, it's often useful to store some additional metadata alongside this core information. This might include the size of our level (how many rows and columns it has) or the version of our software that created the serialized data.
This version tends to be particularly useful as, in the future, we may update our software, and that update might include changes to how data is serialized. By having the version right at the start of our serialized data, we can immediately understand if we're loading a file from an earlier iteration, and adapt accordingly.
So, in this project, our level will serialize:
- The editor version
- The grid width
- The grid height
- The actors in the level
Each actor is also a complex type, so we need to further break down how they will be serialized. For each of our actors, we'll store:
- Their type
- Their grid row
- Their grid column
We'll also introduce a slightly more advanced actor later, that needs to serialize some additional information.
Serialization Format
If this were a real project, we'd likely want to serialize our levels using a standard, text-based format. An example level, serialized as JSON, might look like this:
{
"Version": 1,
"GridWidth": 13,
"GridHeight": 5,
"Actors": [{
"Type": "BlueBlock",
"Row": 2,
"Column": 5
}, {
"Type": "BlueBlock",
"Row": 6,
"Column": 3
}]
}
This makes our data easier to understand and edit, even outside of our level editor program. This is less efficient than binary serialization but, in this case, we don't really need to maximize performance. The trade off of having our levels take slightly longer to save and load is worth the increased clarity.
However, this is a learning project that aims to reinforce concepts we covered in previous lessons, like memory layout and endianness. Therefore, we will opt for a custom binary format. This gives us direct control over the byte-level representation and requires careful handling of data types and byte order.
We'll serialize our levels like this:
- Editor version:
Uint8
- Grid width:
Uint8
- Grid height:
Uint8
- Actor count:
Uint32
(little-endian)

Note that the widths of these values were chosen somewhat arbitrarily. If we thought any of these unsigned 8-bit values could be larger than 255
, for example, we wanted to make much bigger levels, we'd use wider integers.
Following these level-specific values, we'll then serialize our actors as a contiguous block. We don't necessarily know how big this block will be - it depends on the number of actors. In the future, it will also depend on the types of those actors, as different actor types will have different serialization requirements - some might need to save more data than others.

To keep things organised, the specific serialization format of each actor will be defined by that actor's type. For example, the BlueBlock
class will control how a BlueBlock
instance is serialized. It will control what data it seralizes, and how many bytes each data point requires.
However, all our actor types will include the following information, which we'll serialize at the start of the respective actor's block:
- Actor type:
Uint8
- Row:
Uint8
- Column:
Uint8
The following shows the resulting serialization layout if our Level
's Actors
array contained two actors, but this pattern repeats for any actor count.

Serializing the Level
To begin the saving process, we need the version number defined in our serialization strategy.
Let's add this to Config.h
, nested inside Config::Editor
. This constant will be the first byte written to our level files.
// Config.h
// ...
#ifdef WITH_EDITOR
namespace Config::Editor {
inline const Uint8 VERSION{1};
// ...
}
#endif
// ...
Now let's implement the Level::Save()
function in Level.cpp
. The first step is to open the correct file for writing. We construct the filename dynamically using std::format()
from C++20 (or an alternative string concatenation method, which we cover after this code example).
Our file name needs to include the LoadedLevel
number, like "Assets/Level1.bin".
Once we have the file path, we'll try to open it as an existing file for writing in binary using "wb". If this doesn't work, we'll assume the file doesn't exist, so we'll then try to create and open it using "w+b":
// Editor/Source/Level.cpp
// ...
#include <format>
void Level::Save() {
std::string FileName{
std::format("Assets/Level{}.bin", LoadedLevel)
};
SDL_RWops* Handle{SDL_RWFromFile(
FileName.c_str(), "wb")
};
CheckSDLError(
"Saving Level to Existing File");
if (!Handle) {
Handle = SDL_RWFromFile(
FileName.c_str(), "w+b"
);
CheckSDLError(
"Saving Level to New File");
}
// TODO: Serialize stuff...
SDL_RWclose(Handle);
}
With our level file open for writing, let's start by inserting the first three values of our serialization format. Given we've chosen 8 bit values for our VERSION
, GRID_WIDTH
, and GRID_HEIGHT
, we don't need to worry about the byte order, as these all just have a single byte.
We can write a single byte using SDL_WriteU8()
:
// Editor/Source/Level.cpp
// ...
void Level::Save() {
using namespace Config::Editor;
SDL_WriteU8(Handle, VERSION);
SDL_WriteU8(Handle, GRID_WIDTH);
SDL_WriteU8(Handle, GRID_HEIGHT);
// TODO: Serialize the actors
// ...
SDL_RWclose(Handle);
}
Remember, behind the scenes, our SDL_RWops
object is keeping track of where the last write operation occurred, so all the data we write is appended to what we previous wrote. For example, after we call SDL_WriteU8()
to write the VERSION
, the internal byte offset advances by one byte, so the next call to SDL_WriteU8()
ensures the GRID_WIDTH
immediately comes after the VERSION
.
This means we're constantly appending to our data. That remains true even when we pass the Handle
pointer to another function, which we'll do soon. Any writing that that function does is appending to what was already there.
We covered this mechanism, and how to intervene in its operation, in the previous chapter:
Read/Write Offsets and Seeking
Learn how to manipulate the read/write offset of an SDL_RWops
object to control stream interactions.
Serializing the Actors
Let's move on to our Actors
array. We'll start by storing the number of actors in our level. We've chosen a 32 bit (4-byte) value for this, and settled on little-endian byte order, so we'll use SDL_WriteLE32()
.
Then, we iterate through all of our actors, and ask them to serialize themselves by calling a Serialize()
method, which we'll implement next:
// Editor/Source/Level.cpp
// ...
void Level::Save() {
SDL_WriteLE32(Handle, Actors.size());
for (const ActorPtr& A : Actors) {
A->Serialize(Handle);
}
SDL_RWclose(Handle);
}
In our base Actor
class, let's add our Serialize()
method, which serializes the actor's type, followed by its position in the grid. Currently, we only make one actor available to be added to our level (the BlueBlock
), but we'll expand this soon.
We want different actor subtypes to define their own serialziation procedures, so we'll mark this function as virtual
:
// Editor/Actor.h
// ...
namespace Editor {
class Scene;
class Actor {
public:
virtual void Serialize(SDL_RWops* Handle) const {
// Serialize Type
// ...
// Serialize Position
// ...
}
};
}
Serializing the Actor Type
To serialize the actor's type, we first need a defined set of numeric identifiers for each type. An enum class is perfect for this.
In Config.h
, we'll add enum class ActorType : Uint8
inside the Config
namespace. The : Uint8
explicitly sets the underlying type to an 8-bit unsigned integer, fitting our single-byte type identifier plan. We define Actor = 0
and BlueBlock = 1
as the first members.
// Config.h
// ...
namespace Config {
enum class ActorType : Uint8 {
Actor = 0,
BlueBlock = 1,
};
// ...
}
// ...
Now that we have the ActorType
enum, each actor instance needs to be able to report its own type. We can add another virtual function to the Actor
base class for this.
In Editor/Actor.h
, we'll declare the virtual Config::ActorType GetActorType()
function. The base class implementation will return Config::ActorType::Actor
. Derived classes will override this to return their specific enum value.
// Editor/Actor.h
// ...
namespace Editor {
class Scene;
class Actor {
public:
// ...
virtual Config::ActorType GetActorType() const {
return Config::ActorType::Actor;
}
// ...
};
}
Subclasses must implement the GetActorType()
virtual function to identify themselves correctly.
For the BlueBlock
class in Editor/Blocks.h
, we add the overriding function: Config::ActorType GetActorType()
. Its implementation simply returns Config::ActorType::BlueBlock
.
// Editor/Blocks.h
// ...
namespace Editor{
class BlueBlock : public Actor {
public:
// ...
Config::ActorType GetActorType() const override {
return Config::ActorType::BlueBlock;
}
};
}
Back in Actor::Serialize()
(in Actor.h
), we can now write the type identifier. We call the virtual function GetActorType()
to obtain the ActorType
enum value.
We need to write this as a raw byte. We explicitly cast the enum value to Uint8
(its underlying type) and store it in a temporary variable. Then, we use SDL_WriteU8()
to write this byte to the Handle
.
// Editor/Actor.h
// ...
namespace Editor {
class Scene;
class Actor {
public:
// ...
virtual void Serialize(
SDL_RWops* Handle
) const {
using namespace Config::Editor;
Uint8 ActorType{static_cast<Uint8>(
GetActorType())};
SDL_WriteU8(Handle, ActorType);
// Serialize Position
// ...
}
// ...
};
}
Serializing the Actor Position
Next, let's serialize the actor's positions, which are stored in its Rect.x
and Rect.y
values. Unfortunately, these positions represent where the actor is drawn in our editor window.
This is fine if we're only ever going to use this data inside our editor. But, in the future, we'll also be loading these levels into our game, so we should serialize the information in a more generally useful way.
To convert the x
and y
values to their position within the grid, we can divide them by our HORIZONTAL_GRID_SNAP
and VERTICAL_GRID_SNAP
values respectively:
// Editor/Actor.h
// ...
namespace Editor {
class Scene;
class Actor {
public:
// ...
virtual void Serialize(
SDL_RWops* Handle
) const {
using namespace Config::Editor;
Uint8 ActorType{static_cast<Uint8>(
GetActorType())};
SDL_WriteU8(Handle, ActorType);
Uint8 GridRow = Rect.y / VERTICAL_GRID_SNAP;
SDL_WriteU8(Handle, GridRow);
Uint8 GridCol = Rect.x / HORIZONTAL_GRID_SNAP;
SDL_WriteU8(Handle, GridCol);
}
// ...
};
}
Adding Different Actors
Our Actor
is now beign serialized correctly. Indirectly, our BlueBlock
is also being serialized correctly, because the base Serialize()
function that it inherits is sufficient for its needs.
Let's add a new actor, that we'll add some more complex serialization requirements to later. We'll add a GreenBlock
to our ActorType
enum:
// Config.h
// ...
namespace Config {
enum class ActorType : Uint8 {
Actor = 0,
BlueBlock = 1,
GreenBlock = 2,
};
// ...
}
// ...
In Editor/Blocks.h
, we'll declare the GreenBlock
class. It inherits publicly from Actor
.
We declare its constructor, static constexpr
dimensions, and override the GetActorType()
virtual function to return ActorType::GreenBlock
.
We also override Clone()
to ensure it creates a GreenBlock
instance when cloned.
// Editor/Blocks.h
// ...
namespace Editor{
class BlueBlock : public Actor {/*...*/};
class GreenBlock : public Actor {
public:
static constexpr int WIDTH{50};
static constexpr int HEIGHT{25};
GreenBlock(Scene& ParentScene,
SDL_Rect Rect);
Config::ActorType GetActorType() const override {
return Config::ActorType::GreenBlock;
}
std::unique_ptr<Actor> Clone() const override {
return std::make_unique<GreenBlock>(*this);
}
};
}
Let's define the GreenBlock
constructor in Editor/Source/Blocks.cpp
.
The implementation mirrors the BlueBlock
constructor, calling the base Actor
constructor with the scene, the calculated SDL_Rect
, and the specific GreenBlock
image obtained from ParentScene.GetAssets()
.
// Editor/Source/Blocks.cpp
// ...
GreenBlock::GreenBlock(
Scene& ParentScene, SDL_Rect Rect
) : Actor{
ParentScene,
SDL_Rect{Rect.x, Rect.y, WIDTH, HEIGHT},
ParentScene.GetAssets().GreenBlock
} {}
To make the new GreenBlock
available for users to place in the level, we need to add an instance of it to the ActorMenu
.
In Editor/ActorMenu.h
, within the ActorMenu
constructor, let's add another Actors.emplace_back()
call. This time, we'll use std::make_unique<GreenBlock>()
.
We'll calculate its initial Y position based on the height of the BlueBlock
above it and the PADDING
to place it below the blue block in the menu.
// Editor/ActorMenu.h
// ...
namespace Editor {
// ...
class ActorMenu {
public:
ActorMenu(Scene& ParentScene)
: ParentScene{ParentScene}
{
using namespace Config::Editor;
Actors.emplace_back(
std::make_unique<GreenBlock>(
GetScene(),
SDL_Rect{
ACTOR_MENU_POSITION_X + PADDING,
BlueBlock::HEIGHT + PADDING * 2,
0, 0
}
)
);
}
// ...
};
}
Running our program, we should now see two actors available in our menu. We can construct a level using these two actors and, when we click "Save", a binary file is stored in the /Assets
directory in the same location as our executable file.

Overriding Serialization
Let's make things more complex by adding some extra state to GreenBlock
that it needs to serialize. In Blocks.h
, we'll add two members a Sint16
and a std::vector<Sint32>
.
Since GreenBlock
now has data not covered by the base Actor::Serialize()
, it must provide its own override
.
But GreenBlock
still wants its type and position serialized, so the GreenBlock::Serialize()
override will first call Actor::Serialize(Handle)
to ensure these base members are written.
After that, it writes its Sint16
using SDL_WriteLE16()
, followed by the size of its array using SDL_WriteLE32()
, and finally iterates through that array, writing each Sint32
element it contains using SDL_WriteLE32()
.
// Editor/Blocks.h
// ...
namespace Editor{
// ...
class GreenBlock : public Actor {
public:
// ...
Sint16 SomeNumber{32};
std::vector<Sint32> SomeArray{1, 2, 3};
void Serialize(SDL_RWops* Handle) const override {
Actor::Serialize(Handle);
SDL_WriteLE16(Handle, SomeNumber);
SDL_WriteLE32(Handle, SomeArray.size());
for (Sint32 Num : SomeArray) {
SDL_WriteLE32(Handle, Num);
}
}
};
}
Remember, not everything in a class needs to be serialized - we only serialize things that we need to save, and that we can't infer any other way. For example, we don't serialize the image that our actors are using, because that image is entirely dependant on the actor's type, which we're already serializing.
If we had a more complex editor where users could change the image our actors use within the level, without changing their type, then our serialization logic would need to be updated to store this information.
Complete Code
Complete versions of the files we changed in this section are provided below:
Summary
This lesson focused on adding persistence to our level editor by implementing the save functionality. We started by adding UI buttons ("Load 1/2/3", "Save", "Play") to the editor's footer.
We defined new custom SDL event types (LOAD_LEVEL
, SAVE_LEVEL
, etc.) triggered by these buttons. The buttons themselves were implemented as subclasses of Button
, handling their specific event pushing and visual state management (like disabling the Save button when there are no changes).
The core work involved designing a binary serialization format for our levels. We decided to store a version number, grid dimensions, and a list of actors. For each actor, we store its type and grid position, with derived types able to serialize additional custom data.
We implemented the Level::Save()
function using SDL_RWops
and SDL's binary writing functions (SDL_WriteU8()
, SDL_WriteLE32()
, etc.). We added a virtual Serialize()
method to the Actor
hierarchy, allowing each actor type to contribute its data to the save file polymorphically.
Key takeaways:
- Use custom SDL events to decouple UI actions from logic execution.
- Manage button states (
Active
,Disabled
) for better UX. - Define a clear binary serialization format, including versioning and metadata.
- Use
SDL_RWops
for platform-agnostic file I/O. - Employ
virtual
functions for polymorphic serialization in class hierarchies. - Choose appropriate data types (
Uint8
,Uint32
,Sint16
) and endianness (LE) for serialization. - Only serialize essential state, deriving other data where possible during loading.
Loading Saved Levels
Complete the save/load cycle by implementing level deserialization using SDL_RWops and actor factories.