SDL3 Main Callbacks

An introduction to SDL3's main callback pattern - an alternative to the traditional application loop for smoother platform integration.

Ryan McCombe
Published

So far in this course, we've consistently used a specific structure for our main application loop:

  • an initialization phase that runs when our app starts
  • an outer application loop for implementing per-frame object updates
  • an inner event loop for handling events
  • a final cleanup phase that runs before our app quits

Conceptually, we might imagine these four blocks of logic as being functions, coordinated by an overall main function looking something like this:

int main(int, char**) {
  AppInit();

  bool IsRunning = true;
  SDL_Event Event;
  while (IsRunning) {
    while (SDL_PollEvent(&Event)) {
      AppEvent(Event);
    }

    AppIterate();
  }

  AppQuit();
  return 0;
}

This is the traditional and most direct way to control a real time application. However, some platforms, including some platforms that SDL3 supports, do not give us this much control.

If we were building for web, for example, we don't get any control over the application loop. The browser manages it - we just provide a callback (eg, our AppIterate() function) that the browser calls every time its ready for a new frame.

This adds some friction when developing cross-platform programs. If our program is being compiled for desktop platforms, we need to provide a main() function. If it's being compiled for some other more restrictive platform, our program needs to be structured differently.

A new addition in SDL3 makes this simpler - it now supports the main callback pattern. Instead of writing the main function ourselves, we provide SDL with a set of callbacks (such as function pointers) that it will use to set up our program in the way the target platform requires.

If we're compiling for a desktop platform, SDL3 will reconstruct an appropriate main() function from these callbacks behind the scenes.

If we're compiling for some other platform SDL3 supports, it will use our callbacks to meet the requirements of that platform instead.

This lesson explores this alternative pattern. We'll refactor our existing project to use callbacks, discuss the advantages of this approach, and also consider its trade-offs.

Starting Point

If you want to follow along, we'll begin with the code from our previous lesson. It features a standard application loop in main.cpp that manages a World containing a single Goblin object.

Files

src
Select a file to view its content

The Main Callback Pattern

Instead of us managing the application loop, the callback pattern involves us handing control over to SDL. We do this by defining the SDL_MAIN_USE_CALLBACKS macro before we #include SDL, and then we define 4 specific functions:

  1. SDL_AppInit() for initialization
  2. SDL_AppEvent() for event handling
  3. SDL_AppIterate() for each iteration of our application loop
  4. SDL_AppQuit() for shutting down

We'll cover each of these functions in this lesson but, as an overview, a basic main.cpp that implements the pattern might look something like this:

#define SDL_MAIN_USE_CALLBACKS 1
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

SDL_AppResult SDL_AppInit(
  void** AppState, int, char**
) {
  // ...
  return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(
  void* AppState, SDL_Event* Event
) {
  // ...
  return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void* AppState) {
  // ...
  return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void* AppState, SDL_AppResult Result) {
  // ...
}

SDL will then take charge of our overall application flow, calling our functions at the appropriate moments in the application's lifecycle.

Managing Game State

A key challenge with this pattern is sharing state between our different callback functions. Variables that were previously local to main() (like GameWindow, GameWorld, and the PreviousFrame timer) now need to be accessible across multiple, separate functions.

The standard solution is to bundle all this shared state into a single struct or class. SDL will dutifully pass this pointer through each of our callbacks, giving them access to the shared state.

Let's create a GameState struct to hold everything we need:

src/main.cpp

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

struct GameState {
  Window GameWindow;
  World GameWorld;
  Uint64 PreviousFrame{0};
};

// ...

Next, let's add the four callback functions to our main.cpp.

Initialization - AppInit()

This function is called once, at the very beginning of our program. Its job is to set up our game. AppInit() receives a void** argument which we can use to provide our GameState, in addition to the int and char** of a traditional main function.

It should also return an SDL_AppResult of SDL_APP_CONTINUE. We'll talk about this SDL_AppResult value in the next section.

SDL_AppResult AppInit(void** AppState, int, char**) {
  // ...

  return SDL_APP_CONTINUE;
}

Let's use this function body to initialize SDL as well as an instance of our GameState class. This broadly is the same content we had before our application loop in our main function.

This void** (ie, a pointer to a void pointer) argument is where we assign our GameState object, which SDL will then make available to our other callbacks.

We need our GameState to survive after AppInit() ends, so we dynamically allocate it using new:

src/main.cpp

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

struct GameState {
  Window GameWindow;
  World GameWorld;
  bool IsRunning{true};
  Uint64 PreviousFrame{0};
};

SDL_AppResult SDL_AppInit(
  void** AppState, int, char**
) {
  SDL_Init(SDL_INIT_VIDEO);

  GameState* State{new GameState()};
  State->GameWorld.SpawnGoblin(
    "Goblin Rogue", 100, 200
  );
  State->PreviousFrame = SDL_GetTicks();
  *AppState = State;

  return SDL_APP_CONTINUE;
}

// ...

The *AppState = State expression in this example may be confusing, as working with pointers-to-pointers can be difficult to understand.

Our AppState argument is a void** - a pointer to a void*. We're trying to assign our State pointer to the void* that this void** is pointing at.

To access the void* that the void** is pointing to, we need to dereference it, just like we would with any other pointer. Using the * operator on a pointer-to-a-pointer returns a pointer so, *AppState returns a void*:

void* Ptr{*AppState};

We can then assign State to this void*. Remember, a void* is a pointer to anything and, in this case, State is a pointer to the GameState. Putting all of this together, *AppState = State is equivalent to this:

void* Ptr{*AppState};
Ptr = State;

The SDL_APP_RESULT Enum

Previously, we controlled when our application ended simply by returning from our main() function. In SDL's callback implementation, we instead control this using the SDL_AppResult values returned from our functions.

We can return one of three values from our callbacks:

  • SDL_APP_CONTINUE, signalling our application should continue running
  • SDL_APP_SUCCESS, signalling our application should quit because it completed successfully. This is equivalent to returning a 0 exit code from our main function.
  • SDL_APP_FAILURE, signalling our application should quit because of an unrecoverable error. This is equivalent to returning a non-zero exit code from main.

As long as our callbacks keep returning SDL_APP_CONTINUE, our application will keep running. If any of them return SDL_APP_SUCCESS or SDL_APP_FAILURE, control will jump to our SDL_AppQuit() function, which we'll add later in this lesson.

Event Handling - SDL_AppEvent()

SDL calls this function whenever a new event is available in the queue.

It receives the void* we set from AppInit() storing our state, and a pointer to the SDL_Event we need to react to. It should also return an SDL_APP_RESULT:

SDL_APP_RESULT SDL_AppEvent(
  void* AppState, SDL_Event* E
) {
  // ...
  return SDL_APP_CONTINUE;
}

Our job is simply to process that single event. We'll check if it's a quit event and update IsRunning accordingly. For all other events, we'll forward them to our World.

Note that SDL_AppEvent() receives the event as a pointer, whilst GameWorld expected the event to be delivered as a reference. We could update GameWorld to match this signature, or just convert the pointer to a reference using the * operator when forwarding it:

src/main.cpp

// ...

SDL_APP_RESULT SDL_AppEvent(
  void* AppState, SDL_Event* E
) {
  GameState* State{
    static_cast<GameState*>(AppState)
  };

  if (E->type == SDL_EVENT_QUIT) {
    return SDL_APP_SUCCESS;
  } else {
    State->GameWorld.HandleEvent(*E);
  }
  
  return SDL_APP_CONTINUE;
}

// ...

The Main Loop - SDL_AppIterate()

This is the heart of our application. SDL will call this function repeatedly in a loop. It's where we'll put our update and rendering logic. It receives the void* we set from AppInit() storing our latest state, and should also return an SDL_APP_RESULT:

SDL_AppResult SDL_AppIterate(void* AppState) {
  // ...

  return SDL_APP_CONTINUE;
}

We'll start by casting the void* back to a GameState* so we can access our objects. Then, we'll perform the same logic as our old while loop: calculate the time delta, tick the world, render, and update the window:

src/main.cpp

// ...

SDL_AppResult SDL_AppIterate(void* AppState) {
  GameState* State{
    static_cast<GameState*>(AppState)
  };

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

  State->GameWindow.Render();
  State->GameWorld.Render(
    State->GameWindow.GetSurface()
  );
  State->GameWindow.Update();

  return SDL_APP_CONTINUE;
}

// ...

Cleanup - SDL_AppQuit()

This is the final callback, executed once the application loop has finished. It's where we clean up all the resources we allocated in AppInit(), and perform any other shutdown logic we need.

We're also provided with the SDL_AppResult that got us here if we need to implement different logic based on why we're quitting (SDL_APP_SUCCESS vs SDL_APP_FAILURE)

src/main.cpp

// ...

void SDL_AppQuit(
  void* AppState, SDL_AppResult Result
) {
  delete AppState;
}

SDL automatically calls SDL_Quit() when we're using the callback pattern, so we no longer need it, although it's safe to add if we want.

Complete Code

When we're using SDL_MAIN_USE_CALLBACKS, we should delete our old main() function to avoid any linker errors.

A complete version of our main.cpp is provided below:

src/main.cpp

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

struct GameState {
  Window GameWindow;
  World GameWorld;
  bool IsRunning{true};
  Uint64 PreviousFrame{0};
};

SDL_AppResult SDL_AppInit(
  void** AppState, int, char**
) {
  SDL_Init(SDL_INIT_VIDEO);

  GameState* State{new GameState()};
  State->GameWorld.SpawnGoblin(
    "Goblin Rogue", 100, 200
  );
  State->PreviousFrame = SDL_GetTicks();
  *AppState = State;

  return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(
  void* AppState, SDL_Event* Event
) {
  GameState* State{
    static_cast<GameState*>(AppState)
  };

  if (Event->type == SDL_EVENT_QUIT) {
    return SDL_APP_SUCCESS;
  } else {
    State->GameWorld.HandleEvent(*Event);
  }
  
  return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void* AppState) {
  GameState* State{
    static_cast<GameState*>(AppState)
  };

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

  State->GameWindow.Render();
  State->GameWorld.Render(
    State->GameWindow.GetSurface()
  );
  State->GameWindow.Update();

  return SDL_APP_CONTINUE;
}

void SDL_AppQuit(
  void* AppState, SDL_AppResult Result
) {
  delete AppState;
}

Advantages and Disadvantages

This pattern offers a different way to structure an application, with some clear pros and cons.

Advantages

  • Platform Compatibility: This is the primary reason for the callback pattern. Many platforms, especially mobile (iOS, Android) and web (Emscripten), have their own mandatory event loops. They don't let your application run its own loop. By breaking our logic into four specific callback functions, SDL can rearrange our logic to the format required by the target platform.
  • Clear Structure: The code is naturally divided into distinct phases: initialization, event handling, updating, and cleanup. This can make the overall structure of the program easier to understand at a glance.

Disadvantages

  • Loss of Control: You give up direct control over the main loop's timing and structure. Implementing more advanced loop patterns, like a fixed-timestep loop for physics, becomes much more difficult or impossible because you can't control when AppIterate() is called.
  • State Management is Clumsier: Passing all shared state through a void* and casting it in every function is less elegant and less type-safe than having variables scoped within a single main() function.
  • Less Common for Desktop: For applications that only target desktop platforms (Windows, macOS, Linux), the traditional while loop is often simpler, more flexible, and more familiar to C++ developers.

For the remainder of this course, we will return to the traditional while loop in main(). This approach gives us more direct control, which is beneficial for learning and for the desktop-focused projects we'll be building.

It's important to be aware that the callback pattern exists as an alternative to writing our own main function, and you should likely use it if you're targetting non-desktop platforms. But the traditional main function and application loop remains a perfectly valid and usually preferable choice for desktop development.

Summary

In this lesson, we explored SDL3's main callback pattern as an alternative to the traditional application loop.

  • We refactored our project to use the four key callbacks: AppInit(), AppEvent(), AppIterate(), and AppQuit().
  • We learned how to manage application state across these callbacks using a GameState struct, which SDL passes as void pointers.
  • We discussed the primary advantage of this pattern: enhanced compatibility with platforms like mobile and web that have their own event loop requirements.
  • We also covered the disadvantages, including a loss of fine-grained control over the loop and a more complex state management system.
Next Lesson
Lesson 44 of 52

SDL3 Timers and Callbacks

Learn how to use callbacks with SDL_AddTimer() to provide functions that are executed on time-based intervals

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