Sprite Animation in MonoGame
Sprite Animation in MonoGame
Still sprites are fine for static objects, but characters, enemies, and UI elements come alive with animation. MonoGame itself has no built-in animation system — instead you draw a sub-rectangle of a texture atlas each frame, advancing the rectangle on a timer. This article shows you how to build a robust, reusable animation stack from first principles.
💡 This article assumes you are comfortable with loading a Texture2D and drawing with SpriteBatch. If not, start with the Your First MonoGame Sprite tutorial.
What Is a Sprite Sheet?
A sprite sheet (also called a texture atlas) packs many animation frames into a single image file. Instead of loading dozens of individual textures, you load one and select a rectangular sub-region each frame.
Benefits of sprite sheets:
- Fewer GPU state changes — one texture bind covers an entire character’s animation set
- Faster content pipeline builds — one asset to import and compile
- Smaller memory footprint — GPU textures have alignment overhead per object; one large texture wastes less memory than many small ones
- Simpler asset management — one file per character rather than dozens
Defining Frame Rectangles
For a uniform grid sprite sheet where every frame is the same size, the rectangle for frame at column col and row row is:
int frameWidth = 64; // pixels per frame
int frameHeight = 64;
// Frame (col, row) — zero-indexed
Rectangle GetFrame(int col, int row)
=> new Rectangle(col * frameWidth, row * frameHeight, frameWidth, frameHeight);
// Examples for a sheet with rows: 0=idle, 1=walk, 2=run, 3=jump
Rectangle idleFrame0 = GetFrame(0, 0); // first idle frame
Rectangle walkFrame3 = GetFrame(3, 1); // 4th walk frame
Rectangle runFrame1 = GetFrame(1, 2); // 2nd run frame
Pass the source rectangle to SpriteBatch.Draw():
_spriteBatch.Draw(
texture: _spriteSheet,
position: _playerPosition,
sourceRectangle: GetFrame(currentCol, currentRow), // <-- sub-rectangle
color: Color.White,
rotation: 0f,
origin: Vector2.Zero,
scale: Vector2.One,
effects: SpriteEffects.None,
layerDepth: 0f
);
💡 Power-of-two textures: older OpenGL drivers require texture dimensions to be powers of two (64, 128, 256, 512, 1024…). MonoGame’s DX11 and modern OpenGL backends do not strictly require this, but using PoT dimensions can improve GPU cache performance.
The Animation Class
An Animation describes a named sequence of frames: how many frames, which row on the sheet they occupy, how fast to play them, and whether to loop:
using Microsoft.Xna.Framework;
namespace MySpriteGame;
/// <summary>
/// Describes a single named animation as a horizontal strip of frames
/// on a uniform-grid sprite sheet.
/// </summary>
public class Animation
{
/// <summary>The row on the sprite sheet that contains this animation's frames.</summary>
public int Row { get; }
/// <summary>Number of frames in this animation.</summary>
public int FrameCount { get; }
/// <summary>Frames per second.</summary>
public float Fps { get; }
/// <summary>Whether the animation loops when it reaches the last frame.</summary>
public bool IsLooping { get; }
/// <summary>Duration of one complete cycle in seconds.</summary>
public float Duration => FrameCount / Fps;
public Animation(int row, int frameCount, float fps, bool isLooping = true)
{
Row = row;
FrameCount = frameCount;
Fps = fps;
IsLooping = isLooping;
}
}
// Convenience factory for a common sprite-sheet layout
public static class PlayerAnimations
{
public static readonly Animation Idle = new(row: 0, frameCount: 4, fps: 8f, isLooping: true);
public static readonly Animation Walk = new(row: 1, frameCount: 8, fps: 12f, isLooping: true);
public static readonly Animation Run = new(row: 2, frameCount: 8, fps: 18f, isLooping: true);
public static readonly Animation Jump = new(row: 3, frameCount: 5, fps: 10f, isLooping: false);
public static readonly Animation Land = new(row: 4, frameCount: 3, fps: 12f, isLooping: false);
public static readonly Animation Death = new(row: 5, frameCount: 6, fps: 8f, isLooping: false);
}
The AnimatedSprite Component
AnimatedSprite holds a reference to a sprite sheet texture, the frame dimensions, the current Animation, and a timer. Its Update() advances the frame; its Draw() renders the correct sub-rectangle:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
namespace MySpriteGame;
/// <summary>
/// Renders a character from a uniform-grid sprite sheet and advances
/// frames automatically based on the current Animation's FPS.
/// </summary>
public class AnimatedSprite
{
private readonly Texture2D _sheet;
private readonly int _frameWidth;
private readonly int _frameHeight;
private Animation _current;
private int _currentFrame;
private float _elapsedTime;
private bool _isFinished; // true when a non-looping anim reaches its last frame
public bool IsFinished => _isFinished;
public Animation CurrentAnim => _current;
public bool FacingLeft { get; set; } = false;
// Fired when a non-looping animation completes
public event Action OnAnimationComplete;
public AnimatedSprite(Texture2D sheet, int frameWidth, int frameHeight, Animation startAnim)
{
_sheet = sheet;
_frameWidth = frameWidth;
_frameHeight = frameHeight;
Play(startAnim);
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/// <summary>
/// Switch to a new animation. If the new animation is already playing
/// and resetIfSame is false, the call is ignored (prevents jitter).
/// </summary>
public void Play(Animation anim, bool resetIfSame = false)
{
if (_current == anim && !resetIfSame) return;
_current = anim;
_currentFrame = 0;
_elapsedTime = 0f;
_isFinished = false;
}
/// <summary>Advance the animation timer and step frames.</summary>
public void Update(GameTime gameTime)
{
if (_isFinished) return;
float delta = (float)gameTime.ElapsedGameTime.TotalSeconds;
float frameDuration = 1f / _current.Fps;
_elapsedTime += delta;
while (_elapsedTime >= frameDuration)
{
_elapsedTime -= frameDuration;
_currentFrame++;
if (_currentFrame >= _current.FrameCount)
{
if (_current.IsLooping)
{
_currentFrame = 0;
}
else
{
_currentFrame = _current.FrameCount - 1;
_isFinished = true;
OnAnimationComplete?.Invoke();
break;
}
}
}
}
/// <summary>Draw the current frame at the given position.</summary>
public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color, float scale = 1f)
{
Rectangle source = new Rectangle(
_currentFrame * _frameWidth,
_current.Row * _frameHeight,
_frameWidth,
_frameHeight
);
// Flip horizontally when facing left
SpriteEffects effects = FacingLeft
? SpriteEffects.FlipHorizontally
: SpriteEffects.None;
// Origin at bottom-centre for predictable ground-level placement
Vector2 origin = new Vector2(_frameWidth / 2f, _frameHeight);
spriteBatch.Draw(
texture: _sheet,
position: position,
sourceRectangle: source,
color: color,
rotation: 0f,
origin: origin,
scale: scale,
effects: effects,
layerDepth: 0f
);
}
}
💡 Using a bottom-centre origin (x = frameWidth / 2, y = frameHeight) makes it easy to position a character: set position to the character’s feet, not the top-left of the bounding box. This is the standard convention in most 2D engines.
Animation State Machine
A state machine drives which animation plays based on game logic. Define an enum for all animation states and add transition logic in Update():
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
namespace MySpriteGame;
public enum AnimState { Idle, Walk, Run, Jump, Land, Death }
public class Player
{
private AnimatedSprite _sprite;
private AnimState _state = AnimState.Idle;
public Vector2 Position { get; set; }
public Vector2 Velocity { get; set; }
public bool IsGrounded { get; set; } = true;
public bool IsAlive { get; set; } = true;
public Player(AnimatedSprite sprite)
{
_sprite = sprite;
// Receive notification when a one-shot animation ends
_sprite.OnAnimationComplete += HandleAnimationComplete;
}
public void Update(GameTime gameTime, InputManager input)
{
if (!IsAlive) { _sprite.Update(gameTime); return; }
float delta = (float)gameTime.ElapsedGameTime.TotalSeconds;
// --- Movement ---
float moveX = 0f;
if (input.IsKeyDown(Keys.A) || input.IsKeyDown(Keys.Left)) { moveX = -1f; _sprite.FacingLeft = true; }
if (input.IsKeyDown(Keys.D) || input.IsKeyDown(Keys.Right)) { moveX = 1f; _sprite.FacingLeft = false; }
float speed = input.IsKeyDown(Keys.LeftShift) ? 350f : 180f;
Velocity = new Vector2(moveX * speed, Velocity.Y);
// --- Jump ---
if (IsGrounded && input.IsKeyJustPressed(Keys.Space))
{
Velocity = new Vector2(Velocity.X, -500f); // upward impulse
IsGrounded = false;
}
// Apply gravity
if (!IsGrounded)
Velocity += new Vector2(0f, 900f * delta);
Position += Velocity * delta;
// Simulate landing (replace with real collision)
if (Position.Y >= 400f && !IsGrounded)
{
Position = new Vector2(Position.X, 400f);
Velocity = new Vector2(Velocity.X, 0f);
IsGrounded = true;
TransitionTo(AnimState.Land);
}
// --- State transitions ---
UpdateState(moveX);
_sprite.Update(gameTime);
}
private void UpdateState(float moveX)
{
// Death is terminal — never leave it
if (_state == AnimState.Death) return;
// One-shot animations (Land) play to completion before transitioning
if (_state == AnimState.Land && !_sprite.IsFinished) return;
if (!IsGrounded)
{
TransitionTo(AnimState.Jump);
}
else if (Math.Abs(moveX) > 0.01f)
{
bool running = moveX != 0 /* replace with shift check if needed */;
TransitionTo(AnimState.Walk);
}
else
{
TransitionTo(AnimState.Idle);
}
}
private void TransitionTo(AnimState newState)
{
if (_state == newState) return;
_state = newState;
Animation anim = newState switch
{
AnimState.Idle => PlayerAnimations.Idle,
AnimState.Walk => PlayerAnimations.Walk,
AnimState.Run => PlayerAnimations.Run,
AnimState.Jump => PlayerAnimations.Jump,
AnimState.Land => PlayerAnimations.Land,
AnimState.Death => PlayerAnimations.Death,
_ => PlayerAnimations.Idle
};
_sprite.Play(anim);
}
private void HandleAnimationComplete()
{
// After Land, return to Idle automatically
if (_state == AnimState.Land)
TransitionTo(AnimState.Idle);
}
public void Draw(SpriteBatch spriteBatch)
=> _sprite.Draw(spriteBatch, Position, Color.White);
}
Putting It Together in Game1
Wire everything up in Game1.cs:
private InputManager _input = new InputManager();
private Player _player;
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
Texture2D sheet = Content.Load<Texture2D>("player_sheet");
var sprite = new AnimatedSprite(
sheet: sheet,
frameWidth: 64,
frameHeight: 64,
startAnim: PlayerAnimations.Idle
);
_player = new Player(sprite) { Position = new Vector2(300, 400) };
}
protected override void Update(GameTime gameTime)
{
_input.Update();
_player.Update(gameTime, _input);
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.DarkSlateBlue);
_spriteBatch.Begin(samplerState: SamplerState.PointClamp);
_player.Draw(_spriteBatch);
_spriteBatch.End();
base.Draw(gameTime);
}
💡 Use SamplerState.PointClamp with pixel-art sprites to prevent blurry sub-pixel interpolation. Use SamplerState.LinearClamp for smooth high-resolution sprites.
Tips and Tricks
Flipping with SpriteEffects
Rather than creating separate left-facing sprite sheets, flip the sprite horizontally at draw time using SpriteEffects.FlipHorizontally (already handled by the FacingLeft property in AnimatedSprite above). This halves the number of assets you need.
Frame Event Callbacks
For effects that need to fire at a specific frame (footstep sound on step frame, weapon hitbox active on attack frame), add a frame-based event dictionary to AnimatedSprite:
// In AnimatedSprite, add:
private readonly Dictionary<(Animation, int), Action> _frameEvents = new();
public void AddFrameEvent(Animation anim, int frame, Action callback)
=> _frameEvents[(anim, frame)] = callback;
// In the Update() while loop, after advancing _currentFrame:
if (_frameEvents.TryGetValue((_current, _currentFrame), out Action action))
action.Invoke();
// In Game1 / Player setup:
sprite.AddFrameEvent(PlayerAnimations.Walk, frameIndex: 2, () => PlayFootstep());
sprite.AddFrameEvent(PlayerAnimations.Walk, frameIndex: 6, () => PlayFootstep());
Variable Frame Durations
If specific frames need to hold longer (e.g. anticipation or follow-through in an attack animation), extend Animation with a float[] of per-frame durations rather than a single fps value. Replace the uniform frameDuration in the Update() loop with _current.FrameDurations[_currentFrame].
Texture Atlas Tools
Free tools for packing sprite sheets:
- TexturePacker (free tier available) — exports JSON metadata alongside the atlas
- Aseprite — pixel-art editor with built-in sprite sheet export
- LibGDX Texture Packer — open-source Java tool, also used by MonoGame developers
These tools often produce non-uniform frame sizes. For that case you would store a Rectangle[] per animation rather than computing frames from a grid formula.
Next Steps
- Add an
int CurrentFrameIndexproperty toAnimatedSpritefor external hitbox queries - Implement a blend/transition system that cross-fades between two animations during state changes
- Pair animations with the Audio Basics article — add footstep sound effects triggered from frame events
- Read the Input Handling article to add gamepad-driven animation state transitions