Loading Levels

Complete the save/load cycle by implementing level deserialization using SDL_IOStream and actor factories.

Ryan McCombe
Updated

We've learned how to save our level designs, translating the in-memory state of actors and grid settings into a binary file using SDL3's file I/O capabilities. Now, we need the ability to bring those saved designs back to life in the editor.

This lesson focuses entirely on deserialization - the process of reading the binary data and reconstructing the original objects. We'll implement the Level::Load() function, carefully using SDL_ReadU8(), SDL_ReadU32LE(), and other SDL_IOStream functions to read data in the sequence it was saved.

A major focus will be the Factory Pattern. Since we only store a numeric type identifier (like 1 for BlueBlock), we need a way to invoke the correct constructor based on that number. We'll achieve this by:

  • Adding static Construct() methods to actor subclasses.
  • Storing pointers to these methods (using std::function).
  • Using the type identifier read from the file to select and call the right factory function.

This ensures our loading logic is flexible and can handle different actor types correctly.

By the end of this lesson, we'll have completed our level editor project!

Level Deserialization

With the serialization logic in place, we can now turn our attention to the reverse process: deserialization. The Level::Load() function is where we'll read the binary data from a .bin file and reconstruct the level state within the editor.

The first step mirrors saving: we need to open the file. We construct the filename using the LoadedLevel number and attempt to open it using SDL_IOFromFile(), this time with the mode "rb" for reading in binary.

If the file doesn't exist or cannot be opened, SDL_IOFromFile() returns nullptr, which we check for. Before reading any data, we also call Actors.clear() to remove any existing actors from the level currently loaded in the editor.

src/Editor/Level.cpp

// ...

void Level::Load() {
  Actors.clear();
  std::string FileName{
    Config::BASE_PATH + std::format(
      "Assets/Level{}.bin", LoadedLevel
    )
  };
  SDL_IOStream* Handle{SDL_IOFromFile(
    FileName.c_str(), "rb")};
  if (!Handle) {
    CheckSDLError("Loading Level");
    return;
  }

  // TODO: Deserialize stuff

  SDL_CloseIO(Handle);
}

Our deserializing code must closely align with our serialization code. We must deserialize our objects respecting the size, order, and endianness in which they were serialized.

Our serialized data has three Uint8 values representing the version, grid width, and grid height in that order, followed by a little-endian Uint32 representing the number of actors in the level.

In SDL3, the reading functions like SDL_ReadU8 and SDL_ReadU32LE work by updating a variable via a pointer, and returning a boolean to indicate success.

Let's deserialize our header data using this API:

src/Editor/Level.cpp

// ...

void Level::Load() {
Uint8 FileVersion{0}; SDL_ReadU8(Handle, &FileVersion); if (FileVersion != VERSION) { // This file is from a different version of // our software - react as needed } Uint8 GridWidth{0}; SDL_ReadU8(Handle, &GridWidth); Uint8 GridHeight{0}; SDL_ReadU8(Handle, &GridHeight); Uint32 ActorCount{0}; SDL_ReadU32LE(Handle, &ActorCount); std::cout << std::format( "Loading a version " "{} level ({}x{}) with {} actors\\n", FileVersion, GridWidth, GridHeight, ActorCount ); // TODO: Load Actors SDL_CloseIO(Handle); }

Currently, our loading code exists but isn't called automatically when the editor starts. To make the editor load Level 1 immediately on launch, we can call Load() directly from the Level class's constructor.

Since the LoadedLevel member defaults to 1, adding Load() inside the constructor body will trigger the loading sequence for "Assets/Level1.bin" as soon as the Level object is created within the Scene.

src/Editor/Level.h

// ...

namespace Editor {
// ...

class Level {
 public:
  Level(Scene& ParentScene)
  : ParentScene{ParentScene} {
    Load();
  }
  // ...
};
}

If a "Level1.bin" file exists from a previous save, its data should now be read and logged.

Loading a version 1 level (13x6) with 2 actors

Actor Factories

To load our actors, let's use some of the techniques we covered in our . Each specific Actor subtype that can exist in our serialized data will have a function that is responsible for deserializing that data, constructing the object based on that data, and returning a pointer to it.

The natural place to define these functions is alongside the types themselves, so we'll add them as static functions to our Actor subtypes, like BlueBlock and GreenBlock.

Let's start with BlueBlock. To construct a BlueBlock, we need two things:

  • A reference to the parent Scene. Our Level's Load() function can pass this when it's calling our factory function.
  • An SDL_Rect representing its position and size. The position (x, y) can be calculated from the grid row and grid column values in the serialized data. The size (w, h) is the same for all BlueBlock instances, and is currently stored as static WIDTH and HEIGHT values.

All of our block types will need to calculate this SDL_Rect, so let's add a helper function to the Actor base class that they can all use. Note that this function takes an SDL_IOStream*.

src/Editor/Actor.h

// ...

namespace Editor {
class Scene;
class Actor {
 public:
  // ...
  static SDL_Rect GeneratePositionRectangle(
    SDL_IOStream* Handle, int Width, int Height
  ) {
    using namespace Config::Editor;
    Uint8 GridRow{0}, GridCol{0};
    SDL_ReadU8(Handle, &GridRow);
    SDL_ReadU8(Handle, &GridCol);

    SDL_Rect R{0, 0, Width, Height};
    R.x = GridCol * HORIZONTAL_GRID_SNAP;
    R.y = GridRow * VERTICAL_GRID_SNAP;
    return R;
  }
  // ...
};
}

Back in BlueBlock, let's add our static Construct() function:

src/Editor/Blocks.h

// ...

namespace Editor{
class BlueBlock : public Actor {
public:
  // ...

  static std::unique_ptr<Actor> Construct(
    SDL_IOStream* Handle,
    Scene& ParentScene
  ) {
    return std::make_unique<BlueBlock>(
      ParentScene,
      GeneratePositionRectangle(
        Handle, WIDTH, HEIGHT));
  }
};
}

Deserializing Green Blocks

GreenBlock::Construct() will be similar - we'll also just grab the contrived SomeNumber and SomeArray data from the file using the little-endian read functions and apply it to the object we create:

src/Editor/Blocks.h

// ...

namespace Editor{
// ...

class GreenBlock : public Actor {
public:
  // ...

  static std::unique_ptr<Actor> Construct(
    SDL_IOStream* Handle,
    Scene& ParentScene
  ) {
    auto NewActor{std::make_unique<GreenBlock>(
      ParentScene,
      GeneratePositionRectangle(
        Handle, WIDTH, HEIGHT))};

    // Read the 16-bit integer
    Uint16 TempShort{0};
    SDL_ReadU16LE(Handle, &TempShort);
    NewActor->SomeNumber = static_cast<Sint16>(TempShort);

    // Read array size
    Uint32 ArraySize{0};
    SDL_ReadU32LE(Handle, &ArraySize);

    // Read array elements
    NewActor->SomeArray.resize(ArraySize);
    for (Uint32 i{0}; i < ArraySize; ++i) {
      Uint32 TempInt{0};
      SDL_ReadU32LE(Handle, &TempInt);
      NewActor->SomeArray[i] = static_cast<Sint32>(TempInt);
    }
    return NewActor;
  }
};
}

Using the Factories

Back in Level::Load(), let's use these factories to recreate our full level. We'll store our loaders as std::function wrappers. We only need them in our Level.cpp file, so let's add them in an anonymous namespace in that file, above our Load() function:

src/Editor/Level.cpp

// ...
#include <functional>
// ...

namespace{
using namespace Config;
using ActorLoaderFunc = std::function<
  std::unique_ptr<Actor>(SDL_IOStream*, Scene&)>; 

std::vector<ActorLoaderFunc> ActorLoaders{
  {},                    // Index 0
  BlueBlock::Construct,  // Index 1
  GreenBlock::Construct, // Index 2
};
}
// ...

The effect of putting ActorLoaders in an anonymous namespace is that it can now only be accessed from within this same source file, Level.cpp. This prevents us from polluting our global scope with symbols that are only relevant to one area of our program.

The indices of this ActorLoaders array corresponds to the numeric actor types in our serialized data. As a reminder, our ActorType enum is set up like this:

enum class ActorType : Uint8 {
  Actor = 0,
  BlueBlock = 1,
  GreenBlock = 2,
};

So, we store the loader for BlueBlock at index 1, and GreenBlock at index 2. Our program doesn't allow basic Actor instances to be added to the level, so we'll never need to deserialize those. As such, we just store an empty std::function at the base Actor index (0).

Design Improvements and Maps

When our design requires us to keep two values in sync, we should generally try to improve our approach. Inevitably we, or someone on our team, will make some change that breaks that link, thereby introducing a bug. This is particularly likely when the values we need to keep in sync are in totally different files (Config.h and Level.cpp)

We'll learn better techniques for implementing these patterns later, but one simple improvement is to use a std::unordered_map as an alternative to a std::vector. This lets us make the mapping from ActorType values to their corresponding std::function factory more explicit:

src/Editor/Level.cpp

// ...
#include <unordered_map>

// ...
namespace{
using namespace Config;
using ActorLoaderFunc = std::function<
  std::unique_ptr<Actor>(SDL_IOStream*, Scene&)>;

using enum ActorType;
std::unordered_map<
  ActorType, ActorLoaderFunc
> ActorLoaders{
  {Actor, {}},
  {BlueBlock, BlueBlock::Construct},
  {GreenBlock, GreenBlock::Construct}
};
}

// ...

Our Load() function can use this std::unordered_map in much the same way it uses a std::vector, using the [] operator to access the correct loader. The only difference is that it needs to cast the Uint8 representing the actor's type back to the Config::ActorType enum value it originated from:

Uint8 Type{0};
SDL_ReadU8(Handle, &Type);

auto Loader{ActorLoaders[
  // When ActorLoaders is a vector:
  Type

  // When ActorLoaders is an unordered_map:
  static_cast<ActorType>(Type)
]};

We cover maps in more detail later in the course.

Loading All Actors

All that's left to do is update our Load() function to loop ActorCount times, grab the type of each actor, call the corresponding std::function to create it, and then add it to the level:

src/Editor/Level.cpp

// ...

void Level::Load() {
for (size_t i{0}; i < ActorCount; ++i) { Uint8 ActorType{0}; SDL_ReadU8(Handle, &ActorType); auto Loader{ActorLoaders[ActorType]}; if (Loader) { ActorPtr NewActor{Loader(Handle, ParentScene)}; NewActor->SetLocation(ActorLocation::Level); AddToLevel(std::move(NewActor)); } else { std::cout << "Error: No Loader for Actor " "Type " << static_cast<int>(ActorType) << '\\n'; } } SDL_CloseIO(Handle); }

With all our plumbing in place, we can just add new actors to our editor as needed. In the Complete Code section below, we've added RedBlock, OrangeBlock, and YellowBlock actors by following the exact same pattern.

  1. Add our new types to the ActorType enum.
  2. Add new classes that inherit from Actor.
  3. Add our necessary functions - Construct(), Clone(), GetActorType(), and the constructor. This can be done manually or using our preprocessor macro if we created one.
  4. Add an instance of our new type to the ActorMenu.

With that, our program is now complete! We can create levels which get saved in the Assets/ directory alongside our program executable. We can also load those levels later, and see them restored in our Editor for us to modify.

Complete Code

A complete version of the project is provided below:

Files

Assets
src
CMakeLists.txt
Select a file to view its content

Summary

In this final part of the editor's core implementation, we focused on deserialization - loading the saved level data back into the editor. We implemented the Level::Load() method, using SDL_IOStream to read the binary file created in the previous lesson.

We emphasized matching the read operations (SDL_ReadU8(), SDL_ReadU32LE(), etc) to the write operations used during serialization. To handle reconstructing actors of unknown types, we introduced static factory methods (Construct) within each actor class. These factories read the necessary data from the file handle and returned a unique_ptr<Actor>.

A std::vector<std::function> was used to map the serialized actor type ID to the corresponding factory function.

Key Takeaways:

  • Loading involves opening files with "rb" mode using SDL_IOFromFile().
  • Error check the result of SDL_IOFromFile().
  • Read data in the exact sequence and format it was written.
  • Static factory methods provide a clean way to handle object construction during deserialization.
  • std::function allows storing pointers to these static methods for dynamic dispatch.
  • Remember to convert grid coordinates back to rendering coordinates when setting actor positions.
Next Lesson
Lesson 95 of 100

Exponents and cmath

Understand the math foundations needed for game programming in C++, including power and root functions.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate