In this lesson, we'll implement a score counter for our Snake game to help players track their progress.
We'll build a UI component that displays the current score alongside the maximum possible score, complete with a custom background and an apple icon.
ScoreCounter
ClassLet’s begin by creating a ScoreCounter
class to manage our UI element. To get access to the apple image, we’ll accept a reference to the Assets
manager as a constructor.
Our score counter will also need to be notified of events and render itself, so let’s add HandleEvent()
and Render()
methods:
// ScoreCounter.h
#pragma once
#include <SDL.h>
#include "Assets.h"
class ScoreCounter {
public:
ScoreCounter(Assets& Assets) : Assets{Assets} {}
void HandleEvent(SDL_Event& E) {}
void Render(SDL_Surface* Surface) {}
private:
Assets& Assets;
};
Over in our GameUI
class, we’ll create an instance of our ScoreCounter
, pass the Assets
to the constructor, and hook it up to our HandleEvent()
and Render()
pipelines:
// GameUI.h
// ...
#include "ScoreCounter.h"
class GameUI {
public:
GameUI()
: Grid{Assets},
ScoreCounter{Assets}
{}
void HandleEvent(SDL_Event& E) {
Grid.HandleEvent(E);
ScoreCounter.HandleEvent(E);
RestartButton.HandleEvent(E);
}
void Tick(Uint32 DeltaTime) {
Grid.Tick(DeltaTime);
}
void Render(SDL_Surface* Surface) {
Grid.Render(Surface);
ScoreCounter.Render(Surface);
RestartButton.Render(Surface);
}
private:
ScoreCounter ScoreCounter;
// ...
};
To render our ScoreCounter
, we’ll start by rendering a solid rectangle, acting as the background of our element. We’ll add a configuration variable to GameConfig.h
to control what color this background should be:
// GameConfig.h
// ...
namespace Config{
// ...
// Colors
inline constexpr SDL_Color SCORE_BACKGROUND_COLOR{
73, 117, 46, 255};
}
// ...
To control where our ScoreCounter
is positioned within the window, we’ll need to define an SDL_Rect
, with the usual x
, y
, w
, and h
values.
In this case, the width (w
) for our rectangle depends on how much text is going to be rendered on top of it. If our maximum possible score contains 3 digits, we need more space than if it contains only 2 - for example, "12/75" requires more space than "123/520".
To help with this, we’ll calculate a MaxScore
variable from the maximum length of our snake, and we’ll subtract 2 because our snake starts with a length of 2:
// ScoreCounter.h
// ...
#include "GameConfig.h"
class ScoreCounter {
// ...
private:
int MaxScore{Config::MAX_LENGTH - 2};
// ...
};
Next, we’ll define an SDL_Rect
scaled in the following way:
x
: We want the score counter aligned to the left of our window with some padding to offset it from the edge, so our horizontal position will simply be Config::PADDING
.y
: We want the vertical position to be below the grid. The grid’s bottom edge is located at Config::PADDING + Config::GRID_HEIGHT
. We want additional padding between our grid and score counter, so we’ll set the y
position to be Config::GRID_HEIGHT + Config::PADDING * 2
.w
: We’ll set the width of our score counter based on our MaxScore
variable. For example: MaxScore > 99 ? 250 : 190
will adopt a size of 250 pixels if we need to support 3 digits or 190 otherwise.h
: We’ll set our score counter to match the full height of the footer, with some padding below. So we’ll set y
to Config::FOOTER_HEIGHT - Config::PADDING
Let’s add this to our class. Note that because it depends on the MaxScore
variable, we need to initialize it after MaxScore
:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
// Snake's initial length is 2
int MaxScore{Config::MAX_LENGTH - 2};
SDL_Rect BackgroundRect{
Config::PADDING,
Config::GRID_HEIGHT + Config::PADDING * 2,
MaxScore > 99 ? 250 : 190,
Config::FOOTER_HEIGHT - Config::PADDING
};
// ...
};
We can now update our Render()
function to use these BackgroundRect
and Config::SCORE_BACKGROUND
to render our background rectangle in the correct position:
// ScoreCounter.h
// ...
class ScoreCounter {
public:
// ...
void Render(SDL_Surface* Surface) {
using namespace Config;
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(Surface->format,
SCORE_BACKGROUND_COLOR.r,
SCORE_BACKGROUND_COLOR.g,
SCORE_BACKGROUND_COLOR.b
));
}
// ...
};
Running our program, we should now see our score counter’s background rendered in the correct location:
Let’s add the apple image to our score counter. We already have access to the apple image through the Assets
member, so we just need to define an SDL_Rect
to control where the image is rendered.
The x
and y
positions will match the x
and y
positions of our background rectangle, with some additional padding to move the apple away from the edge.
Our apple image is square, so we can set both the w
and h
of our image to match the background rectangle’s height. We’ll subtract Config::PADDING * 2
from these values, to add spacing on both sides of each dimension:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
// ...
SDL_Rect AppleRect{
BackgroundRect.x + Config::PADDING,
BackgroundRect.y + Config::PADDING,
BackgroundRect.h - Config::PADDING * 2,
BackgroundRect.h - Config::PADDING * 2
};
};
We can now update our Render()
function to render the apple image in this rectangle:
// ScoreCounter.h
// ...
class ScoreCounter {
public:
// ...
void Render(SDL_Surface* Surface) {
using namespace Config;
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(Surface->format,
SCORE_BACKGROUND_COLOR.r,
SCORE_BACKGROUND_COLOR.g,
SCORE_BACKGROUND_COLOR.b
));
Assets.Apple.Render(Surface, &AppleRect);
}
// ...
};
We want to render text in the format "12/34" where "12" is our current score, and "34" is the maximum possible score. We already have the MaxScore
variable, so we just need to add the CurrentScore
, which we’ll initialize to 0
:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
int CurrentScore{0};
int MaxScore{Config::MAX_LENGTH - 2};
// ...
};
In our HandleEvent()
function, we’ll increment this score every time an apple is eaten, and set it back to 0
every time our game restarts:
// ScoreCounter.h
// ...
class ScoreCounter {
public:
// ...
void HandleEvent(SDL_Event& E) {
if (E.type == UserEvents::APPLE_EATEN) {
++CurrentScore;
} else if (E.type == UserEvents::RESTART_GAME) {
CurrentScore = 0;
}
}
// ...
};
To create the CurrentScore/MaxScore
string, we’ll create a private GetScoreString()
helper:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
std::string GetScoreString() {
return std::to_string(CurrentScore) + "/"
+ std::to_string(MaxScore);
}
// ...
};
To manage our text, we’ll create an instance of our Text
class from Engine/Text.h
. For the constructor, we pass in the initial content of our text, and the font size we want to use:
// ScoreCounter.h
// ...
#include "Engine/Text.h"
class ScoreCounter {
// ...
private:
// ...
Text Text{GetScoreString(), 40};
};
We need to define another SDL_Rect
to control where our text should be rendered.
x
position to be AppleRect.x + AppleRect.w + Config::PADDING
.y
to be the same as AppleRect.y
.The Render()
method within Text
uses SDL_BlitSurface()
, which doesn’t require the SDL_Rect
to have a width and height. As such, we can just set these to 0
.
Let’s create an SDL_Rect
object called TextRect
that uses these values. Note that because TextRect
depends on AppleRect
, we should ensure AppleRect
is initialized first:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
// ...
SDL_Rect TextRect{
AppleRect.x + AppleRect.w + Config::PADDING,
AppleRect.y,
0, 0};
};
We now have what we need to render our Text
object to the Surface
provided to ScoreCounter::Render()
:
// ScoreCounter.h
// ...
class ScoreCounter {
public:
// ...
void Render(SDL_Surface* Surface) {
using namespace Config;
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(Surface->format,
SCORE_BACKGROUND_COLOR.r,
SCORE_BACKGROUND_COLOR.g,
SCORE_BACKGROUND_COLOR.b
));
Assets.Apple.Render(Surface, &AppleRect);
Text.Render(Surface, &TextRect);
}
// ...
};
Running our program, we should now see our score rendered in the correct position:
Finally, we need to update the content our Text
object is rendering any time the score changes. We can do this in our HandleEvent()
function:
// ScoreCounter.h
// ...
#include "Engine/Text.h"
class ScoreCounter {
public:
// ...
void HandleEvent(SDL_Event& E) {
if (E.type == UserEvents::APPLE_EATEN) {
++CurrentScore;
Text.SetText(GetScoreString());
} else if (E.type == UserEvents::RESTART_GAME) {
CurrentScore = 0;
Text.SetText(GetScoreString());
}
}
// ...
};
Complete versions of the files we changed in this part are available below
#pragma once
#define CHECK_ERRORS
#include <iostream>
#include <SDL.h>
#include <string>
namespace UserEvents{
inline Uint32 ADVANCE =
SDL_RegisterEvents(1);
inline Uint32 APPLE_EATEN =
SDL_RegisterEvents(1);
inline Uint32 GAME_WON =
SDL_RegisterEvents(1);
inline Uint32 GAME_LOST =
SDL_RegisterEvents(1);
inline Uint32 RESTART_GAME =
SDL_RegisterEvents(1);
}
namespace Config{
// Game Settings
inline const std::string GAME_NAME{
"Snake"};
inline constexpr int ADVANCE_INTERVAL{200};
inline constexpr int GRID_COLUMNS{16};
static_assert(
GRID_COLUMNS >= 12,
"Grid must be at least 12 columns wide");
inline constexpr int GRID_ROWS{5};
static_assert(
GRID_ROWS >= 5,
"Grid must be at least 5 rows tall");
inline constexpr int MAX_LENGTH{
GRID_COLUMNS * GRID_ROWS};
// Size and Positioning
inline constexpr int PADDING{5};
inline constexpr int CELL_SIZE{36};
inline constexpr int FOOTER_HEIGHT{60};
inline constexpr int GRID_HEIGHT{
CELL_SIZE * GRID_ROWS};
inline constexpr int GRID_WIDTH{
CELL_SIZE * GRID_COLUMNS};
inline constexpr int WINDOW_HEIGHT{
GRID_HEIGHT + FOOTER_HEIGHT
+ PADDING * 2};
inline constexpr int WINDOW_WIDTH{
GRID_WIDTH + PADDING * 2};
// Colors
inline constexpr SDL_Color BACKGROUND_COLOR{
85, 138, 52, 255};
inline constexpr SDL_Color CELL_COLOR_A{
171, 214, 82, 255};
inline constexpr SDL_Color CELL_COLOR_B{
161, 208, 74, 255};
inline constexpr SDL_Color SNAKE_COLOR{
67, 117, 234, 255};
inline constexpr SDL_Color SNAKE_LOST_COLOR{
227, 67, 97, 255};
inline constexpr SDL_Color SNAKE_VICTORY_COLOR{
255, 140, 0, 255};
inline constexpr SDL_Color BUTTON_COLOR{
73, 117, 46, 255};
inline constexpr SDL_Color BUTTON_HIGHLIGHT_COLOR{
67, 117, 234, 255};
inline constexpr SDL_Color SCORE_BACKGROUND_COLOR{
73, 117, 46, 255};
inline constexpr SDL_Color FONT_COLOR{
255, 255, 255, 255};
// Asset Paths
inline const std::string APPLE_IMAGE{
"apple.png"};
inline const std::string FONT{
"Rubik-SemiBold.ttf"};
}
inline void CheckSDLError(
const std::string& Msg){
#ifdef CHECK_ERRORS
const char* error = SDL_GetError();
if (*error != '\0') {
std::cerr << Msg << " Error: "
<< error << '\n';
SDL_ClearError();
}
#endif
}
#pragma once
#include <SDL.h>
#include "Engine/Text.h"
#include "Assets.h"
#include "GameConfig.h"
class ScoreCounter {
public:
ScoreCounter(Assets& Assets) : Assets{Assets} {}
void HandleEvent(SDL_Event& E) {
if (E.type == UserEvents::APPLE_EATEN) {
++CurrentScore;
Text.SetText(GetScoreString());
} else if (E.type == UserEvents::RESTART_GAME) {
CurrentScore = 0;
Text.SetText(GetScoreString());
}
}
void Render(SDL_Surface* Surface) {
using namespace Config;
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(Surface->format,
SCORE_BACKGROUND_COLOR.r,
SCORE_BACKGROUND_COLOR.g,
SCORE_BACKGROUND_COLOR.b
));
Assets.Apple.Render(Surface, &AppleRect);
Text.Render(Surface, &TextRect);
}
private:
std::string GetScoreString() {
return std::to_string(CurrentScore) + "/"
+ std::to_string(MaxScore);
}
Assets& Assets;
int CurrentScore{0};
int MaxScore{Config::MAX_LENGTH - 2};
Text Text{GetScoreString(), 40};
SDL_Rect BackgroundRect{
Config::PADDING,
Config::GRID_HEIGHT + Config::PADDING * 2,
MaxScore > 99 ? 250 : 190,
Config::FOOTER_HEIGHT - Config::PADDING};
SDL_Rect AppleRect{
BackgroundRect.x + Config::PADDING,
BackgroundRect.y + Config::PADDING,
BackgroundRect.h - Config::PADDING * 2,
BackgroundRect.h - Config::PADDING * 2};
SDL_Rect TextRect{
AppleRect.x + AppleRect.w + Config::PADDING,
AppleRect.y,
0, 0};
};
#pragma once
#include <SDL.h>
#include "Grid.h"
#include "Assets.h"
#include "ScoreCounter.h"
#include "RestartButton.h"
class GameUI {
public:
GameUI()
: Grid{Assets},
ScoreCounter{Assets} {}
void HandleEvent(SDL_Event& E) {
Grid.HandleEvent(E);
ScoreCounter.HandleEvent(E);
RestartButton.HandleEvent(E);
}
void Tick(Uint32 DeltaTime) {
Grid.Tick(DeltaTime);
}
void Render(SDL_Surface* Surface) {
Grid.Render(Surface);
ScoreCounter.Render(Surface);
RestartButton.Render(Surface);
}
private:
Grid Grid;
Assets Assets;
ScoreCounter ScoreCounter;
RestartButton RestartButton;
};
Files not listed above have not been changed since the previous section.
In this lesson, we built a score counter UI component for our Snake game. We implemented background rendering, image display, and text updates to create a polished user interface element that tracks the player's progress.
In the final lesson, we’ll update the visual rendering of our snake, letting it slide between cells using frame-by-frame animation.
Build a dynamic score counter that tracks the player's progress and displays it with custom graphics and text.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games