Breakout: Loading Levels

Add breakable bricks to the game by loading and parsing level files saved from our level editor.
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

View Full CourseGet Started for Free
Abstract art representing computer programming
Breakout: Part 6
Ryan McCombe
Ryan McCombe
Posted

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.

Deserializing Levels

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:

Diagram showing our level serialization strategy with actors

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

Breakout/Source/BreakoutScene.cpp

// ...
#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

Deserializing Actors

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.

Diagram showing our actor serialization strategy

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.

Adding a Block Class

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:

Breakout/Block.h

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

Handling Additional Data

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

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

Breakout/Block.h

// ...
#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"; } } };

Loading Actors

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:

Breakout/Source/BreakoutScene.cpp

// ...
#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)

Seeking

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:

  1. The SDL_RWops handle
  2. The offset we want to seek by
  3. How we want that offset to be used. RW_SEEK_CUR means we want the offset to be updated relative to its current position.

In the following example:

  1. We call SDL_RWseek() to advance past our 16-bit (2-byte) integer
  2. We then read the next 32-bit integer so we can understand how many elements are in our array
  3. We then call SDL_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

Breakout/Block.h

// ...

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:

Generalizing Dynamically Sized Entities

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:

Diagram showing the actor size being included in serialized data

Alternatively, our level could serialize an additional array, storing the size of each entity:

Diagram showing the actor size being included as an array

Or the offset of each entity within the data:

Diagram showing the actor offsets being included as an array

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.

Positioning Blocks and Colliders

Finally, let's add image and collision components to our blocks to make them visible and interactive.

Mapping Images

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:

Breakout/Block.h

// ...

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

Adding Components

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:

Breakout/Block.h

// ...

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}; // ... };

Configuring Components

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:

Breakout/Block.h

// ...

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:

Screenshot showing the level loaded

In the next section, we'll update our blocks to let them be destroyed when the ball hits them.

Complete Code

Complete versions of the files we updated in this section are available below:

Files

Breakout
Select a file to view its content

Summary

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:

  • We opened and read from a binary file using SDL_RWFromFile().
  • We parsed a byte stream containing level metadata and a dynamic array of actor data.
  • We created a Block class that deserializes itself from an SDL_RWops handle.
  • We ensured our file parsing was robust by correctly handling variable-sized entity data.
  • We converted the editor's grid coordinates into world-space positions for our game.
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

View Course
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

View Full CourseGet Started for Free

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

This course includes:

  • 128 Lessons
  • 92% Positive Reviews
  • Regularly Updated
  • Help and FAQs
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

View Course
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