Animating Snake Movement

Learn to animate the snake's movement across cells for a smooth, dynamic visual effect.
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Digital art showing a retro snake game
Ryan McCombe
Ryan McCombe
Posted

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!

Screenshot showing the final state of the snake game

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 every Tick() 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.

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;
      }
    }
  }
  // ...
};

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 our FillPercent
  • To fill the cell from top to bottom, we scale the height (h) of the snake rectangle based on our FillPercent
  • To fill the cell from right to left, we move the horizontal position (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).
  • To fill the cell from bottom to top, we move the vertical position (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.

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 and ADVANCE_INTERVAL to decrease the FillPercent
  • If this causes the 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:

  • The FillDirection == Right logic in ShrinkTail() is the same as the FillDirection == Left logic in GrowHead()
  • The FillDirection == Left logic in ShrinkTail() is the same as the FillDirection == Right logic in GrowHead()
  • The FillDirection == Top logic in ShrinkTail() is the same as the FillDirection == Bottom logic in GrowHead()
  • The 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);
    }
  }
  // ...
};

Complete Code

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
)

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.

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;
  // ...
};

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:

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 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.
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Ryan McCombe
Ryan McCombe
Posted
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Get Started for Free
Project: Snake

    69.
    Animating Snake Movement

    Learn to animate the snake's movement across cells for a smooth, dynamic visual effect.


sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

This course includes:

  • 84 Lessons
  • 92% Positive Reviews
  • Regularly Updated
  • Help and FAQs
Free and Unlimited Access

Professional C++

Unlock the true power of C++ by mastering complex features, optimizing performance, and learning expert workflows used in professional development

Screenshot from Warhammer: Total War
Screenshot from Tomb Raider
Screenshot from Jedi: Fallen Order
Contact|Privacy Policy|Terms of Use
Copyright © 2025 - All Rights Reserved