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)};
}
// ...
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.
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);
}
};
}
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);
}
Let’s work on serializing our level, such that we can store it on our hard drive. We need to decide on two things:
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:
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:
We’ll also introduce a slightly more advanced actor later, that needs to serialize some additional information.
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:
Uint8
Uint8
Uint8
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:
Uint8
Uint8
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.
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);
}
std::format()
and AlternativesThe previous example uses std::format()
to create our dynamic string representing the path to the level’s binary file. We covered std::format()
in our introductory course here:
The std::format()
function was added in C++20. In older language specifications, we can construct our string using this technique instead:
std::string FileName{"Assets/Level"};
FileName += std::to_string(LoadedLevel) += ".bin";
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:
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
// ...
}
};
}
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
// ...
}
// ...
};
}
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);
}
// ...
};
}
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);
}
};
}
You might notice that the declarations for BlueBlock
and GreenBlock
share a lot of repetitive code - constructor declaration, Clone()
, and GetActorType()
. This is a fairly common scenario when dealing with designs that rely on run-time polymorphism.
When dealing with many similar classes, this boilerplate can become tedious and error-prone.
One way C++ offers to reduce this repetition is through preprocessor macros. We can define a function-like macro that takes the block type (e.g., BlueBlock
, GreenBlock
) and expands it out to the common code.
For example, we could define a macro like this:
#define DECLARE_BLOCK_TYPE(BlockName) \
public: \
static constexpr int WIDTH{50}; \
static constexpr int HEIGHT{25}; \
BlockName(Scene& ParentScene, SDL_Rect Rect); \
std::unique_ptr<Actor> Clone() const override { \
return std::make_unique<BlockName>(*this); \
} \
Config::ActorType GetActorType() const override { \
return Config::ActorType::BlockName; \
}
Then, defining our block classes becomes much shorter:
// Editor/Blocks.h
#pragma once
#include "Actor.h"
#define DECLARE_BLOCK_TYPE(BlockName)
namespace Editor {
class BlueBlock : public Actor {
DECLARE_BLOCK_TYPE(BlueBlock)
};
class GreenBlock : public Actor {
DECLARE_BLOCK_TYPE(GreenBlock)
};
}
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.
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 versions of the files we changed in this section are provided below:
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>
#include <vector>
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)};
}
namespace Config {
enum class ActorType : Uint8 {
Actor = 0,
BlueBlock = 1,
GreenBlock = 2,
};
inline const std::vector BUTTON_COLORS{
SDL_Color{15, 15, 15, 255}, // Normal
SDL_Color{15, 155, 15, 255}, // Hover
SDL_Color{225, 15, 15, 255}, // Active
SDL_Color{60, 60, 60, 255} // Disabled
};
inline constexpr SDL_Color FONT_COLOR{
255, 255, 255, 255};
inline const std::string FONT{
"Assets/Rubik-SemiBold.ttf"};
}
#ifdef WITH_EDITOR
namespace Config::Editor {
inline const Uint8 VERSION{1};
// Level
inline const int HORIZONTAL_GRID_SNAP{50};
inline const int VERTICAL_GRID_SNAP{25};
inline const Uint8 GRID_WIDTH{13};
inline const Uint8 GRID_HEIGHT{6};
inline const int LEVEL_WIDTH{
HORIZONTAL_GRID_SNAP * GRID_WIDTH};
inline const int LEVEL_HEIGHT{
VERTICAL_GRID_SNAP * GRID_HEIGHT};
inline constexpr SDL_Color LEVEL_BACKGROUND{
50, 50, 50, 255};
// ActorMenu
inline const int ACTOR_MENU_WIDTH{70};
inline const int ACTOR_MENU_POSITION_X{LEVEL_WIDTH};
inline const SDL_Color ACTOR_MENU_BACKGROUND{
15, 15, 15, 255};
inline const int PADDING{10};
// Window
inline const std::string WINDOW_TITLE{"Editor"};
inline const int WINDOW_WIDTH{
LEVEL_WIDTH + ACTOR_MENU_WIDTH};
inline const int WINDOW_HEIGHT{LEVEL_HEIGHT + 50};
inline const SDL_Color WINDOW_BACKGROUND{
35, 35, 35, 255};
}
#endif
inline void CheckSDLError(
const std::string& Msg) {
#ifdef CHECK_ERRORS
const char* error = SDL_GetError();
if (*error != '\0') {
std::cerr << Msg << " Error: "
<< error << '\n';
SDL_ClearError();
}
#endif
}
#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);
}
}
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);
}
}
}
void HandleLeftClick() override {
SDL_Event E{UserEvents::LOAD_LEVEL};
E.user.code = Level;
SDL_PushEvent(&E);
}
private:
int Level;
};
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);
}
};
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);
}
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);
}
}
void HandleLeftClick() override {
SDL_Event E{UserEvents::SAVE_LEVEL};
SDL_PushEvent(&E);
}
};
}
#pragma once
#include <SDL.h>
#include "ActorMenu.h"
#include "ActorTooltip.h"
#include "AssetManager.h"
#include "Level.h"
#include "Window.h"
#include "EditorButtons.h"
namespace Editor{
class Scene {
public:
Scene(Window& ParentWindow)
: ParentWindow{ParentWindow}
{}
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);
}
AssetManager& GetAssets() {
return Assets;
}
bool HasMouseFocus() const {
return ParentWindow.HasMouseFocus();
}
Window& GetWindow() const {
return ParentWindow;
}
Level& GetLevel() {
return CurrentLevel;
}
private:
ActorMenu ActorShelf{*this};
ActorTooltip TooltipWindow{*this};
Level CurrentLevel{*this};
Window& ParentWindow;
AssetManager Assets;
LoadLevelButton LoadButton1{*this, 1};
LoadLevelButton LoadButton2{*this, 2};
LoadLevelButton LoadButton3{*this, 3};
SaveLevelButton SaveButton{*this};
PlayLevelButton PlayButton{*this};
};
}
#pragma once
#include <SDL.h>
#include <memory>
#include <vector>
#include "Actor.h"
namespace Editor {
class Scene;
using ActorPtr = std::unique_ptr<Actor>;
using ActorPtrs = std::vector<ActorPtr>;
class Level {
public:
Level(Scene& ParentScene)
: ParentScene{ParentScene} {}
void HandleEvent(const SDL_Event& E);
void HandleDrop(Actor* DragActor);
void Tick(float DeltaTime);
void Render(SDL_Surface* Surface);
bool HasMouseFocus() const;
void AddToLevel(ActorPtr NewActor);
SDL_Point SnapToGridPosition(int x, int y);
void DeleteAtPosition(
int x, int y, const Actor* Unless);
void Load();
void Save();
void SaveAndPlay();
private:
Scene& ParentScene;
ActorPtrs Actors;
Actor* SelectedActor{nullptr};
int LoadedLevel{1};
SDL_Rect Rect{
0, 0,
Config::Editor::LEVEL_WIDTH,
Config::Editor::LEVEL_HEIGHT
};
};
}
#include <ranges>
#include <format>
#include "Editor/Level.h"
#include "Editor/Scene.h"
using namespace Editor;
void Level::HandleEvent(const SDL_Event& E) {
using namespace std::views;
using namespace UserEvents;
for (ActorPtr& A : reverse(Actors)) {
if (A->HandleEvent(E)) {
break;
}
}
if (
E.type == SDL_MOUSEBUTTONDOWN &&
E.button.button == SDL_BUTTON_LEFT
) {
SelectedActor = nullptr;
} else if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_DELETE &&
SelectedActor
) {
std::erase_if(Actors,
[&](ActorPtr& Actor){
return Actor.get() == SelectedActor;
}
);
SelectedActor = nullptr;
} 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::Tick(float DeltaTime) {
for (ActorPtr& A : Actors) {
A->Tick(DeltaTime);
}
}
void Level::Render(SDL_Surface* Surface) {
auto [r, g, b, a]{
Config::Editor::LEVEL_BACKGROUND
};
SDL_FillRect(Surface, &Rect, SDL_MapRGB(
Surface->format, r, g, b));
for (ActorPtr& A : Actors) {
if (SelectedActor &&
A.get() == SelectedActor &&
SelectedActor->GetIsVisible()
) {
auto [x, y, w, h]{
SelectedActor->GetRect()
};
SDL_Rect Rect{x - 1, y - 1, w + 2, h + 2};
SDL_FillRect(Surface, &Rect, SDL_MapRGB(
Surface->format, 255, 255, 255)
);
}
A->Render(Surface);
}
}
void Level::HandleDrop(Actor* DragActor) {
DragActor->SetIsVisible(true);
if (!HasMouseFocus()) {
return;
}
int MouseX, MouseY;
SDL_GetMouseState(&MouseX, &MouseY);
auto [GridX, GridY]{
SnapToGridPosition(
MouseX,
MouseY
)
};
DeleteAtPosition(GridX, GridY, DragActor);
using enum ActorLocation;
if (DragActor->GetLocation() == Menu) {
ActorPtr NewActor{DragActor->Clone()};
NewActor->SetPosition(GridX, GridY);
SelectedActor = NewActor.get();
AddToLevel(std::move(NewActor));
} else {
DragActor->SetPosition(GridX, GridY);
SelectedActor = DragActor;
}
SDL_Event E{UserEvents::LEVEL_EDIT};
SDL_PushEvent(&E);
}
void Level::AddToLevel(ActorPtr NewActor) {
NewActor->SetLocation(ActorLocation::Level);
Actors.push_back(std::move(NewActor));
}
bool Level::HasMouseFocus() const {
if (!ParentScene.HasMouseFocus()) {
return false;
}
int x, y;
SDL_GetGlobalMouseState(&x, &y);
auto [WinX, WinY]{
ParentScene.GetWindow().GetPosition()
};
if (
x >= WinX + Config::Editor::LEVEL_WIDTH ||
y >= WinY + Config::Editor::LEVEL_HEIGHT
) {
return false;
}
return true;
}
SDL_Point Level::SnapToGridPosition(
int x, int y
) {
using namespace Config::Editor;
int sx{HORIZONTAL_GRID_SNAP};
int sy{VERTICAL_GRID_SNAP};
return {
(x / sx) * sx,
(y / sy) * sy,
};
}
void Level::DeleteAtPosition(
int x, int y, const Actor* Unless
) {
std::erase_if(Actors,
[&](const ActorPtr& Actor){
auto [ax, ay]{Actor->GetPosition()};
return
ax == x &&
ay == y &&
Actor.get() != Unless;
}
);
}
void Level::Load() {
// Coming soon...
}
void Level::Save() {
std::string FileName{
std::format("Assets/Level{}.bin", LoadedLevel)
};
SDL_RWops* Handle{SDL_RWFromFile(
FileName.c_str(), "wb")
};
if (!Handle) {
CheckSDLError("Saving Level");
Handle = SDL_RWFromFile(
FileName.c_str(), "w+b"
);
}
using namespace Config::Editor;
SDL_WriteU8(Handle, VERSION);
SDL_WriteU8(Handle, GRID_WIDTH);
SDL_WriteU8(Handle, GRID_HEIGHT);
SDL_WriteLE32(Handle, Actors.size());
for (const ActorPtr& A : Actors) {
A->Serialize(Handle);
}
SDL_RWclose(Handle);
}
void Level::SaveAndPlay() {
Save();
SDL_Event E{UserEvents::LAUNCH_LEVEL};
E.user.code = LoadedLevel;
SDL_PushEvent(&E);
}
#pragma once
#include <SDL.h>
#include "Image.h"
enum class ActorLocation { Level, Menu };
namespace Editor {
class Scene;
class Actor {
public:
Actor(
Scene& ParentScene,
const SDL_Rect& Rect,
Image& Image
) : ParentScene{ParentScene},
Rect{Rect},
Art{Image}
{}
bool HasMouseFocus() const;
virtual bool HandleEvent(const SDL_Event& E);
void Tick(float DeltaTime) {}
void Render(SDL_Surface* Surface) {
if (isVisible) {
Art.Render(Surface, Rect);
}
}
const SDL_Rect& GetRect() const {
return Rect;
}
const SDL_Point& GetDragOffset() const {
return DragOffset;
}
const Image& GetArt() const {
return Art;
}
SDL_Point GetPosition() const {
return {Rect.x, Rect.y};
}
void SetPosition(int x, int y) {
Rect.x = x;
Rect.y = y;
}
virtual std::unique_ptr<Actor> Clone() const {
return std::make_unique<Actor>(*this);
}
ActorLocation GetLocation() const {
return Location;
}
void SetLocation(ActorLocation NewLocation) {
Location = NewLocation;
}
bool GetIsVisible() const {
return isVisible;
}
void SetIsVisible(bool NewVisibility) {
isVisible = NewVisibility;
}
virtual Config::ActorType GetActorType() const {
return Config::ActorType::Actor;
}
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);
}
protected:
Scene& ParentScene;
SDL_Rect Rect;
Image& Art;
SDL_Point DragOffset{0, 0};
ActorLocation Location{ActorLocation::Menu};
bool isVisible{true};
};
}
#pragma once
#include <SDL.h>
#include <vector>
#include <memory>
#include "Actor.h"
#include "Blocks.h"
#include "Config.h"
namespace Editor {
class Scene;
using ActorPtr = std::unique_ptr<Actor>;
using ActorPtrs = std::vector<ActorPtr>;
class ActorMenu {
public:
ActorMenu(Scene& ParentScene)
: ParentScene{ParentScene}
{
using namespace Config::Editor;
Actors.emplace_back(
std::make_unique<BlueBlock>(
GetScene(),
SDL_Rect{
ACTOR_MENU_POSITION_X + PADDING,
PADDING,
0, 0
}
)
);
Actors.emplace_back(
std::make_unique<GreenBlock>(
GetScene(),
SDL_Rect{
ACTOR_MENU_POSITION_X + PADDING,
BlueBlock::HEIGHT + PADDING * 2,
0, 0
}
)
);
}
void HandleEvent(const SDL_Event& E) {
for (ActorPtr& A : Actors) {
A->HandleEvent(E);
}
}
void Tick(float DeltaTime) {
for (ActorPtr& A : Actors) {
A->Tick(DeltaTime);
}
}
void Render(SDL_Surface* Surface) {
using namespace Config::Editor;
auto [r, g, b, a]{ACTOR_MENU_BACKGROUND};
SDL_FillRect(
Surface,
&Rect,
SDL_MapRGB(Surface->format, r, g, b)
);
for (ActorPtr& A : Actors) {
A->Render(Surface);
}
}
Scene& GetScene() const {
return ParentScene;
}
private:
Scene& ParentScene;
ActorPtrs Actors;
SDL_Rect Rect{
Config::Editor::ACTOR_MENU_POSITION_X, 0,
Config::Editor::ACTOR_MENU_WIDTH,
Config::Editor::WINDOW_HEIGHT
};
};
}
#pragma once
#include "Actor.h"
namespace Editor{
class BlueBlock : public Actor {
public:
static constexpr int WIDTH{50};
static constexpr int HEIGHT{25};
BlueBlock(
Scene& ParentScene, SDL_Rect Rect
);
std::unique_ptr<Actor> Clone() const override {
return std::make_unique<BlueBlock>(*this);
}
Config::ActorType GetActorType() const override {
return Config::ActorType::BlueBlock;
}
};
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);
}
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);
}
}
};
}
#include "Editor/Blocks.h"
#include "Editor/Scene.h"
using namespace Editor;
BlueBlock::BlueBlock(
Scene& ParentScene, SDL_Rect Rect
) : Actor{
ParentScene,
SDL_Rect{Rect.x, Rect.y, WIDTH, HEIGHT},
ParentScene.GetAssets().BlueBlock
} {}
GreenBlock::GreenBlock(
Scene& ParentScene, SDL_Rect Rect
) : Actor{
ParentScene,
SDL_Rect{Rect.x, Rect.y, WIDTH, HEIGHT},
ParentScene.GetAssets().GreenBlock
} {}
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:
Active
, Disabled
) for better UX.SDL_RWops
for platform-agnostic file I/O.virtual
functions for polymorphic serialization in class hierarchies.Uint8
, Uint32
, Sint16
) and endianness (LE) for serialization.Implement footer buttons and binary serialization to save and load your custom game levels to disk.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games