Breakout: Loading Levels

Add breakable bricks to the game by loading and parsing level files saved from our level editor.

Ryan McCombe
Published

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:

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.

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)

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:

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.
Next Lesson
Lesson 128 of 129

Breakout: Game Progression

Implement the core gameplay loop of destroying blocks and advancing through levels using SDL events.

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