Published: 2026-07-28 | By: Carlos Montiel | Reading time: ~4 minutes
Colored rectangles are fine for testing logic, but no player wants to look at that. Today we turn those rectangles into real animated characters.
From Rectangles to Textures
In the previous lesson we built Pong using solid rectangles drawn with `SDL_RenderFillRect`. That works for prototyping, but a real game uses sprites: images loaded as textures and drawn on screen. We'll use `SDL2_image` to load PNGs, including transparency (alpha channel).
#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
SDL_Texture* load_texture(SDL_Renderer* renderer, const char* path) {
SDL_Surface* surface = IMG_Load(path);
if (!surface) {
SDL_Log("Error loading image %s: %s", path, IMG_GetError());
return nullptr;
}
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface); // the surface is no longer needed, the texture replaces it
return texture;
}
Don't forget to initialize `SDL2_image` before using it, and to free the texture when the program ends:
IMG_Init(IMG_INIT_PNG);
// ... normal game usage ...
SDL_DestroyTexture(my_texture);
IMG_Quit();
What a Spritesheet Is
Instead of having one image file per animation frame (walking, jumping, attacking), the usual approach is to pack all frames into a single image called a spritesheet, laid out in a grid. To draw a specific frame, we use an `SDL_Rect` as a "crop" (source rect) over that image.
struct Spritesheet {
SDL_Texture* texture;
int frame_width;
int frame_height;
int columns;
};
SDL_Rect get_frame_rect(const Spritesheet& sheet, int frame_index) {
int col = frame_index % sheet.columns;
int row = frame_index / sheet.columns;
return SDL_Rect {
col * sheet.frame_width,
row * sheet.frame_height,
sheet.frame_width,
sheet.frame_height
};
}
Drawing a Specific Frame
`SDL_RenderCopy` takes a source rectangle (where to crop from in the texture) and a destination rectangle (where to draw it on screen).
void draw_sprite(SDL_Renderer* renderer, const Spritesheet& sheet,
int frame_index, int x, int y, int scale = 1) {
SDL_Rect src = get_frame_rect(sheet, frame_index);
SDL_Rect dst {
x, y,
sheet.frame_width * scale,
sheet.frame_height * scale
};
SDL_RenderCopy(renderer, sheet.texture, &src, &dst);
}
Building an Animation Component
To animate, we need to know how long the current frame has been showing and when to advance to the next one. We'll encapsulate this in a reusable structure.
struct Animation {
int current_frame = 0;
int total_frames;
float time_per_frame; // in seconds
float elapsed_time = 0.0f;
void update(float delta) {
elapsed_time += delta;
if (elapsed_time >= time_per_frame) {
elapsed_time -= time_per_frame;
current_frame = (current_frame + 1) % total_frames;
}
}
};
Typical usage inside the main loop:
Animation walk_anim { 0, 6, 0.1f }; // 6 frames, changes every 0.1 seconds
// inside the game loop, in the "update" phase:
walk_anim.update(delta);
// in the "draw" phase:
draw_sprite(renderer, character_sheet, walk_anim.current_frame, player_x, player_y, 2);
Flipping the Sprite Based on Direction
A common problem: the character walks left but the sprite keeps facing right. SDL2 solves this with `SDL_RenderCopyEx`, which lets you flip horizontally without needing a second spritesheet.
void draw_sprite_directional(SDL_Renderer* renderer, const Spritesheet& sheet,
int frame_index, int x, int y,
bool facing_left) {
SDL_Rect src = get_frame_rect(sheet, frame_index);
SDL_Rect dst { x, y, sheet.frame_width * 2, sheet.frame_height * 2 };
SDL_RendererFlip flip = facing_left ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE;
SDL_RenderCopyEx(renderer, sheet.texture, &src, &dst, 0.0, nullptr, flip);
}
Switching Animations Based on State
In a real game, a character has several animations: idle, walking, jumping. A simple approach (which we'll later formalize with a state machine in lesson 7) is to have an `enum` for the current state and pick which `Animation` to use.
enum class CharacterState { IDLE, WALKING, JUMPING };
CharacterState current_state = CharacterState::IDLE;
Animation idle_anim { 0, 2, 0.3f };
Animation walking_anim { 0, 6, 0.1f };
Animation jumping_anim { 0, 3, 0.15f };
Animation& get_active_animation() {
switch (current_state) {
case CharacterState::WALKING: return walking_anim;
case CharacterState::JUMPING: return jumping_anim;
default: return idle_anim;
}
}
A Tip for Working with AI in This Lesson
Animation bugs tend to be subtle: the sprite "freezes" on a frame, or flickers, or gets cropped wrong. When this happens, instead of asking your AI assistant to "fix my animation," give it specific context: paste your `Animation` struct, tell it exactly which frame you expected to see and which one you saw, and the value of your `scale` and `frame_width`. An AI assistant with concrete data catches errors in seconds — a bad division in `columns`, or a `time_per_frame` left at 0 — things that are easy to miss at a glance.
With sprites and animation handled, in the next lesson we'll formalize something we solved on the fly in Pong: collision detection, with real math (AABB and circles) that you'll reuse in every game you build from here on.