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::newandrgb()are bothconst 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
AnimatedSpritefor 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
SpriteSheetapproach scales to any frame size. A 32ร32 character with 8 frames uses a 256ร32 sheet; just update the dimensions passed toSpriteSheet::new. - Tile maps โ pair your character with a scrolling background using
TileMapandset_camera. See the API reference fordraw_tilemap. - Collision โ the
collisionmodule in the prelude provides axis-aligned bounding box (AABB) helpers that work directly with sprite positions and sizes.