Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games
Welcome to the home stretch! In this last lesson, we'll add the finishing touches that turn our Breakout project into a complete, replayable game. Our focus will be on the overall game flow - managing how the game begins, how it can be won or lost, and how it can be started again.
We'll implement a GameState
enum to keep track of the current situation. We'll use this state to trigger a loss when the ball misses the paddle and to provide visual feedback to the player when they win or lose. We'll also add a pause system, so the action doesn't start until the player is ready, and a simple keypress to restart the entire game from level one.
Finally, we'll look at how to prepare our project for distribution by creating a "release" build configuration that strips out the level editor and other development tools, leaving us with a clean, standalone game.
Finally, let's implement our game ending and restarting logic.
GameState
Let's begin by tracking what state the game is currently in - whether the player has won, lost, or the game is still in progress. This is likely to be useful to a wide variety of games, so we'll add it to the Scene
base class in our engine. We'll default it to InProgress
:
// ...
enum class GameState { InProgress, Won, Lost };
class Scene {
public:
// ...
GameState GetState() const { return State; }
void SetState(GameState NewState) {
State = NewState;
}
private:
// ...
GameState State{GameState::InProgress};
};
Back in our BreakoutScene
class, let's set this state in response to the GAME_WON
and GAME_LOST
events. We're already broadcasting the GAME_WON
event at the appropriate time and will implement the GAME_LOST
scenario in the next section.
In our level skip cheat, we'll also set the game state to be InProgress
. This allows our cheat to complete the level even if we lost:
// ...
class BreakoutScene : public Scene {
public:
// ...
void HandleEvent(const SDL_Event& E) {
Scene::HandleEvent(E);
using namespace UserEvents;
if (E.type == BLOCK_DESTROYED) {
--BlocksRemaining;
if (BlocksRemaining == 0) {
CompleteLevel();
}
}
#ifdef ENABLE_CHEATS
else if (E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_c
) {
SetState(GameState::InProgress);
CompleteLevel();
}
#endif
else if (E.type == LAUNCH_LEVEL) {
Load(E.user.code);
} else if (E.type == GAME_WON) {
SetState(GameState::Won);
} else if (E.type == GAME_LOST) {
SetState(GameState::Lost);
}
}
// ...
};
When the player misses the ball with their paddle, we want the game to end. We can detect this if the ball collides with our bottom wall. When that happens, we'll push our GAME_LOST
event:
// ...
#include "Breakout/Ball.h"
// ...
class Wall : public Entity {
public:
// ...
void HandleCollision(Entity& Other) override {
if (
dynamic_cast<Ball*>(&Other) &&
Position == WallPosition::Bottom
) {
SDL_Event E{UserEvents::GAME_LOST};
SDL_PushEvent(&E);
}
}
// ...
};
We'll let the player restart the game by pressing the "R" key. When we detect that event, we'll set our game state back to InProgress
, and load the first level:
// ...
class BreakoutScene : public Scene {
public:
// ...
void HandleEvent(const SDL_Event& E) {
Scene::HandleEvent(E);
using namespace UserEvents;
if (E.type == BLOCK_DESTROYED) {/*...*/}
else if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_r
) {
SetState(GameState::InProgress);
Load(1);
}
}
// ...
};
Now that win and loss events are being broadcast through our program, we're free to make any part of our application react to those events in the usual ways. Alternatively, we can check the state of our game at any time using the scene's GetState()
function.
For example, let's override our scene's Render()
function to change the background color depending on the game state. We'll fill the surface with a solid color first, and then call the base Scene
's Render()
method to render our entities on top of that rectangle:
// ...
class BreakoutScene : public Scene {
public:
// ...
void Render(SDL_Surface* Surface) {
if (GetState() == GameState::Won) {
SDL_FillRect(Surface, nullptr,
SDL_MapRGB(Surface->format, 20, 50, 20)
);
} else if (GetState() == GameState::Lost) {
SDL_FillRect(Surface, nullptr,
SDL_MapRGB(Surface->format, 50, 0, 0)
);
}
Scene::Render(Surface);
}
// ...
};
With those changes, our background turns red when we lose:
And green when we win:
For our final feature, let's implement pausing. To support a paused state, let's first allow our physics and input to be paused. We'll pause them simply by disabling them, so let's update those components to support that.
To have our physics component pause when it is disabled, we can simply stop all of the updates that happen in our Tick()
function:
// ...
void PhysicsComponent::Tick(float DeltaTime) {
if (!GetIsEnabled()) return;
}
// ...
Again, we may want to take this further. For example, in our current implementation, a disabled physics component will still allow ApplyForce()
and ApplyImpulse()
to be used. The effect of those will be to change the acceleration and velocity of the component, which will be used as soon as it's unpaused.
This may be the preferred approach. Alternatively, it may be more intuitive to have the component effectively ignore those instructions when disabled, perhaps with an error message.
The time between frames, DeltaTime
, is usually very small. However, our program can sometimes stall for a much longer period.
This might be because the operating system is busy, another application is hogging resources, or we may even pause our program intentionally in a debugger.
When our program starts responding again, a huge DeltaTime
will have accumulated and, when multiplied by our Velocity
, the ball may move a very large distance in a single step.
This can break our game, as it can make our ball move further than the thickness of our walls in a single step. This will cause the collision to be missed and the ball to no longer be within the window.
We can rarely detect that our program is going to stop updating in advance. Instead, on each iteration of our application loop, we can check if it has been a long time since the previous iteration. Below, we'll prevent our physics component from ticking on any iteration that takes longer than 0.1
seconds:
// ...
void PhysicsComponent::Tick(float DeltaTime) {
if (DeltaTime > 0.1) return;
// ...
}
// ...
An easy way to test this behavior on Windows is to move the window whilst our game is running. When this happens, Windows will pause the execution of our application loop. With our new DeltaTime
check, as long as that pause is longer than 100 milliseconds, our physics simulation will pause too.
Move elegant options to deal specifically with this Windows behavior are discussed in this thread on SDL's GitHub repository.
Our current input commands are setting the velocity of the physics component so, when the physics component is disabled, the input component is effectively disabled too.
However, that won't be true for any other inputs we might potentially use in the future, so let's explicitly support disabling our InputComponent
.
To prevent inputs from having any effect on a disabled input component, it also shouldn't tick, which will therefore stop it from polling the keyboard state. We should also disable the HandleEvent()
logic so that the input component stops responding to SDL_KEYDOWN
events:
// ...
void InputComponent::Tick(float DeltaTime) {
if (!GetIsEnabled()) return;
}
void InputComponent::HandleEvent(
const SDL_Event& E) {
if (!GetIsEnabled()) return;
}
Currently, our ball starts moving as soon as the program starts. That can be a little annoying, so let's open the game in a paused state, and only start the level when the player is ready.
We'll do this by disabling our paddle's PhysicsComponent
and InputComponent
, and also our ball's PhysicsComponent
. Let's add a new private helper function, and call it in the constructor:
// ...
class Paddle : public Entity {
public:
Paddle(BreakoutScene& Scene) : Entity{Scene} {
// ...
SetIsPaused(true);
}
// ...
private:
void SetIsPaused(bool isPaused) {
Input->SetIsEnabled(!isPaused);
Physics->SetIsEnabled(!isPaused);
}
// ...
};
Similarly, for our ball:
// ...
class Ball : public Entity {
public:
Ball(BreakoutScene& Scene) : Entity{Scene} {
// ...
SetIsPaused(true);
}
// ...
private:
void SetIsPaused(bool isPaused) {
Physics->SetIsEnabled(!isPaused);
}
};
If we run our game now, it starts in a paused state. Every time we load a new level, we load a new ball and paddle, so we also effectively pause between levels.
We'll let the user press their space bar to start each level. When they do that, and the game is in the InProgress
state (that is, they haven't already won or lost), then we'll unpause our paddle:
// ...
class Paddle : public Entity {
public:
// ...
void HandleEvent(const SDL_Event& E) override {
if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_SPACE &&
GetScene().GetState() == GameState::InProgress
) {
SetIsPaused(false);
} else if (
E.type == UserEvents::GAME_WON ||
E.type == UserEvents::GAME_LOST
) {
SetIsPaused(true);
}
}
// ...
};
And similarly, for our ball:
// ...
class Ball : public Entity {
public:
// ...
void HandleEvent(const SDL_Event& E) override {
if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_SPACE &&
GetScene().GetState() == GameState::InProgress
) {
SetIsPaused(false);
} else if (
E.type == UserEvents::GAME_WON ||
E.type == UserEvents::GAME_LOST
) {
SetIsPaused(true);
}
}
// ...
};
We just added some duplicate code across our Paddle
and Ball
classes, with the HandleEvent()
function in particular being identical. A small amount of duplication - particularly at the end of the project - isn't particularly problematic.
However, if we found ourselves needing to repeated this a third time, or if we were going to expand these functions with more identical logic in the future, we should consider refining our approach.
Our component system often gives us the natural way of solving problems like this - creating a new component that our entities can share. For example, a BreakoutPauseManager
component might look something like this:
#pragma once
#include "Engine/ECS/Component.h"
#include "Engine/ECS/PhysicsComponent.h"
#include "Engine/ECS/InputComponent.h"
#include "Config.h"
class BreakoutPauseManager : public Component {
public:
BreakoutPauseManager(Entity* Parent)
: Component{Parent} {
SetIsPaused(true);
}
void HandleEvent(const SDL_Event& E) override {
if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_SPACE &&
GetScene().GetState() == GameState::InProgress
) {
SetIsPaused(false);
} else if (
E.type == UserEvents::GAME_WON ||
E.type == UserEvents::GAME_LOST
) {
SetIsPaused(true);
}
}
private:
void SetIsPaused(bool isPaused) {
if (InputComponent* Input{
GetOwner()->GetComponent<InputComponent>()
}) {
Input->SetIsEnabled(!isPaused);
}
if (PhysicsComponent* Physics{
GetOwner()->GetComponent<PhysicsComponent>()
}) {
Physics->SetIsEnabled(!isPaused);
}
}
};
We could then delete the HandleEvent()
and SetIsPaused()
functions from both our Paddle
and Ball
classes, and instead attach a BreakoutPauseManager
component to them in their constructors:
// ...
#include "Breakout/BreakoutPauseManager.h"
class Paddle : public Entity {
public:
Paddle(BreakoutScene& Scene) : Entity{Scene} {
// ...
SetIsPaused(true);
AddComponent<BreakoutPauseManager>();
}
void HandleEvent(const SDL_Event& E) override {
if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_SPACE &&
GetScene().GetState() == GameState::InProgress
) {
SetIsPaused(false);
} else if (
E.type == UserEvents::GAME_WON ||
E.type == UserEvents::GAME_LOST
) {
SetIsPaused(true);
}
}
// ...
private:
void SetIsPaused(bool isPaused) {
Input->SetIsEnabled(!isPaused);
Physics->SetIsEnabled(!isPaused);
}
// ...
};
// ...
#include "Breakout/BreakoutPauseManager.h"
class Ball : public Entity {
public:
Ball(BreakoutScene& Scene) : Entity{Scene} {
// ...
SetIsPaused(true);
AddComponent<BreakoutPauseManager>();
}
// ...
void HandleEvent(const SDL_Event& E) override {
if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_SPACE &&
GetScene().GetState() == GameState::InProgress
) {
SetIsPaused(false);
} else if (
E.type == UserEvents::GAME_WON ||
E.type == UserEvents::GAME_LOST
) {
SetIsPaused(true);
}
}
private:
void SetIsPaused(bool isPaused) {
Physics->SetIsEnabled(!isPaused);
}
// ...
};
With all our features in place, we're now ready to ship our game! The last step is to disable any undesirable preprocessor definitions (such as DRAW_DEBUG_HELPERS
and ENABLE_CHEATS
.
We also need detach our editor from the project. Our files already include preprocessor directives that will exclude editor-specific logic when the WITH_EDITOR
macro is not defined.
However, our editor source files are still being sent to the compiler, and that may cause compilation issues or other problems. The simplest way to solve this is to include the #ifdef
checks in those files, too. This means that, when WITH_EDITOR
isn't defined, those files will be empty by the time the compiler sees them.
For example:
#ifdef WITH_EDITOR
#endif
A more elegant solution would involve conditionally not sending those files to be compiled at all. The build management tools included in most IDEs should let us automate this.
In Visual Studio, for example, you can select the files in the Solution Explorer, right-click on them, click on Properties, and set Excluded From Build to "Yes" for any build configuration that should not include the editor:
If you're using CMake, the simplest option is to set()
a variable. We can then append the files to the build and set the preprocessor directive only if that variable is ON
:
cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 20)
# Change me to ON to enable the editor
SET(WITH_EDITOR OFF)
project(Breakout VERSION 1.0.0)
set(SOURCE_FILES
"main.cpp"
"Engine/Source/Scene.cpp"
"Engine/ECS/Source/Component.cpp"
"Engine/ECS/Source/CollisionComponent.cpp"
"Engine/ECS/Source/ImageComponent.cpp"
"Engine/ECS/Source/PhysicsComponent.cpp"
"Engine/ECS/Source/InputComponent.cpp"
"Engine/ECS/Source/Commands.cpp"
"Breakout/Source/BreakoutScene.cpp"
"Breakout/Source/Ball.cpp"
)
# Include the editor files only if WITH_EDITOR is ON
if(WITH_EDITOR)
list(APPEND SOURCE_FILES
"Editor/Source/Button.cpp"
"Editor/Source/Blocks.cpp"
"Editor/Source/Actor.cpp"
"Editor/Source/ActorTooltip.cpp"
"Editor/Source/Level.cpp"
)
endif()
add_executable(Breakout
${SOURCE_FILES}
)
target_compile_definitions(Breakout PUBLIC
CHECK_ERRORS
# ENABLE_CHEATS
# DRAW_DEBUG_HELPERS
)
# Conditionally set the WITH_EDITOR preprocessor definition
if(WITH_EDITOR)
target_compile_definitions(Breakout PUBLIC
WITH_EDITOR
)
endif()
target_include_directories(
Breakout PUBLIC
${PROJECT_SOURCE_DIR}
)
add_subdirectory(external/SDL)
add_subdirectory(external/SDL_image)
add_subdirectory(external/SDL_ttf)
target_link_libraries(Breakout PRIVATE
SDL2
SDL2_image
SDL2_ttf
)
if (WIN32)
target_link_libraries(
Breakout PRIVATE SDL2main
)
endif()
set(AssetDirectory "${PROJECT_SOURCE_DIR}/Assets")
add_custom_command(
TARGET Breakout POST_BUILD
COMMAND
${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:SDL2>"
"$<TARGET_FILE:SDL2_image>"
"$<TARGET_FILE:SDL2_ttf>"
"$<TARGET_FILE_DIR:Breakout>"
COMMAND
${CMAKE_COMMAND} -E copy_directory_if_different
"${AssetDirectory}"
"$<TARGET_FILE_DIR:Breakout>/Assets"
VERBATIM
)
With those changes, we can now easily prepare a version of our game that doesn't include our developer tools. Remember, our game still needs the output of those tools, such as the "Level1.bin" files.
Ensure that those are included alongside all the other assets (such as image files, fonts, and SDL's library files) that our program needs at run time.
Complete versions of all the files we updated in this lesson are available below:
A zip file containing all of the code is available here:
The fonts and images used in our screenshots are available here:
Our Breakout clone is now finished! This lesson was about adding the final logic that defines a complete game experience.
We introduced a GameState
enum, implemented win and loss conditions, gave the player a way to restart, and created a pause system that activates at the start of levels and on game-end conditions.
Finally, we prepared our build system to easily exclude the editor for a release version.
Here's what we've accomplished in this project:
Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development
View CourseLearn to manage game states for winning, losing, and pausing, and prepare the final game for distribution.
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