SDL School
SDL version Which one?

Scenes

Most programs have more than one screen. This small game has a menu, a game, and a result screen. Each screen is a scene, with its own functions. A table of function pointers runs the correct one, so the main loop stays short.

Click the demo to start, and then click each square.

The full program

main.c
/* Three scenes: a menu, a game, and a result. A table of functions runs each one. */
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <stdlib.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FONT_PATH "assets/DejaVuSans.ttf"
#define MAX_DT 0.05f /* seconds */

#define GAME_SECONDS 8.0f
#define TARGET_SIZE 64.0f

typedef enum {
    SCENE_MENU,
    SCENE_GAME,
    SCENE_RESULT,
    SCENE_COUNT
} SceneId;

/* Each scene is a set of functions. A scene does not need all of them. */
typedef struct {
    void (*enter)(void);
    void (*click)(float x, float y);
    void (*update)(float dt);
    void (*draw)(void);
} Scene;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static TTF_Font *font = NULL;
static SDL_Texture *message = NULL;
static Uint64 last_ticks = 0;

static const Scene *current_scene = NULL;

static int score = 0;
static float time_left = 0.0f;
static SDL_FRect target;

static const Scene scenes[SCENE_COUNT];

/* Change to another scene. */
static void go_to(SceneId id)
{
    current_scene = &scenes[id];
    if (current_scene->enter != NULL) {
        current_scene->enter();
    }
}

static void set_message(const char *text)
{
    SDL_Color color = { 235, 236, 240, SDL_ALPHA_OPAQUE };
    SDL_Surface *surface = TTF_RenderText_Blended_Wrapped(font, text, 0, color, 560);

    SDL_DestroyTexture(message);
    message = NULL;
    if (surface == NULL) {
        SDL_Log("TTF_RenderText_Blended_Wrapped failed: %s", SDL_GetError());
        return;
    }

    message = SDL_CreateTextureFromSurface(renderer, surface);
    SDL_DestroySurface(surface);
}

static void draw_message(float y)
{
    float width, height;

    if (message == NULL) {
        return;
    }

    SDL_GetTextureSize(message, &width, &height);

    SDL_FRect destination = { (WINDOW_WIDTH - width) / 2.0f, y, width, height };
    SDL_RenderTexture(renderer, message, NULL, &destination);
}

/* ---- The menu scene ---- */

static void menu_enter(void)
{
    set_message("Click to start. Then click each square as fast as you can.");
}

static void menu_click(float x, float y)
{
    go_to(SCENE_GAME);
}

static void menu_draw(void)
{
    draw_message(200.0f);
}

/* ---- The game scene ---- */

static void move_target(void)
{
    target.x = (float)(rand() % (int)(WINDOW_WIDTH - TARGET_SIZE));
    target.y = 40.0f + (float)(rand() % (int)(WINDOW_HEIGHT - TARGET_SIZE - 40.0f));
}

static void game_enter(void)
{
    score = 0;
    time_left = GAME_SECONDS;
    target.w = TARGET_SIZE;
    target.h = TARGET_SIZE;
    move_target();
}

static void game_click(float x, float y)
{
    SDL_FPoint point = { x, y };

    if (SDL_PointInRectFloat(&point, &target)) {
        score++;
        move_target();
    }
}

static void game_update(float dt)
{
    time_left -= dt;
    if (time_left <= 0.0f) {
        go_to(SCENE_RESULT);
    }
}

static void game_draw(void)
{
    SDL_FRect time_bar = { 0.0f, 0.0f, WINDOW_WIDTH * time_left / GAME_SECONDS, 12.0f };

    SDL_SetRenderDrawColor(renderer, 80, 200, 160, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRect(renderer, &time_bar);

    SDL_SetRenderDrawColor(renderer, 255, 157, 92, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRect(renderer, &target);
}

/* ---- The result scene ---- */

static void result_enter(void)
{
    char text[64];

    SDL_snprintf(text, sizeof(text), "You hit %d squares. Click for the menu.", score);
    set_message(text);
}

static void result_click(float x, float y)
{
    go_to(SCENE_MENU);
}

static void result_draw(void)
{
    draw_message(200.0f);
}

/* ---- The table of scenes ---- */

static const Scene scenes[SCENE_COUNT] = {
    [SCENE_MENU] = { menu_enter, menu_click, NULL, menu_draw },
    [SCENE_GAME] = { game_enter, game_click, game_update, game_draw },
    [SCENE_RESULT] = { result_enter, result_click, NULL, result_draw }
};

/* ---- The program ---- */

SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
    if (!SDL_Init(SDL_INIT_VIDEO)) {
        SDL_Log("SDL_Init failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    if (!TTF_Init()) {
        SDL_Log("TTF_Init failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    if (!SDL_CreateWindowAndRenderer("Scenes", WINDOW_WIDTH, WINDOW_HEIGHT, 0, &window, &renderer)) {
        SDL_Log("SDL_CreateWindowAndRenderer failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    SDL_SetRenderVSync(renderer, 1);

    font = TTF_OpenFont(FONT_PATH, 24.0f);
    if (font == NULL) {
        SDL_Log("TTF_OpenFont failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    go_to(SCENE_MENU);
    last_ticks = SDL_GetTicksNS();
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
    if (event->type == SDL_EVENT_QUIT) {
        return SDL_APP_SUCCESS;
    }

    SDL_ConvertEventToRenderCoordinates(renderer, event);

    if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && current_scene->click != NULL) {
        current_scene->click(event->button.x, event->button.y);
    }
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void *appstate)
{
    Uint64 now = SDL_GetTicksNS();
    float dt = (float)(now - last_ticks) / (float)SDL_NS_PER_SECOND;

    last_ticks = now;
    if (dt > MAX_DT) {
        dt = MAX_DT;
    }

    if (current_scene->update != NULL) {
        current_scene->update(dt);
    }

    SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);
    current_scene->draw();
    SDL_RenderPresent(renderer);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
    SDL_DestroyTexture(message);
    TTF_CloseFont(font);
    TTF_Quit();
}
main.c
/* Three scenes: a menu, a game, and a result. A table of functions runs each one. */
#include <SDL.h>
#include <SDL_ttf.h>
#include <stdbool.h>
#include <stdlib.h>

#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FONT_PATH "assets/DejaVuSans.ttf"
#define MAX_DT 0.05f /* seconds */

#define GAME_SECONDS 8.0f
#define TARGET_SIZE 64.0f

typedef enum {
    SCENE_MENU,
    SCENE_GAME,
    SCENE_RESULT,
    SCENE_COUNT
} SceneId;

/* Each scene is a set of functions. A scene does not need all of them. */
typedef struct {
    void (*enter)(void);
    void (*click)(float x, float y);
    void (*update)(float dt);
    void (*draw)(void);
} Scene;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static TTF_Font *font = NULL;
static SDL_Texture *message = NULL;
static Uint64 last_ticks = 0;

static const Scene *current_scene = NULL;

static int score = 0;
static float time_left = 0.0f;
static SDL_FRect target;
static bool running = true;

static const Scene scenes[SCENE_COUNT];

/* Change to another scene. */
static void go_to(SceneId id)
{
    current_scene = &scenes[id];
    if (current_scene->enter != NULL) {
        current_scene->enter();
    }
}

static void set_message(const char *text)
{
    SDL_Color color = { 235, 236, 240, SDL_ALPHA_OPAQUE };
    SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text, color, 560);

    SDL_DestroyTexture(message);
    message = NULL;
    if (surface == NULL) {
        SDL_Log("TTF_RenderUTF8_Blended_Wrapped failed: %s", TTF_GetError());
        return;
    }

    message = SDL_CreateTextureFromSurface(renderer, surface);
    SDL_FreeSurface(surface);
}

static void draw_message(float y)
{
    int width, height;

    if (message == NULL) {
        return;
    }

    SDL_QueryTexture(message, NULL, NULL, &width, &height);

    SDL_FRect destination = { (WINDOW_WIDTH - width) / 2.0f, y, (float)width, (float)height };
    SDL_RenderCopyF(renderer, message, NULL, &destination);
}

/* ---- The menu scene ---- */

static void menu_enter(void)
{
    set_message("Click to start. Then click each square as fast as you can.");
}

static void menu_click(float x, float y)
{
    go_to(SCENE_GAME);
}

static void menu_draw(void)
{
    draw_message(200.0f);
}

/* ---- The game scene ---- */

static void move_target(void)
{
    target.x = (float)(rand() % (int)(WINDOW_WIDTH - TARGET_SIZE));
    target.y = 40.0f + (float)(rand() % (int)(WINDOW_HEIGHT - TARGET_SIZE - 40.0f));
}

static void game_enter(void)
{
    score = 0;
    time_left = GAME_SECONDS;
    target.w = TARGET_SIZE;
    target.h = TARGET_SIZE;
    move_target();
}

static void game_click(float x, float y)
{
    SDL_FPoint point = { x, y };

    if (SDL_PointInFRect(&point, &target)) {
        score++;
        move_target();
    }
}

static void game_update(float dt)
{
    time_left -= dt;
    if (time_left <= 0.0f) {
        go_to(SCENE_RESULT);
    }
}

static void game_draw(void)
{
    SDL_FRect time_bar = { 0.0f, 0.0f, WINDOW_WIDTH * time_left / GAME_SECONDS, 12.0f };

    SDL_SetRenderDrawColor(renderer, 80, 200, 160, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRectF(renderer, &time_bar);

    SDL_SetRenderDrawColor(renderer, 255, 157, 92, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRectF(renderer, &target);
}

/* ---- The result scene ---- */

static void result_enter(void)
{
    char text[64];

    SDL_snprintf(text, sizeof(text), "You hit %d squares. Click for the menu.", score);
    set_message(text);
}

static void result_click(float x, float y)
{
    go_to(SCENE_MENU);
}

static void result_draw(void)
{
    draw_message(200.0f);
}

/* ---- The table of scenes ---- */

static const Scene scenes[SCENE_COUNT] = {
    [SCENE_MENU] = { menu_enter, menu_click, NULL, menu_draw },
    [SCENE_GAME] = { game_enter, game_click, game_update, game_draw },
    [SCENE_RESULT] = { result_enter, result_click, NULL, result_draw }
};

/* ---- The program ---- */

static void frame(void)
{
    SDL_Event event;
    Uint64 now = SDL_GetPerformanceCounter();
    float dt = (float)(now - last_ticks) / (float)SDL_GetPerformanceFrequency();

    last_ticks = now;
    if (dt > MAX_DT) {
        dt = MAX_DT;
    }

    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            running = false;
        } else if (event.type == SDL_MOUSEBUTTONDOWN && current_scene->click != NULL) {
            current_scene->click((float)event.button.x, (float)event.button.y);
        }
    }

    if (current_scene->update != NULL) {
        current_scene->update(dt);
    }

    SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);
    current_scene->draw();
    SDL_RenderPresent(renderer);
}

int main(int argc, char *argv[])
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        SDL_Log("SDL_Init failed: %s", SDL_GetError());
        return 1;
    }

    if (TTF_Init() != 0) {
        SDL_Log("TTF_Init failed: %s", TTF_GetError());
        SDL_Quit();
        return 1;
    }


    if (SDL_CreateWindowAndRenderer(WINDOW_WIDTH, WINDOW_HEIGHT, 0, &window, &renderer) != 0) {
        SDL_Log("SDL_CreateWindowAndRenderer failed: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    SDL_SetWindowTitle(window, "Scenes");
    SDL_RenderSetVSync(renderer, 1);

    font = TTF_OpenFont(FONT_PATH, 24);
    if (font == NULL) {
        SDL_Log("TTF_OpenFont failed: %s", TTF_GetError());
        SDL_Quit();
        return 1;
    }

    go_to(SCENE_MENU);
    last_ticks = SDL_GetPerformanceCounter();


#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop(frame, 0, 1);
#else
    while (running) {
        frame();
    }
#endif

    SDL_DestroyTexture(message);
    TTF_CloseFont(font);
    TTF_Quit();
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

To build this program, use the setup from Draw text.

The problem with switch

In Pong and the quiz, an enum and if statements chose what to do. That works for a few states. With many screens, each function fills with if statements, and the code for one screen is spread across the file.

The solution is to group the code by scene. Each scene gets its own functions, and the main loop calls them without knowing which scene is active.

A scene is a set of functions

typedef struct {
    void (*enter)(void);
    void (*click)(float x, float y);
    void (*update)(float dt);
    void (*draw)(void);
} Scene;

Each field is a function pointer, a variable that holds the address of a function. The syntax looks strange, but you read it from the name outward: click is a pointer to a function that takes two floats and returns nothing.

A table of scenes

static const Scene scenes[SCENE_COUNT] = {
    [SCENE_MENU] = { menu_enter, menu_click, NULL, menu_draw },
    [SCENE_GAME] = { game_enter, game_click, game_update, game_draw },
    [SCENE_RESULT] = { result_enter, result_click, NULL, result_draw }
};

The name of a function gives its address, so this table needs no &. The form [SCENE_MENU] = is a designated initializer. It names the index, so the order cannot go wrong. A NULL means that a scene does not need that function.

To add a screen, you add an enum value, write its functions, and add one line here. The main loop does not change.

Call through the pointer

    if (current_scene->update != NULL) {
        current_scene->update(dt);
    }
    if (current_scene->update != NULL) {
        current_scene->update(dt);
    }

The main loop holds a pointer to the active scene. A call through a function pointer looks like a normal call. The test for NULL is necessary, because a call through a null pointer crashes the program.

Change the scene

static void go_to(SceneId id)
{
    current_scene = &scenes[id];
    if (current_scene->enter != NULL) {
        current_scene->enter();
    }
}

One function makes each change, so enter always runs. This is the place to reset a scene. The game scene sets the score and the timer here, so a second game starts clean.

Declare before you use

static const Scene scenes[SCENE_COUNT];

The function go_to uses the table, and the table uses the scene functions, which use go_to. Each one needs the other. So the file declares the table near the top, and it defines it after the functions.

Try this

  1. Add a scene that shows the rules, between the menu and the game.
  2. Add a leave function to each scene, and call it in go_to.
  3. Add a Pause scene that stops the timer and returns to the game. You need to keep the game data.
  4. Convert Pong to scenes.

What you learned