Skip to content

Sprite Animation

Cycle through animation frames with configurable timing. Walk cycles, explosions, idle animations.

Code status: the maintained Z80 routine is CPU-executed; the other examples are illustrative. The Z80 check includes a table spanning a 256-byte boundary. The 6502 renderer adapter and language-neutral variants need their own caller and target checks.

Audit execution record · Scope and remaining checks

Overview

Sprite animation cycles through a sequence of frames at a fixed rate. A frame counter ticks each game frame; when it reaches the delay threshold, advance to the next animation frame. Wrap back to the first frame at the end.

The same logic works for walk cycles, explosions, flickering torches — any repeating animation.

Algorithm

Code
; Animation state
anim_frame: byte = 0       ; Current frame index (0, 1, 2...)
anim_counter: byte = 0     ; Elapsed updates since the last animation step

; Animation data
ANIM_DELAY = 8             ; Frames between animation steps
ANIM_LENGTH = 4            ; Number of frames in animation

update_animation:
    anim_counter = anim_counter + 1
    if anim_counter < ANIM_DELAY:
        return             ; Not time yet

    ; Time to advance
    anim_counter = 0
    anim_frame = anim_frame + 1
    if anim_frame >= ANIM_LENGTH:
        anim_frame = 0     ; Wrap to start

    return

Pseudocode

Code
; Animation frames (sprite indices or pointers)
walk_frames: byte[4] = { 0, 1, 2, 1 }    ; Walk cycle
WALK_DELAY = 6
WALK_LENGTH = 4

; Player animation state
player_anim_frame: byte = 0
player_anim_counter: byte = 0

; Call every game frame
update_player_animation:
    player_anim_counter = player_anim_counter + 1
    if player_anim_counter < WALK_DELAY:
        return

    player_anim_counter = 0
    player_anim_frame = player_anim_frame + 1
    if player_anim_frame >= WALK_LENGTH:
        player_anim_frame = 0

    ; Get actual sprite index from frame table
    sprite_index = walk_frames[player_anim_frame]
    set_player_sprite(sprite_index)
    return

Implementation Notes

6502:

Assembly
ANIM_DELAY  = 6
ANIM_LENGTH = 4

walk_frames:
    !byte 0, 1, 2, 1      ; Frame indices

update_animation:
    inc anim_counter
    lda anim_counter
    cmp #ANIM_DELAY
    bcc .done             ; Not time yet

    lda #0
    sta anim_counter

    inc anim_frame
    lda anim_frame
    cmp #ANIM_LENGTH
    bcc .set_sprite
    lda #0
    sta anim_frame

.set_sprite:
    tax
    lda walk_frames,x     ; Get sprite index
    sta SPRITE_POINTER    ; Platform-specific
.done:
    rts

Z80:

Z80 animation state

z80.inc
; Pasmo syntax. ANIM_DELAY and ANIM_LENGTH are constants in1.animation_255.
; Caller allocates anim_counter, anim_frame and sprite_index as writable bytes.
; Initialise counters to0 and sprite_index to walk_frames[0] on state entry.
; walk_frames has ANIM_LENGTH bytes and remains readable for all calls.
; One call per chosen update. Changes AF/DE/HL; preserves BC/IX/IY.
; A=new sprite_index only when the delay expires; use the state byte to draw.
update_animation:
    ld a,(anim_counter)
    inc a
    ld (anim_counter),a
    cp ANIM_DELAY
    ret c                 ; Not time yet

    xor a
    ld (anim_counter),a

    ld a,(anim_frame)
    inc a
    cp ANIM_LENGTH
    jr c,animation_set_frame
    xor a
animation_set_frame:
    ld (anim_frame),a

    ld e,a
    ld d,0
    ld hl,walk_frames
    add hl,de             ; propagate carry when the table crosses a page
    ld a,(hl)             ; Get sprite index
    ld (sprite_index),a    ; renderer consumes this logical image number
    ret

Supply ANIM_DELAY and ANIM_LENGTH in 1–255, a readable walk_frames table of that length, and writable bytes anim_frame, anim_counter and sprite_index. Start both counters at zero and put walk_frames[0] in sprite_index. Call once per chosen update; the renderer reads sprite_index. The routine changes AF/DE/HL and preserves BC/IX/IY. The whole address addition matters: a table may cross a 256-byte boundary.

Trade-offs

Aspect Cost
CPU A counter update on waiting calls; table lookup and renderer work on a change. Measure the actual target implementation.
Memory 2 bytes state + frame table
Flexibility Easy to change speed and sequence

When to use: Character walk cycles, enemy animations, environmental effects.

When to avoid: Single-frame sprites, or when animation is tied to movement (use distance-based instead).

Variations

Speed from movement: Only advance animation when moving

Code
if player_moving:
    update_player_animation()
else:
    player_anim_frame = 0    ; Reset to idle frame

One-shot animations: Explosions that don’t loop

Code
update_explosion:
    if anim_frame >= EXPLOSION_LENGTH:
        return             ; Stay on last frame (or destroy)
    ; ... normal update without wrap

Variable speed: Store delay per animation

Code
animations:
    ; delay, length, frame_data...
    !byte 6, 4, 0, 1, 2, 1    ; Walk: 6 frame delay, 4 frames
    !byte 3, 6, 0, 1, 2, 3, 4, 5  ; Run: faster, more frames

Direction-aware: Different frames for left/right

Code
if facing_left:
    base_frame = LEFT_WALK_START
else:
    base_frame = RIGHT_WALK_START

sprite = base_frame + walk_frames[anim_frame]

State-driven animation

For characters with multiple animations (idle, walk, run, jump, attack), pair the animation pattern with a state machine — each state has its own frame table and delay:

Code
; Descriptors have a delay and a reference to their own frame sequence.
animations[IDLE]   = { delay:16, frames:[0,1] }
animations[WALK]   = { delay:6,  frames:[2,3,4,3] }
animations[RUN]    = { delay:3,  frames:[5,6,7,8,9,8] }
animations[JUMP]   = { delay:1,  frames:[10] }

change_animation(next_state):
    player_anim_state = next_state
    anim_frame = 0
    anim_counter = 0
    sprite_index = animations[next_state].frames[0]

update_animation:
    descriptor = animations[player_anim_state]
    delay = descriptor.delay
    length = LENGTH(descriptor.frames)
    ; Apply the counter/step logic above using this descriptor.

A compact machine representation can use a table of pointers to variable-length records. Multiplying the state by one fixed record size only works when every record has that size. Here the frame sequences have different lengths. Validate state, length and delay before choosing a record.

When the state changes (e.g. player presses jump), reset both counters and publish the first image immediately. Without this, switching from walk to run might briefly show the wrong frame index.

Explore the cross-platform sprite family for software drawing, hardware limits, restoration and overload policies.