OpenSNES
Modern Open-Source SNES Development SDK
Loading...
Searching...
No Matches
Animation Tutorial

This tutorial covers sprite and background animation techniques on the SNES, from the declarative anim module to manual frame cycling and the dynamic sprite engine.

The anim Module (Recommended)

The library ships a declarative animation player (<snes/anim.h>, add anim to LIB_MODULES). Instead of hand-rolling a tick counter, a modulo gate and manual tile pokes per character, you describe each animation as data and tick a player once per frame:

#include <snes.h>
#include <snes/anim.h>
/* One clip per action: frames + speed + loop mode, all data. */
static AnimPlayer hero_anim = ANIM_PLAYER_INIT;
void hero_animate(void) {
/* Callable unconditionally every frame: continue-if-same keeps a
* running clip's phase instead of restarting it. */
animPlay(&hero_anim, walking ? &clip_walk : &clip_stand);
animTickOam(&hero_anim, 0); /* dynamic engine: refresh only on change */
}
Declarative animation player — data-driven frame sequencing.
#define ANIM_PLAYER_INIT
Static initializer: AnimPlayer p = ANIM_PLAYER_INIT;.
Definition anim.h:102
#define DECLARE_ANIM_CLIP(name, mode_, speed_,...)
Declare a static const AnimClip with uniform frame duration.
Definition anim.h:189
void animPlay(AnimPlayer *p, const AnimClip *clip)
Start (or keep) a clip — continue-if-same semantics.
#define ANIM_LOOP
Loop mode: wrap to frame 0 after the last frame.
Definition anim.h:55
#define animTickOam(_p, _id)
Tick + apply to the dynamic sprite engine (the 90% one-liner).
Definition anim.h:155
#define FRAME_STAND
Dynamic sprite frame index: standing idle pose.
Definition main.c:131
static AnimClip clip_walk
Definition main.c:175
static AnimClip clip_stand
Definition main.c:177
#define FRAME_WALK0
Dynamic sprite frame index: walk animation frame 0.
Definition main.c:127
#define FRAME_WALK1
Dynamic sprite frame index: walk animation frame 1.
Definition main.c:129
OpenSNES common-case master header.
A playback head — RAM, 8 bytes, array-friendly.
Definition anim.h:93

A frame value is an opaque u16 — the player sequences it without interpreting it. Three consumption patterns:

  • animTickOam(&p, id) — the value is an oamframeid for the dynamic sprite engine; the VRAM re-upload happens only when the frame actually changes (examples/games/likemario, examples/sprites/animated_sprite);
  • animTickMeta(&p, table) — the value indexes a MetaspriteItem* pointer table, feeding oamDrawMeta() directly (examples/sprites/metasprite);
  • animTick(&p) — raw value, yours to apply (oamSetTile(), a background tile, anything).

ANIM_ONCE clips hold their last frame and raise animDone(&p); pausing is simply not ticking. Per-frame durations use a raw AnimClip struct with a durations array — see <snes/anim.h> for the full API, the layout contract, and the bank $00 note for nearly-full ROMs.

The sections below cover what the module does under the hood — worth understanding, and still the right tool for one-off effects.

Frame-Based Animation

The simplest animation technique: cycle through tile numbers at a fixed interval. Each animation frame corresponds to a different tile (or group of tiles) in VRAM.

#include <snes.h>
#define ANIM_FRAMES 3
#define ANIM_DELAY 6 /* VBlanks between frame changes */
u8 anim_frame = 0;
u8 anim_timer = 0;
void update_animation(void) {
anim_timer++;
if (anim_timer >= ANIM_DELAY) {
anim_timer = 0;
anim_frame++;
if (anim_frame >= ANIM_FRAMES)
anim_frame = 0;
}
/* Each 16x16 sprite occupies 2 tile columns in VRAM,
* so frame 0 = tile 0, frame 1 = tile 2, frame 2 = tile 4 */
oamSetTile(0, anim_frame * 2);
}
unsigned char u8
8-bit unsigned integer (0 to 255)
Definition types.h:47
void oamSetTile(u8 id, u16 tile)
Set sprite tile.
#define ANIM_DELAY
Number of frames to hold each animation frame before advancing.
Definition main.c:107

The key insight: animation only advances when the timer expires, not every frame. This decouples visual speed from the 60 Hz game loop.

Frame Timing

Two approaches for controlling animation speed:

Manual Counter (Recommended)

A dedicated u8 counter gives full control and costs almost nothing:

u8 anim_timer = 0;
/* In the main loop, after WaitForVBlank() */
anim_timer++;
if (anim_timer >= 8) {
anim_timer = 0;
/* Advance to next animation frame */
}

This is the pattern used in examples/sprites/dynamic_sprite/. The counter increments once per VBlank, and every 8th frame the animation advances.

Using getFrameCount()

The system frame_count (incremented by the NMI handler) is available via getFrameCount(). Useful for simple periodic animations where you do not need to pause or reset the timer independently:

/* Cycle through 4 frames, changing every 8 VBlanks */
u8 frame = (getFrameCount() / 8) % 4;
oamSetTile(0, frame * 2);
u16 getFrameCount(void)
Get frame counter.
Definition console.h:282
u16 frame
Current sprite tile number.
Definition main.c:81

The manual counter is preferred for gameplay animations because you can pause it (stop incrementing when the character is idle) or reset it on state changes.

Sprite Sheet Layout

VRAM Tile Numbering

For 16x16 sprites in 4bpp mode, each sprite is a 2x2 group of 8x8 tiles (32 bytes per 8x8 tile, 128 bytes per 16x16 frame). Tile numbers follow the SNES OBJ character layout:

VRAM row 0: tile 0 tile 1 tile 2 tile 3 ... tile 15
VRAM row 1: tile 16 tile 17 tile 18 tile 19 ... tile 31
VRAM row 2: tile 32 tile 33 ...

A single 16x16 sprite uses a 2x2 block. Its tile number is the top-left 8x8 tile:

16x16 Frame Tile Number 8x8 tiles used
Frame 0 0 0, 1, 16, 17
Frame 1 2 2, 3, 18, 19
Frame 2 4 4, 5, 20, 21

Sprite Sheet Organization

A typical character sprite sheet is organized as rows of animation directions. For the animated sprite example (examples/sprites/animated_sprite/):

Row 0 (tiles 0-15): Walk Down frames | Walk Up frames | Walk Right frames
Row 1 (tiles 16-31): (lower halves) | (lower halves) | (lower halves)
Row 2 (tiles 32-47): Walk Right frame 3 | ...

Tile numbers for each direction:

  • Down: 0, 2, 4 (three frames, each 2 tiles apart)
  • Up: 6, 8, 10
  • Right: 12, 14, 32 (frame 3 wraps to next VRAM row)
  • Left: same tiles as Right, drawn with H-flip

Converting Sprite Sheets with gfx4snes

# 16x16 sprites, 4bpp, with palette output
gfx4snes -s 16 -o 16 -u 16 -p -i sprites.png

Include the output in an assembly data file:

.section ".rodata1" superfree
sprite_tiles: .incbin "res/sprites.pic"
sprite_tiles_end:
sprite_pal: .incbin "res/sprites.pal"
sprite_pal_end:
.ends

Direction-Based Animation

Most game characters have different frames for each movement direction. The common pattern uses an enum for states and H-flip for left/right mirroring.

From examples/sprites/animated_sprite/:

#define ANIM_DELAY 6
W_DOWN = 0,
W_UP = 1,
W_RIGHT = 2,
W_LEFT = 2 /* Reuses W_RIGHT tiles with H-flip */
};
typedef struct {
s16 x, y;
u16 gfx_frame;
u8 state;
Monster monster = {100, 100, 0, W_DOWN, 0};
/* One clip per facing — the frame values are raw 16x16 tile numbers. */
DECLARE_ANIM_CLIP(walk_down, ANIM_LOOP, ANIM_DELAY, 0, 2, 4);
DECLARE_ANIM_CLIP(walk_up, ANIM_LOOP, ANIM_DELAY, 6, 8, 10);
DECLARE_ANIM_CLIP(walk_side, ANIM_LOOP, ANIM_DELAY, 12, 14, 32);
signed short s16
16-bit signed integer (-32768 to 32767)
Definition types.h:50
unsigned short u16
16-bit unsigned integer (0 to 65535)
Definition types.h:53
u16 flipx
Horizontal flip flag (1 = facing left).
Definition main.c:84
Monster monster
The player-controlled character sprite, initialized facing down at (100, 100).
Definition main.c:110
AnimPlayer monster_anim
Playback head for the monster (zero-init = stopped).
Definition main.c:125
SpriteState
Animation direction states.
Definition main.c:62
@ W_UP
Definition main.c:64
@ W_DOWN
Definition main.c:63
@ W_RIGHT
Definition main.c:65
@ W_LEFT
Definition main.c:66
Sprite state structure holding position, animation, and direction.
Definition main.c:93

Input and State Changes

if (pad0 & KEY_UP) {
monster.y--;
monster.state = W_UP;
monster.flipx = 0;
}
if (pad0 & KEY_LEFT) {
monster.x--;
monster.state = W_LEFT;
monster.flipx = 1; /* Mirror the RIGHT frames */
}
if (pad0 & KEY_RIGHT) {
monster.x++;
monster.state = W_RIGHT;
monster.flipx = 0;
}
if (pad0 & KEY_DOWN) {
monster.y++;
monster.state = W_DOWN;
monster.flipx = 0;
}
static u16 pad0
Definition main.c:167
#define KEY_RIGHT
Definition input.h:78
#define KEY_DOWN
Definition input.h:76
#define KEY_LEFT
Definition input.h:77
#define KEY_UP
Definition input.h:75
u16 padHeld(u8 pad)
Get buttons currently held.

Selecting the Clip and Ticking

Map the facing to a clip, tick, and apply the flip flag. animPlay() has continue-if-same semantics, so calling it every frame keeps a running walk cycle instead of restarting it:

if (pad0 != 0) {
if (monster.state == W_DOWN)
animPlay(&monster_anim, &walk_down);
else if (monster.state == W_UP)
animPlay(&monster_anim, &walk_up);
else
animPlay(&monster_anim, &walk_side); /* W_RIGHT / W_LEFT */
monster.gfx_frame = animTick(&monster_anim);
}
/* No buttons held: no tick — the sprite freezes on its current frame. */
u16 flags = monster.flipx ? OBJ_FLIPX : 0;
oamSet(0, monster.x, monster.y, monster.gfx_frame, 0, 3, flags);
u16 animTick(AnimPlayer *p)
Advance one tick; return the current frame value.
void oamSet(u16 id, u16 x, u16 y, u16 tile, u16 palette, u16 priority, u16 flags)
Set sprite properties.
#define OBJ_FLIPX
Metasprite horizontal flip flag.
Definition sprite.h:524

Pausing on idle costs nothing: an un-ticked player simply holds its state, and the walk resumes mid-cycle when input returns. This is the full pattern of examples/sprites/animated_sprite/.

The Dynamic Sprite Engine

For sprites with many animation frames, pre-loading all frames into VRAM wastes space. The dynamic sprite engine streams tile data from ROM to VRAM on demand, uploading only the current frame each time it changes.

How It Works

  1. All sprite frames live in ROM (the .pic file)
  2. The engine maintains a VRAM upload queue
  3. When oamrefresh = 1, the current frame's tiles are queued for DMA
  4. The NMI handler auto-flushes the queue during VBlank (no user call)
  5. Up to 7 sprite uploads per VBlank to stay within DMA budget; larger one-shot batches at init use oamDynamicDrainQueue() to wait for the queue to fully drain before setScreenOn()

Initialization

#include <snes.h>
extern u8 spr16_tiles[];
extern u8 spr16_pal[];
/* Initialize dynamic sprite engine via the struct-based API.
* vramLarge = VRAM $0000 (large-tile pool)
* vramSmall = VRAM $1000 (small-tile pool)
* slotLargeInit = first OAM slot for the large pool
* slotSmallInit = first OAM slot for the small pool
* sizeMode = OBJ_SIZE8_L16 (8x8 small, 16x16 large)
*/
static const OamDynamicConfig dyn = {
.vramLarge = 0x0000,
.vramSmall = 0x1000,
.slotLargeInit = 0,
.slotSmallInit = 0,
.sizeMode = OBJ_SIZE8_L16,
};
/* Load palette (sprite palettes start at CGRAM 128) */
dmaCopyCGram(spr16_pal, 128, 32);
void dmaCopyCGram(const u8 *source, u16 startColor, u16 size)
Copy palette data to CGRAM (PVSnesLib compatible).
void oamDynamicInit(const OamDynamicConfig *cfg)
Initialize the dynamic sprite engine from a config struct.
#define OBJ_SIZE8_L16
Sprite size indices (for oamInit, oamInitGfxSet).
Definition sprite.h:49
u8 spr16_tiles[]
ROM source for the 16x16 sprite sheet tile data.
Configuration for the dynamic sprite engine.
Definition sprite.h:601

Setting Up a Dynamic Sprite

Use the oambuffer[] array (type t_sprites) instead of oamSet():

oambuffer[0].oamx = 100;
oambuffer[0].oamy = 80;
oambuffer[0].oamframeid = 0; /* Frame index in sprite sheet */
oambuffer[0].oamattribute = OBJ_PRIO(3); /* Priority 3 */
oambuffer[0].oamrefresh = 1; /* Request VRAM upload */
OAM_SET_GFX(0, spr16_tiles); /* Point to ROM tile data */
#define OAM_SET_GFX(id, gfx)
Set sprite graphics address (bank $00 only).
Definition sprite.h:221
t_sprites oambuffer[128]
Dynamic sprite buffer (128 entries, 2048 bytes).
#define OBJ_PRIO(prio)
Metasprite priority attribute macro.
Definition sprite.h:521

The Game Loop

From examples/sprites/dynamic_sprite/:

u16 current_frame = 0;
while (1) {
/* Animate every 8th VBlank */
if (frame_counter >= 8) {
current_frame++;
if (current_frame >= 24) current_frame = 0;
oambuffer[0].oamframeid = current_frame;
oambuffer[0].oamrefresh = 1; /* Trigger VRAM upload */
}
/* Draw sprite (updates oambuffer + OAM, queues tile DMA).
* The NMI handler auto-flushes the queue and hides last
* frame's leftovers — no manual flush calls needed. */
}
void WaitForVBlank(void)
Wait for next VBlank period.
void oamDynamicDraw(u16 id)
Draw a dynamic sprite — engine picks the size routine.
static u8 frame_counter
Global frame counter for animation timing.
Definition main.c:62

Action-Based Animation (LikeMario)

The examples/games/likemario/ example shows how to combine the dynamic sprite engine with action states. Each action maps to specific frame indices:

#define FRAME_STAND 6
#define FRAME_JUMP 1
#define FRAME_WALK0 2
#define FRAME_WALK1 3
u8 mario_anim_idx = 0;
void mario_animate(void) {
/* Toggle between walk frames every 4 VBlanks */
if ((anim_tick & 3) == 3) {
mario_anim_idx ^= 1;
oambuffer[0].oamframeid = FRAME_WALK0 + mario_anim_idx;
oambuffer[0].oamrefresh = 1;
}
} else if (mario_action == ACT_JUMP || mario_action == ACT_FALL) {
if (oambuffer[0].oamframeid != FRAME_JUMP) {
oambuffer[0].oamframeid = FRAME_JUMP;
oambuffer[0].oamrefresh = 1;
}
} else {
/* Standing — only update if frame changed */
if (oambuffer[0].oamframeid != FRAME_STAND) {
oambuffer[0].oamframeid = FRAME_STAND;
oambuffer[0].oamrefresh = 1;
}
}
}
static u8 mario_action
Definition main.c:160
static void mario_animate(void)
Update Mario's sprite animation frame based on current action.
Definition main.c:642
#define FRAME_JUMP
Dynamic sprite frame index: jump pose.
Definition main.c:125
static u8 anim_tick
Definition main.c:171
#define ACT_JUMP
Definition map.h:95
#define ACT_FALL
Definition map.h:96
#define ACT_WALK
Definition map.h:94

Key details:

  • oamrefresh is only set to 1 when the frame actually changes, avoiding redundant VRAM uploads
  • Direction is handled separately via oamattribute (setting or clearing the H-flip bit 0x40)
  • oamDynamicDraw() reads oambuffer[].oamx and oambuffer[].oamy for positioning

One Draw Function, Engine-Resolved Size

oamDynamicDraw(id) looks up the sprite's pixel size from the size pair set at init plus an optional per-sprite override (oamDynamicSetSize), then dispatches to the matching internal routine. Callers no longer pick a function by sprite size — the engine knows.

For metasprite groups, oamMetaDrawDyn(id, x, y, meta, gfx, size_class) walks a MetaspriteItem array and dispatches each sub-sprite the same way; pass OBJ_LARGE or OBJ_SMALL to select which half of the configured size pair to use.

Background Tile Animation

Backgrounds can be animated by cycling tilemap entries. This is useful for water, lava, torches, and other environmental effects.

Approach 1: Swap Tilemap Entries

Write new tile numbers into the tilemap at fixed intervals. This changes which tiles appear without modifying tile graphics:

#define WATER_TILE_A 20
#define WATER_TILE_B 21
#define WATER_ANIM_SPEED 16
u8 water_frame = 0;
u8 water_timer = 0;
void animate_water_tiles(void) {
u16 tile;
water_timer++;
if (water_timer < WATER_ANIM_SPEED) return;
water_timer = 0;
water_frame ^= 1;
tile = water_frame ? WATER_TILE_B : WATER_TILE_A;
/* Write during VBlank or forced blank only */
REG_VMAIN = 0x80;
/* Update each water tile position in the tilemap */
REG_VMADDL = (VRAM_MAP + water_col + water_row * 32) & 0xFF;
REG_VMADDH = (VRAM_MAP + water_col + water_row * 32) >> 8;
REG_VMDATAL = tile & 0xFF;
REG_VMDATAH = tile >> 8;
}
#define VRAM_MAP
krom's VRAM layout: map at word $4000, tiles at word $8000
Definition main.c:40
#define REG_VMADDH
VRAM address high (W).
Definition registers.h:124
#define REG_VMAIN
VRAM address increment mode (W).
Definition registers.h:112
#define REG_VMADDL
VRAM address low (W).
Definition registers.h:121
#define REG_VMDATAL
VRAM data write low (W).
Definition registers.h:127
#define REG_VMDATAH
VRAM data write high (W).
Definition registers.h:130

Approach 2: Overwrite Tile Graphics

Instead of changing the tilemap, DMA new pixel data into the same tile slot. Every tile referencing that slot updates simultaneously:

extern u8 water_frame0[]; /* 32 bytes (one 4bpp 8x8 tile) */
extern u8 water_frame1[];
void animate_water_gfx(void) {
u8 *src;
water_timer++;
if (water_timer < WATER_ANIM_SPEED) return;
water_timer = 0;
water_frame ^= 1;
src = water_frame ? water_frame1 : water_frame0;
/* DMA 32 bytes to the water tile's VRAM address (VBlank only) */
REG_VMAIN = 0x80;
REG_VMADDL = (WATER_TILE_VRAM) & 0xFF;
REG_VMADDH = (WATER_TILE_VRAM) >> 8;
dmaCopyVram(src, WATER_TILE_VRAM, 32);
}
void dmaCopyVram(const u8 *source, u16 vramAddr, u16 size)
Copy data to VRAM (PVSnesLib compatible).

This approach is more efficient when many tilemap positions use the same animated tile, since you update the graphics once rather than rewriting every tilemap entry.

VBlank Budget

Background tile animation involves VRAM writes, which must happen during VBlank or forced blank. Keep animated tile DMA small (under 1 KB per frame) to stay within the VBlank budget alongside sprite updates and scroll register writes.

Performance Considerations

  1. oamSet() is cheap — the old framesize=158 cliff was resolved (see KNOWN_LIMITATIONS.md); use it freely. For extreme sprite counts, oamSetFast() / oamSetXYFast() or direct oamMemory[] writes trim a little more
  2. Only set oamrefresh = 1 when the frame changes – redundant VRAM uploads waste VBlank time
  3. The dynamic engine uploads up to 7 sprites per frame. If you need more animated sprites, spread their refresh across multiple frames
  4. BG tile animation competes with sprite DMA for VBlank time. Budget carefully: ~4 KB total per VBlank

Example References

  • examples/sprites/animated_sprite/ – basic 4-direction sprite animation with H-flip
  • examples/sprites/dynamic_sprite/ – dynamic sprite engine with VRAM streaming
  • examples/games/likemario/ – action-state animation (walk, jump, stand) with camera and physics

Next Steps