Loading Saved Levels

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

Ryan McCombe
Published

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

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

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

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

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

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

Level Deserialization

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

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

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

// Editor/Source/Level.cpp
// ...

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

  // TODO: Deserialize stuff

  SDL_RWclose(Handle);
}

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

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

Let's deserialize them in that order, and log out what we found:

// Editor/Source/Level.cpp
// ...

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

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

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

// Editor/Level.h
// ...

namespace Editor {
// ...

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

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

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

Actor Factories

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

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

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

  • A reference to the parent Scene. 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:

// Editor/Actor.h
// ...

namespace Editor {
class Scene;
class Actor {
 public:
  // ...
  static SDL_Rect GeneratePositionRectangle(
    SDL_RWops* Handle, int Width, int Height
  ) {
    using namespace Config::Editor;
    Uint8 GridRow{SDL_ReadU8(Handle)};
    Uint8 GridCol{SDL_ReadU8(Handle)};
    SDL_Rect R{0, 0, Width, Height};
    R.x = GridCol * HORIZONTAL_GRID_SNAP;
    R.y = GridRow * VERTICAL_GRID_SNAP;
    return R;
  }
  // ...
};
}

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

// Editor/Blocks.h
// ...

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

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

Deserializing Green Blocks

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

// Editor/Blocks.h
// ...

namespace Editor{
// ...

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

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

    NewActor->SomeNumber = SDL_ReadLE16(Handle);

    Uint32 ArraySize{SDL_ReadLE32(Handle)};
    NewActor->SomeArray.resize(ArraySize);
    for (int i{0}; i < ArraySize; ++i) {
      NewActor->SomeArray[i] = SDL_ReadLE32(Handle);
    }
    return NewActor;
  }
};
}

Using the Factories

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

// Editor/Source/Level.cpp
// ...
#include <functional>
// ...

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

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

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

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

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

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

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

// Editor/Source/Level.cpp
// ...

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

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

  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 of 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 be complete! We can create levels which get saved in the Assets/ directory alongside our program executable. We can also load those levels later, and see them restored in our Editor for us to modify.

Complete Code

Complete versions of the files we changed in this lesson are provided below:

The remaining project files, which were not changed in this lesson, are available below:

Summary

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

We emphasized matching the read operations (SDL_ReadU8(), SDL_ReadLE32(), etc) to the write operations used during serialization. To handle reconstructing actors of unknown types, we introduced static factory methods (Construct) within each actor class. These factories read the necessary data from the file handle and returned a unique_ptr<Actor>. A std::vector<std::function> was used to map the serialized actor type ID to the corresponding factory function.

Key Takeaways:

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

Exponents and cmath

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

Have a question about this lesson?
Purchase the course to ask your own questions