SDL3 Main Callbacks
An introduction to SDL3's main callback pattern - an alternative to the traditional application loop for smoother platform integration.
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
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:
SDL_AppInit()
for initializationSDL_AppEvent()
for event handlingSDL_AppIterate()
for each iteration of our application loopSDL_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 runningSDL_APP_SUCCESS
, signalling our application should quit because it completed successfully. This is equivalent to returning a0
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 frommain
.
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 singlemain()
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()
, andAppQuit()
. - We learned how to manage application state across these callbacks using a
GameState
struct, whichSDL
passes asvoid
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.
SDL3 Timers and Callbacks
Learn how to use callbacks with SDL_AddTimer()
to provide functions that are executed on time-based intervals