Tick Rate and Time Deltas

Learn how to create smooth, time-aware game loops that behave consistently across different hardware configurations

Ryan McCombe
Updated

In the previous lesson, we introduced the concept of ticking and tick functions, which allow our game objects to update on every iteration of our application loop.

However, we should note that when we're implementing logic in a Tick() function, we don't know how quickly our objects will tick. That is, we do not know how much time has passed since the previous invocation of that object's Tick() function.

// Goblin.h
#pragma once
#include "GameObject.h"

class Goblin : public GameObject {
 public:
  int xPosition;

  void Tick() override {
    // This object moves by one pixel per
    // invocation of Tick(), but how frequently
    // is Tick() invoked?
    xPosition += 1;
  }
};

As we add more complexity to our tick functions or more ticking objects, our game will need to perform more work on each iteration of our application loop. As such, it will iterate less frequently, meaning Tick() is invoked less frequently, meaning our object will move slower.

On the other hand, if we perform optimizations or our user has a more powerful computer, our loop can iterate faster, causing our objects to move faster.

To address this inconsistency, we typically want to define properties like movement speed in terms of real-world units of time, such as seconds or milliseconds. Let's learn how to do this.

Starting Point

This lesson builds on the concepts from the previous lesson, where we set up a basic World and GameObject hierarchy.

Our starting point includes the Window, GameObject, World, and Goblin classes, with a single Goblin spawned in our main() function.

Compared to the last lesson, we've also updated our x and y position values from integers to floating point numbers, which is more common when implementing time deltas:

Files

src
Select a file to view its content

Using SDL_GetTicks()

The SDL_GetTicks() function returns a 64-bit unsigned integer, representing the number of milliseconds that have passed since SDL was initialized. Let's call it on every iteration of our application loop:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Event Event;
  bool IsRunning{true};

  while (IsRunning) {
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_EVENT_QUIT) {
        IsRunning = false;
      }
    }

    std::cout << "Milliseconds Passed: "
      << SDL_GetTicks() << '\n';
  }

  SDL_Quit();
  return 0;
}

The value returned from this function will increase over time, although subsequent calls may return the same value if invoked in quick succession:

Milliseconds Passed: 0
Milliseconds Passed: 1
Milliseconds Passed: 1
Milliseconds Passed: 2
...

Time Deltas

To understand how many milliseconds have passed between each iteration of our application loop, we need to compare the value returned by SDL_GetTicks() on this iteration to the value it returned in the previous iteration:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Event Event;
  bool IsRunning{true};
  Uint64 PreviousFrame{0};

  while (IsRunning) {
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_EVENT_QUIT) {
        IsRunning = false;
      }
    }

    Uint64 ThisFrame{SDL_GetTicks()};
    std::cout << "Time Delta: "
      << ThisFrame - PreviousFrame << '\n';
    PreviousFrame = ThisFrame;
  }

  SDL_Quit();
  return 0;
}

This value is often called a time delta. The Greek letter delta - δ\delta or Δ\Delta - is often used in maths and physics to represent change. As such, a time delta is how much time has passed between two points - in this case, two calls to SDL_GetTicks().

Given this program is very simple, our application loop can complete many iterations per millisecond. So, even though some time has passed, the time delta when expressed in milliseconds will often be 0:

Time Delta: 0
Time Delta: 1
Time Delta: 0
Time Delta: 0

We cover high-resolution clocks with sub-millisecond accuracy later in this chapter. Real-world interactive applications typically aim to have their loops iterate dozens or hundreds of times per second, meaning time deltas will be in the region of 5-50 milliseconds.

Using Time Deltas in Tick Functions

Now that we have calculated the time delta in our application loop, we need to give our objects access to that value so they can use it in their Tick() logic.

The easiest and most common approach is to simply provide it as a parameter. Let's update our GameObject base class to include this argument:

src/GameObject.h

#pragma once
#include <SDL3/SDL.h>

class GameObject {
 public:
  virtual void HandleEvent(const SDL_Event& E) {}
  virtual void Tick(float TimeDelta) {}
  virtual void Render(SDL_Surface* Surface) {}
  virtual ~GameObject() = default;
};

We'll also need to update the class that manages all of our GameObject instances to pass it through:

src/World.h

#pragma once
#include <vector>
#include <memory>
#include <string>
#include "GameObject.h"
#include "Goblin.h"

class World {
 public:
  void HandleEvent(const SDL_Event& E) {
    for (auto& Object : Objects) {
      Object->HandleEvent(E);
    }
  }

  void Tick(float TimeDelta) {
    for (auto& Object : Objects) {
      Object->Tick(TimeDelta);
    }
  }

  void Render(SDL_Surface* Surface) {
    for (auto& Object : Objects) {
      Object->Render(Surface);
    }
  }

  Goblin& SpawnGoblin(
    const std::string& Name, int x, int y
  ) {
    Objects.emplace_back(
      std::make_unique<Goblin>(Name, x, y)
    );
    return static_cast<Goblin&>(*Objects.back());
  }

 private:
  std::vector<std::unique_ptr<GameObject>> Objects;
};

Finally, let's pass it through as an argument from our application loop, but it can be helpful to convert the time deltas to a more intuitive unit first.

Converting Time Deltas to Seconds

For most objects, receiving the time delta in terms of seconds will be more intuitive than working with milliseconds. We can do this by updating our parameter type to be a floating-point number, and dividing the millisecond value by 1000.0 before passing it as an argument:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include "Window.h"
#include "World.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;
  World GameWorld;
  GameWorld.SpawnGoblin("Goblin Rogue", 100, 200);

  bool IsRunning = true;
  SDL_Event Event;
  Uint64 PreviousFrame{0};

  while (IsRunning) {
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_EVENT_QUIT) {
        IsRunning = false;
      }
      GameWorld.HandleEvent(Event);
    }

    Uint64 ThisFrame{SDL_GetTicks()};
    Uint64 TimeDelta{ThisFrame - PreviousFrame};
    PreviousFrame = ThisFrame;
    GameWorld.Tick(TimeDelta / 1000.0f);

    GameWindow.Render();
    GameWorld.Render(GameWindow.GetSurface());
    GameWindow.Update();
  }

  SDL_Quit();
  return 0;
}

Finally, let's update our Goblin to move with a Velocity of 100 units per second:

src/Goblin.h

#pragma once
#include <string>
#include <iostream>
#include "GameObject.h"

class Goblin : public GameObject {
 public:
  Goblin(const std::string& name, int x, int y)
    : Name(name), xPosition(x), yPosition(y) {}

  void Tick(float TimeDelta) override {
    xPosition += Velocity * TimeDelta;
    std::cout << "Goblin position: "
      << xPosition << '\n';
  }

  std::string Name;
  float xPosition;
  float yPosition;
  float Velocity{100.0f};
};
Goblin position: 100
Goblin position: 100.1
Goblin position: 100.1
Goblin position: 100.2
...

Remember, if our application completes two iterations of its main loop within the same millisecond, our time delta can be 0 milliseconds, or 0.0 seconds after our conversion. Any logic we build should consider that possibility. For example, if we try to divide something by the time delta, and it has a value of 0, we will run into problems.

Capping Tick Rate

In some scenarios, we may find it valuable to limit how fast our application loop (or any other loop) can iterate.

The SDL_Delay() function accepts an integer argument and will wait for at least that many milliseconds before returning:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);

  while (true) {
   std::cout << "Ticking ("
      << SDL_GetTicks() << ")\n";

    SDL_Delay(1000);
  }

  SDL_Quit();
  return 0;
}
Ticking (6)
Ticking (1007)
Ticking (2008)
Ticking (3008)

We can use this to limit the run rate of a loop using the following recipe. If the DoWork() part of our loop body completes in under 1,000ms, we use SDL_Delay() to extend the duration of our iteration.

SDL_Delay() expects a Uint32, whilst SDL_GetTicks() returns a Uint64. We can safely cast our Uint64 to a Uint32 in this scenario, as our delays will be relatively small numbers in this context - no larger than 1000 milliseconds:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>

void DoWork() {
  // ...
}

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);

  while (true) {
    Uint64 StartTimer{SDL_GetTicks()};
    std::cout << "Ticking ("
      << SDL_GetTicks() << ")\n";

    DoWork();

    Uint64 EndTimer{SDL_GetTicks()};
    Uint64 TimeElapsed{EndTimer - StartTimer};
    if (TimeElapsed <= 1000) {
      SDL_Delay(1000 - Uint32(TimeElapsed));
    }
  }

  SDL_Quit();
  return 0;
}
Ticking (9)
Ticking (1010)
Ticking (2010)
Ticking (3011)

Let's use it to ensure each iteration of our application loop takes at least 10 milliseconds to complete, thereby ensuring our tick rate doesn't exceed 100 ticks per second:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include "Window.h"
#include "World.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;
  World GameWorld;
  GameWorld.SpawnGoblin("Goblin Rogue", 100, 200);

  bool IsRunning = true;
  SDL_Event Event;
  Uint64 PreviousFrame{0};

  while (IsRunning) {
    Uint64 StartTimer{SDL_GetTicks()};

    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_EVENT_QUIT) {
        IsRunning = false;
      }
      GameWorld.HandleEvent(Event);
    }

    // Update Objects
    Uint64 ThisFrame{SDL_GetTicks()};
    Uint64 TimeDelta{ThisFrame - PreviousFrame};
    PreviousFrame = ThisFrame;
    GameWorld.Tick(TimeDelta / 1000.0f);

    // Render
    GameWindow.Render();
    GameWorld.Render(GameWindow.GetSurface());
    GameWindow.Update();

    // Capping Frame Rate
    Uint64 EndTimer{SDL_GetTicks()};
    Uint64 TimeElapsed{EndTimer - StartTimer};
    if (TimeElapsed < 10) {
      SDL_Delay(10 - Uint32(TimeElapsed));
    }
  }

  SDL_Quit();
  return 0;
}

Time Drift

SDL_Delay() causes our application to pause for at least the number of milliseconds we pass as an argument. Because of nuances in how work is scheduled, the delay will always be slightly longer.

We can see this in previous examples, where a SDL_Delay(1000) invocation often causes a delay of 1,001 milliseconds:

Ticking (6)
Ticking (1007)
Ticking (2008)
Ticking (3008)

These inaccuracies are not a problem for most use cases, but it does mean we should avoid using SDL_Delay() or similar techniques to keep track of time.

For example, if we expect a function to be called every 1.000 second, but it's usually being called every 1.001 seconds, these millisecond delays accumulate to significant inaccuracies over longer periods of time. This is sometimes referred to as time drift or clock drift.

Our on std::chrono provides techniques to compensate for this.

Complete Code

A complete version of the architecture we set up in this lesson is available below:

Files

src
Select a file to view its content

Summary

This lesson covered the core time-tracking techniques we need for games and other real-time programs. Here are the key points:

  • Using SDL_GetTicks() to measure elapsed time.
  • Calculating time deltas between frames to achieve frame-rate independent animation.
  • Implementing time-based movement for game objects.
  • Capping tick rates for consistent performance across different hardware.

By applying these concepts, you can create games that run smoothly and predictably, regardless of the user's hardware.

Next Lesson
Lesson 40 of 52

High-Resolution Timers

Learn to measure time intervals with high accuracy in your games

Have a question about this lesson?
Answers are generated by AI models and may not be accurate