SDL School
SDL version Which one?

Fixed time steps

Both balls in this demo fall and bounce under gravity. The orange ball moves by the frame time, as in earlier lessons. The green ball moves in equal small steps. Click the demo to pretend that the computer is slow, and the window turns red. Watch the orange ball change its height, while the green ball stays the same.

The full program

main.c
/* Two bouncing balls under gravity. Click to switch to a low frame rate. */
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FLOOR_Y 440.0f
#define BALL_SIZE 40.0f
#define START_Y 60.0f

#define GRAVITY 900.0f /* pixels per second, per second */
#define FIXED_STEP (1.0f / 120.0f) /* seconds */
#define MAX_FRAME_TIME 0.25f /* seconds */
#define SLOW_FRAME_TIME 0.08f /* seconds */

typedef struct {
    float y;     /* The top of the ball. */
    float speed; /* pixels per second, and positive means down */
} Ball;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static Uint64 last_ticks = 0;

static Ball variable_ball = { START_Y, 0.0f };
static Ball fixed_ball = { START_Y, 0.0f };
static float accumulator = 0.0f;
static float slow_timer = 0.0f;
static bool slow_mode = false;

/* Move one ball forward by a time step. */
static void step_ball(Ball *ball, float dt)
{
    ball->speed += GRAVITY * dt;
    ball->y += ball->speed * dt;

    if (ball->y + BALL_SIZE > FLOOR_Y) {
        ball->y = FLOOR_Y - BALL_SIZE;
        ball->speed = -ball->speed;
    }
}

/* Advance the simulation by the real time, in equal small steps. */
static void step_fixed(float frame_time)
{
    accumulator += frame_time;
    while (accumulator >= FIXED_STEP) {
        step_ball(&fixed_ball, FIXED_STEP);
        accumulator -= FIXED_STEP;
    }
}

/* One pass of the program, with this much time since the last pass. */
static void update(float frame_time)
{
    step_ball(&variable_ball, frame_time);
    step_fixed(frame_time);
}

static void draw(void)
{
    SDL_FRect floor_rect = { 0.0f, FLOOR_Y, WINDOW_WIDTH, WINDOW_HEIGHT - FLOOR_Y };
    SDL_FRect left = { 180.0f, variable_ball.y, BALL_SIZE, BALL_SIZE };
    SDL_FRect right = { 420.0f, fixed_ball.y, BALL_SIZE, BALL_SIZE };

    if (slow_mode) {
        SDL_SetRenderDrawColor(renderer, 60, 30, 34, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    }
    SDL_RenderClear(renderer);

    SDL_SetRenderDrawColor(renderer, 70, 74, 84, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRect(renderer, &floor_rect);

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

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

    SDL_RenderPresent(renderer);
}

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 (!SDL_CreateWindowAndRenderer("Fixed time steps", WINDOW_WIDTH, WINDOW_HEIGHT, 0,
                                     &window, &renderer)) {
        SDL_Log("SDL_CreateWindowAndRenderer failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    SDL_SetRenderVSync(renderer, 1);
    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;
    }

    if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
        slow_mode = !slow_mode;
    }
    return SDL_APP_CONTINUE;
}

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

    last_ticks = now;
    if (frame_time > MAX_FRAME_TIME) {
        frame_time = MAX_FRAME_TIME;
    }

    if (slow_mode) {
        /* Pretend that this is a slow computer. Do nothing on most frames. */
        slow_timer += frame_time;
        if (slow_timer >= SLOW_FRAME_TIME) {
            update(slow_timer);
            draw();
            slow_timer = 0.0f;
        }
    } else {
        update(frame_time);
        draw();
    }
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
}
main.c
/* Two bouncing balls under gravity. Click to switch to a low frame rate. */
#include <SDL.h>
#include <stdbool.h>

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

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define FLOOR_Y 440.0f
#define BALL_SIZE 40.0f
#define START_Y 60.0f

#define GRAVITY 900.0f /* pixels per second, per second */
#define FIXED_STEP (1.0f / 120.0f) /* seconds */
#define MAX_FRAME_TIME 0.25f /* seconds */
#define SLOW_FRAME_TIME 0.08f /* seconds */

typedef struct {
    float y;     /* The top of the ball. */
    float speed; /* pixels per second, and positive means down */
} Ball;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static Uint64 last_ticks = 0;

static Ball variable_ball = { START_Y, 0.0f };
static Ball fixed_ball = { START_Y, 0.0f };
static float accumulator = 0.0f;
static float slow_timer = 0.0f;
static bool slow_mode = false;
static bool running = true;

/* Move one ball forward by a time step. */
static void step_ball(Ball *ball, float dt)
{
    ball->speed += GRAVITY * dt;
    ball->y += ball->speed * dt;

    if (ball->y + BALL_SIZE > FLOOR_Y) {
        ball->y = FLOOR_Y - BALL_SIZE;
        ball->speed = -ball->speed;
    }
}

/* Advance the simulation by the real time, in equal small steps. */
static void step_fixed(float frame_time)
{
    accumulator += frame_time;
    while (accumulator >= FIXED_STEP) {
        step_ball(&fixed_ball, FIXED_STEP);
        accumulator -= FIXED_STEP;
    }
}

/* One pass of the program, with this much time since the last pass. */
static void update(float frame_time)
{
    step_ball(&variable_ball, frame_time);
    step_fixed(frame_time);
}

static void draw(void)
{
    SDL_FRect floor_rect = { 0.0f, FLOOR_Y, WINDOW_WIDTH, WINDOW_HEIGHT - FLOOR_Y };
    SDL_FRect left = { 180.0f, variable_ball.y, BALL_SIZE, BALL_SIZE };
    SDL_FRect right = { 420.0f, fixed_ball.y, BALL_SIZE, BALL_SIZE };

    if (slow_mode) {
        SDL_SetRenderDrawColor(renderer, 60, 30, 34, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    }
    SDL_RenderClear(renderer);

    SDL_SetRenderDrawColor(renderer, 70, 74, 84, SDL_ALPHA_OPAQUE);
    SDL_RenderFillRectF(renderer, &floor_rect);

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

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

    SDL_RenderPresent(renderer);
}

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

    last_ticks = now;
    if (frame_time > MAX_FRAME_TIME) {
        frame_time = MAX_FRAME_TIME;
    }

    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            running = false;
        }

        if (event.type == SDL_MOUSEBUTTONDOWN) {
            slow_mode = !slow_mode;
        }
    }

    if (slow_mode) {
        /* Pretend that this is a slow computer. Do nothing on most frames. */
        slow_timer += frame_time;
        if (slow_timer >= SLOW_FRAME_TIME) {
            update(slow_timer);
            draw();
            slow_timer = 0.0f;
        }
    } else {
        update(frame_time);
        draw();
    }
}

int main(int argc, char *argv[])
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        SDL_Log("SDL_Init failed: %s", SDL_GetError());
        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, "Fixed time steps");
    SDL_RenderSetVSync(renderer, 1);
    last_ticks = SDL_GetPerformanceCounter();


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

    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

Why delta time is not enough

In Move with time, the speed was constant, so dt gave the correct answer at any frame rate. Gravity changes the speed in each step, so the size of the step changes the result.

This table shows the real code, run for 20 seconds. The ball drops from height 60, and a smaller number means higher on the screen.

Frames each second Delta time: highest bounce Fixed step: highest bounce
144 63.6 65.2
60 68.5 65.2
30 75.0 65.2
12.5 83.2 65.2

With delta time, the same jump in a game reaches a different height on a different computer. A player on a fast computer can reach a platform that is too high for a player on a slow computer. A fixed step gives one result everywhere.

The accumulator

The idea is to keep the step size constant, and to run as many steps as the real time needs.

    accumulator += frame_time;
    while (accumulator >= FIXED_STEP) {
        step_ball(&fixed_ball, FIXED_STEP);
        accumulator -= FIXED_STEP;
    }

The accumulator holds the real time that the simulation has not used yet. Each frame adds to it, and each step takes FIXED_STEP away.

On a fast computer, a frame can run one step or none. On a slow computer, a frame runs many steps. Any time left over stays in the accumulator for the next frame, so no time gets lost.

Choose a step size

This program uses 120 steps each second. A smaller step is more accurate and uses more CPU time. Many games use 60 or 120.

Limit the frame time

    if (frame_time > MAX_FRAME_TIME) {
        frame_time = MAX_FRAME_TIME;
    }
    if (frame_time > MAX_FRAME_TIME) {
        frame_time = MAX_FRAME_TIME;
    }

Each step takes some CPU time. If the program falls behind, it must run more steps in the next frame, and that frame takes longer again. This loop can freeze a program. The limit stops it, because the program gives up on the lost time and slows down.

What about smooth drawing?

A fixed step can leave small time gaps before a frame. At a high step rate you do not see this. At a low step rate, the usual fix is to draw each object between its last two positions. The fraction left in the accumulator tells you where. This is interpolation, and it is an exercise below.

Try this

  1. Change FIXED_STEP to 1.0f / 30.0f. The green ball is less smooth, but it is still the same at each frame rate.
  2. Add a previous_y field and interpolate the drawing position.
  3. Move the ball in Pong to a fixed step.

What you learned