SDL2 Timers and Callbacks

Learn how to use callbacks with SDL_AddTimer() to provide functions that are executed on time-based intervals
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

Free, Unlimited Access
Abstract art representing programming
Ryan McCombe
Ryan McCombe
Posted

In this lesson, we’ll introduce SDL’s timer mechanisms, which interact with function pointers to implement commonly required functionality. The capabilities this unlocks primarily fall into two categories:

  • Performing an action after a delay, such as restricting the use of an ability until some time has passed
  • Performing an action on a repeating interval, such as spawning a new enemy every few seconds

To use SDL timers, we should pass the SDL_INIT_TIMER flag to SDL_Init():

SDL_Init(SDL_INIT_TIMER);

We can combine multiple initialization flags using the bitwise | operator:

SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER);

For this lesson, let’s imagine we have some EnemySpawner object, responsible for adding enemy monsters into the world.

As a starting point, we’ll create a basic application loop that forwards keydown events to a Spawner object:

// main.cpp
#include <SDL.h>
#include <iostream>
#include "Window.h"
#include "Spawner.h"

int main(int argc, char** argv) {
  SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER);
  Window GameWindow;
  Spawner EnemySpawner;

  SDL_Event Event;
  bool shouldContinue{true};
  while (shouldContinue) {
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_QUIT) {
        shouldContinue = false;
      } else if (Event.type == SDL_KEYDOWN) {
        EnemySpawner.HandleKeyDownEvent(Event.key);
      }
    }

    GameWindow.Update();
  }

  SDL_Quit();
  return 0;
}

Our Spawner class currently looks like this, which we’ll expand with timers throughout this lesson.

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

class Spawner {
 public:
  void HandleKeyDownEvent(SDL_KeyboardEvent& E) {
    std::cout << "Button Pressed\n";
  }
};

After running our program and pressing some keyboard buttons, we should see our EnemySpawner responding to events:

Button Pressed
Button Pressed

SDL_AddTimer()

SDL_AddTimer() is the main way we create timers. It requires three arguments - an unsigned integer representing a time delay in milliseconds, a function pointer we want to call after that delay, and a void pointer (void*).

To create a timer that invokes SpawnGoblin() after 1,000 milliseconds (one second), our call would look like the following. We’ll discuss what the void pointer is for later - for now, let’s just pass a nullptr there:

SDL_AddTimer(1000, SpawnGoblin, nullptr);

Our callback should return a Uint32 and accept Uint32 and void* arguments. We’ll discuss what these values represent later - for now, let’s just ignore the parameters and return 0:

Uint32 SpawnGoblin(Uint32, void*) {
  std::cout << "Spawning a Goblin\n";
  return 0;
}

Let’s make our Spawner objects invoke SpawnGoblin() one second (1,000 milliseconds) after the space bar is pressed:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {/*...*} class Spawner { public: void HandleKeyDownEvent( SDL_KeyboardEvent& E ) { if (E.keysym.sym == SDLK_SPACE) { StartTimer(); } } void StartTimer() { SDL_AddTimer(1000, SpawnGoblin, nullptr); } };

Running our program and pressing the spacebar should generate the expected output after a second:

Spawning a Goblin

Invoking Callbacks on an Interval

Our previous example invoked the callback a single time, but SDL_AddTimer() allows us to call a function repeatedly based on some time interval.

The value returned by our SpawnGoblin() callback lets us specify the delay until the function is called again, or we can return 0 if we don’t want it to be called again.

Let’s update our program to call SpawnGoblin() every second, by having the function return 1000:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {
  std::cout << "Spawning a Goblin\n";
  return 1000; 
}

class Spawner {/*...*/};

Now, every time we press the spacebar, we kick off an endless sequence that spawns a goblin every second:

Spawning a Goblin
Spawning a Goblin
Spawning a Goblin

Repeated presses of the spacebar will cause multiple timers to run concurrently. We can prevent that if needed:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {/*...*} class Spawner { public:
void HandleKeyDownEvent(SDL_KeyboardEvent&) void StartTimer() { if (isSpawning) { std::cout << "Already spawning" " - ignoring event\n"; } else { std::cout << "Starting Spawner\n"; SDL_AddTimer(1000, SpawnGoblin, nullptr); isSpawning = true; } } private: bool isSpawning{false}; };
Starting Spawner
Already spawnining - ignoring event
Spawning a Goblin
Spawning a Goblin

Getting the Current Interval

The Uint32 argument passed to our callback represents the current value of the interval. As such, we can improve our previous implementation by returning this value, rather than fixing it to 1000:

Uint32 SpawnGoblin(Uint32 Interval, void*) {
  std::cout << "Spawning a Goblin\n";
  return Interval; 
}

Of course, we can use this parameter in any way we want - we don’t just have to return it. In the following example, we halve the interval on every invocation, causing our Goblins to spawn faster over time, capping at every 200 milliseconds:

Uint32 SpawnGoblin(Uint32 Interval, void*) {
  std::cout << "Spawning a Goblin - Interval: "
    << Interval << '\n';

  Uint32 NewInterval{Interval * 0.5}; 
  return std::max(NewInterval, Uint32(200)); 
}
Starting Spawner
Spawning a Goblin - NewInterval: 1000
Spawning a Goblin - NewInterval: 500
Spawning a Goblin - NewInterval: 250
Spawning a Goblin - NewInterval: 200
Spawning a Goblin - NewInterval: 200

Passing Arbitrary Data to the Callback

The third argument to SDL_AddTimer() allows us to pass any arbitrary data to the callback function. The callback receives this as its second argument:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32 Interval, void* Ptr) {
  std::cout << "Receiving Address " << Ptr << '\n';
  return 0;
}

class Spawner {
public:
void HandleKeyDownEvent(SDL_KeyboardEvent&) void StartTimer() { std::cout << "Passing Address " << &SomeValue << '\n'; SDL_AddTimer(1000, SpawnGoblin, &SomeValue); } private: int SomeValue; };
Passing Address   000000505BEFFCC0
Receiving Address 000000505BEFFCC0

As usual, we need to static_cast the void pointer to the correct type before we can use it. One of our previous examples invoked our callback on a decreasing interval, down to a minimum value of 200 milliseconds.

Let’s update this to allow the caller to specify this minimum value through the void pointer:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(
  Uint32 CurrentDelay,
  void* MinimumIntervalPtr 
) {
  std::cout << "Spawning a Goblin - Delay: "
    << CurrentDelay << '\n';

  Uint32 NextDelay{CurrentDelay * 0.5};

  Uint32* MinimumInterval{
    static_cast<Uint32*>(MinimumIntervalPtr)};  

  return std::max(NextDelay, *MinimumInterval);  
}

class Spawner {
public:
void HandleKeyDownEvent(SDL_KeyboardEvent&) void StartTimer() { if (isSpawning) { std::cout << "Already spawning" " - ignoring event\n"; } else { std::cout << "Starting Spawner\n"; SDL_AddTimer(1000, SpawnGoblin, &MinInterval); isSpawning = true; } } private: bool isSpawning{false}; Uint32 MinInterval{300}; };
Starting Spawner
Spawning a Goblin - NewInterval: 1000
Spawning a Goblin - NewInterval: 500
Spawning a Goblin - NewInterval: 300
Spawning a Goblin - NewInterval: 300

Given the argument is a void pointer, it can point to any type of data we need, including custom data. We just need to update our static_cast call to specify the correct type.

Let's define a custom struct that provides both the interval multiplier and minimum value:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

struct SpawnerConfig {
  float IntervalMultiplier;
  Uint32 MinInterval;
};

Uint32 SpawnGoblin(
  Uint32 CurrentDelay,
  void* ConfigPtr 
) {
  std::cout << "Spawning a Goblin - Delay: "
    << CurrentDelay << '\n';

  SpawnerConfig* Config{
    static_cast<SpawnerConfig*>(ConfigPtr)};

  Uint32 NextDelay{static_cast<Uint32>(
    CurrentDelay * Config->IntervalMultiplier)};

  return std::max(NextDelay, Config->MinInterval);  
}

class Spawner {
public:
void HandleKeyDownEvent(SDL_KeyboardEvent&) void StartTimer() { if (isSpawning) { std::cout << "Already spawning" " - ignoring event\n"; } else { std::cout << "Starting Spawner\n"; SDL_AddTimer(1000, SpawnGoblin, &Config); isSpawning = true; } } private: bool isSpawning{false}; SpawnerConfig Config{0.8f, 600}; };
Starting Spawner
Spawning a Goblin - Delay: 1000
Spawning a Goblin - Delay: 800
Spawning a Goblin - Delay: 640
Spawning a Goblin - Delay: 600
Spawning a Goblin - Delay: 600

As usual, we should be careful with memory management here. Specifically, we should ensure the memory location being pointing at remains alive as long as the callback still needs it.

Error Checking and SDL_TimerID

The SDL_AddTimer() function returns a timer identifier, which is a simple integer aliased to SDL_TimerID. This value has two uses.

Firstly, we can store it and use it later to cancel the timer. We cover this in the next section

Secondly, we can examine its value to check if the timer was created successfully. If the value is 0, the creation of the timer failed, and we can call SDL_GetError() to find out why:

SDL_TimerID Timer{SDL_AddTimer(
  1000, SpawnGoblin, nullptr)};
  
if (!Timer) {
  std::cout << "Error creating timer: "
    << SDL_GetError();
}

Let’s update our Spawner objects to keep track of the SDL_TimerID they’re managing:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {/*...*} class Spawner { public:
void HandleKeyDownEvent(SDL_KeyboardEvent&) void StartTimer() { if (Timer) { std::cout << "Already spawning" " - ignoring event\n"; } else { std::cout << "Starting Spawner\n"; Timer = SDL_AddTimer( 1000, SpawnGoblin, nullptr); if (!Timer) { std::cout << "Error creating timer: " << SDL_GetError(); } } } SDL_TimerID Timer{0}; };

SDL_RemoveTimer()

If we need to cancel a timer, we call SDL_RemoveTimer(), and pass the SDL_TimerID that was returned by the SDL_AddTimer() call that we want to cancel.

Let’s update our program to allow a timer to be canceled using the escape key:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {/*...*} class Spawner { public: void HandleKeyDownEvent( SDL_KeyboardEvent& E ) { if (E.keysym.sym == SDLK_SPACE) { StartTimer(); } else if (E.keysym.sym == SDLK_ESCAPE) { RemoveTimer(); } }
void StartTimer() {/*...*/} void RemoveTimer() { std::cout << "Removing Timer\n"; SDL_RemoveTimer(Timer); Timer = 0; } SDL_TimerID Timer{0}; };
Starting Spawner
Spawning a Goblin
Spawning a Goblin
Spawning a Goblin
Removing Timer

It is safe to pass 0, or any other integer to this function. It will return true if it was able to find and remove the timer, or false if no timer was found with the provided ID:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

Uint32 SpawnGoblin(Uint32, void*) {/*...*} class Spawner { public:
void HandleKeyDownEvent(SDL_KeyboardEvent&)
void StartTimer() {/*...*/} void RemoveTimer() { std::cout << "Removing Timer: "; if(SDL_RemoveTimer(Timer)) { std::cout << "Timer with ID " << Timer << " Removed Successfully\n"; Timer = 0; } else { std::cout << "Timer with ID " << Timer << " was not found\n"; } } SDL_TimerID Timer{0}; };
Removing Timer: Timer with ID 0 was not found
Starting Spawner
Spawning a Goblin
Spawning a Goblin
Removing Timer: Timer with ID 1 Removed Successfully

If an object is managing a timer and it is no longer relevant after the object is destroyed, we should ensure we remove that timer in the destructor:

class Spawner {
public:
  // ...
  ~Spawner() {
    if (Timer) {
      SDL_RemoveTimer(Timer);
    }
  }
  
private:
  // ...
  SDL_TimerID Timer{0};
}

Example 1 - Interaction with the Event Queue

In many scenarios where we’re using a timer, the expiration of that timer represents some event that may be meaningful to other components within our game.

As such, objects that create timers will often also broadcast that expiration to our event system. The following example creates and pushes an ENEMY_SPAWN_EVENT to SDL’s event queue every time our timer expires:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>

// Custom event type for our timer
const Uint32 ENEMY_SPAWN_EVENT = SDL_RegisterEvents(1);

Uint32 QueueSpawnEvent(Uint32 Interval, void*) {
  SDL_Event SpawnEvent;
  SpawnEvent.type = ENEMY_SPAWN_EVENT;
  SDL_PushEvent(&SpawnEvent);
  return Interval;  // Continue spawning at same interval
}

class Spawner {
public:
  void HandleEvent(SDL_Event& E) {
    if (E.type == ENEMY_SPAWN_EVENT) {
      SpawnEnemy();
    } else if (E.type == SDL_KEYDOWN) {
      HandleKeyDownEvent(E.key);
    }
  }

  void HandleKeyDownEvent(SDL_KeyboardEvent& E) {
    if (E.keysym.sym == SDLK_SPACE) {
      StartTimer();
    } else if (E.keysym.sym == SDLK_ESCAPE) {
      RemoveTimer();
    }
  }

  void StartTimer() {
    if (Timer) {
      std::cout << "Already spawning - ignoring event\n";
    } else {
      std::cout << "Starting Spawner\n";
      Timer = SDL_AddTimer(1000, QueueSpawnEvent, nullptr);
      if (!Timer) {
        std::cout << "Error creating timer: " << SDL_GetError();
      }
    }
  }

  void SpawnEnemy() {
    std::cout << "Enemy spawned!\n";
  }

  void RemoveTimer() {
    if (SDL_RemoveTimer(Timer)) {
      std::cout << "Timer removed successfully\n";
      Timer = 0;
    }
  }

private:
  SDL_TimerID Timer{0};
};

Example 2 - Respawning Enemies

Our previous examples spawned enemies at some regular interval, meaning we have an endless stream of monsters being created. That may be what we want, a common alternative requirement is to respawn enemies some time after they have been defeated.

There are a few ways to set this up, but they commonly require the defeated object to notify the relevant spawner that it has been defeated.

This can be done through the event queue, or simply by having the enemy remember what spawner created it, and then invoking some function on that spawner at the appropriate time:

// Enemy.h
#pragma once
#include "Spawner.h"

class Enemy {
public:
  Enemy(Spawner* Owner) : MySpawner(Owner) {}
  
  void TakeDamage(int Amount) {
    Health -= Amount;
    if (Health <= 0) {
      Die();
    }
  }

private:
  void Die() {
    std::cout << "Enemy defeated!\n";
    MySpawner->ScheduleRespawn();
  }

  Spawner* MySpawner;
  int Health{100};
};

Our Spawner holds a std::unique_ptr to the Enemy it is managing. When that enemy calls its spawner’s ScheduleRespawn() method, the spawner will delete the enemy, and create a new one 10 seconds later:

// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>
#include <memory>
#include "Enemy.h"

class Spawner {
 public:
  void ScheduleRespawn() {
    // Delete the current enemy
    CurrentEnemy.reset();
    
    std::cout << "Scheduling respawn in 10s\n";
    Timer = SDL_AddTimer(
      10000, SpawnCallback, this);
  }

  static Uint32 SpawnCallback(
    Uint32, void* SpawnerPtr) {
    auto* Spawner{
      static_cast<class Spawner*>(SpawnerPtr)};
    Spawner->SpawnEnemy();
    return 0;  // Don't repeat
  }

  void SpawnEnemy() {
    CurrentEnemy = std::make_unique<Enemy>(this);
    std::cout << "Enemy Spawned!\n";
    Timer = 0;
  }

 private:
  SDL_TimerID Timer{0};
  std::unique_ptr<Enemy> CurrentEnemy;
};
Enemy Spawned!
Enemy Defeated!
Scheduling respawn in 10s
Enemy Spawned!

We start the Spawner by calling ScheduleRespawn():

Spawner MySpawner;
MySpawner.ScheduleRespawn();
Scheduling respawn in 10s
Enemy Spawned!

When the Enemy it is managing is defeated, it will automatically respawn it:

Enemy Defeated!
Scheduling respawn in 10s
Enemy Spawned!

Example 3 - Multiple Timers

Finally, let’s see a more advanced example. Below, our Spawner class is managing two timers:

  • A short timer called SpawnTimer that creates enemies on a short interval
  • A longer timer called DifficultyTimer, which changes the behavior of the spawner on a longer interval. Every 30 seconds, the spawner upgrades to spawn more powerful enemies at a shorter interval.
// Spawner.h
#pragma once
#include <SDL.h>
#include <iostream>
#include <vector>

struct DifficultyLevel {
  Uint32 SpawnInterval;
  int EnemyHealth;
  float EnemySpeed;
};

class Spawner {
public:
  Spawner() {
    // Configure difficulty progression
    Difficulty = {
      {2000, 100, 1.0f},
      {1500, 120, 1.2f},
      {1000, 150, 1.5f},
      {800,  200, 1.8f},
      {500,  250, 2.0f} 
    };
  }

  static Uint32 DifficultyCallback(
    Uint32 Interval, void* SpawnerPtr) {
    auto* Spawner = static_cast<
      class Spawner*>(SpawnerPtr);
    Spawner->IncreaseDifficulty();
    return Interval;
  }

  void Start() {
    // Start spawning enemies
    UpdateSpawnTimer();
    
    // Start difficulty scaling timer
    DifficultyTimer = SDL_AddTimer(30000, 
      DifficultyCallback, this);
  }

  void IncreaseDifficulty() {
    if (CurrentLevel < Difficulty.size() - 1) {
      CurrentLevel++;

      std::cout << "Difficulty increased to "
        "level " << CurrentLevel + 1 << "!\n";

      UpdateSpawnTimer();
    }
  }

private:
  void UpdateSpawnTimer() {
    if (SpawnTimer) {
      SDL_RemoveTimer(SpawnTimer);
    }

    // Start new timer with current difficulty
    auto& Level = Difficulty[CurrentLevel];
    SpawnTimer = SDL_AddTimer(
      Level.SpawnInterval, SpawnEnemy, this);
  }

  static Uint32 SpawnEnemy(
    Uint32 Interval, void* SpawnerPtr) {
    auto* Spawner{
      static_cast<class Spawner*>(SpawnerPtr)};

    auto& Level{Spawner->DifficultyLevels[
      Spawner->CurrentLevel]};
    
    std::cout << "Spawning enemy with:\n"
      << "  Health: " << Level.EnemyHealth << "\n"
      << "  Speed:  " << Level.EnemySpeed << "\n";
    
    return Interval;
  }

  std::vector<DifficultyLevel> Difficulty;
  size_t CurrentLevel{0};
  SDL_TimerID SpawnTimer{0};
  SDL_TimerID DifficultyTimer{0};
};

To start spawning enemies, we call the Start() method:

Spawner MySpawner;
MySpawner.Start();
Spawning enemy with:
  Health: 100
  Speed:  1
Spawning enemy with:
  Health: 100
  Speed:  1
...
Difficulty increased to level 2!
Spawning enemy with:
  Health: 120
  Speed:  1.2
Spawning enemy with:
  Health: 120
  Speed:  1.2

Summary

In this lesson, we've covered SDL's timer functionality and how it integrates with games. Let's recap what we've covered:

  • Creating timers using SDL_AddTimer()
  • Managing timer intervals for both one-time and repeating events
  • Passing data to timer callbacks using void pointers
  • Canceling timers with SDL_RemoveTimer()
  • Integrating timers with SDL's event system
  • Practical examples including enemy spawning and difficulty scaling

Was this lesson useful?

Next Lesson

Delegates and the Observer Pattern

An overview of the options we have for building flexible notification systems between game components
Abstract art representing computer programming
New: AI-Powered AssistanceAI Assistance

Questions and HelpNeed Help?

Get instant help using our free AI assistant, powered by state-of-the-art language models.

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

Free, Unlimited Access
Ticks, Timers and Callbacks
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

Free, unlimited access

This course includes:

  • 67 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Delegates and the Observer Pattern

An overview of the options we have for building flexible notification systems between game components
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved