SDL School
SDL version Which one?

Multiple choice quiz

This project shows a question and four answer buttons. A click shows the correct answer, the next click moves on, and the end shows your score. The same design works for many programs that are not games, such as a revision tool or a menu.

Click an answer in the demo, then click again to continue.

The full program

main.c
/* A multiple choice quiz. Add a question by adding one entry to the array. */
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FONT_PATH "assets/DejaVuSans.ttf"
#define ANSWER_COUNT 4
#define NO_BUTTON -1

#define BUTTON_X 40.0f
#define BUTTON_Y 170.0f
#define BUTTON_WIDTH 560.0f
#define BUTTON_HEIGHT 56.0f
#define BUTTON_STEP 70.0f

typedef struct {
    const char *text;
    const char *answers[ANSWER_COUNT];
    int correct;
} Question;

typedef struct {
    SDL_FRect rect;
    SDL_Texture *label;
} Button;

typedef enum {
    STATE_ASK,      /* The player chooses an answer. */
    STATE_ANSWERED, /* The colors show the answer. A click moves on. */
    STATE_RESULT    /* The quiz is over. A click starts again. */
} QuizState;

static const Question questions[] = {
    { "Where is the point (0, 0) in an SDL window?",
      { "The top-left corner", "The bottom-left corner", "The center", "The top-right corner" }, 0 },
    { "Which step puts the finished frame on the screen?",
      { "Clear", "Present", "Fill", "Poll" }, 1 },
    { "An object moves too fast on a fast computer. What is missing?",
      { "A larger window", "A second renderer", "Delta time", "A new font" }, 2 },
    { "Which form of an image is on the graphics card?",
      { "A file", "A surface", "A string", "A texture" }, 3 },
    { "When does a button click happen?",
      { "When the mouse moves over it", "At the press only",
        "At a press and a release on it", "At the release only" }, 2 }
};
#define QUESTION_COUNT ((int)SDL_arraysize(questions))

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static TTF_Font *font = NULL;
static SDL_Texture *heading = NULL;
static Button buttons[ANSWER_COUNT];

static QuizState state = STATE_ASK;
static int current = 0;  /* The index of the question. */
static int score = 0;
static int chosen = NO_BUTTON;
static int hovered = NO_BUTTON;
static int pressed = NO_BUTTON;

static SDL_Texture *make_text(const char *text, int wrap_width)
{
    SDL_Color color = { 235, 236, 240, SDL_ALPHA_OPAQUE };
    SDL_Surface *surface = TTF_RenderText_Blended_Wrapped(font, text, 0, color, wrap_width);
    SDL_Texture *texture;

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

    texture = SDL_CreateTextureFromSurface(renderer, surface);
    SDL_DestroySurface(surface);
    return texture;
}

/* Replace a texture with new text, and free the old one. */
static void set_text(SDL_Texture **texture, const char *text, int wrap_width)
{
    SDL_DestroyTexture(*texture);
    *texture = make_text(text, wrap_width);
}

static void show_question(void)
{
    const Question *question = &questions[current];

    set_text(&heading, question->text, (int)BUTTON_WIDTH);
    for (int i = 0; i < ANSWER_COUNT; i++) {
        set_text(&buttons[i].label, question->answers[i], 0);
    }
    state = STATE_ASK;
    chosen = NO_BUTTON;
}

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

    SDL_snprintf(text, sizeof(text), "You scored %d out of %d. Click to play again.",
                 score, QUESTION_COUNT);
    set_text(&heading, text, (int)BUTTON_WIDTH);
    state = STATE_RESULT;
}

static void start_quiz(void)
{
    current = 0;
    score = 0;
    show_question();
}

static bool point_in_rect(float x, float y, const SDL_FRect *rect)
{
    return x >= rect->x && x < rect->x + rect->w &&
           y >= rect->y && y < rect->y + rect->h;
}

static int button_at(float x, float y)
{
    if (state == STATE_RESULT) {
        return NO_BUTTON;
    }

    for (int i = 0; i < ANSWER_COUNT; i++) {
        if (point_in_rect(x, y, &buttons[i].rect)) {
            return i;
        }
    }
    return NO_BUTTON;
}

static void choose_answer(int index)
{
    chosen = index;
    if (index == questions[current].correct) {
        score++;
    }
    state = STATE_ANSWERED;
}

static void next_step(void)
{
    if (state == STATE_RESULT) {
        start_quiz();
    } else if (current + 1 < QUESTION_COUNT) {
        current++;
        show_question();
    } else {
        show_result();
    }
}

/* A click needs a press and a release on the same button. */
static void handle_release(float x, float y)
{
    int released = button_at(x, y);

    if (state == STATE_ASK) {
        if (released != NO_BUTTON && released == pressed) {
            choose_answer(released);
        }
    } else {
        next_step();
    }
    pressed = NO_BUTTON;
}

static void set_button_color(int index)
{
    if (state == STATE_ANSWERED && index == questions[current].correct) {
        SDL_SetRenderDrawColor(renderer, 30, 120, 80, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ANSWERED && index == chosen) {
        SDL_SetRenderDrawColor(renderer, 150, 40, 44, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ASK && index == pressed && index == hovered) {
        SDL_SetRenderDrawColor(renderer, 32, 36, 44, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ASK && index == hovered) {
        SDL_SetRenderDrawColor(renderer, 70, 76, 92, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 44, 48, 58, SDL_ALPHA_OPAQUE);
    }
}

static void draw_text(SDL_Texture *texture, float x, float y)
{
    float width, height;

    if (texture == NULL) {
        return;
    }

    SDL_GetTextureSize(texture, &width, &height);

    SDL_FRect destination = { x, y, width, height };
    SDL_RenderTexture(renderer, texture, NULL, &destination);
}

static void draw_button(int index)
{
    const Button *button = &buttons[index];
    float height = 0.0f;

    set_button_color(index);
    SDL_RenderFillRect(renderer, &button->rect);

    /* Center the label on the button, from top to bottom. */
    SDL_GetTextureSize(button->label, NULL, &height);
    draw_text(button->label, button->rect.x + 16.0f,
              button->rect.y + (button->rect.h - height) / 2.0f);
}

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("Quiz", 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, 22.0f);
    if (font == NULL) {
        SDL_Log("TTF_OpenFont failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    for (int i = 0; i < ANSWER_COUNT; i++) {
        buttons[i].rect = (SDL_FRect){ BUTTON_X, BUTTON_Y + (float)i * BUTTON_STEP,
                                       BUTTON_WIDTH, BUTTON_HEIGHT };
    }

    start_quiz();
    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_MOTION) {
        hovered = button_at(event->motion.x, event->motion.y);
    } else if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN &&
               event->button.button == SDL_BUTTON_LEFT) {
        pressed = button_at(event->button.x, event->button.y);
        hovered = pressed;
    } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP &&
               event->button.button == SDL_BUTTON_LEFT) {
        handle_release(event->button.x, event->button.y);
    }
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void *appstate)
{
    SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);

    draw_text(heading, BUTTON_X, 40.0f);

    if (state != STATE_RESULT) {
        for (int i = 0; i < ANSWER_COUNT; i++) {
            draw_button(i);
        }
    }

    SDL_RenderPresent(renderer);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
    SDL_DestroyTexture(heading);
    for (int i = 0; i < ANSWER_COUNT; i++) {
        SDL_DestroyTexture(buttons[i].label);
    }
    TTF_CloseFont(font);
    TTF_Quit();
}
main.c
/* A multiple choice quiz. Add a question by adding one entry to the array. */
#include <SDL.h>
#include <SDL_ttf.h>
#include <stdbool.h>

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

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FONT_PATH "assets/DejaVuSans.ttf"
#define ANSWER_COUNT 4
#define NO_BUTTON -1

#define BUTTON_X 40.0f
#define BUTTON_Y 170.0f
#define BUTTON_WIDTH 560.0f
#define BUTTON_HEIGHT 56.0f
#define BUTTON_STEP 70.0f

typedef struct {
    const char *text;
    const char *answers[ANSWER_COUNT];
    int correct;
} Question;

typedef struct {
    SDL_FRect rect;
    SDL_Texture *label;
} Button;

typedef enum {
    STATE_ASK,      /* The player chooses an answer. */
    STATE_ANSWERED, /* The colors show the answer. A click moves on. */
    STATE_RESULT    /* The quiz is over. A click starts again. */
} QuizState;

static const Question questions[] = {
    { "Where is the point (0, 0) in an SDL window?",
      { "The top-left corner", "The bottom-left corner", "The center", "The top-right corner" }, 0 },
    { "Which step puts the finished frame on the screen?",
      { "Clear", "Present", "Fill", "Poll" }, 1 },
    { "An object moves too fast on a fast computer. What is missing?",
      { "A larger window", "A second renderer", "Delta time", "A new font" }, 2 },
    { "Which form of an image is on the graphics card?",
      { "A file", "A surface", "A string", "A texture" }, 3 },
    { "When does a button click happen?",
      { "When the mouse moves over it", "At the press only",
        "At a press and a release on it", "At the release only" }, 2 }
};
#define QUESTION_COUNT ((int)SDL_arraysize(questions))

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static TTF_Font *font = NULL;
static SDL_Texture *heading = NULL;
static Button buttons[ANSWER_COUNT];

static QuizState state = STATE_ASK;
static int current = 0;  /* The index of the question. */
static int score = 0;
static int chosen = NO_BUTTON;
static int hovered = NO_BUTTON;
static int pressed = NO_BUTTON;
static bool running = true;

static SDL_Texture *make_text(const char *text, int wrap_width)
{
    SDL_Color color = { 235, 236, 240, SDL_ALPHA_OPAQUE };
    SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text, color, wrap_width);
    SDL_Texture *texture;

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

    texture = SDL_CreateTextureFromSurface(renderer, surface);
    SDL_FreeSurface(surface);
    return texture;
}

/* Replace a texture with new text, and free the old one. */
static void set_text(SDL_Texture **texture, const char *text, int wrap_width)
{
    SDL_DestroyTexture(*texture);
    *texture = make_text(text, wrap_width);
}

static void show_question(void)
{
    const Question *question = &questions[current];

    set_text(&heading, question->text, (int)BUTTON_WIDTH);
    for (int i = 0; i < ANSWER_COUNT; i++) {
        set_text(&buttons[i].label, question->answers[i], 0);
    }
    state = STATE_ASK;
    chosen = NO_BUTTON;
}

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

    SDL_snprintf(text, sizeof(text), "You scored %d out of %d. Click to play again.",
                 score, QUESTION_COUNT);
    set_text(&heading, text, (int)BUTTON_WIDTH);
    state = STATE_RESULT;
}

static void start_quiz(void)
{
    current = 0;
    score = 0;
    show_question();
}

static bool point_in_rect(float x, float y, const SDL_FRect *rect)
{
    return x >= rect->x && x < rect->x + rect->w &&
           y >= rect->y && y < rect->y + rect->h;
}

static int button_at(float x, float y)
{
    if (state == STATE_RESULT) {
        return NO_BUTTON;
    }

    for (int i = 0; i < ANSWER_COUNT; i++) {
        if (point_in_rect(x, y, &buttons[i].rect)) {
            return i;
        }
    }
    return NO_BUTTON;
}

static void choose_answer(int index)
{
    chosen = index;
    if (index == questions[current].correct) {
        score++;
    }
    state = STATE_ANSWERED;
}

static void next_step(void)
{
    if (state == STATE_RESULT) {
        start_quiz();
    } else if (current + 1 < QUESTION_COUNT) {
        current++;
        show_question();
    } else {
        show_result();
    }
}

/* A click needs a press and a release on the same button. */
static void handle_release(float x, float y)
{
    int released = button_at(x, y);

    if (state == STATE_ASK) {
        if (released != NO_BUTTON && released == pressed) {
            choose_answer(released);
        }
    } else {
        next_step();
    }
    pressed = NO_BUTTON;
}

static void set_button_color(int index)
{
    if (state == STATE_ANSWERED && index == questions[current].correct) {
        SDL_SetRenderDrawColor(renderer, 30, 120, 80, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ANSWERED && index == chosen) {
        SDL_SetRenderDrawColor(renderer, 150, 40, 44, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ASK && index == pressed && index == hovered) {
        SDL_SetRenderDrawColor(renderer, 32, 36, 44, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ASK && index == hovered) {
        SDL_SetRenderDrawColor(renderer, 70, 76, 92, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 44, 48, 58, SDL_ALPHA_OPAQUE);
    }
}

static void draw_text(SDL_Texture *texture, float x, float y)
{
    int width, height;

    if (texture == NULL) {
        return;
    }

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

    SDL_FRect destination = { x, y, (float)width, (float)height };
    SDL_RenderCopyF(renderer, texture, NULL, &destination);
}

static void draw_button(int index)
{
    const Button *button = &buttons[index];
    int height = 0;

    set_button_color(index);
    SDL_RenderFillRectF(renderer, &button->rect);

    /* Center the label on the button, from top to bottom. */
    SDL_QueryTexture(button->label, NULL, NULL, NULL, &height);
    draw_text(button->label, button->rect.x + 16.0f,
              button->rect.y + (button->rect.h - (float)height) / 2.0f);
}

static void handle_event(const SDL_Event *event)
{
    if (event->type == SDL_QUIT) {
        running = false;
    } else if (event->type == SDL_MOUSEMOTION) {
        hovered = button_at((float)event->motion.x, (float)event->motion.y);
    } else if (event->type == SDL_MOUSEBUTTONDOWN &&
               event->button.button == SDL_BUTTON_LEFT) {
        pressed = button_at((float)event->button.x, (float)event->button.y);
        hovered = pressed;
    } else if (event->type == SDL_MOUSEBUTTONUP &&
               event->button.button == SDL_BUTTON_LEFT) {
        handle_release((float)event->button.x, (float)event->button.y);
    }
}

static void frame(void)
{
    SDL_Event event;

    while (SDL_PollEvent(&event)) {
        handle_event(&event);
    }

    SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);

    draw_text(heading, BUTTON_X, 40.0f);

    if (state != STATE_RESULT) {
        for (int i = 0; i < ANSWER_COUNT; i++) {
            draw_button(i);
        }
    }

    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());
        TTF_Quit();
        SDL_Quit();
        return 1;
    }

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

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

    for (int i = 0; i < ANSWER_COUNT; i++) {
        buttons[i].rect = (SDL_FRect){ BUTTON_X, BUTTON_Y + (float)i * BUTTON_STEP,
                                       BUTTON_WIDTH, BUTTON_HEIGHT };
    }

    start_quiz();

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

    SDL_DestroyTexture(heading);
    for (int i = 0; i < ANSWER_COUNT; i++) {
        SDL_DestroyTexture(buttons[i].label);
    }
    TTF_CloseFont(font);
    TTF_Quit();
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

To build this program on your computer, use the setup from Draw text. Save DejaVuSans.ttf in your assets folder.

Keep the data in a table

The questions are data, so they stay apart from the code:

typedef struct {
    const char *text;
    const char *answers[ANSWER_COUNT];
    int correct;
} Question;

The field correct holds the index of the correct answer. The program holds an array of these structs, and each entry is one question. To add a question, you add one entry, and no other code changes.

#define QUESTION_COUNT ((int)SDL_arraysize(questions))

SDL_arraysize works out the number of entries in the array. This is why the count stays correct after you add a question.

Three states

The program uses the same state machine idea as Pong:

typedef enum {
    STATE_ASK,      /* The player chooses an answer. */
    STATE_ANSWERED, /* The colors show the answer. A click moves on. */
    STATE_RESULT    /* The quiz is over. A click starts again. */
} QuizState;

A click means something different in each state. A click on a button chooses an answer in STATE_ASK, and any click continues in the other two states. The handle_release function holds this rule in one place.

Replace text safely

Text changes with each question, so the program needs new textures. This function takes care of the old one:

static void set_text(SDL_Texture **texture, const char *text, int wrap_width)
{
    SDL_DestroyTexture(*texture);
    *texture = make_text(text, wrap_width);
}

The first argument is a pointer to a pointer. The function must change the caller's SDL_Texture * variable, and C passes arguments by value. So the caller passes the address of that variable, &heading, and the function writes through it. You saw the same pattern in SDL_CreateWindowAndRenderer, in the first lesson.

The program makes these textures only after the question changes, not in each frame.

Show the answer with color

    if (state == STATE_ANSWERED && index == questions[current].correct) {
        SDL_SetRenderDrawColor(renderer, 30, 120, 80, SDL_ALPHA_OPAQUE);
    } else if (state == STATE_ANSWERED && index == chosen) {
        SDL_SetRenderDrawColor(renderer, 150, 40, 44, SDL_ALPHA_OPAQUE);

The correct answer turns green, and a wrong choice turns red. The drawing code only reads the state, and it changes nothing. This is the same split as update and draw in Pong.

Keep the buttons from the lesson

The buttons work the same way as in Make a button. The press records the button, and the release acts only on a match. The program adds a label to each button, and the labels come from the question.

The label is centered from top to bottom. The space left over is the button height minus the text height, so half of that space goes above the text.

Try this

  1. Add three questions of your own. You change only the array.
  2. Show the question number, such as "Question 2 of 5", at the top of the window.
  3. Shuffle the order of the questions. Use an array of indexes, and swap entries at random.
  4. Add a time limit for each question. Use dt, and draw a bar that gets shorter.

What you learned