Skip to content

Sprite animation system

Eldred Habert edited this page Oct 20, 2026 · 1 revision

This is a fairly elaborate system for rather self-contained animations rendered to OAM.

Its structure is a little roundabout, however much of this can be explained by the structure of professional game development, where some boundary exists between artists and programmers.

Note

Two somewhat uncommon terms are used throughout this document:

Cel

A cel is pretty much one image from an animation. This is commonly called a “frame”, but since that word also means “the duration between two VBlanks”, it would be ambiguous here: “this frame will be shown for three frames”.

OBJ

An OBJ[1] is one of the entries in the Game Boy's OAM: it renders one or two tiles at an arbitrary position on the screen. They are commonly called “sprites”, but that term is also often used to refer to larger entities rendered using multiple OBJs, so it would be ambiguous here: “this sprite is composed of two sprites”. Plus, “OAM” stands for “OBJ Attribute Memory”, so that's more consistent. ;)

A sprite animation is conceptually composed of:

  • A cel set, which describes the animation's visuals and how it is (passively) animated;
  • A position, rather naturally;
  • A behavior: a function that gets executed every time the animation is about to be rendered (and thus can tweak said animation dynamically);
  • Some modifiers, which alter some of the rendering parameters (position, base tile, animation speed).

The sprite_anim_struct carries more members, but the above covers the main functionality.

There is room for NUM_SPRITE_ANIM_STRUCTS concurrent sprite animations (10 in the base game), 256 cel sets, and 256 behaviors.

Note that sprite animations do not interact with the loading of tiles or palettes; they assume that the correct tiles and palettes are already loaded[2].

Rendering

Handled by UpdateAnimFrame.

The entire animation is considered to have an origin point, whose (X, Y) coords are computed each as anim's coord + anim's coord offset + [wGlobalAnim*Offset]. Each OBJ will be rendered relative to this origin; further, if the entire cel is flipped, each OBJ's coordinates will also be flipped relative to the origin (AddOrSubtract*, FIXME: bad names).

  1. The cel set is read: the anim's celsetth pointer from SpriteAnimFrameData is read and followed. A cel set is a list of (cel ID, duration, flip flags) tuples, except for a few special values:

    Command Value Effect
    oamend $FF Repeats the previous cel ad infinitum
    oamrestart $FE Immediately restarts the animation (loop)
    oamwait $FD Displays nothing for its duration
    oamdelete $FC immediately deletes the object (does not have a duration[3])

    The cel will be shown for duration frames, plus anim's duration offset[4], plus 1. (This is only computed when switching to a new cel and cached as the anim's duration, so changes to the anim's duration offset will only take effect when the anim's duration next elapses.)

  2. The current cel is read: the cel IDth entry from SpriteAnimOAMData is read. A cel consists of a base tile ID, and a pointer to an OAM set which is followed. The cel ID is re-read on every frame, and changes thus takes effect on the next render.

  3. An OAM set is a length-prefixed list of OBJs: position, tile ID, attributes. Each OBJ is written to (shadow) OAM with some offsets applied:

    • The positions are relative to the origin's position (taking into account the flip flags read from the cel set earlier: if X-flipped, OBJs wil be offset towards the left of the origin instead of its right);
    • The tile ID is added to the anim's base tile ID plus the cel's base tile ID;
      • The attributes are XOR'd with the flip flags[5].

Code

struct SpriteAnim {
	u8 index; // 0 = slot is empty. The exact value is otherwise ignored.
	u8 celset_id;
	u8 behavior_id;
	u8 base_tile_id;
	u8 x;
	u8 y;
	u8 x_ofs;
	u8 y_ofs;
	u8 duration;
	u8 duration_ofs;
	u8 cel;
	u8 behavior_vars[5]; // First one is commonly used as a jump table index.
};

Allocation

InitSpriteAnimStruct searches for an empty slot, returning with carry set iff not found.

If one is found:

  • wSpriteAnimCount is incremented (overflowing from 255 to 1, however), and the slot's index is set to that new value.
  • Its celset, behavior, and base_tile are copied from the requested entry in SpriteAnimObjects.
  • Its x and y coords are set as requested.
  • All subsequent fields are zeroed (except cel, since it will be pre-incremented).
  • The base pointer is saved to wSpriteAnimAddrBackup and returned in bc for any touch-ups.

Processing

Every time PlaySpriteAnimations is called, each active slot first has its behavior ticked, and then it is rendered.

Behavior

Handled by DoSpriteAnimFrame.

The behavior function is called with a pointer to the animation slot being processed; any parameter can be freely modified, and will be taken into account immediately, since it is called right before the slot is rendered.

Note

Exception: calling DeinitializeSprite (or manually taking equivalent actions) will still render the animation for this frame!

The behavior function can run arbitrary code, and some of those functions actually contain game logic; this is poor practice since it puts that logic further away from other code that it interacts with, and should be avoided in new code when possible.

The five behavior_var bytes, as well as the index, are free to be used by the behavior however it sees fit. In particular, the index can be used when spawning otherwise identical copies of the same animation, to have each of them act differently.

Dynamic modification

ReinitSpriteAnimFrame forces a struct to restart playing a specified celset ID (but its behavior ID is left alone).

Footnotes

1

OBJ is short for “object”, but written that way that term has very a broad meaning, so spelling it out “OBJ” avoids some of that ambiguity.

2

The behavior function can, in principle, be used to load tiles and/or palettes on the fly. The base game never does so.

3

Technically, no duration byte is stored, but one is read nonetheless even if to be ignored. If a oamdelete command happens to be stored at CPU address $7FFF, this could unsafely access VRAM, which is harmless in this case but would trigger a warning in many emulators.

4

No clamping or overflow checks are made when adding the duration offset to the base durations; thus, in principle, a negative duration offset could be used to speed up animations, but beware of underflowing.

5

Interestingly, part of the code seems to make provisions for cels to have a priority flag in addition to the X and Y flip flags, but the cel sets have no room to store such a priority flag, so that functionality goes unused.

Clone this wiki locally