Right now, our snake moves instantly between cells. We'll enhance this by implementing a sliding animation, giving the illusion of the snake smoothly traversing the grid. This involves dynamically adjusting the portion of each cell occupied by the snake.
To do this, we’ll be using our Tick()
mechanism to provide frame-by-frame adjustments to our snake’s visuals.
This is the final part of our project, so we’ll also finish things off with a list of suggestions on how we can further develop the game and put our skills to the test!
Currently, when our cell has a CellState
of Snake
, we fill the entire cell with our snake color. This is done in our Render()
function, where we’re currently using the BackgroundRect
rectangle to define the bounds of both our background color and our snake segment:
// Cell.h
// ...
class Cell {
public:
// ...
void Render(SDL_Surface* Surface) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
BackgroundColor.r,
BackgroundColor.g,
BackgroundColor.b
)
);
if (CellState == Apple) {
Assets.Apple.Render(
Surface, &BackgroundRect);
} else if (CellState == Snake) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
SnakeColor.r,
SnakeColor.g,
SnakeColor.b
)
);
}
}
// ...
};
Instead, we want to create the visual effect of our snake sliding across the cell. From a high level, this involves two steps:
SDL_Rect
member to store what part of our cell should be filled by the snake colorSDL_Rect
on every frame (ie, on every Tick()
invocation) to create the sliding animation effectLet’s work through this step by step.
SnakeRect
First, let’s define the SDL_Rect
for our snake blitting. We’ll call it SnakeRect
:
// Cell.h
// ...
class Cell {
// ...
private:
// ...
SDL_Rect SnakeRect;
};
We’ll initialize our SnakeRect
to have the same dimensions as our BackgroundRect
:
// Cell.h
// ...
class Cell {
// ...
private:
void Initialize() {
SnakeRect = BackgroundRect;
// ...
}
// ...
};
Let’s update our Render()
function to use this SnakeRect
for our snake, rather than the BackgroundRect
it is currently using:
// Cell.h
// ...
class Cell {
public:
// ...
void Render(SDL_Surface* Surface) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
BackgroundColor.r,
BackgroundColor.g,
BackgroundColor.b
)
);
if (CellState == Apple) {
Assets.Apple.Render(
Surface, &BackgroundRect);
} else if (FillPercent > 0) {
SDL_FillRect(Surface, &SnakeRect,
SDL_MapRGB(
Surface->format,
SnakeColor.r,
SnakeColor.g,
SnakeColor.b
)
);
}
}
// ...
};
FillPercent
and FillDirection
To control the effect of the snake sliding across our cell, we’ll need two new member variables:
FillPercent
variable, controlling how far the snake has slid across our cellFillDirection
variable, controlling which direction the snake needs to slideThink of FillPercent
as how much of the cell the snake occupies, ranging from 0.0
(empty) to 1.0
(completely full). FillDirection
indicates where the snake enters the cell (Up
, Down
, Left
, or Right
).
Here are some examples of how they work together:
FillPercent
Let’s add the FillPercent
floating point member to our class:
// Cell.h
// ...
class Cell {
// ...
private:
float FillPercent{0};
// ...
};
When our game starts or restarts, we’ll initialize FillPercent
to 0
, except for the two cells that contain our initial snake segments:
// Cell.h
// ...
class Cell {
// ...
private:
void Initialize() {
CellState = Empty;
SnakeRect = BackgroundRect;
SnakeColor = Config::SNAKE_COLOR;
SnakeDuration = 0;
FillPercent = 0.0;
int StartRow{Config::GRID_ROWS / 2};
if (Row == StartRow && Column == 2) {
CellState = Snake;
SnakeDuration = 1;
FillPercent = 1.0;
} else if (Row == StartRow && Column ==3) {
CellState = Snake;
SnakeDuration = 2;
FillPercent = 1.0;
} else if (
Row == StartRow &&
Column == 11
) {
CellState = Apple;
}
}
// ...
};
Currently, our Render()
function checks if CellState == Snake
to decide if it needs to render the snake in our cell. We’ll update this to check if FillPercent > 0
instead:
// Cell.h
// ...
class Cell {
public:
void Render(SDL_Surface* Surface) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
BackgroundColor.r,
BackgroundColor.g,
BackgroundColor.b
)
);
if (CellState == Apple) {
Assets.Apple.Render(
Surface, &BackgroundRect);
} else if (FillPercent > 0) {
SDL_FillRect(Surface, &SnakeRect,
SDL_MapRGB(
Surface->format,
SnakeColor.r,
SnakeColor.g,
SnakeColor.b
)
);
}
}
// ...
};
FillDirection
We’ll also need a FillDirection
value representing which direction we need to fill our cell. For example, if the snake is entering the cell from the left, the cell should be filled with the snake color from left to right.
For example, if on a given frame, FillPercent
is 0.5
and FillDirection
is Right
, that corresponds to the left half of the cell containing the snake color, and the right half of the cell containing the background color.
Let’s add FillDirection
to our class, using the MovementDirection
type we have already declared:
// Cell.h
// ...
class Cell {
// ...
private:
MovementDirection FillDirection{Right};
// ...
};
We’ll initialize it to Right
for all cells, as our snake always starts by moving right:
// Cell.h
// ...
class Cell {
// ...
private:
void Initialize() {
FillDirection = Right;
// ...
}
// ...
};
However, when a snake visits a cell, we’ll update that cell’s FillDirection
to keep track of which direction the snake entered the cell from. We’ll also set FillPercent
to 0
when the snake head first enters, and we’ll animate this value from 0
to 1
in the next section:
// Cell.h
// ...
class Cell {
// ...
private:
void Advance(SDL_UserEvent& E) {
SnakeData* Data{
static_cast<SnakeData*>(E.data1)};
bool isThisCell{
Data->HeadRow == Row &&
Data->HeadCol == Column
};
if (isThisCell) {
if (CellState == Snake) {
SDL_Event Event{UserEvents::GAME_LOST};
SDL_PushEvent(&Event);
return;
}
if (CellState == Apple) {
SDL_Event Event{
UserEvents::APPLE_EATEN};
SDL_PushEvent(&Event);
}
CellState = Snake;
SnakeDuration = Data->Length;
FillDirection = Data->Direction;
FillPercent = 0;
} else if (CellState == Snake) {
if (SnakeDuration == Data->Length) {
FillDirection = Data->Direction;
}
SnakeDuration--;
if (SnakeDuration <= 0) {
CellState = Empty;
}
}
}
// ...
};
To give our snake’s head the illusion of sliding into the cell, we’ll update our SnakeRect
variable to gradually increase how much of the cell is filled with our snake color. Our FillPercent
should start at 0.0
when the snake first enters the cell and, by the time the snake is ready to advance to the next cell, FillPercent
should be at 1.0
.
As we want our animation to update every frame, we’ll use the Tick()
function to implement this. We know our cell has animation work to do if its CellState
is Snake
, but its FillPercent
hasn’t yet reached 1.0
. In that case, we’ll call a new private GrowHead()
function:
// Cell.h
// ...
class Cell {
public:
void Tick(Uint32 DeltaTime) {
if (CellState == Snake && FillPercent < 1) {
GrowHead(DeltaTime);
}
}
// ...
private:
void GrowHead(float DeltaTime) {
// TODO: Grow Head
}
// ...
};
Note that the GrowHead()
invocation is converting the DeltaTime
from a Uint32
to a float
. This is important, as we want to use floating point calculations in GrowHead()
.
Within successive invocations of GrowHead()
, we want to increase our FillPercent
such that it reaches 1.0
by the time our snake is ready to advance to the next cell. For example, if our Config::ADVANCE_INTERVAL
is set to 200
, our snake advances every 200 milliseconds, so we want our fill percent to increase from 0
to 1
over that interval.
For example, if DeltaTime
is 20, that means 20 milliseconds have passed since the previous frame. We’d want our fill percent to increase by 0.1
, as the 20 milliseconds delta time is 10% of the 200-millisecond interval time.
So, in general, we increase our FillPercent
by DeltaTime
divided by ADVANCE_INTERVAL
. We also don’t want FillPercent
to go above 1.0
, so if this calculation increases it beyond that, we reduce it back down:
// Cell.h
// ...
class Cell {
// ...
private:
void GrowHead(float DeltaTime) {
using namespace Config;
FillPercent += DeltaTime / ADVANCE_INTERVAL;
if (FillPercent > 1) FillPercent = 1;
// TODO: Grow Head
}
};
We now use this FillPercent
value in combination with our FillDirection
to modify the size or position of our SnakeRect
.
Our SnakeRect
value should have the same x
, y
, w
, and h
values as our BackgroundRect
, with the exception of one property. The property we need to change depends on our FillDirection
, and what its new value should be depends on our FullPercent
.
w
) of the snake rectangle by our FillPercent
h
) of the snake rectangle based on our FillPercent
x
) of our snake rectangle. Since x
represents the left edge of the rectangle, we need to start it at the right edge of the cell and move it leftwards. We calculate the starting x
position by adding BackgroundRect.x
(the left edge of the cell) to the remaining portion of the cell width, which is CELL_SIZE * (1 - FillPercent)
.y
) of our snake rectangle. Since y
represents the top edge of the rectangle, we need to start it at the bottom edge of the cell and move it upwards. We calculate the starting y
position by adding BackgroundRect.y
(the top edge of the cell) to the remaining portion of the cell height, which is CELL_SIZE * (1 - FillPercent)
.Putting all this logic together, we have the following. Note that we’re using Config::CELL_SIZE
to retrieve the width or height of each cell. We could alternatively have used BackgroundRect.w
or BackgroundRect.h
- they all have the same value:
// Cell.h
// ...
class Cell {
// ...
private:
void GrowHead(float DeltaTime) {
using namespace Config;
FillPercent += DeltaTime / ADVANCE_INTERVAL;
if (FillPercent > 1) FillPercent = 1;
SnakeRect = BackgroundRect;
if (FillDirection == Right) {
SnakeRect.w = CELL_SIZE * FillPercent;
} else if (FillDirection == Down) {
SnakeRect.h = CELL_SIZE * FillPercent;
} else if (FillDirection == Left) {
SnakeRect.x = BackgroundRect.x +
CELL_SIZE * (1 - FillPercent);
} else if (FillDirection == Up) {
SnakeRect.y = BackgroundRect.y +
CELL_SIZE * (1 - FillPercent);
}
}
// ...
};
Running our game, we should now see our snake’s head animating smoothly. However, our other snake segments remain on screen even after the snake has left those cells. Let’s apply similar logic in reverse to animate the snake’s tail leaving cells it no longer occupies.
We can animate the snake’s tail leaving a cell in much the same way we animated the head entering. First, we need to update the FillDirection
variable with the direction that the snake’s head leaves our cell - that is, the turn after the snake entered the cell.
We can determine this from the UserEvents::ADVANCE
event that comes after the ADVANCE
event where the snake entered our cell. There are many ways we could detect this - we could add a new variable to our Cell
class for example.
In our setup, we can also infer this by comparing the snake’s current length to the SnakeDuration
remaining on our Cell
, before we decrement it. If those values are the same, that means the snake’s head entered our cell on the previous turn, and is leaving our cell on this turn.
As such, its Direction
is the direction that it is leaving our cell, and we can set FillDirection
accordingly:
// Cell.h
// ...
class Cell {
// ...
private:
void Advance(SDL_UserEvent& E) {
SnakeData* Data{
static_cast<SnakeData*>(
E.data1)};
bool isThisCell{
Data->HeadRow == Row &&
Data->HeadCol == Column
};
if (isThisCell) {
// ...
} else if (CellState == Snake) {
if (SnakeDuration == Data->Length) {
FillDirection = Data->Direction;
}
SnakeDuration--;
if (SnakeDuration <= 0) {
CellState = Empty;
}
}
}
// ...
};
In our Tick()
function, we know that we need to animate the snake out of our cell if its CellState
is not Snake
, but it is still rendering some part of the snake segment, that is, its FillPercent
is greater than 0
. We’ll create a new ShrinkTail()
private method to handle this animation:
// Cell.h
// ...
class Cell {
public:
void Tick(Uint32 DeltaTime) {
if (CellState == Snake && FillPercent < 1) {
GrowHead(DeltaTime);
} else if (CellState != Snake && FillPercent > 0) {
ShrinkTail(DeltaTime);
}
}
// ...
private:
// ...
void ShrinkTail(float DeltaTime) {
// TODO: Shrink Tail
}
// ...
};
The logic we need for ShrinkTail()
will effectively be the inverse of what we had in GrowHead()
. Therefore, we update FillPercent
and SnakeRect
in the opposite way we did within GrowHead()
:
DeltaTime
and ADVANCE_INTERVAL
to decrease the FillPercent
FillPercent
to fall below 0
, we increase it back to 0
.Our SnakeRect
calculations are the same as they were in GrowHead()
, but in the opposite direction:
FillDirection == Right
logic in ShrinkTail()
is the same as the FillDirection == Left
logic in GrowHead()
FillDirection == Left
logic in ShrinkTail()
is the same as the FillDirection == Right
logic in GrowHead()
FillDirection == Top
logic in ShrinkTail()
is the same as the FillDirection == Bottom
logic in GrowHead()
FillDirection == Bottom
logic in ShrinkTail()
is the same as the FillDirection == Top
logic in GrowHead()
Our complete ShrinkTail()
function looks like this:
// Cell.h
// ...
class Cell {
// ...
private:
void ShrinkTail(float DeltaTime) {
using namespace Config;
FillPercent -= DeltaTime / ADVANCE_INTERVAL;
if (FillPercent < 0) FillPercent = 0;
if (FillDirection == Right) {
SnakeRect.x = BackgroundRect.x +
CELL_SIZE * (1 - FillPercent);
} else if (FillDirection == Left) {
SnakeRect.w = CELL_SIZE * FillPercent;
} else if (FillDirection == Up) {
SnakeRect.h = CELL_SIZE * FillPercent;
} else if (FillDirection == Down) {
SnakeRect.y = BackgroundRect.y +
CELL_SIZE * (1 - FillPercent);
}
}
// ...
};
Our final Cell.h
code is available below:
#pragma once
#include <SDL.h>
#include "GameConfig.h"
#include "SnakeData.h"
#include "Assets.h"
enum CellState { Snake, Apple, Empty };
class Cell {
public:
Cell(int Row, int Column, Assets& Assets)
: Row(Row),
Column(Column),
Assets{Assets}
{
Initialize();
}
void HandleEvent(SDL_Event& E) {
using namespace UserEvents;
if (E.type == ADVANCE) {
Advance(E.user);
} else if (E.
type == APPLE_EATEN) {
if (CellState == Snake) {
++SnakeDuration;
}
} else if (E.type == GAME_LOST) {
SnakeColor = Config::SNAKE_LOST_COLOR;
} else if (E.type == GAME_WON) {
SnakeColor = Config::SNAKE_VICTORY_COLOR;
} else if (E.type == RESTART_GAME) {
Initialize();
}
}
void Tick(Uint32 DeltaTime) {
if (CellState == Snake && FillPercent < 1) {
GrowHead(DeltaTime);
} else if (CellState != Snake && FillPercent > 0) {
ShrinkTail(DeltaTime);
}
}
void Render(SDL_Surface* Surface) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
BackgroundColor.r,
BackgroundColor.g,
BackgroundColor.b
)
);
if (CellState == Apple) {
Assets.Apple.Render(
Surface, &BackgroundRect);
} else if (FillPercent > 0) {
SDL_FillRect(Surface, &SnakeRect,
SDL_MapRGB(
Surface->format,
SnakeColor.r,
SnakeColor.g,
SnakeColor.b
)
);
}
}
bool PlaceApple() {
if (CellState != Empty) return false;
CellState = Apple;
return true;
}
private:
void Initialize() {
CellState = Empty;
SnakeColor = Config::SNAKE_COLOR;
SnakeDuration = 0;
FillPercent = 0;
FillDirection = Right;
int StartRow{Config::GRID_ROWS / 2};
if (Row == StartRow && Column == 2) {
CellState = Snake;
SnakeDuration = 1;
FillPercent = 1;
} else if (Row == StartRow && Column ==3) {
CellState = Snake;
SnakeDuration = 2;
FillPercent = 1;
} else if (
Row == StartRow &&
Column == 11
) {
CellState = Apple;
}
SnakeRect = BackgroundRect;
}
void Advance(SDL_UserEvent& E) {
SnakeData* Data{
static_cast<SnakeData*>(
E.data1)};
bool isThisCell{
Data->HeadRow == Row &&
Data->HeadCol == Column
};
if (isThisCell) {
if (CellState == Snake) {
SDL_Event Event{UserEvents::GAME_LOST};
SDL_PushEvent(&Event);
return;
}
if (CellState == Apple) {
SDL_Event Event{
UserEvents::APPLE_EATEN};
SDL_PushEvent(&Event);
}
CellState = Snake;
SnakeDuration = Data->Length;
FillDirection = Data->Direction;
FillPercent = 0;
} else if (CellState == Snake) {
if (SnakeDuration == Data->Length) {
FillDirection = Data->Direction;
}
SnakeDuration--;
if (SnakeDuration <= 0) {
CellState = Empty;
}
}
}
void GrowHead(float DeltaTime) {
using namespace Config;
FillPercent += DeltaTime / ADVANCE_INTERVAL;
if (FillPercent > 1) FillPercent = 1;
SnakeRect = BackgroundRect;
if (FillDirection == Right) {
SnakeRect.w = CELL_SIZE * FillPercent;
} else if (FillDirection == Down) {
SnakeRect.h = CELL_SIZE * FillPercent;
} else if (FillDirection == Left) {
SnakeRect.x = BackgroundRect.x +
CELL_SIZE * (1 - FillPercent);
} else if (FillDirection == Up) {
SnakeRect.y = BackgroundRect.y +
CELL_SIZE * (1 - FillPercent);
}
}
void ShrinkTail(float DeltaTime) {
using namespace Config;
FillPercent -= DeltaTime / ADVANCE_INTERVAL;
if (FillPercent < 0) FillPercent = 0;
SnakeRect = BackgroundRect;
if (FillDirection == Right) {
SnakeRect.x = BackgroundRect.x +
CELL_SIZE * (1 - FillPercent);
} else if (FillDirection == Left) {
SnakeRect.w = CELL_SIZE * FillPercent;
} else if (FillDirection == Up) {
SnakeRect.h = CELL_SIZE * FillPercent;
} else if (FillDirection == Down) {
SnakeRect.y = BackgroundRect.y +
CELL_SIZE * (1 - FillPercent);
}
}
int Row;
int Column;
CellState CellState;
SDL_Color SnakeColor;
int SnakeDuration;
SDL_Rect BackgroundRect{
Column * Config::CELL_SIZE + Config::PADDING,
Row * Config::CELL_SIZE + Config::PADDING,
Config::CELL_SIZE,
Config::CELL_SIZE
};
SDL_Color BackgroundColor{
(Row + Column) % 2 == 0
? Config::CELL_COLOR_A
: Config::CELL_COLOR_B
};
SDL_Rect SnakeRect;
Assets& Assets;
float FillPercent{0};
MovementDirection FillDirection{Right};
};
The remaining project files (which were not changed in this section) are available here:
#pragma once
#include <SDL.h>
#include "GameConfig.h"
class Window {
public:
Window() {
SDLWindow = SDL_CreateWindow(
Config::GAME_NAME.c_str(),
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
Config::WINDOW_WIDTH,
Config::WINDOW_HEIGHT, 0
);
CheckSDLError("Creating Window");
}
~Window() {
if (SDLWindow) {
SDL_DestroyWindow(SDLWindow);
}
}
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
void Render() {
SDL_FillRect(
GetSurface(), nullptr,
SDL_MapRGB(GetSurface()->format,
Config::BACKGROUND_COLOR.r,
Config::BACKGROUND_COLOR.g,
Config::BACKGROUND_COLOR.b));
}
void Update() {
SDL_UpdateWindowSurface(SDLWindow);
}
SDL_Surface* GetSurface() {
return SDL_GetWindowSurface(SDLWindow);
}
private:
SDL_Window* SDLWindow;
};
#pragma once
#include <SDL.h>
#include <SDL_image.h>
#include <string>
class Image {
public:
Image(const std::string& Path) {
ImageSurface = IMG_Load(Path.c_str());
CheckSDLError("Loading Image");
}
~Image() {
if (ImageSurface) {
SDL_FreeSurface(ImageSurface);
}
}
void Render(
SDL_Surface* Surface, SDL_Rect* Rect
) {
SDL_BlitScaled(
ImageSurface, nullptr, Surface, Rect);
}
// Prevent copying
Image(const Image&) = delete;
Image& operator=(const Image&) = delete;
private:
SDL_Surface* ImageSurface{nullptr};
};
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include <string>
#include "GameConfig.h"
class Text {
public:
Text(
const std::string& InitialText,
int FontSize
) : Content(InitialText) {
Font = TTF_OpenFont(
Config::FONT.c_str(), FontSize);
CheckSDLError("Opening Font");
SetText(InitialText);
}
~Text() {
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
if (Font) {
TTF_CloseFont(Font);
}
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
void SetText(const std::string& NewText) {
Content = NewText;
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
TextSurface = TTF_RenderText_Blended(
Font, Content.c_str(), Config::FONT_COLOR);
CheckSDLError("Creating Text Surface");
}
void Render(
SDL_Surface* Surface, SDL_Rect* Rect
) {
if (TextSurface) {
SDL_BlitSurface(
TextSurface, nullptr, Surface, Rect
);
}
}
private:
std::string Content;
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
};
#pragma once
#include <random>
namespace Random {
inline std::random_device SEEDER;
inline std::mt19937 ENGINE{SEEDER()};
inline int Int(int Min, int Max) {
std::uniform_int_distribution Get{Min, Max};
return Get(ENGINE);
}
}
#pragma once
#include "GameConfig.h"
#include "Engine/Image.h"
struct Assets {
Image Apple{Config::APPLE_IMAGE};
};
#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_SCORE{
GRID_COLUMNS * GRID_ROWS - 1};
// 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 "GameConfig.h"
#include "SnakeData.h"
class GameState {
public:
void HandleEvent(SDL_Event& E) {
using namespace UserEvents;
using namespace Config;
if (E.type == SDL_KEYDOWN) {
HandleKeyEvent(E.key);
} else if (E.type == APPLE_EATEN) {
++Snake.Length;
if (Snake.Length == MAX_LENGTH) {
SDL_Event Event{GAME_WON};
SDL_PushEvent(&Event);
}
} else if (E.type == RESTART_GAME) {
RestartGame();
} else if (E.type == GAME_LOST ||
E.type == GAME_WON) {
IsGameOver = true;
}
}
void Tick(Uint32 DeltaTime) {
if (IsPaused || IsGameOver) return;
ElapsedTime += DeltaTime;
if (ElapsedTime >= Config::ADVANCE_INTERVAL) {
ElapsedTime = 0;
UpdateSnake();
}
}
private:
void HandleKeyEvent(SDL_KeyboardEvent& E) {
if (IsGameOver) return;
switch (E.keysym.sym) {
case SDLK_UP:
case SDLK_w:
if (Snake.Direction != Down) {
NextDirection = Up;
}
break;
case SDLK_DOWN:
case SDLK_s:
if (Snake.Direction != Up) {
NextDirection = Down;
}
break;
case SDLK_LEFT:
case SDLK_a:
if (Snake.Direction != Right) {
NextDirection = Left;
}
break;
case SDLK_RIGHT:
case SDLK_d:
if (IsPaused) {
NextDirection = Right;
IsPaused = false;
UpdateSnake();
} else if (Snake.Direction != Left) {
NextDirection = Right;
}
break;
}
}
void UpdateSnake() {
Snake.Direction = NextDirection;
switch (NextDirection) {
case Up:
Snake.HeadRow--;
break;
case Down:
Snake.HeadRow++;
break;
case Left:
Snake.HeadCol--;
break;
case Right:
Snake.HeadCol++;
break;
}
if (
Snake.HeadRow < 0 ||
Snake.HeadRow >= Config::GRID_ROWS ||
Snake.HeadCol < 0 ||
Snake.HeadCol >= Config::GRID_COLUMNS
) {
SDL_Event Event{UserEvents::GAME_LOST};
SDL_PushEvent(&Event);
} else {
SDL_Event Event{UserEvents::ADVANCE};
Event.user.data1 = &Snake;
SDL_PushEvent(&Event);
}
}
void RestartGame() {
IsPaused = true;
IsGameOver = false;
ElapsedTime = 0;
Snake = {Config::GRID_ROWS / 2, 3, 2, Right};
NextDirection = Right;
}
bool IsPaused{true};
bool IsGameOver{false};
Uint32 ElapsedTime{0};
SnakeData Snake{Config::GRID_ROWS / 2, 3, 2};
MovementDirection NextDirection{Right};
};
#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;
};
#pragma once
#include <SDL.h>
#include <vector>
#include "Cell.h"
#include "GameConfig.h"
#include "Engine/Random.h"
class Grid {
public:
Grid(Assets& Assets) {
using namespace Config;
Cells.reserve(GRID_ROWS * GRID_COLUMNS);
for (int R{0}; R < GRID_ROWS; ++R) {
for (int C{0}; C < GRID_COLUMNS; ++C) {
Cells.emplace_back(R, C, Assets);
}
}
}
void HandleEvent(SDL_Event& E) {
for (auto& Cell : Cells) {
Cell.HandleEvent(E);
}
if (E.type == UserEvents::APPLE_EATEN) {
PlaceRandomApple();
}
}
void Tick(Uint32 DeltaTime) {
for (auto& Cell : Cells) {
Cell.Tick(DeltaTime);
}
}
void Render(SDL_Surface* surface) {
for (auto& Cell : Cells) {
Cell.Render(surface);
}
}
private:
std::vector<Cell> Cells;
void PlaceRandomApple() {
while (true) {
using Random::Int;
size_t RandomIndex{Int(0, Cells.size() - 1)};
if (Cells[RandomIndex].PlaceApple()) {
break;
}
}
}
};
#pragma once
#include <SDL.h>
#include "GameConfig.h"
#include "Engine/Text.h"
class RestartButton {
public:
void Render(SDL_Surface* Surface) {
SDL_FillRect(Surface, &ButtonRect,
SDL_MapRGB(
Surface->format,
CurrentColor.r,
CurrentColor.g,
CurrentColor.b
)
);
Text.Render(Surface, &TextRect);
}
void HandleEvent(SDL_Event& E) {
using namespace UserEvents;
using namespace Config;
if (E.type == SDL_MOUSEBUTTONDOWN) {
HandleClick(E.button);
} else if (
E.type == GAME_LOST ||
E.type == GAME_WON
) {
CurrentColor = BUTTON_HIGHLIGHT_COLOR;
} else if (E.type == RESTART_GAME) {
CurrentColor = BUTTON_COLOR;
}
}
private:
void HandleClick(SDL_MouseButtonEvent& E) {
using namespace UserEvents;
if (
E.x >= ButtonRect.x &&
E.x <= ButtonRect.x + ButtonRect.w &&
E.y >= ButtonRect.y &&
E.y <= ButtonRect.y + ButtonRect.h
) {
SDL_Event RestartEvent{RESTART_GAME};
SDL_PushEvent(&RestartEvent);
}
}
Text Text{"RESTART", 20};
SDL_Rect ButtonRect{
Config::WINDOW_WIDTH - 150,
Config::GRID_HEIGHT + 10,
150 - Config::PADDING,
Config::FOOTER_HEIGHT - Config::PADDING
};
SDL_Rect TextRect{
ButtonRect.x + Config::PADDING * 5,
ButtonRect.y + Config::PADDING * 3,
0, 0
};
SDL_Color CurrentColor{Config::BUTTON_COLOR};
};
#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
enum MovementDirection { Up, Down, Left, Right };
struct SnakeData {
int HeadRow;
int HeadCol;
int Length;
MovementDirection Direction{Right};
};
cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 20)
project(Snake VERSION 1.0.0)
add_executable(Snake
"main.cpp"
"GameConfig.h"
"Engine/Window.h"
"Engine/Image.h"
"Engine/Text.h"
"Engine/Random.h"
"GameUI.h"
"Assets.h"
"GameState.h"
"Cell.h"
"Grid.h"
"SnakeData.h"
"ScoreCounter.h"
"RestartButton.h")
target_include_directories(
Snake PUBLIC ${PROJECT_SOURCE_DIR}
)
add_subdirectory(external/SDL)
add_subdirectory(external/SDL_image)
add_subdirectory(external/SDL_ttf)
target_link_libraries(Snake PRIVATE
SDL2
SDL2_image
SDL2_ttf
)
if (WIN32)
target_link_libraries(
Snake PRIVATE SDL2main
)
endif()
set(AssetDirectory "${PROJECT_SOURCE_DIR}/Assets")
add_custom_command(
TARGET Snake POST_BUILD COMMAND
${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:SDL2>"
"$<TARGET_FILE:SDL2_image>"
"$<TARGET_FILE:SDL2_ttf>"
"${AssetDirectory}/apple.png"
"${AssetDirectory}/Rubik-SemiBold.ttf"
"$<TARGET_FILE_DIR:Snake>"
VERBATIM
)
Assets/Rubik-SemiBold.tff
available from Google FontsAssets/bomb.png
available from IconFinderAssets/flag.png
available from IconsDBCongratulations on building your working Snake game! You've learned a lot about SDL2, game loops, event handling, and animation. However, the learning process doesn't stop here.
A crucial part of becoming a better programmer is to experiment, iterate, and improve upon your creations. By taking on self-directed challenges, you solidify your understanding and develop problem-solving skills.
Now that you have a functional base, consider exploring some of the following improvements. These will not only enhance your game but also deepen your understanding of C++ and game development principles. Think of these as opportunities to apply what you've learned and push your abilities further.
Currently, many of the game's core parameters, like grid size (GRID_ROWS
, GRID_COLUMNS
) and the snake's movement speed (ADVANCE_INTERVAL
), are hardcoded as constants. A more flexible and user-friendly approach would be to allow the player to customize these settings.
Consider adding a simple menu system, perhaps accessible at the start of the game or through a pause screen. This menu could present options to:
Implementing these features will require you to:
Currently, every time our game advances, only the most recent keyboard input is used to determine how the snake turns (GameState::NextDirection
). This can make our game effectively ignore some inputs if the user provides an additional input before the next Advance()
.
This behavior is sometimes fine, but many games use an input queue to allow all inputs provided in quick succession to be acted upon.
This involves creating a data structure (like a std::vector
or std::queue
) that stores a sequence of player inputs. Then, in your game's update loop, you would process all of the inputs from the queue in a way that feels better for the type of game you’re making.
To keep things simple, our project routed pretty much every event through to every relevant object in our game. This results in a lot of excess traffic, where the object’s HandleEvent()
functions are invoked with an event the object has no interest in.
Every function call that results in no effect has an unnecessary performance cost. An obvious way to reduce this traffic is having each parent check an event is relevant to its children before forwarding it:
void HandleEvent(const SDL_Event& E){
if (isRelevantToChildren(E.type) {
for (auto& Child : Children) {
Child.HandleEvent(E);
}
}
};
However, this limits flexibility. When creating a parent class, we often won’t know what type of children we’ll eventually be managing.
Even if we did know exactly what event types the children are interested in, declaring that within the parent would typically be a bad design.
To keep our code well organized and understandable, the event types that a class is interested in should be managed within that same class. For example, the event types that Cell
objects are interested in should be declared in the Cell
class.
Objects can then provide that list to their parent, or to some object that is managing event traffic. For example:
class Child: public EventReceiver {
// Subscribe to event types from constructor
Child(EventReceiver* Parent) {
Parent->SubscribeToEventTypes({
UserEvents::GAME_START,
UserEvents::GAME_END,
// ...
})
}
// ...
}
This system can become increasingly elaborate - for example, objects may subscribe or unsubscribe from events at different points during their lifecycle:
class Child: public EventReceiver {
// ...
void SetIsDisabled() {
// No longer interested in mouse events
Parent->UnsubscribeToEventTypes({
SDL_MOUSEMOTION,
SDL_MOUSEBUTTON
})
}
// ...
}
This situation is one example of a problem that can be solved by concepts like function pointers, delegates, and observers that we covered earlier in the course.
Now that you have a complete game, it's a good time to revisit your code and look for areas to improve its structure, readability, and maintainability.
If a class or system doesn’t make sense or is difficult to follow, that’s a good indicator that it could use some refactoring. Here are some examples that might be worth considering:
GameState
and GameUI
Interaction: Consider separating game logic from UI updates more cleanly. GameState
could focus solely on the game's internal state, and GameUI
(and its children) could observe changes in GameState
.
This can perhaps be through the observer pattern, or implementing logic in the Tick()
function that queries the GameState
for information that is relevant to what needs to be displayed on the GameUI
.
Cell Responsibilities: The Cell
class currently handles a lot of different tasks: rendering, animation, collision detection, and even some game logic (like pushing APPLE_EATEN
events). You might consider breaking down Cell
into smaller, more specialized classes or components. For example, you could have a separate SnakeSegment
class to handle the snake-specific logic and animation, and Cell
could focus on representing the grid cell itself.
const
To keep our lessons as simple as possible, we restrict the syntax as much as possible to keep the focus on the core concepts. However, there are some more advanced C++ features that you may want to add if you’re more comfortable.
One such example is the const
keyword, which we covered in more detail here:
The most useful place where we can apply const
to our game is when our functions are receiving arguments by reference or pointer. If the function is not going to modify that argument, we should mark it as const
. This applies to all of our HandleEvent()
functions, for example:
// ScoreCounter.h
// ...
class ScoreCounter {
public:
void HandleEvent(const SDL_Event& E) {
// ...
}
// ...
};
When a class method does not modify any of the class members, it is also useful to mark that method as const
. For example:
// ScoreCounter.h
// ...
class ScoreCounter {
// ...
private:
std::string GetScoreString() const {
return std::to_string(CurrentScore) + "/"
+ std::to_string(MaxScore);
}
// ...
};
static
and inline
More keywords we should consider deploying are static
and inline
. We cover these keywords in more detail here in our advanced course:
An example that can be made static
and inline
in our snake game is the SnakeColor
, as this variable has the same value in every Cell
instance:
// ScoreCounter.h
// ...
class Cell {
// ...
private:
inline static SDL_Color SnakeColor;
// ...
};
private
ConstructorsFinally, we should consider how our objects can be constructed. Currently, all of our classes have public constructors, meaning they can be created by any other class.
However, this may not align with our designs. For example, Asset
objects are intended to only be constructed by GameUI
objects, so we may want to make that constraint official.
To do this, we can make the Asset
constructor private
, and give GameUI
access to this constructor by declaring it to be a friend
:
#pragma once
#include "GameConfig.h"
#include "Engine/Image.h"
struct Assets {
Image Apple{Config::APPLE_IMAGE};
private:
friend class GameUI;
Assets() = default;
};
We cover the friend
keyword in more detail here:
This lesson focused on implementing a visual improvement: animating the snake's movement across the grid. We replaced the instantaneous cell-to-cell jumps with a smooth sliding effect. This was done with the following components:
FillPercent
(0.0
to 1.0
) controls how much of a cell is filled by the snake.FillDirection
indicates the direction of snake movement within a cell.SnakeRect
is dynamically resized and repositioned in each frame to create the animation.GrowHead()
handles the animation of the snake entering a cell.ShrinkTail()
handles the animation of the snake leaving a cell.Learn to animate the snake's movement across cells for a smooth, dynamic visual effect.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games