Saving Your Editor Levels

Implement footer buttons and binary serialization to save and load your custom game levels to disk.
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Abstract art representing a video game map editor
Level Editor: Part 7
Ryan McCombe
Ryan McCombe
Posted

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.

Diagram showing a level with two actor types

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);
  }
};
}

Connecting the Buttons

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)
Diagram showing our level serialization strategy

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.

Diagram showing our level serialization strategy with actors

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.

Diagram showing our actor serialization strategy

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);
}

std::format() and Alternatives

The 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:

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); } }; }

Preprocessor Macros

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.

Diagram showing a level with two actor types

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:

#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
} {}

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.
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Ryan McCombe
Ryan McCombe
Posted
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Project: Level Editor
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Contact|Privacy Policy|Terms of Use
Copyright © 2025 - All Rights Reserved