This tutorial builds a complete playable demo: a character that walks around the screen with a looping animation, flips to face the direction of movement, and stops animating when the player lets go of the D-pad.

By the end you'll understand sprite sheets, AnimatedSprite, and how the SDK handles transparency and horizontal flip โ€” all the building blocks you need for any character-driven game.


How sprites work on Console32

The display is a 320ร—240 RGB565 framebuffer. Sprites are slices of u16 pixel data in the same format. A sprite sheet packs multiple animation frames side by side in a single buffer:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  frame 0  โ”‚  frame 1  โ”‚  ...  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
        sheet_width = W ร— frames

The SDK's SpriteSheet type handles the address arithmetic. AnimatedSprite wraps a SpriteSheet and keeps a frame counter and timer, advancing the frame every N game ticks.


Step 1 โ€” Define the sprite sheet

Sprite data is a &'static [Colour] โ€” either embedded from a file with include! (covered in Using real art) or defined inline as a static array.

Here's an 8ร—8 walking character as a four-frame horizontal filmstrip:

use console32_sdk::prelude::*;

// Sheet layout: 4 frames ร— 8 px wide = 32 px total width, 8 px tall.
// T = transparent (skipped when blitting), G = body green, D = dark accent.
#[rustfmt::skip]
static WALK_DATA: [Colour; 32 * 8] = {
    const T: Colour = TRANSPARENT;        // 0xF81F โ€” the blit key colour
    const G: Colour = rgb(80, 200, 80);   // body
    const D: Colour = rgb(30, 120, 30);   // shadow / legs
    [
        // 32 pixels per row ร— 8 rows.  Four 8ร—8 frames sit side by side.
        //  frame 0           frame 1           frame 2           frame 3
        T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,
        T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,
        T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,
        G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,
        T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,
        T,D,T,T,T,T,D,T,  T,T,D,T,T,D,T,T,  T,D,T,T,T,T,D,T,  T,T,D,T,T,D,T,T,
        T,D,T,T,T,T,D,T,  T,T,T,D,D,T,T,T,  T,D,T,T,T,T,D,T,  T,T,T,D,D,T,T,T,
        T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,
    ]
};

// Tell the SDK how to slice the buffer into frames.
// SpriteSheet::new(data, sheet_width, frame_width, frame_height)
static WALK_SHEET: SpriteSheet<'static> = SpriteSheet::new(&WALK_DATA, 32, 8, 8);

A few things to note:

  • TRANSPARENT (0xF81F) is the blit key colour. Any pixel with that value is skipped when drawing, letting the background show through. Never use it as an intentional colour in your art.
  • SpriteSheet::new and rgb() are both const fn, so both statics can be initialised at compile time โ€” no runtime cost.
  • The #[rustfmt::skip] attribute stops the formatter from collapsing the pixel grid into an unreadable line.

Step 2 โ€” Game struct

The game struct holds all mutable state. AnimatedSprite<'static> stores a reference to WALK_SHEET and keeps track of which frame to show.

pub struct Walker {
    x: i16,
    y: i16,
    facing_right: bool,
    moving: bool,
    anim: AnimatedSprite<'static>,
}

x and y are the top-left screen position of the sprite in pixels. facing_right drives a horizontal flip so we don't need separate left-facing art.


Step 3 โ€” init

init is called once when the game slot loads. It constructs the initial state and returns it โ€” there are no globals, no singletons.

impl Game for Walker {
    fn init(_c: &mut Console) -> Self {
        Walker {
            x: 156,              // centre of 320 px screen minus half sprite width
            y: 116,              // centre of 240 px screen minus half sprite height
            facing_right: true,
            moving: false,
            // AnimatedSprite::new(sheet, frame_count, frames_per_tick)
            // frames_per_tick = 8 means the frame advances every 8 game ticks,
            // giving 60 รท 8 โ‰ˆ 7.5 animation frames per second.
            anim: AnimatedSprite::new(&WALK_SHEET, 4, 8),
        }
    }

Step 4 โ€” update

update runs every frame at 60 Hz. It reads the D-pad, moves the character, clamps the position to keep it on screen, and decides whether to advance the animation.

    fn update(&mut self, c: &mut Console) {
        let speed: i16 = 2;
        self.moving = false;

        if c.input.is_held(buttons::LEFT) {
            self.x -= speed;
            self.facing_right = false;
            self.moving = true;
        }
        if c.input.is_held(buttons::RIGHT) {
            self.x += speed;
            self.facing_right = true;
            self.moving = true;
        }
        if c.input.is_held(buttons::UP) {
            self.y -= speed;
            self.moving = true;
        }
        if c.input.is_held(buttons::DOWN) {
            self.y += speed;
            self.moving = true;
        }

        // Keep the sprite fully on screen.
        // Screen is 320ร—240; sprite is 8ร—8.
        self.x = self.x.clamp(0, 312);
        self.y = self.y.clamp(0, 232);

        // Advance the animation only when the character is moving.
        // When still, reset to frame 0 so it always returns to the same pose.
        if self.moving {
            self.anim.tick();
        } else {
            self.anim.reset();
        }
    }

is_held vs is_pressed โ€” is_held returns true on every frame the button is physically down, giving smooth continuous motion. is_pressed fires only on the first frame the button goes down (edge detection). For movement, always use is_held.


Step 5 โ€” render

render runs after update. Get the current animation frame, then draw it with draw_sprite_ex so you can pass the horizontal flip flag.

    fn render(&mut self, c: &mut Console) {
        c.display.clear(BLACK);

        // draw_sprite_ex(x, y, sprite, flip_h, flip_v)
        // Flip horizontally when facing left โ€” no separate left-facing art needed.
        let frame = self.anim.current();
        c.display.draw_sprite_ex(
            self.x, self.y,
            &frame,
            !self.facing_right,  // flip_h
            false,               // flip_v
        );

        c.display.draw_text("D-pad: move", 4, 228, WHITE);
    }
}

draw_sprite_ex calls blit with BLIT_KEYED always set, so TRANSPARENT pixels are automatically skipped.


Complete listing

Put this all together in src/game.rs:

use console32_sdk::prelude::*;

#[rustfmt::skip]
static WALK_DATA: [Colour; 32 * 8] = {
    const T: Colour = TRANSPARENT;
    const G: Colour = rgb(80, 200, 80);
    const D: Colour = rgb(30, 120, 30);
    [
        T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,  T,T,G,G,G,G,T,T,
        T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,  T,G,D,G,G,D,G,T,
        T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,
        G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,  G,G,G,G,G,G,G,G,
        T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,  T,G,G,G,G,G,G,T,
        T,D,T,T,T,T,D,T,  T,T,D,T,T,D,T,T,  T,D,T,T,T,T,D,T,  T,T,D,T,T,D,T,T,
        T,D,T,T,T,T,D,T,  T,T,T,D,D,T,T,T,  T,D,T,T,T,T,D,T,  T,T,T,D,D,T,T,T,
        T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,  T,T,T,T,T,T,T,T,
    ]
};

static WALK_SHEET: SpriteSheet<'static> = SpriteSheet::new(&WALK_DATA, 32, 8, 8);

pub struct Walker {
    x: i16,
    y: i16,
    facing_right: bool,
    moving: bool,
    anim: AnimatedSprite<'static>,
}

impl Game for Walker {
    fn init(_c: &mut Console) -> Self {
        Walker {
            x: 156,
            y: 116,
            facing_right: true,
            moving: false,
            anim: AnimatedSprite::new(&WALK_SHEET, 4, 8),
        }
    }

    fn update(&mut self, c: &mut Console) {
        let speed: i16 = 2;
        self.moving = false;

        if c.input.is_held(buttons::LEFT)  { self.x -= speed; self.facing_right = false; self.moving = true; }
        if c.input.is_held(buttons::RIGHT) { self.x += speed; self.facing_right = true;  self.moving = true; }
        if c.input.is_held(buttons::UP)    { self.y -= speed; self.moving = true; }
        if c.input.is_held(buttons::DOWN)  { self.y += speed; self.moving = true; }

        self.x = self.x.clamp(0, 312);
        self.y = self.y.clamp(0, 232);

        if self.moving {
            self.anim.tick();
        } else {
            self.anim.reset();
        }
    }

    fn render(&mut self, c: &mut Console) {
        c.display.clear(BLACK);
        let frame = self.anim.current();
        c.display.draw_sprite_ex(self.x, self.y, &frame, !self.facing_right, false);
        c.display.draw_text("D-pad: move", 4, 228, WHITE);
    }
}

Wire it up in src/lib.rs and src/main.rs by replacing the scaffolded game type with Walker:

// src/lib.rs
mod game;
use console32_sdk::prelude::*;
use game::Walker;

#[cfg(target_arch = "wasm32")]
wasm_emu::wasm_entrypoints!();

console32_game!(Walker);

Then run it in the emulator:

cargo console32 run

Using real art

Inline pixel arrays work fine for prototyping. For actual game art, draw your sprite sheet as a PNG and convert it with the bundled tool:

cd console32_software/sdk/tools/convert
cargo run -- sprite walk.png --frame-width 16 --frame-height 16 --out walk_data.rs

The tool outputs a walk_data.rs file containing a static WALK_DATA: [Colour; ...] array with your PNG pixels converted to RGB565 and the transparency key applied. Include it in your game:

// src/game.rs
include!("../assets/walk_data.rs");  // defines WALK_DATA

static WALK_SHEET: SpriteSheet<'static> =
    SpriteSheet::new(&WALK_DATA, 64, 16, 16);  // update dimensions to match

The TRANSPARENT key colour (0xF81F, a specific shade of magenta) is replaced during conversion โ€” any fully transparent pixel in the PNG becomes TRANSPARENT in the output array. Use that magenta value as your art program's transparency colour if you want explicit control, otherwise just use the PNG alpha channel and the converter handles it.


Next steps

  • Multiple animation states โ€” keep a separate AnimatedSprite for idle, walk, and jump, and switch between them based on player state. Call .reset() on the outgoing animation when you switch so it restarts cleanly.
  • Larger sprites โ€” the same SpriteSheet approach scales to any frame size. A 32ร—32 character with 8 frames uses a 256ร—32 sheet; just update the dimensions passed to SpriteSheet::new.
  • Tile maps โ€” pair your character with a scrolling background using TileMap and set_camera. See the API reference for draw_tilemap.
  • Collision โ€” the collision module in the prelude provides axis-aligned bounding box (AABB) helpers that work directly with sprite positions and sizes.