Animating Snake Movement
Learn to animate the snake's movement across cells for a smooth, dynamic visual effect.
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:
- Creating a different
SDL_Rect
member to store what part of our cell should be filled by the snake color - Updating the values of this
SDL_Rect
on every frame (ie, on everyTick()
invocation) to create the sliding animation effect
Let's work through this step by step.
Adding 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
)
);
}
}
// ...
};
Adding FillPercent
and FillDirection
To control the effect of the snake sliding across our cell, we'll need two new member variables:
- A
FillPercent
variable, controlling how far the snake has slid across our cell - A
FillDirection
variable, controlling which direction the snake needs to slide
Think 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 = 0.0: The cell is entirely the background color. The snake hasn't entered yet.
- FillPercent = 1.0: The cell is entirely the snake color. The snake completely fills the cell.
- FillPercent = 0.5, FillDirection = Right: The snake is entering from the left. The left half of the cell is the snake color, and the right half is the background color.
- FillPercent = 0.25, FillDirection = Down: The snake is entering from the top. The top quarter of the cell is the snake color, and the bottom three-quarters are the background color.
Using 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
)
);
}
}
// ...
};
Using 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;
}
}
}
// ...
};
Animating the Snake's Head
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
.
- To fill the cell from left to right, we scale the width (
w
) of the snake rectangle by ourFillPercent
- To fill the cell from top to bottom, we scale the height (
h
) of the snake rectangle based on ourFillPercent
- To fill the cell from right to left, we move the horizontal position (
x
) of our snake rectangle. Sincex
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 startingx
position by addingBackgroundRect.x
(the left edge of the cell) to the remaining portion of the cell width, which isCELL_SIZE * (1 - FillPercent)
. - To fill the cell from bottom to top, we move the vertical position (
y
) of our snake rectangle. Sincey
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 startingy
position by addingBackgroundRect.y
(the top edge of the cell) to the remaining portion of the cell height, which isCELL_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.
Animating the Snake's Tail
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;
}
}
}
// ...
};
Updating Tick()
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()
:
- We use the
DeltaTime
andADVANCE_INTERVAL
to decrease theFillPercent
- If this causes the
FillPercent
to fall below0
, we increase it back to0
.
Our SnakeRect
calculations are the same as they were in GrowHead()
, but in the opposite direction:
- The
FillDirection == Right
logic inShrinkTail()
is the same as theFillDirection == Left
logic inGrowHead()
- The
FillDirection == Left
logic inShrinkTail()
is the same as theFillDirection == Right
logic inGrowHead()
- The
FillDirection == Top
logic inShrinkTail()
is the same as theFillDirection == Bottom
logic inGrowHead()
- The
FillDirection == Bottom
logic inShrinkTail()
is the same as theFillDirection == Top
logic inGrowHead()
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);
}
}
// ...
};
Complete Code
Our final Cell.h
code is available below:
The remaining project files (which were not changed in this section) are available here:
Rubik-SemiBold.tff
from Google Fontsapple.png
by AomAm on IconFinder
Improvement Ideas
Congratulations 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.
User-Defined Game Settings
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:
- Adjust Grid Size: Let the player choose between different grid dimensions (e.g., small, medium, large). This will affect the game's difficulty and play area.
- Change Snake Speed: Offer options to control how quickly the snake moves (e.g., slow, normal, fast). This can significantly alter the game's challenge.
- Customize Colors: Allow the player to personalize the snake's color, the background, or the cell colors.
Implementing these features will require you to:
- Create UI elements to represent the settings.
- Handle user input to change the settings.
- Store the selected settings (perhaps in a separate GameSettings class).
- Modify your game logic to use these settings instead of hardcoded values.
Input Queue
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.
Reducing Event Traffic
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.
Refactoring
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.
Adding 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:
Constants and const
-Correctness
Learn the intricacies of using const
and how to apply it in different contexts
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);
}
// ...
};
Adding 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:
Static Class Variables and Functions
A guide to sharing values between objects using static class variables and functions
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;
// ...
};
Friends and private
Constructors
Finally, 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:
Friend Classes and Functions
An introduction to the friend
keyword, which allows classes to give other objects and functions enhanced access to its members
Summary
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
to1.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.
Video Displays
Learn how to handle multiple monitors in SDL, including creating windows on specific displays.