Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
So far, our game scene has been hard-coded. To create a real game, we need the ability to load different level layouts dynamically. This lesson will implement the level loading system, reading the .bin
files generated by our editor and using them to build our game world.
We will create a Block
entity to represent the bricks. The core of our work will be in the Block
constructor and the BreakoutScene::Load()
method, where we'll use SDL_RWops
to perform binary file reads.
We'll interpret the file's byte stream to determine each block's type and position, and then add the corresponding components to make them visible and collidable.
Once this system is in place, creating new levels for our game will be as simple as designing them in our editor and saving the file - no code changes required.
In our editor project, we serialized data using the format provided below. We have three one-byte integers, representing the serialization version, width of the grid, and height of the grid respectively.
We then have a 4-byte integer, which lets us know how many actors (or entities) are in the level. We then have the data for those actors. The amount of actor data is unknown in advance - it depends on the number of actors and the type of those actors.
All multi-byte values use the little-endian (LE) order:
Let's expand our Load()
function to load the serialized data. The following code assumes that the levels created by our level editor are stored in an "Assets/" directory alongside our executable, with file names like "Level1.bin":
// ...
#include <format>
void BreakoutScene::Load(int Level) {
// ...
std::string FileName{
std::format("Assets/Level{}.bin", Level)
};
SDL_RWops* Handle{
SDL_RWFromFile(FileName.c_str(), "rb")};
if (!Handle) {
CheckSDLError("Loading Level");
return;
}
Uint8 FileVersion{SDL_ReadU8(Handle)};
Uint8 GridWidth{SDL_ReadU8(Handle)};
Uint8 GridHeight{SDL_ReadU8(Handle)};
Uint32 BlockCount{SDL_ReadLE32(Handle)};
std::cout << std::format(
"Loading a version "
"{} level ({}x{}) with {} blocks\n",
FileVersion, GridWidth, GridHeight,
BlockCount
);
}
Loading a version 1 level (13x6) with 55 blocks
Now that we've deserialized the fixed-size level data, let's work on deserializing the dynamic array of entities. Each actor in the array starts with a type, row, and column integer, each being a single byte. They then have a variable amount of additional data, depending on their type.
The following diagram shows an example where we have two actors in the array, but this pattern repeats for however many blocks are in the level, as represented by the BlockCount
integer.
Most of our blocks have no additional data beyond their type, row, and column. However, in our editor project, we serialized some additional data from our green blocks as a proof of concept. We'll handle that additional data a bit later in this section.
In our editor project, each block type was a different class. We could do the same here, but we don't need to.
We can get a lot of diverse behavior from the same object type, simply by changing which components that object has, and how those components are configured.
Let's create a Block
type that all of our deserialized entities will use. Our constructor will accept the SDL_RWops
handle, and will start by grabbing the Type
, Row
, and Column
values (in that order) from it:
#pragma once
#include <format>
#include "Engine/ECS/Entity.h"
class Block : public Entity {
public:
Block(SDL_RWops* Handle, BreakoutScene& Scene)
: Entity{Scene}
{
Uint8 Type{SDL_ReadU8(Handle)};
Uint8 GridRow{SDL_ReadU8(Handle)};
Uint8 GridCol{SDL_ReadU8(Handle)};
std::cout << std::format(
"Loaded type {} block (Row={}, Col={})\n",
Type, GridRow, GridCol
);
}
};
Beyond using data from the SDL_RWops
stream, our Block
constructor additionally needs to advance the read/write offset within that stream such that, for the next block, the offset is at the start of that block's data.
Reading data using functions like SDL_ReadU8()
automatically advances the offset. Therefore, as long as each invocation of the constructor is reading all the entity's data, the next block's constructor will be handed a stream whose offset is in the correct position - right at the start of the entity that the constructor is responsible for deserializing.
We are reading all the data for most of our block types but, in our editor project, we had our green blocks serialize some additional data, just to show how it could be done.
Our green block's serialization function is additionally saving a 16-bit integer, a second 32-bit integer representing the size of an array, and then that quantity of 32-bit integers:
// 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);
}
}
// ...
};
// ...
We're not currently using that additional data in our block constructor so, when dealing with a green block, our byte offset is left in the wrong position. This means that every entity that we load after the green block will not be deserialized correctly.
When we encounter a green block, we should read all of those elements too. This ensures that, by the end of the constructor, the byte offset in our stream has advanced to the end of the object we're supposed to be deserializing.
When our constructor is dealing with a green block, the deserialized Type
will have a value of 2
. However, in our editor project, we added an ActorType
enum in our config file which lets us map those type integers to more descriptive names. As such, we can cast that Type
integer to an ActorType
, and then check if it is equal to ActorType::GreenBlock
.
We don't really need this additional green block data for our game, but let's log it out to confirm it's working:
// ...
#include "Config.h"
class Block : public Entity {
public:
Block(SDL_RWops* Handle, BreakoutScene& Scene)
: Entity{Scene}
{
if (
static_cast<Config::ActorType>(Type) ==
Config::ActorType::GreenBlock
) {
std::cout << " Green Block Data: ";
Sint16 SomeNumber = SDL_ReadLE16(Handle);
std::cout << SomeNumber << ", [";
size_t ArraySize{(SDL_ReadLE32(Handle))};
for (size_t i{0}; i < ArraySize; ++i) {
Sint32 ArrayValue = SDL_ReadLE32(Handle);
std::cout << ArrayValue
<< (i < ArraySize - 1 ? ", " : "");
}
std::cout << "]\n";
}
}
};
Now that we have our Block
class and constructor set up, let's use it back in our BreakoutScene
's Load()
function to create all the entities we need:
// ...
#include "Breakout/Block.h"
void BreakoutScene::Load(int Level) {
for (size_t i{0}; i < BlockCount; ++i) {
Entities.emplace_back(
std::make_unique<Block>(Handle, *this)
);
}
SDL_RWclose(Handle);
}
We should see our block data being loaded in our terminal output:
Loaded type 4 block (Row=4, Col=3)
Loaded type 1 block (Row=4, Col=2)
Loaded type 3 block (Row=4, Col=1)
Loaded type 2 block (Row=3, Col=2)
Green Block Data: 32, [1, 2, 3]
Loaded type 5 block (Row=3, Col=3)
In this section, we read our additional green block data to ensure our read/write offset was updated to the correct position. But there is an alternative approach, we could just update our byte offset directly.
If we don't need to read the additional data, we can instead just directly advance (or "seek") our read/write offset to the end of our current object.
To do this, we need to calculate how many bytes we need to seek forward, and then call SDL_RWseek()
. To use SDL_RWseek()
, we pass three arguments:
SDL_RWops
handleRW_SEEK_CUR
means we want the offset to be updated relative to its current position.In the following example:
SDL_RWseek()
to advance past our 16-bit (2-byte) integerSDL_RWseek()
again to advance past the array. We know each element in the array is 32 bits (4 bytes), so we multiply that by the number of elements in the array// ...
class Block : public Entity {
public:
Block(SDL_RWops* Handle, BreakoutScene& Scene)
: Entity{Scene}
{
if (
static_cast<Config::ActorType>(Type) ==
Config::ActorType::GreenBlock
) {
SDL_RWseek(Handle, 2, RW_SEEK_CUR);
size_t ArraySize{(SDL_ReadLE32(Handle))};
SDL_RWseek(
Handle, ArraySize * 4, RW_SEEK_CUR
);
}
}
};
We covered SDL_RWseek()
and read/write offsets in general earlier in the course:
Our implementation depends on our deserialization function to know exactly which entities contain additional data, and the nature of that data.
In more advanced projects, we could solve this problem by serializing additional metadata to explain what is going on. For example, each entity could serialize an additional integer, representing its size:
Alternatively, our level could serialize an additional array, storing the size of each entity:
Or the offset of each entity within the data:
This would allow the deserialization logic to easily jump around within the data. It can use these size or position values to calculate the offset of the object it's interested in within the data, and then call SDL_RWseek()
to jump right to it.
Remember, when we update our serialization format, there will still be files out there using the older format. This means that anywhere we're deserializing those files may now need to support both versions.
This is why we typically include a version number in our serialized data - it helps our deserialization code easily understand what it's dealing with.
Finally, let's add image and collision components to our blocks to make them visible and interactive.
The image associated with each Block
entity will depend on the block's Type
. We'll add a std::unordered_map
to map those types to their associated image paths:
// ...
class Block : public Entity {
// ...
private:
using ImageMap = std::unordered_map<
Config::ActorType, std::string>;
using enum Config::ActorType;
inline static ImageMap Images{
{Actor, ""},
{BlueBlock, "Assets/Brick_Blue_A.png"},
{GreenBlock, "Assets/Brick_Green_A.png"},
{CyanBlock, "Assets/Brick_Cyan_A.png"},
{OrangeBlock, "Assets/Brick_Orange_A.png"},
{RedBlock, "Assets/Brick_Red_A.png"},
{YellowBlock, "Assets/Brick_Yellow_A.png"}
};
};
Let's add our components. We'll add a TransformComponent
, a CollisionComponent
, and an ImageComponent
. We'll need to reference these components later, so let's save their pointers as member variables:
// ...
class Block : public Entity {
public:
Block(SDL_RWops* Handle, BreakoutScene& Scene)
: Entity{Scene}
{
Transform = AddComponent<TransformComponent>();
Collision = AddComponent<CollisionComponent>();
Image = AddComponent<ImageComponent>(Images[
static_cast<Config::ActorType>(Type)
]);
}
private:
TransformComponent* Transform{nullptr};
ImageComponent* Image{nullptr};
CollisionComponent* Collision{nullptr};
// ...
};
As our last step, let's position and size our blocks and their colliders so they appear in the correct location in our scene.
We can do this using the SetPosition()
function of our transform component, the SetSize()
function of the collision component, and the SetWidth()
and SetHeight()
methods on the image component.
An important point is that the vertical position of our blocks will be based on the negative of the grid row. This is because increasing the grid row corresponds to moving down within our editor, but increasing y
values in our transform component corresponds to moving up.
The sizing and positioning that work for the images we're using are provided below, but you may need to use different values. Remember, you can enable the DRAW_DEBUG_HELPERS
flag to help with this:
// ...
class Block : public Entity {
public:
Block(SDL_RWops* Handle, BreakoutScene& Scene)
: Entity{Scene}
{
Transform = AddComponent<TransformComponent>();
Transform->SetPosition(Vec2{
static_cast<float>(GridCol),
static_cast<float>(GridRow) * -0.75f + 12
});
Collision = AddComponent<CollisionComponent>();
Collision->SetSize(1, 0.75);
Image = AddComponent<ImageComponent>(Images[
static_cast<Config::ActorType>(Type)
]);
Image->SetWidth(55);
Image->SetHeight(27);
}
// ...
};
With these changes, we should now see the blocks positioned in our world, and our ball can bounce off them:
In the next section, we'll update our blocks to let them be destroyed when the ball hits them.
Complete versions of the files we updated in this section are available below:
We've now successfully loaded our custom levels into the game, filling the screen with breakable bricks.
This lesson focused on deserialization, where we used SDL_RWops
to read the binary data we saved from our level editor. We created a Block
class designed to be instantiated from a file stream and configured its components to match the loaded data.
Here's what we've accomplished:
SDL_RWFromFile()
.Block
class that deserializes itself from an SDL_RWops
handle.Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View CourseAdd breakable bricks to the game by loading and parsing level files saved from our level editor.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View Course