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:
void hero_animate(void) {
}
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.
#define ANIM_FRAMES 3
#define ANIM_DELAY 6
void update_animation(void) {
anim_timer++;
anim_timer = 0;
anim_frame++;
if (anim_frame >= ANIM_FRAMES)
anim_frame = 0;
}
}
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:
anim_timer++;
if (anim_timer >= 8) {
anim_timer = 0;
}
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:
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
};
typedef struct {
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
}
}
}
}
static u16 pad0
Definition main.c:167
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:
else
}
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
- All sprite frames live in ROM (the .pic file)
- The engine maintains a VRAM upload queue
- When oamrefresh = 1, the current frame's tiles are queued for DMA
- The NMI handler auto-flushes the queue during VBlank (no user call)
- 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
.vramLarge = 0x0000,
.vramSmall = 0x1000,
.slotLargeInit = 0,
.slotSmallInit = 0,
};
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():
#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/:
while (1) {
current_frame++;
if (current_frame >= 24) current_frame = 0;
}
}
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
mario_anim_idx ^= 1;
}
}
} else {
}
}
}
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
void animate_water_tiles(void) {
water_timer++;
if (water_timer < WATER_ANIM_SPEED) return;
water_timer = 0;
water_frame ^= 1;
tile = water_frame ? WATER_TILE_B : WATER_TILE_A;
}
#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[];
extern u8 water_frame1[];
void animate_water_gfx(void) {
water_timer++;
if (water_timer < WATER_ANIM_SPEED) return;
water_timer = 0;
water_frame ^= 1;
src = water_frame ? water_frame1 : water_frame0;
}
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
- 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
- Only set oamrefresh = 1 when the frame changes – redundant VRAM uploads waste VBlank time
- The dynamic engine uploads up to 7 sprites per frame. If you need more animated sprites, spread their refresh across multiple frames
- 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