Snake Movement and Navigation

Learn how to implement core snake game mechanics including movement, controls, and state management
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

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.

Adding a SnakeData Struct

Let’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;
};

Alternative Implementations

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.

Adding a GameState Class

When 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:

  • Its head will be on the middle row, ie CONFIG::GRID_ROWS / 2
  • Its head will be on column 3
  • Its length will be 2
  • It will be moving Right

Let’s initialize our SnakeData accordingly:

// GameState.h
// ...
#include "SnakeData.h"

class GameState {
  // ...
private:
  SnakeData Snake{
    Config::GRID_ROWS / 2, 3, 2, Right};
};

Designated Initialization

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

Advancing the Game State

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

Why do we Need to Tick?

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

Rendering the Moving Snake

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:

snake3.png

Screenshot of the snake stretching towards the apple

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.

Managing Snake Segments

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:

  1. When a cell becomes the snake's head, we set its duration to the snake's total length
  2. Every game advance decrements the duration of all snake cells
  3. When a cell's duration reaches zero, it reverts to an empty cell

This system handles the snake's movement:

  • New head segments get the full snake length as their duration
  • Tail segments naturally disappear when their duration expires
  • The snake maintains its length as it moves

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:

snake4.png

Screenshot of the snake moving towards the apple

Turning the Snake

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:

snake5.png

Screenshot of the snake changing movement direction

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 Code

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.

Summary

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:

  • Game state management using a dedicated GameState class
  • Event-based movement control using SDL keyboard events
  • Snake segment tracking using a duration-based system
  • Frame-independent game updates using SDL_GetTicks()
  • Custom event handling for game state changes
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

    64.
    Snake Movement and Navigation

    Learn how to implement core snake game mechanics including movement, controls, and state management


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