SDL School
SDL version Which one?

Platformer

This is the final project, and it uses most of the course. Click the game, then use the arrow keys or A and D to walk, and Space to jump. Collect the coins, then reach the exit. The small squares at the top show the coins that are left, and the exit turns green after you collect them all.

This game needs a physical keyboard.

The full program

main.c
/* A small platformer. Arrow keys or A and D to move, and Space to jump.
   The level comes from a text file. Collect each coin, then reach the flag. */
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define TILE_SIZE 32
#define LEVEL_PATH "assets/level.txt"
#define SHEET_PATH "assets/walker.png"
#define MAX_LEVEL_WIDTH 256
#define MAX_LEVEL_HEIGHT 64

#define FIXED_STEP (1.0f / 120.0f) /* seconds */
#define MAX_FRAME_TIME 0.25f       /* seconds */

#define PLAYER_WIDTH 22.0f
#define PLAYER_HEIGHT 30.0f
#define WALK_SPEED 190.0f   /* pixels per second */
#define JUMP_SPEED 430.0f
#define GRAVITY 1150.0f     /* pixels per second, per second */
#define MAX_FALL_SPEED 560.0f

#define SPRITE_SIZE 16.0f
#define DRAW_SIZE 32.0f
#define FRAME_COUNT 4
#define SECONDS_PER_FRAME 0.1f

#define SOLID '#'
#define COIN 'o'
#define START 'S'
#define EXIT 'E'
#define EMPTY '.'

typedef struct {
    bool left;
    bool right;
    bool jump;
} Input;

typedef struct {
    float x;
    float y;
    float vx;
    float vy;
    bool on_ground;
    bool jump_held; /* A jump needs a new key press. */
    bool facing_left;
} Player;

typedef struct {
    char tiles[MAX_LEVEL_HEIGHT][MAX_LEVEL_WIDTH];
    int width;  /* tiles */
    int height;
    float start_x;
    float start_y;
    int coin_count;
} Level;

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

static Level level;
static Player player;
static int coins_left = 0;
static bool won = false;
static float accumulator = 0.0f;
static float camera_x = 0.0f;
static int current_frame = 0;
static float frame_timer = 0.0f;

/* ---- The level ---- */

/* Store one line of the level. Return false for a line that does not fit. */
static bool add_level_row(const char *line, int width)
{
    int row = level.height;

    if (row >= MAX_LEVEL_HEIGHT || width > MAX_LEVEL_WIDTH) {
        return false;
    }

    for (int col = 0; col < width; col++) {
        char tile = line[col];

        if (tile == START) {
            level.start_x = (float)(col * TILE_SIZE);
            level.start_y = (float)(row * TILE_SIZE);
            tile = EMPTY;
        } else if (tile == COIN) {
            level.coin_count++;
        }
        level.tiles[row][col] = tile;
    }

    level.width = SDL_max(level.width, width);
    level.height++;
    return true;
}

/* Read each line of the text into the tile grid. */
static bool parse_level(char *text)
{
    char *line = text;

    SDL_zero(level);

    while (line != NULL) {
        char *newline = SDL_strchr(line, '\n');
        int width;

        if (newline != NULL) {
            *newline = '\0';
        }

        width = (int)SDL_strlen(line);
        if (width > 0 && line[width - 1] == '\r') {
            width--;
        }

        if (width > 0 && !add_level_row(line, width)) {
            SDL_Log("The level is too large.");
            return false;
        }

        line = (newline != NULL) ? newline + 1 : NULL;
    }
    return level.height > 0;
}

static bool load_level(const char *path)
{
    char *text = SDL_LoadFile(path, NULL);
    bool ok;

    if (text == NULL) {
        SDL_Log("SDL_LoadFile failed: %s", SDL_GetError());
        return false;
    }

    ok = parse_level(text);
    SDL_free(text);
    return ok;
}

/* The space outside the sides of the level is solid. The space below is not,
   so the player can fall. */
static bool is_solid(int col, int row)
{
    if (col < 0 || col >= level.width) {
        return true;
    }
    if (row < 0 || row >= level.height) {
        return false;
    }
    return level.tiles[row][col] == SOLID;
}

/* Test each tile that the player's rectangle touches. */
static bool player_hits_solid(float x, float y)
{
    int left = (int)SDL_floorf(x / TILE_SIZE);
    int right = (int)SDL_floorf((x + PLAYER_WIDTH - 0.01f) / TILE_SIZE);
    int top = (int)SDL_floorf(y / TILE_SIZE);
    int bottom = (int)SDL_floorf((y + PLAYER_HEIGHT - 0.01f) / TILE_SIZE);

    for (int row = top; row <= bottom; row++) {
        for (int col = left; col <= right; col++) {
            if (is_solid(col, row)) {
                return true;
            }
        }
    }
    return false;
}

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

static void reset_player(void)
{
    SDL_zero(player);
    player.x = level.start_x;
    player.y = level.start_y;
}

/* Put the level back as the file describes it. Coins come back too. */
static void restart(void)
{
    reset_player();
    coins_left = level.coin_count;
    won = false;
    for (int row = 0; row < level.height; row++) {
        for (int col = 0; col < level.width; col++) {
            if (level.tiles[row][col] == '*') {
                level.tiles[row][col] = COIN; /* A collected coin. */
            }
        }
    }
}

/* Move on the x axis, and stop at the edge of a solid tile. */
static void move_x(float dt)
{
    float next = player.x + player.vx * dt;

    if (player_hits_solid(next, player.y)) {
        if (player.vx > 0.0f) {
            int col = (int)SDL_floorf((next + PLAYER_WIDTH) / TILE_SIZE);
            next = (float)(col * TILE_SIZE) - PLAYER_WIDTH;
        } else {
            int col = (int)SDL_floorf(next / TILE_SIZE);
            next = (float)((col + 1) * TILE_SIZE);
        }
        player.vx = 0.0f;
    }
    player.x = next;
}

/* Move on the y axis. A stop at a floor means that the player can jump. */
static void move_y(float dt)
{
    float next = player.y + player.vy * dt;

    player.on_ground = false;

    if (player_hits_solid(player.x, next)) {
        if (player.vy > 0.0f) {
            int row = (int)SDL_floorf((next + PLAYER_HEIGHT) / TILE_SIZE);
            next = (float)(row * TILE_SIZE) - PLAYER_HEIGHT;
            player.on_ground = true;
        } else {
            int row = (int)SDL_floorf(next / TILE_SIZE);
            next = (float)((row + 1) * TILE_SIZE);
        }
        player.vy = 0.0f;
    }
    player.y = next;
}

/* Collect a coin or reach the exit, at the middle of the player. */
static void check_tile(void)
{
    int col = (int)SDL_floorf((player.x + PLAYER_WIDTH / 2.0f) / TILE_SIZE);
    int row = (int)SDL_floorf((player.y + PLAYER_HEIGHT / 2.0f) / TILE_SIZE);

    if (col < 0 || col >= level.width || row < 0 || row >= level.height) {
        return;
    }

    if (level.tiles[row][col] == COIN) {
        level.tiles[row][col] = '*';
        coins_left--;
    } else if (level.tiles[row][col] == EXIT && coins_left == 0) {
        won = true;
    }
}

/* One fixed step of the game. This code reads no keys and draws nothing. */
static void step_game(Input input)
{
    float dt = FIXED_STEP;

    if (won) {
        return;
    }

    player.vx = 0.0f;
    if (input.left) {
        player.vx = -WALK_SPEED;
        player.facing_left = true;
    }
    if (input.right) {
        player.vx = WALK_SPEED;
        player.facing_left = false;
    }

    if (input.jump && !player.jump_held && player.on_ground) {
        player.vy = -JUMP_SPEED;
    }
    player.jump_held = input.jump;

    player.vy = SDL_min(player.vy + GRAVITY * dt, MAX_FALL_SPEED);

    move_x(dt);
    move_y(dt);
    check_tile();

    if (player.y > (float)(level.height * TILE_SIZE)) {
        reset_player(); /* The player fell. The coins stay collected. */
    }
}

static void update_animation(float dt)
{
    if (player.vx == 0.0f || !player.on_ground) {
        current_frame = 2; /* The standing frame. */
        return;
    }

    frame_timer += dt;
    if (frame_timer >= SECONDS_PER_FRAME) {
        frame_timer -= SECONDS_PER_FRAME;
        current_frame = (current_frame + 1) % FRAME_COUNT;
    }
}

/* Keep the player near the middle, and stay inside the level. */
static void update_camera(void)
{
    float level_width = (float)(level.width * TILE_SIZE);
    float target = player.x + PLAYER_WIDTH / 2.0f - WINDOW_WIDTH / 2.0f;

    camera_x = SDL_clamp(target, 0.0f, SDL_max(level_width - WINDOW_WIDTH, 0.0f));
}

/* ---- Input and drawing ---- */

static Input read_input(void)
{
    const bool *keys = SDL_GetKeyboardState(NULL);
    Input input;

    input.left = keys[SDL_SCANCODE_LEFT] || keys[SDL_SCANCODE_A];
    input.right = keys[SDL_SCANCODE_RIGHT] || keys[SDL_SCANCODE_D];
    input.jump = keys[SDL_SCANCODE_SPACE] || keys[SDL_SCANCODE_UP] || keys[SDL_SCANCODE_W];
    return input;
}

static void draw_tile(char tile, float x, float y)
{
    SDL_FRect rect = { x, y, TILE_SIZE, TILE_SIZE };

    if (tile == SOLID) {
        SDL_SetRenderDrawColor(renderer, 70, 74, 84, SDL_ALPHA_OPAQUE);
        SDL_RenderFillRect(renderer, &rect);
    } else if (tile == COIN) {
        SDL_FRect coin = { x + 10.0f, y + 10.0f, 12.0f, 12.0f };

        SDL_SetRenderDrawColor(renderer, 255, 214, 92, SDL_ALPHA_OPAQUE);
        SDL_RenderFillRect(renderer, &coin);
    } else if (tile == EXIT) {
        if (coins_left == 0) {
            SDL_SetRenderDrawColor(renderer, 80, 200, 160, SDL_ALPHA_OPAQUE);
        } else {
            SDL_SetRenderDrawColor(renderer, 120, 60, 64, SDL_ALPHA_OPAQUE);
        }
        SDL_RenderFillRect(renderer, &rect);
    }
}

/* Draw only the columns that the camera can see. */
static void draw_level(void)
{
    int first = (int)(camera_x / TILE_SIZE);
    int last = SDL_min(first + WINDOW_WIDTH / TILE_SIZE + 1, level.width - 1);

    for (int row = 0; row < level.height; row++) {
        for (int col = first; col <= last; col++) {
            draw_tile(level.tiles[row][col], (float)(col * TILE_SIZE) - camera_x,
                      (float)(row * TILE_SIZE));
        }
    }
}

static void draw_player(void)
{
    SDL_FRect source = { (float)current_frame * SPRITE_SIZE, 0.0f, SPRITE_SIZE, SPRITE_SIZE };
    SDL_FRect destination = {
        player.x + PLAYER_WIDTH / 2.0f - DRAW_SIZE / 2.0f - camera_x,
        player.y + PLAYER_HEIGHT - DRAW_SIZE,
        DRAW_SIZE,
        DRAW_SIZE
    };
    SDL_FlipMode flip = player.facing_left ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE;

    SDL_RenderTextureRotated(renderer, sheet, &source, &destination, 0.0, NULL, flip);
}

/* One small square for each coin that is left. */
static void draw_coins_left(void)
{
    SDL_SetRenderDrawColor(renderer, 255, 214, 92, SDL_ALPHA_OPAQUE);
    for (int i = 0; i < coins_left; i++) {
        SDL_FRect mark = { 12.0f + (float)i * 16.0f, 12.0f, 10.0f, 10.0f };
        SDL_RenderFillRect(renderer, &mark);
    }
}

static void draw(void)
{
    if (won) {
        SDL_SetRenderDrawColor(renderer, 24, 84, 60, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    }
    SDL_RenderClear(renderer);

    draw_level();
    draw_player();
    draw_coins_left();
    SDL_RenderPresent(renderer);
}

static SDL_Texture *load_texture(const char *path)
{
    SDL_Surface *surface = SDL_LoadPNG(path);
    SDL_Texture *texture;

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

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

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

    SDL_SetRenderVSync(renderer, 1);

    sheet = load_texture(SHEET_PATH);
    if (sheet == NULL || !load_level(LEVEL_PATH)) {
        return SDL_APP_FAILURE;
    }
    SDL_SetTextureScaleMode(sheet, SDL_SCALEMODE_NEAREST);

    restart();
    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_KEY_DOWN && !event->key.repeat && won) {
        restart();
    }
    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;
    Input input = read_input();

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

    accumulator += frame_time;
    while (accumulator >= FIXED_STEP) {
        step_game(input);
        accumulator -= FIXED_STEP;
    }

    update_animation(frame_time);
    update_camera();
    draw();
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
    SDL_DestroyTexture(sheet);
}
main.c
/* A small platformer. Arrow keys or A and D to move, and Space to jump.
   The level comes from a text file. Collect each coin, then reach the flag. */
#include <SDL.h>
#include <SDL_image.h>
#include <stdbool.h>

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

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define TILE_SIZE 32
#define LEVEL_PATH "assets/level.txt"
#define SHEET_PATH "assets/walker.png"
#define MAX_LEVEL_WIDTH 256
#define MAX_LEVEL_HEIGHT 64

#define FIXED_STEP (1.0f / 120.0f) /* seconds */
#define MAX_FRAME_TIME 0.25f       /* seconds */

#define PLAYER_WIDTH 22.0f
#define PLAYER_HEIGHT 30.0f
#define WALK_SPEED 190.0f   /* pixels per second */
#define JUMP_SPEED 430.0f
#define GRAVITY 1150.0f     /* pixels per second, per second */
#define MAX_FALL_SPEED 560.0f

#define SPRITE_SIZE 16
#define DRAW_SIZE 32.0f
#define FRAME_COUNT 4
#define SECONDS_PER_FRAME 0.1f

#define SOLID '#'
#define COIN 'o'
#define START 'S'
#define EXIT 'E'
#define EMPTY '.'

typedef struct {
    bool left;
    bool right;
    bool jump;
} Input;

typedef struct {
    float x;
    float y;
    float vx;
    float vy;
    bool on_ground;
    bool jump_held; /* A jump needs a new key press. */
    bool facing_left;
} Player;

typedef struct {
    char tiles[MAX_LEVEL_HEIGHT][MAX_LEVEL_WIDTH];
    int width;  /* tiles */
    int height;
    float start_x;
    float start_y;
    int coin_count;
} Level;

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

static Level level;
static Player player;
static int coins_left = 0;
static bool won = false;
static float accumulator = 0.0f;
static float camera_x = 0.0f;
static int current_frame = 0;
static float frame_timer = 0.0f;
static bool running = true;

/* ---- The level ---- */

/* Store one line of the level. Return false for a line that does not fit. */
static bool add_level_row(const char *line, int width)
{
    int row = level.height;

    if (row >= MAX_LEVEL_HEIGHT || width > MAX_LEVEL_WIDTH) {
        return false;
    }

    for (int col = 0; col < width; col++) {
        char tile = line[col];

        if (tile == START) {
            level.start_x = (float)(col * TILE_SIZE);
            level.start_y = (float)(row * TILE_SIZE);
            tile = EMPTY;
        } else if (tile == COIN) {
            level.coin_count++;
        }
        level.tiles[row][col] = tile;
    }

    level.width = SDL_max(level.width, width);
    level.height++;
    return true;
}

/* Read each line of the text into the tile grid. */
static bool parse_level(char *text)
{
    char *line = text;

    SDL_zero(level);

    while (line != NULL) {
        char *newline = SDL_strchr(line, '\n');
        int width;

        if (newline != NULL) {
            *newline = '\0';
        }

        width = (int)SDL_strlen(line);
        if (width > 0 && line[width - 1] == '\r') {
            width--;
        }

        if (width > 0 && !add_level_row(line, width)) {
            SDL_Log("The level is too large.");
            return false;
        }

        line = (newline != NULL) ? newline + 1 : NULL;
    }
    return level.height > 0;
}

static bool load_level(const char *path)
{
    char *text = SDL_LoadFile(path, NULL);
    bool ok;

    if (text == NULL) {
        SDL_Log("SDL_LoadFile failed: %s", SDL_GetError());
        return false;
    }

    ok = parse_level(text);
    SDL_free(text);
    return ok;
}

/* The space outside the sides of the level is solid. The space below is not,
   so the player can fall. */
static bool is_solid(int col, int row)
{
    if (col < 0 || col >= level.width) {
        return true;
    }
    if (row < 0 || row >= level.height) {
        return false;
    }
    return level.tiles[row][col] == SOLID;
}

/* Test each tile that the player's rectangle touches. */
static bool player_hits_solid(float x, float y)
{
    int left = (int)SDL_floorf(x / TILE_SIZE);
    int right = (int)SDL_floorf((x + PLAYER_WIDTH - 0.01f) / TILE_SIZE);
    int top = (int)SDL_floorf(y / TILE_SIZE);
    int bottom = (int)SDL_floorf((y + PLAYER_HEIGHT - 0.01f) / TILE_SIZE);

    for (int row = top; row <= bottom; row++) {
        for (int col = left; col <= right; col++) {
            if (is_solid(col, row)) {
                return true;
            }
        }
    }
    return false;
}

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

static void reset_player(void)
{
    SDL_zero(player);
    player.x = level.start_x;
    player.y = level.start_y;
}

/* Put the level back as the file describes it. Coins come back too. */
static void restart(void)
{
    reset_player();
    coins_left = level.coin_count;
    won = false;
    for (int row = 0; row < level.height; row++) {
        for (int col = 0; col < level.width; col++) {
            if (level.tiles[row][col] == '*') {
                level.tiles[row][col] = COIN; /* A collected coin. */
            }
        }
    }
}

/* Move on the x axis, and stop at the edge of a solid tile. */
static void move_x(float dt)
{
    float next = player.x + player.vx * dt;

    if (player_hits_solid(next, player.y)) {
        if (player.vx > 0.0f) {
            int col = (int)SDL_floorf((next + PLAYER_WIDTH) / TILE_SIZE);
            next = (float)(col * TILE_SIZE) - PLAYER_WIDTH;
        } else {
            int col = (int)SDL_floorf(next / TILE_SIZE);
            next = (float)((col + 1) * TILE_SIZE);
        }
        player.vx = 0.0f;
    }
    player.x = next;
}

/* Move on the y axis. A stop at a floor means that the player can jump. */
static void move_y(float dt)
{
    float next = player.y + player.vy * dt;

    player.on_ground = false;

    if (player_hits_solid(player.x, next)) {
        if (player.vy > 0.0f) {
            int row = (int)SDL_floorf((next + PLAYER_HEIGHT) / TILE_SIZE);
            next = (float)(row * TILE_SIZE) - PLAYER_HEIGHT;
            player.on_ground = true;
        } else {
            int row = (int)SDL_floorf(next / TILE_SIZE);
            next = (float)((row + 1) * TILE_SIZE);
        }
        player.vy = 0.0f;
    }
    player.y = next;
}

/* Collect a coin or reach the exit, at the middle of the player. */
static void check_tile(void)
{
    int col = (int)SDL_floorf((player.x + PLAYER_WIDTH / 2.0f) / TILE_SIZE);
    int row = (int)SDL_floorf((player.y + PLAYER_HEIGHT / 2.0f) / TILE_SIZE);

    if (col < 0 || col >= level.width || row < 0 || row >= level.height) {
        return;
    }

    if (level.tiles[row][col] == COIN) {
        level.tiles[row][col] = '*';
        coins_left--;
    } else if (level.tiles[row][col] == EXIT && coins_left == 0) {
        won = true;
    }
}

/* One fixed step of the game. This code reads no keys and draws nothing. */
static void step_game(Input input)
{
    float dt = FIXED_STEP;

    if (won) {
        return;
    }

    player.vx = 0.0f;
    if (input.left) {
        player.vx = -WALK_SPEED;
        player.facing_left = true;
    }
    if (input.right) {
        player.vx = WALK_SPEED;
        player.facing_left = false;
    }

    if (input.jump && !player.jump_held && player.on_ground) {
        player.vy = -JUMP_SPEED;
    }
    player.jump_held = input.jump;

    player.vy = SDL_min(player.vy + GRAVITY * dt, MAX_FALL_SPEED);

    move_x(dt);
    move_y(dt);
    check_tile();

    if (player.y > (float)(level.height * TILE_SIZE)) {
        reset_player(); /* The player fell. The coins stay collected. */
    }
}

static void update_animation(float dt)
{
    if (player.vx == 0.0f || !player.on_ground) {
        current_frame = 2; /* The standing frame. */
        return;
    }

    frame_timer += dt;
    if (frame_timer >= SECONDS_PER_FRAME) {
        frame_timer -= SECONDS_PER_FRAME;
        current_frame = (current_frame + 1) % FRAME_COUNT;
    }
}

/* Keep the player near the middle, and stay inside the level. */
static void update_camera(void)
{
    float level_width = (float)(level.width * TILE_SIZE);
    float target = player.x + PLAYER_WIDTH / 2.0f - WINDOW_WIDTH / 2.0f;

    camera_x = SDL_clamp(target, 0.0f, SDL_max(level_width - WINDOW_WIDTH, 0.0f));
}

/* ---- Input and drawing ---- */

static Input read_input(void)
{
    const Uint8 *keys = SDL_GetKeyboardState(NULL);
    Input input;

    input.left = keys[SDL_SCANCODE_LEFT] || keys[SDL_SCANCODE_A];
    input.right = keys[SDL_SCANCODE_RIGHT] || keys[SDL_SCANCODE_D];
    input.jump = keys[SDL_SCANCODE_SPACE] || keys[SDL_SCANCODE_UP] || keys[SDL_SCANCODE_W];
    return input;
}

static void draw_tile(char tile, float x, float y)
{
    SDL_FRect rect = { x, y, TILE_SIZE, TILE_SIZE };

    if (tile == SOLID) {
        SDL_SetRenderDrawColor(renderer, 70, 74, 84, SDL_ALPHA_OPAQUE);
        SDL_RenderFillRectF(renderer, &rect);
    } else if (tile == COIN) {
        SDL_FRect coin = { x + 10.0f, y + 10.0f, 12.0f, 12.0f };

        SDL_SetRenderDrawColor(renderer, 255, 214, 92, SDL_ALPHA_OPAQUE);
        SDL_RenderFillRectF(renderer, &coin);
    } else if (tile == EXIT) {
        if (coins_left == 0) {
            SDL_SetRenderDrawColor(renderer, 80, 200, 160, SDL_ALPHA_OPAQUE);
        } else {
            SDL_SetRenderDrawColor(renderer, 120, 60, 64, SDL_ALPHA_OPAQUE);
        }
        SDL_RenderFillRectF(renderer, &rect);
    }
}

/* Draw only the columns that the camera can see. */
static void draw_level(void)
{
    int first = (int)(camera_x / TILE_SIZE);
    int last = SDL_min(first + WINDOW_WIDTH / TILE_SIZE + 1, level.width - 1);

    for (int row = 0; row < level.height; row++) {
        for (int col = first; col <= last; col++) {
            draw_tile(level.tiles[row][col], (float)(col * TILE_SIZE) - camera_x,
                      (float)(row * TILE_SIZE));
        }
    }
}

static void draw_player(void)
{
    SDL_Rect source = { current_frame * SPRITE_SIZE, 0, SPRITE_SIZE, SPRITE_SIZE };
    SDL_FRect destination = {
        player.x + PLAYER_WIDTH / 2.0f - DRAW_SIZE / 2.0f - camera_x,
        player.y + PLAYER_HEIGHT - DRAW_SIZE,
        DRAW_SIZE,
        DRAW_SIZE
    };
    SDL_RendererFlip flip = player.facing_left ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE;

    SDL_RenderCopyExF(renderer, sheet, &source, &destination, 0.0, NULL, flip);
}

/* One small square for each coin that is left. */
static void draw_coins_left(void)
{
    SDL_SetRenderDrawColor(renderer, 255, 214, 92, SDL_ALPHA_OPAQUE);
    for (int i = 0; i < coins_left; i++) {
        SDL_FRect mark = { 12.0f + (float)i * 16.0f, 12.0f, 10.0f, 10.0f };
        SDL_RenderFillRectF(renderer, &mark);
    }
}

static void draw(void)
{
    if (won) {
        SDL_SetRenderDrawColor(renderer, 24, 84, 60, SDL_ALPHA_OPAQUE);
    } else {
        SDL_SetRenderDrawColor(renderer, 24, 26, 32, SDL_ALPHA_OPAQUE);
    }
    SDL_RenderClear(renderer);

    draw_level();
    draw_player();
    draw_coins_left();
    SDL_RenderPresent(renderer);
}

static SDL_Texture *load_texture(const char *path)
{
    SDL_Surface *surface = IMG_Load(path);
    SDL_Texture *texture;

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

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

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

    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;
        } else if (event.type == SDL_KEYDOWN && !event.key.repeat && won) {
            restart();
        }
    }

    input = read_input();

    accumulator += frame_time;
    while (accumulator >= FIXED_STEP) {
        step_game(input);
        accumulator -= FIXED_STEP;
    }

    update_animation(frame_time);
    update_camera();
    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, "Platformer");
    SDL_RenderSetVSync(renderer, 1);

    sheet = load_texture(SHEET_PATH);
    if (sheet == NULL || !load_level(LEVEL_PATH)) {
        SDL_Quit();
        return 1;
    }
    SDL_SetTextureScaleMode(sheet, SDL_ScaleModeNearest);

    restart();
    last_ticks = SDL_GetPerformanceCounter();

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

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

To build this program, use the setup from Load an image. Save level.txt and walker.png in your assets folder.

The level is a text file

This uses the file skills from Read a file. Each character is one tile:

Character Meaning
. Empty space
# A solid block
o A coin
S The start
E The exit

You can open level.txt in any text editor and draw a new level, with no new build. The program stores the tiles in a two-dimensional array, and level.tiles[row][col] gives one tile.

Separate the input from the rules

typedef struct {
    bool left;
    bool right;
    bool jump;
} Input;

read_input turns keys into this small struct. step_game takes the struct, reads no keys, and draws nothing. This has a big benefit: a test program can drive the game with no window. That is how this level was tested. A search program ran the real step_game through every position that a player can reach. It proved that a player can reach each coin and the exit. The first version of the level failed this test.

A fixed step for jumps

The game uses the accumulator from Fixed time steps, so a jump reaches the same height on every computer. This matters here, because the level depends on it. A jump rises about 2.5 tiles, and it crosses about 4.4 tiles of flat ground. So the level uses rises of 2 tiles and gaps of 3 tiles. If you change JUMP_SPEED or GRAVITY, a level can become impossible.

Tile collision

Collision against tiles is simple, because a position gives you the tile directly. Divide by the tile size, and you have a column or a row:

    int left = (int)SDL_floorf(x / TILE_SIZE);
    int right = (int)SDL_floorf((x + PLAYER_WIDTH - 0.01f) / TILE_SIZE);

The program tests only the tiles that the player touches, not the whole level. So the cost does not grow with the size of the level. The 0.01f matters: a player that stands exactly on the edge of a tile does not touch the next tile.

Move on one axis at a time

This is the method from Detect collisions. The program moves on x and fixes any overlap, then it does the same on y.

    if (player_hits_solid(player.x, next)) {
        if (player.vy > 0.0f) {
            int row = (int)SDL_floorf((next + PLAYER_HEIGHT) / TILE_SIZE);
            next = (float)(row * TILE_SIZE) - PLAYER_HEIGHT;
            player.on_ground = true;

The code does not undo the move here. It puts the player exactly against the tile, so a fast fall stops at the floor and not above it.

The on_ground flag comes from this code. A player can jump only from the ground, and a floor stop in the last step sets the flag. Gravity pulls the player down in each step, so the flag stays correct while the player stands still.

A jump needs a new press

    if (input.jump && !player.jump_held && player.on_ground) {
        player.vy = -JUMP_SPEED;
    }
    player.jump_held = input.jump;

Without jump_held, the player bounces for as long as you hold the key. The program uses the key state in place of an event, because the key state fits the fixed step.

The camera

The level is wider than the window. A camera is only a number: the position in the level that appears at the left edge of the window.

    float target = player.x + PLAYER_WIDTH / 2.0f - WINDOW_WIDTH / 2.0f;

    camera_x = SDL_clamp(target, 0.0f, SDL_max(level_width - WINDOW_WIDTH, 0.0f));

The game rules use level positions, and the drawing code subtracts camera_x. The player stays near the middle of the window, and the clamp stops the camera at the ends of the level.

The drawing code also uses the camera to skip the tiles outside the window.

Where each idea came from

Part of the game Lesson
Level file Read a file
Physics Fixed time steps
Collision Detect collisions
Walking robot Sprites and animation
Keys Read the keyboard

Try this

  1. Edit level.txt, and make a level of your own.
  2. Add a variable jump, so that a short press makes a short jump.
  3. Add sound, with the code from Play a sound, for a jump and a coin.
  4. Add an enemy that walks between two walls. Use an array of objects.
  5. Add a menu and a win screen, with the code from Scenes.
  6. Load a second level after the first.

What you learned

You now have the main skills to build your own game. Read the SDL documentation for the functions that this course left out, and start a small project of your own.