In this lesson, we'll implement the core mechanics of our snake game. We'll create the game state management system, handle user input for controlling the snake's movement, and implement the snake's movement logic. By the end, you'll have a fully functional snake that responds to player controls.
We'll start by defining the fundamental data structures needed to track our snake's state, including its position, length, and direction. Then we'll expand our state system to advance the game through time.
Finally, we’ll allow our game state to respond to user input, allowing the player to control the direction their snake moves.
SnakeData
StructLet’s start by defining a simple struct that stores the current state of our snake. We’ll store the row and column of the snake’s head, how long the snake is, and the direction it is currently moving:
// SnakeData.h
#pragma once
enum MovementDirection { Up, Down, Left, Right };
struct SnakeData {
int HeadRow;
int HeadCol;
int Length;
MovementDirection Direction;
};
In this version of the game, the type we use to manage our snake - SnakeData
- will be a very simple type comprising only 4 variables and no functions. This is because much of the complexity is going to be managed by the Cell
class, which will keep track of whether or not each cell contains a snake segment.
As with all programming projects, there are many ways to solve any problem. We could have a more complex Snake
type and transfer some responsibility away from our Cell
class. It is likely to feel more intuitive that all snake behavior be the responsibility of a Snake
class rather than a bunch of Cell
objects managing individual segments.
The Snake
approach tends to be a little more complex to implement, but feel free to experiment here and implement the game differently from what we’re showing if you feel more comfortable.
GameState
ClassWhen we’re working on a game, it is common to have an object that is designed to manage the overall state of our game world. We’ll create a GameState
class for this. Game states typically don’t need to be rendered, but they usually need to be notified of events and ticks, so we’ll add our HandleEvent()
and Tick()
functions:
// GameState.h
#pragma once
#include <SDL.h>
class GameState {
public:
void HandleEvent(SDL_Event& E) {}
void Tick(Uint32 DeltaTime) {}
};
This GameState
class will also be responsible for managing the state of our snake, so we’ll store a SnakeData
object. As we covered before, the snake will initially have the following characteristics:
CONFIG::GRID_ROWS / 2
3
2
Right
Let’s initialize our SnakeData
accordingly:
// GameState.h
// ...
#include "SnakeData.h"
class GameState {
// ...
private:
SnakeData Snake{
Config::GRID_ROWS / 2, 3, 2, Right};
};
The initialization in our previous example can be a little difficult to read. We have four arguments provided as Config::GRID_ROWS / 2, 3, 2, Right
, and it’s not immediately clear which argument maps to which SnakeData
member variable.
Arguments are mapped to to the members based on their order but, without opening the SnakeData.h
file, it’s not clear what the order should be.
C++20 added designated initialization, which we can use to initialize data members by name rather than by order. The syntax is as follows - note the .
before each name:
SnakeData Snake{
.HeadRow = Config::GRID_ROWS / 2,
.HeadCol = 3,
.Length = 2,
.Direction = Right
};
This makes our initialization a little easier to follow, but it is also more resilient, as it will continue to work as expected even if the members within the SnakeData
struct are reordered.
We cover dedicated initialization and similar techniques in detail in our advanced course:
Over in main.cpp
, let’s initialize our GameState
struct and call HandleEvent()
and Tick()
at the appropriate times within our game loop:
// main.cpp
#include <SDL.h>
#include <SDL_image.h>
#include <SDL_ttf.h>
#include "Engine/Window.h"
#include "GameState.h"
#include "GameUI.h"
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
CheckSDLError("Initializing SDL");
IMG_Init(IMG_INIT_PNG);
CheckSDLError("Initializing SDL_image");
TTF_Init();
CheckSDLError("Initializing SDL_ttf");
Window GameWindow;
GameUI UI;
GameState State;
Uint32 PreviousTick{SDL_GetTicks()};
Uint32 CurrentTick;
Uint32 DeltaTime;
SDL_Event Event;
while (true) {
CurrentTick = SDL_GetTicks();
DeltaTime = CurrentTick - PreviousTick;
// Events
while (SDL_PollEvent(&Event)) {
UI.HandleEvent(Event);
State.HandleEvent(Event);
if (Event.type == SDL_QUIT) {
SDL_Quit();
IMG_Quit();
return 0;
}
}
// Tick
State.Tick(DeltaTime);
UI.Tick(DeltaTime);
// Render
GameWindow.Render();
UI.Render(GameWindow.GetSurface());
// Swap
GameWindow.Update();
PreviousTick = CurrentTick;
}
return 0;
}
In most Snake implementations, our game advances on a specific interval. For example, every 200 milliseconds, our snake’s position changes. Let’s start by defining this advance interval as a variable within our GameConfig.h
file:
// GameConfig.h
// ...
namespace Config{
// ...
inline constexpr int ADVANCE_INTERVAL{200};
// ...
}
// ...
We’ll also define a user event that we can use with SDL’s event mechanism. We can dispatch this user event any time our game advances, so any component that needs to react to this can do so. We covered user events in more detail in a dedicated lesson earlier in the course:
Let’s start by registering our event within our GameConfig.h
. We’ll have a few of these custom event types as we progress through our course, so let’s put them in a UserEvents
namespace:
// GameConfig.h
// ...
namespace UserEvents{
inline Uint32 ADVANCE{SDL_RegisterEvents(1)};
}
// ...
In our GameState.h
, we’ll keep track of how much time has passed by accumulating the time deltas reported to our Tick()
function invocations. When we’ve accumulated enough milliseconds to satisfy our ADVANCE_INTERVAL
configuration, we’ll reset our accumulated time and advance our game state.
We’ll put this advance logic in a private UpdateSnake()
function which we’ll implement next:
// GameState.h
#pragma once
#include <SDL.h>
#include "GameConfig.h"
#include "SnakeData.h"
class GameState {
public:
void HandleEvent(SDL_Event& E) {}
void Tick(Uint32 DeltaTime) {
ElapsedTime += DeltaTime;
if (ElapsedTime >= Config::ADVANCE_INTERVAL) {
ElapsedTime = 0;
UpdateSnake();
}
}
private:
void UpdateSnake() {}
Uint32 ElapsedTime{0};
// ...
};
You may be wondering why this game needs to Tick()
at all. We could instead have a timer, such as an SDL_Timer
, that advances the game every 200 milliseconds.
This is true, but even in games that only update on a specific interval, there are almost always parts of the program that we want to update in real-time. This can include animations and visual effects that don’t meaningfully modify the game’s state, but still improve the player experience.
Later in this chapter, we’ll use our Tick()
functions to smoothly animate our snake’s position between cells, rather than have the jolted movement pattern of only updating every 200 milliseconds.
Our new UpdateSnake()
function will update our SnakeData
to reflect its new position. Where our snake moves next will be based on a new NextDirection
variable that our GameState
manages.
For now, the NextDirection
will always be Right
, but we’ll later update our class to allow players to change this direction based on keyboard input.
Finally, our UpdateSnake()
function will dispatch one of our new UserEvents::ADVANCE
events, notifying other components that the game state has advanced. We’ll include a pointer to the SnakeData
in that event which other components can access as needed to determine how they need to react:
// GameState.h
// ...
class GameState {
// ...
private:
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;
}
SDL_Event Event{UserEvents::ADVANCE};
Event.user.data1 = &Snake;
SDL_PushEvent(&Event);
}
// Direction the snake will move in when the
// game state next advances
MovementDirection NextDirection{Right};
// ...
};
Over in Cell.h
, let’s react to our game state advancing, and therefore our snake moving. Within our HandleEvent()
function, we’ll check if the event has the UserEvents::ADVANCE
type and, if it does, we’ll pass it off to a new private Advance()
method:
// Cell.h
// ...
class Cell {
public:
void HandleEvent(SDL_Event& E) {
using namespace UserEvents;
if (E.type == ADVANCE) {
Advance(E.user);
}
}
// ...
private:
void Advance(SDL_UserEvent& E) {
// TODO - Update Cell
}
// ...
};
Within our Advance()
method, we first need to understand if the snake advanced into this cell. To do this, we can first retrieve the SnakeData
pointer that our GameState
attaches to all UserEvents::ADVANCE
events:
// Cell.h
// ...
#include "SnakeData.h"
// ...
class Cell {
// ...
private:
void Advance(SDL_UserEvent& E) {
SnakeData* Data{static_cast<SnakeData*>(
E.data1)};
// TODO - Update Cell
}
// ...
};
By comparing the HeadRow
and HeadCol
of the SnakeData
to the Row
and Column
of the cell reacting to the event, we can understand if the snake has advanced into this cell. If it has, we’ll update the CellState
to Snake
:
// 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) {
CellState = Snake;
}
}
};
If we run our game, we should now notice our snake continuously growing to the right, expanding by one cell every time our Config::ADVANCE_INTERVAL
elapses:
We don’t want our snake growing to the right - rather, we want it moving to the right. To do this, we’ll need to remove snake segments from the tail.
When a snake moves in our game, we need to both add new segments at the head and remove old segments from the tail. We'll implement this using a duration system where each cell keeps track of how long it should remain part of the snake.
Let's add a SnakeDuration
counter to our Cell
class. We’ll initialize this to 0
for now, but we’ll update its value later:
// Cell.h
// ...
class Cell {
// ...
private:
// ...
// How many more advances this cell
// remains part of the snake
int SnakeDuration{0};
};
This counter works as follows:
This system handles the snake's movement:
Let's implement this logic in our Advance()
function:
// 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) {
CellState = Snake;
} else if (CellState == Snake) {
--SnakeDuration;
if (SnakeDuration == 0) {
CellState = Empty;
}
}
}
};
Finally, we need to set SnakeDuration
to appropriate values any time a cell becomes a Snake
segment. In Advance()
, if the snake advances into our cell, we know how long the cell needs to remain a snake segment by looking at the overall length of the snake:
// 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) {
CellState = Snake;
SnakeDuration = Data->Length;
} else if (CellState == Snake) {
--SnakeDuration;
if (SnakeDuration == 0) {
CellState = Empty;
}
}
}
};
When our game is initialized, we start with a two-segment snake. We need to initialize the SnakeDuration
for those cells too, with the snake’s head segment having an initial duration of 2
and the tail segment having a duration of 1
. All other cells should have a duration of 0
:
// Cell.h
// ...
class Cell {
// ...
private:
// ...
void Initialize() {
CellState = Empty;
SnakeDuration = 0;
int InitialRow{Config::GRID_ROWS / 2};
if (Row == InitialRow && Column == 2) {
CellState = Snake;
SnakeDuration = 1;
} else if (Row == InitialRow && Column == 3) {
CellState = Snake;
SnakeDuration = 2;
} else if (Row == InitialRow && Column == 11) {
CellState = Apple;
}
}
};
Running our program, our snake should now move right:
As the final step in this section, let’s allow the player to change the snake’s direction using the arrow keys or the WASD keys. Our GameState
is already receiving keyboard events - we just need to react to them appropriately.
Let’s update HandleEvent()
to check for SDL_KEYDOWN
events, and forward them to a new HandleKeyEvent()
private method:
// GameState.h
// ...
class GameState {
public:
// ...
void HandleEvent(SDL_Event& E) {
if (E.type == SDL_KEYDOWN) {
HandleKeyEvent(E.key);
}
}
private:
// ...
void HandleKeyEvent(SDL_KeyboardEvent& E) {}
};
Our HandleKeyEvent()
will check for arrow or WASD keypresses, and then update the NextDirection
variable as appropriate.
We don’t want our snake to turn 180 degrees in a single step. For example, if the snake is currently moving Right
it won’t be able to change its direction to Left
in a single advance. It can only turn Up
or Down
, or continue moving Right
.
We’ll implement these restrictions using a set of if
statements:
// GameState.h
// ...
class GameState {
// ...
private:
void HandleKeyEvent(SDL_KeyboardEvent& E) {
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 (Snake.Direction != Left) {
NextDirection = Right;
}
break;
}
}
// ...
};
If we run our game, we should now be able to move our snake around:
Our snake’s movement will feel quite jolted as we’re only updating its visual position by a large step every time the game state advances, rather than by a small step on every frame.
We may prefer this jolted movement for the more retro feel, but later in the chapter we’ll demonstrate how to make the snake’s movement feel smoother by updating it on every frame.
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)};
}
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 GRID_HEIGHT{
CELL_SIZE * GRID_ROWS
};
inline constexpr int GRID_WIDTH{
CELL_SIZE * GRID_COLUMNS};
inline constexpr int WINDOW_HEIGHT{
GRID_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 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
enum MovementDirection { Up, Down, Left, Right };
struct SnakeData {
int HeadRow;
int HeadCol;
int Length;
MovementDirection Direction;
};
#pragma once
#include <SDL.h>
#include "GameConfig.h"
#include "SnakeData.h"
class GameState {
public:
void HandleEvent(SDL_Event& E) {
if (E.type == SDL_KEYDOWN) {
HandleKeyEvent(E.key);
}
}
void Tick(Uint32 DeltaTime) {
ElapsedTime += DeltaTime;
if (ElapsedTime >= Config::ADVANCE_INTERVAL) {
ElapsedTime = 0;
UpdateSnake();
}
}
private:
void HandleKeyEvent(SDL_KeyboardEvent& E) {
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 (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;
}
SDL_Event Event{UserEvents::ADVANCE};
Event.user.data1 = &Snake;
SDL_PushEvent(&Event);
}
Uint32 ElapsedTime{0};
SnakeData Snake{
Config::GRID_ROWS / 2, 3, 2, Right};
MovementDirection NextDirection{Right};
};
#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},
BackgroundRect{
Column * Config::CELL_SIZE + Config::PADDING,
Row * Config::CELL_SIZE + Config::PADDING,
Config::CELL_SIZE, Config::CELL_SIZE},
BackgroundColor {
(Row + Column) % 2 == 0
? Config::CELL_COLOR_A
: Config::CELL_COLOR_B}
{
Initialize();
}
void HandleEvent(SDL_Event& E) {
using namespace UserEvents;
if (E.type == ADVANCE) {
Advance(E.user);
}
}
void Tick(Uint32 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 (CellState == Snake) {
SDL_FillRect(Surface, &BackgroundRect,
SDL_MapRGB(
Surface->format,
Config::SNAKE_COLOR.r,
Config::SNAKE_COLOR.g,
Config::SNAKE_COLOR.b
)
);
}
}
private:
void Initialize() {
CellState = Empty;
SnakeDuration = 0;
int MiddleRow{Config::GRID_ROWS / 2};
if (Row == MiddleRow && Column == 2) {
CellState = Snake;
SnakeDuration = 1;
} else if (Row == MiddleRow && Column == 3) {
CellState = Snake;
SnakeDuration = 2;
} else if (Row == MiddleRow && Column == 11) {
CellState = Apple;
}
}
void Advance(SDL_UserEvent& E) {
SnakeData* Data{static_cast<SnakeData*>(
E.data1)};
bool isThisCell{
Data->HeadRow == Row &&
Data->HeadCol == Column
};
if (isThisCell) {
CellState = Snake;
SnakeDuration = Data->Length;
} else if (CellState == Snake) {
--SnakeDuration;
if (SnakeDuration == 0) {
CellState = Empty;
}
}
}
int Row;
int Column;
CellState CellState;
int SnakeDuration;
SDL_Rect BackgroundRect;
SDL_Color BackgroundColor;
Assets& Assets;
};
#include <SDL.h>
#include <SDL_image.h>
#include <SDL_ttf.h>
#include "Engine/Window.h"
#include "GameState.h"
#include "GameUI.h"
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
CheckSDLError("Initializing SDL");
IMG_Init(IMG_INIT_PNG);
CheckSDLError("Initializing SDL_image");
TTF_Init();
CheckSDLError("Initializing SDL_ttf");
Window GameWindow{};
GameState State{};
GameUI UI{};
Uint32 PreviousTick{SDL_GetTicks()};
Uint32 CurrentTick;
Uint32 DeltaTime;
SDL_Event Event;
while (true) {
CurrentTick = SDL_GetTicks();
DeltaTime = CurrentTick - PreviousTick;
// Events
while (SDL_PollEvent(&Event)) {
UI.HandleEvent(Event);
State.HandleEvent(Event);
if (Event.type == SDL_QUIT) {
SDL_Quit();
IMG_Quit();
return 0;
}
}
// Tick
State.Tick(DeltaTime);
UI.Tick(DeltaTime);
// Render
GameWindow.Render();
UI.Render(GameWindow.GetSurface());
// Swap
GameWindow.Update();
PreviousTick = CurrentTick;
}
return 0;
}
Files not listed above have not been changed since the previous section.
In this lesson, we built the core movement mechanics for our snake game. We implemented a state-based game system that manages the snake's position, handles user input, and updates the game at regular intervals. The key steps we took include:
GameState
classSDL_GetTicks()
Learn how to implement core snake game mechanics including movement, controls, and state management
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games