Tutorial: Build Breakout

In this tutorial you build a complete Breakout game — paddle, ball, bricks, score — in about 150 lines of C++. Along the way you meet the core engine concepts: the pg::Engine entry point, ECS systems, tick updates, keyboard input, entity creation and deletion, text, and sound.

Everything here uses APIs exactly as the bundled examples do. If you get stuck, examples/SimpleBoxBouncer is the closest working reference.

Setup

Start from the quickstart template (engine installed via the install script — see Getting Started):

cp -r templates/quickstart ~/breakout
cd ~/breakout
./build.sh          # verify the bouncing-box template runs first
cd build && ./ColumbaEngineTestApp

Once the bouncing box works, replace src/boxbouncersystem.h with a new file src/breakoutsystem.h and update the include and createSystem call in src/application.cpp:

#include "breakoutsystem.h"

GameApp::GameApp(const std::string &appName) : engine(appName)
{
    engine.setSetupFunction([this](EntitySystem& ecs, Window& window)
    {
        auto config = engine.getConfig();
        ecs.createSystem<BreakoutSystem>(config.width, config.height);
    });
}

pg::Engine hides all SDL and platform boilerplate (on the web build it even manages the Emscripten main loop) — main.cpp stays a dozen lines forever.

Step 1 — The paddle

A system is a class inheriting System<...traits...>, where each template argument is a trait — a capability you opt into. The engine wires up exactly the hooks you list and nothing more (this is the “pay for what you use” part of the architecture). Our declaration:

class BreakoutSystem : public System<InitSys, DeltaTime,
                                     Listener<OnSDLScanCode>, Listener<OnSDLScanCodeReleased>>

reads as follows:

  • InitSys — gives the system an init() hook, called once at ECS startup. That’s where we’ll create the paddle, ball, and bricks.

  • DeltaTime — gives the system an onExecute(float deltaTime) hook, called once per frame with the elapsed time in seconds. Under the hood it listens to TickEvent and accumulates ticks for you (events can arrive from other threads, so raw tick handling needs care — the trait does it correctly so you don’t have to).

  • Listener<OnSDLScanCode> / Listener<OnSDLScanCodeReleased> — subscribe to key press/release; one onEvent overload each.

Every system also gets an ecsRef pointer for creating entities and sending events. (Later you may also meet Listener<TickEvent> for manual tick handling — SimpleBoxBouncer shows that lower-level pattern — plus QueuedListener<E>, which batches events and delivers them at a safe point, and SaveSys, which adds save/load hooks.)

Entities are created through factory helpers — makeSimple2DShape returns a component list whose .entity you can keep as a handle.

// src/breakoutsystem.h
#pragma once

#include <vector>

#include "Systems/basicsystems.h"
#include "2D/simple2dobject.h"
#include "Input/sdlevents.h"

using namespace pg;

class BreakoutSystem : public System<InitSys, DeltaTime,
                                     Listener<OnSDLScanCode>, Listener<OnSDLScanCodeReleased>>
{
public:
    BreakoutSystem(float width, float height) : screenWidth(width), screenHeight(height) {}

    virtual std::string getSystemName() const override { return "Breakout System"; }

    void onExecute(float) override {}   // required by DeltaTime; filled in next step

    void init() override
    {
        auto paddleShape = makeSimple2DShape(ecsRef, Shape2D::Square,
            screenWidth / 2 - 60, screenHeight - 40,
            {220.0f, 220.0f, 220.0f, 255.0f});

        auto pos = paddleShape.get<PositionComponent>();
        pos->width = 120.0f;
        pos->height = 16.0f;

        paddle = paddleShape.entity;
    }

private:
    float screenWidth, screenHeight;
    EntityRef paddle;
};

Build and run: a white paddle sits at the bottom of the window.

Step 2 — Moving the paddle

Key events arrive as OnSDLScanCode (press) and OnSDLScanCodeReleased (release), carrying an SDL_Scancode. For held-key movement, track pressed state and apply it every tick:

// add to the class:
virtual void onEvent(const OnSDLScanCode& event) override
{
    if (event.key == SDL_SCANCODE_LEFT)
        movingLeft = true;

    if (event.key == SDL_SCANCODE_RIGHT)
        movingRight = true;
}

virtual void onEvent(const OnSDLScanCodeReleased& event) override
{
    if (event.key == SDL_SCANCODE_LEFT)
        movingLeft = false;

    if (event.key == SDL_SCANCODE_RIGHT)
        movingRight = false;
}

void onExecute(float deltaTime) override
{
    auto pos = paddle->get<PositionComponent>();

    if (movingLeft)  pos->setX(std::max(0.0f, pos->x - paddleSpeed * deltaTime));
    if (movingRight) pos->setX(std::min(screenWidth - pos->width, pos->x + paddleSpeed * deltaTime));
}

// members:
bool movingLeft = false, movingRight = false;
float paddleSpeed = 420.0f;

Note the split: key events only record state, and the world is mutated in onExecute, which the scheduler calls at a safe point once per frame with the elapsed seconds. The DeltaTime trait handles the tick accumulation and reset behind the scenes.

Step 3 — The ball

Create the ball in init() and bounce it off walls and the paddle in onExecute(). An AABB overlap test is four comparisons — collision response deliberately lives in your game logic in this engine (the built-in CollisionSystem gives you broad-phase, layers, and raycasts when a game outgrows this; see examples/Asteroid).

// in init():
auto ballShape = makeSimple2DShape(ecsRef, Shape2D::Square,
    screenWidth / 2, screenHeight / 2, {255.0f, 200.0f, 80.0f, 255.0f});
ballShape.get<PositionComponent>()->width = 14.0f;
ballShape.get<PositionComponent>()->height = 14.0f;
ball = ballShape.entity;

// helper:
static bool overlaps(PositionComponent* a, PositionComponent* b)
{
    return a->x < b->x + b->width  and a->x + a->width  > b->x
       and a->y < b->y + b->height and a->y + a->height > b->y;
}

// in onExecute(), after the paddle movement:
auto bpos = ball->get<PositionComponent>();

float x = bpos->x + ballVelX * deltaTime;
float y = bpos->y + ballVelY * deltaTime;

if (x <= 0 or x + bpos->width >= screenWidth)  ballVelX = -ballVelX;
if (y <= 0)                                    ballVelY = -ballVelY;

bpos->setX(x);
bpos->setY(y);

if (overlaps(bpos, pos) and ballVelY > 0)
    ballVelY = -ballVelY;

if (y > screenHeight)   // missed: reset
{
    bpos->setX(screenWidth / 2);
    bpos->setY(screenHeight / 2);
}

// members:
EntityRef ball;
float ballVelX = 240.0f, ballVelY = -240.0f;

Step 4 — Bricks

A grid of colored shapes, each kept as an EntityRef. On hit, delete the entity with ecsRef->removeEntity(...) — the same call the bundled games use (see examples/InvadersBreaker/powerups.h).

// in init():
for (int row = 0; row < 5; ++row)
{
    for (int col = 0; col < 8; ++col)
    {
        auto brick = makeSimple2DShape(ecsRef, Shape2D::Square,
            20.0f + col * 98.0f, 60.0f + row * 34.0f,
            {60.0f + row * 40.0f, 120.0f, 255.0f - row * 40.0f, 255.0f});

        auto bp = brick.get<PositionComponent>();
        bp->width = 90.0f;
        bp->height = 26.0f;

        bricks.push_back(brick.entity);
    }
}

// in onExecute(), after the paddle bounce:
for (auto it = bricks.begin(); it != bricks.end(); ++it)
{
    auto brickPos = (*it)->get<PositionComponent>();

    if (overlaps(bpos, brickPos))
    {
        ballVelY = -ballVelY;
        ecsRef->removeEntity(it->entity);
        bricks.erase(it);
        score += 10;
        break;
    }
}

// members:
std::vector<EntityRef> bricks;
int score = 0;

Build and run — you have a playable Breakout.

Step 5 — Score text

Text needs the TTFTextSystem with a registered font. Create it in the setup function in application.cpp (fonts are registered under a short alias, which is what makeTTFText takes — not the file path):

#include "UI/ttftext.h"

engine.setSetupFunction([this](EntitySystem& ecs, Window& window)
{
    auto ttfSys = ecs.createSystem<TTFTextSystem>(window.masterRenderer);
    ttfSys->registerFont("res/font/Inter/static/Inter_28pt-Regular.ttf", "regular");

    auto config = engine.getConfig();
    ecs.createSystem<BreakoutSystem>(config.width, config.height);
});

Copy the font from the engine repo next to your executable (cp -r <engine>/res/font/Inter build/res/font/Inter — asset paths are resolved relative to the working directory). Then in the system:

#include "UI/ttftext.h"

// in init():
auto text = makeTTFText(ecsRef, 10.0f, 8.0f, 1.0f, "regular", "Score: 0", 0.4f);
scoreText = text.entity;

// when a brick breaks:
scoreText->get<TTFText>()->setText("Score: " + std::to_string(score));

Step 6 — Sound

An AudioSystem is registered by the engine automatically, so playing audio is just sending an event with a path to an .ogg/.mp3 file (again relative to the working directory):

#include "Audio/audiosystem.h"

ecsRef->sendEvent(PlaySoundEffect{"res/audio/hit.ogg"});   // on brick hit
ecsRef->sendEvent(StartAudio{"res/audio/music.ogg", -1});  // looping music, e.g. in init()

Where to go from here

  • Hot-reloadable scripting — move brick-hit logic into a .pg PgScript file and iterate without recompiling. Start with the PgScript quick start.

  • Real collision layers — replace the manual AABB checks with CollisionSystem + CollisionComponent (examples/Asteroid and examples/TugOfWar show the pattern, including script-driven collision handlers).

  • Sprites instead of shapes — register textures and use make2DTexture / makeUiTexture (examples/RenderingTest).

  • Configurable keys — map scancodes to your own action enum with ConfiguredKeySystem (examples/TetrisClone/keyconfig.h).

  • Ship it to the web — build the engine with Emscripten (scripts/install/install-emscripten.sh) and your game compiles to an .html + .wasm bundle; pg::Engine already handles the browser main loop.