Tutorial 13: Sprite Sheets and Frame Animation

CNA Tutorial Series  ·  Intermediate

ℹ

What you’ll learn

  • Cutting frames out of one texture with the source-rectangle Draw overload.
  • Advancing frames on a timer to produce an animation.
  • The reusable animation-player pattern.

Before you start — Tutorial 08: Loading and Drawing Textures for the Draw overloads and Tutorial 12: Moving Sprites and Basic Animation for the per-frame timing this builds on.

What Is a Sprite Sheet?

A sprite sheet (also called a texture atlas or sprite strip) is a single image file that contains multiple animation frames arranged in a grid. Instead of loading one file per frame, you load one PNG and select different rectangular regions of it to draw each frame.

Benefits:

  • Fewer GPU texture switches — SpriteBatch batches draws to the same texture more efficiently.
  • Fewer file I/O operations — one asset load instead of dozens.
  • Easier asset management — all frames of an animation live in one file.

A typical 4-frame walk cycle sprite sheet (each frame 64×64) looks like:

// Sheet layout (256 x 64 pixels total):
// +--------+--------+--------+--------+
// | frame0 | frame1 | frame2 | frame3 |
// |  64x64 |  64x64 |  64x64 |  64x64 |
// +--------+--------+--------+--------+
//  x=0      x=64     x=128    x=192

Source Rectangle (Rectangle Struct)

Rectangle defines a rectangular region in screen or texture coordinates with four integer members: X, Y, Width, Height.

#include "Microsoft/Xna/Framework/Rectangle.hpp"

Rectangle r(10, 20, 64, 64);  // left=10, top=20, width=64, height=64

// Access members
int x = r.X;         // left edge
int y = r.Y;         // top edge
int w = r.Width;
int h = r.Height;

// Derived properties
int right  = r.getRightProperty();   // X + Width
int bottom = r.getBottomProperty();  // Y + Height
Point centre = r.getCenterProperty();

// Collision / containment
bool inside = r.Contains(Point(50, 50));
bool overlap = r.Intersects(Rectangle(0, 0, 100, 100));

To draw frame 2 (x=128) from a 64×64 sprite sheet:

Rectangle frame2src(128, 0, 64, 64);  // x=128, y=0, w=64, h=64
spriteBatch_->Draw(*sheetTex_, position_, frame2src, Color::White);

Frame-Based Animation

Animation works by advancing the source rectangle over time. Given a sheet with N frames of width W arranged in a single horizontal row:

const int frameWidth  = 64;
const int frameHeight = 64;
const int frameCount  = 4;       // 4-frame walk cycle
float     frameDuration = 0.12f; // seconds per frame (about 8fps)

int   currentFrame_ = 0;
float frameTimer_   = 0.0f;

// In Update():
void MyGame::Update(GameTime& gameTime) {
    float dt = static_cast<float>(gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty());

    frameTimer_ += dt;
    if (frameTimer_ >= frameDuration) {
        frameTimer_ -= frameDuration;
        currentFrame_ = (currentFrame_ + 1) % frameCount;  // loop
    }
}

// In Draw():
void MyGame::Draw(const GameTime&) {
    Rectangle src(currentFrame_ * frameWidth, 0, frameWidth, frameHeight);
    spriteBatch_->Draw(*sheetTex_, position_, src, Color::White);
}

This advances one frame every frameDuration seconds and wraps back to frame 0 after the last frame.

AnimationPlayer Pattern

When your game has multiple animations (idle, walk, run, jump, attack), encapsulate the animation logic in a reusable class. Here is a complete AnimationPlayer:

Animation.hpp

#pragma once
#include "Microsoft/Xna/Framework/Rectangle.hpp"

// CNA's types live in namespaces; a using-declaration keeps the code below short.
// (In a larger project, put your own types in a namespace and qualify instead.)
using Microsoft::Xna::Framework::Rectangle;

struct Animation {
    int   frameWidth;
    int   frameHeight;
    int   frameCount;
    int   sheetRow;        // row in the sprite sheet (y = sheetRow * frameHeight)
    float frameDuration;   // seconds per frame
    bool  loop = true;     // loop or play once

    // Get the source rectangle for a given frame index
    Rectangle GetSourceRect(int frame) const {
        return Rectangle(
            frame * frameWidth,
            sheetRow * frameHeight,
            frameWidth,
            frameHeight
        );
    }
};

AnimationPlayer.hpp

#pragma once
#include "Animation.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteEffects.hpp"

using Microsoft::Xna::Framework::Color;
using Microsoft::Xna::Framework::Vector2;
using Microsoft::Xna::Framework::Graphics::SpriteBatch;
using Microsoft::Xna::Framework::Graphics::SpriteEffects;
using Microsoft::Xna::Framework::Graphics::Texture2D;

class AnimationPlayer {
public:
    void Play(const Animation& anim) {
        if (&anim != currentAnim_) {
            currentAnim_  = &anim;
            currentFrame_ = 0;
            frameTimer_   = 0.0f;
            finished_     = false;
        }
    }

    void Update(float dt) {
        if (!currentAnim_ || finished_) return;

        frameTimer_ += dt;
        if (frameTimer_ >= currentAnim_->frameDuration) {
            frameTimer_ -= currentAnim_->frameDuration;
            currentFrame_++;
            if (currentFrame_ >= currentAnim_->frameCount) {
                if (currentAnim_->loop) {
                    currentFrame_ = 0;
                } else {
                    currentFrame_ = currentAnim_->frameCount - 1;
                    finished_ = true;
                }
            }
        }
    }

    void Draw(SpriteBatch& batch, Texture2D& sheet,
              Vector2 position, SpriteEffects effects = SpriteEffects::None) {
        if (!currentAnim_) return;
        Rectangle src = currentAnim_->GetSourceRect(currentFrame_);
        batch.Draw(sheet, position, src, Color::White,
                   0.0f, Vector2::Zero, 1.0f, effects, 0.0f);
    }

    bool IsFinished() const { return finished_; }

private:
    const Animation* currentAnim_ = nullptr;
    int   currentFrame_ = 0;
    float frameTimer_   = 0.0f;
    bool  finished_     = false;
};

Timer-Based Frame Switching

Here is a complete game that loads a sprite sheet and plays a walk animation using AnimationPlayer:

#include <algorithm>
#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteEffects.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Animation.hpp"
#include "AnimationPlayer.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

class WalkDemo final : public Game {
public:
    WalkDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());

        // Sprite sheet: 256x128, two rows (64x64 per frame): row 0 has the
        // 4-frame walk cycle, row 1 a 2-frame idle blink. Facing left is done
        // with SpriteEffects::FlipHorizontally instead of a second set of frames.
        // (A relative path is resolved against the working directory.)
        sheet_ = std::make_unique<Texture2D>("assets/character.png",
                                              getGraphicsDeviceProperty());

        // Define animations (row in sheet, number of frames, fps)
        walkAnim_ = Animation{ 64, 64, 4, 0, 0.10f, true };   // 10fps walk cycle
        idleAnim_ = Animation{ 64, 64, 2, 1, 0.40f, true };   // slow idle blink

        // Start with idle
        player_.Play(idleAnim_);
    }

    void Update(GameTime& gameTime) override {
        float dt = static_cast<float>(gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty());
        KeyboardState kb = Keyboard::GetState();

        Vector2 input(0.0f, 0.0f);
        bool moving = false;
        SpriteEffects flip = SpriteEffects::None;

        if (kb.IsKeyDown(Keys::A) || kb.IsKeyDown(Keys::Left)) {
            input.X -= 1.0f;
            moving = true;
            flip = SpriteEffects::FlipHorizontally;  // face left
        }
        if (kb.IsKeyDown(Keys::D) || kb.IsKeyDown(Keys::Right)) {
            input.X += 1.0f;
            moving = true;
        }
        if (kb.IsKeyDown(Keys::W) || kb.IsKeyDown(Keys::Up)) {
            input.Y -= 1.0f;
            moving = true;
        }
        if (kb.IsKeyDown(Keys::S) || kb.IsKeyDown(Keys::Down)) {
            input.Y += 1.0f;
            moving = true;
        }

        // Switch animation based on movement
        if (moving) {
            player_.Play(walkAnim_);
        } else {
            player_.Play(idleAnim_);
        }

        flip_ = flip;

        // Move
        if (input.Length() > 0.0f) input = Vector2::Normalize(input);
        const float speed = 180.0f;
        pos_ += input * speed * dt;

        // Clamp to screen
        auto& vp = getGraphicsDeviceProperty().getViewportProperty();
        pos_.X = std::clamp(pos_.X, 0.0f, static_cast<float>(vp.getWidthProperty())  - 64.0f);
        pos_.Y = std::clamp(pos_.Y, 0.0f, static_cast<float>(vp.getHeightProperty()) - 64.0f);

        // Advance animation
        player_.Update(dt);

        if (kb.IsKeyDown(Keys::Escape)) Exit();
    }

    void Draw(const GameTime&) override {
        auto& device = getGraphicsDeviceProperty();
        device.Clear(Color::CornflowerBlue);

        // The PNG is loaded straight from disk, so it has straight (not
        // premultiplied) alpha: draw it with NonPremultiplied blending (Tutorial 22).
        spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::NonPremultiplied);
        player_.Draw(*spriteBatch_, *sheet_, pos_, flip_);
        spriteBatch_->End();
        // Game::EndDraw() presents the frame after Draw() returns.
    }

private:
    GraphicsDeviceManager graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    std::unique_ptr<Texture2D> sheet_;

    Animation     walkAnim_;
    Animation     idleAnim_;
    AnimationPlayer player_;

    Vector2 pos_{368.0f, 268.0f};
    SpriteEffects flip_ = SpriteEffects::None;
};

The demo:

  • Uses WASD or arrow keys to move the character.
  • Switches to the walk animation when moving, idle animation when still.
  • Flips the sprite horizontally when moving left.
  • Clamps the character inside the screen boundaries.
  • The AnimationPlayer handles frame timing and looping automatically.

Multi-row sprite sheets

Extend the pattern to handle sheets with multiple animation rows by specifying sheetRow:

// Sheet layout:
// Row 0 (y=0):   idle — 2 frames
// Row 1 (y=64):  walk — 4 frames
// Row 2 (y=128): run  — 6 frames
// Row 3 (y=192): jump — 3 frames (one-shot)

Animation idleAnim_{ 64, 64, 2, 0, 0.5f,  true  };
Animation walkAnim_{ 64, 64, 4, 1, 0.10f, true  };
Animation runAnim_ { 64, 64, 6, 2, 0.07f, true  };
Animation jumpAnim_{ 64, 64, 3, 3, 0.15f, false }; // plays once

// Play jump on spacebar press, auto-return to walk when finished
if (jumpPressed) player_.Play(jumpAnim_);
if (player_.IsFinished()) player_.Play(walkAnim_);

Congratulations — you have completed the core 2D track! You now know how to open a window, use the game lifecycle, move objects with delta time, render sprites and text, handle keyboard and mouse input, and animate sprite sheets. The next tutorial adds sound; after that the series continues with content, 2D techniques, 3D rendering and more — see the tutorial index, or explore the documentation for audio, 3D rendering, and advanced effects.