Console32 games are Rust crates. You write a struct, implement three methods, and the SDK handles everything else. The same binary compiles to WebAssembly for the emulator and to bare-metal ARM for real hardware.
This guide takes you from an empty directory to a running game in the desktop emulator. Hardware deployment is covered at the end.
Prerequisites
You need Rust installed via rustup, then:
# Embedded target for hardware builds
rustup target add thumbv8m.main-none-eabihf
# WASM target for the emulator
rustup target add wasm32-unknown-unknown
# CLI tooling
cargo install cargo-console32
cargo install wasm-bindgen-cli
cargo install console32-tauri
wasm-bindgen-climust match the version of thewasm-bindgencrate in your game'sCargo.lock. If you see a version mismatch error after the first build, runcargo install wasm-bindgen-cli --version <version>with the version from your lock file.
Create a project
cargo console32 new my_game
cd my_game
This scaffolds a complete project:
my_game/
├── src/
│ ├── game.rs ← your game logic (edit this)
│ ├── main.rs ← hardware entry point (rarely touch this)
│ └── lib.rs ← WASM entry point (rarely touch this)
├── Cargo.toml
├── Console32.toml ← name, version, signing key path
├── memory_slot.x ← linker script for hardware
├── build.rs
└── .cargo/config.toml
You write your game in src/game.rs. The main.rs and lib.rs files are thin wrappers that wire the game up to the hardware ABI or the WASM emulator respectively — you won't normally need to touch them.
Run in the emulator
cargo console32 run
That's it. The CLI builds your game to WASM, then opens a native desktop window running the emulator with your game loaded. The window behaves like a real Console32: 320×240 pixels, keyboard mapped to buttons.
To rebuild and relaunch after changes, just run it again. For a browser tab instead of a native window:
cargo console32 run --target browser
The game loop
Open src/game.rs. You'll see a struct implementing the Game trait:
use console32_sdk::prelude::*;
pub struct MyGame {
x: i16,
y: i16,
}
impl Game for MyGame {
fn init(_c: &mut Console) -> Self {
MyGame { x: 160, y: 120 }
}
fn update(&mut self, c: &mut Console) {
if c.input.is_held(buttons::LEFT) { self.x -= 2; }
if c.input.is_held(buttons::RIGHT) { self.x += 2; }
if c.input.is_held(buttons::UP) { self.y -= 2; }
if c.input.is_held(buttons::DOWN) { self.y += 2; }
}
fn render(&mut self, c: &mut Console) {
c.display.clear(BLACK);
c.display.fill_circle(self.x, self.y, 10, GREEN);
c.display.draw_text("Hello, Console32!", 8, 8, WHITE);
}
}
The three methods are called every frame at 60 Hz:
| Method | Called | Purpose |
|---|---|---|
init | Once at startup | Construct initial game state |
update | Every frame | Read input, advance simulation |
render | Every frame after update | Draw the current state |
Console is your interface to everything the hardware provides. It gets passed in as &mut Console — there are no globals, no singletons.
Drawing
The screen is 320×240 pixels. Origin (0, 0) is top-left. All coordinates are i16.
// Fill the screen with a colour
c.display.clear(BLACK);
// Primitives
c.display.fill_rect(x, y, width, height, RED);
c.display.draw_rect(x, y, width, height, WHITE); // outline only
c.display.fill_circle(cx, cy, radius, CYAN);
c.display.draw_circle(cx, cy, radius, YELLOW);
c.display.draw_line(x0, y0, x1, y1, GREEN);
// Text
c.display.draw_text("score: 0", x, y, WHITE);
Colours are u16 RGB565. The prelude exports named constants: BLACK, WHITE, RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA. For anything else:
let orange = rgb(255, 140, 0);
let dark_grey = rgb(40, 40, 40);
Input
Console32 has ten buttons across three groups:
| Group | Constants |
|---|---|
| Face | A, B, X, Y |
| D-pad | UP, DOWN, LEFT, RIGHT |
| Shoulder | L, R |
use buttons::*;
// True every frame the button is physically held down
if c.input.is_held(UP) { player.y -= 2; }
if c.input.is_held(DOWN) { player.y += 2; }
if c.input.is_held(LEFT) { player.x -= 2; }
if c.input.is_held(RIGHT) { player.x += 2; }
// True only on the first frame the button goes down
if c.input.is_pressed(A) { /* jump, confirm */ }
if c.input.is_pressed(L) { /* pause menu */ }
// True only on the frame the button is released
if c.input.is_released(B) { /* charge-release mechanic */ }
The constants can be OR'd together: is_held(L | R) returns true only when both are held simultaneously.
Fixed-point maths
There's no floating point on Console32. Positions use Fixed (16.16 fixed-point, wrapping i32):
let speed = Fixed::from(3); // 3.0 as fixed-point
let x = Fixed::from(160);
let angle: u16 = 8192; // u16 full-circle; 8192 = 45°
let dx = c.math.cos(angle); // returns i16 in 1.15 fixed-point
let dy = c.math.sin(angle); // >> 15 to get a ratio
// Move in a direction
let new_x = x + Fixed::from((speed.to_i32() * dx as i32) >> 15);
For positions and velocities, prefer Vec2i from the prelude — it pairs two Fixed values and has helpers for length, distance, and lerp.
No heap allocation
Console32 games cannot use Vec, Box, or any dynamic allocator. All data structures must have sizes known at compile time.
For collections with a runtime-varying count, use Pool<T, N>:
// At most 64 bullets in flight at once
let mut bullets: Pool<Bullet, 64> = Pool::new();
bullets.spawn_with(Bullet { x: player.x, y: player.y, vx: 0, vy: -4 });
for bullet in bullets.iter_mut() {
bullet.y -= bullet.vy;
}
bullets.retain(|b| b.y > 0); // remove off-screen bullets
This is a firm constraint, not a guideline. Attempting to use a global allocator will fail to compile.
Building for hardware
When you have real hardware and the firmware flashed:
# Build a signed .c32 package
cargo console32 build --release
# Copy to the device over USB
cargo console32 deploy
deploy puts the device into USB mass storage mode and copies the .c32 file. The firmware verifies the binary's header and signature, copies it into RAM, and starts the game loop.
For iterative on-hardware testing with a debug probe connected:
cargo console32 run --target hardware
This uses picotool to flash and restart in one step.
Key constraints at a glance
| Constraint | Why |
|---|---|
| No heap allocation | Bare-metal, no allocator, no fragmentation surprises |
| No floating point | Deterministic frame budget; WASM/ARM FPU parity |
| 192 KB code limit | Game binary runs from a reserved RAM region |
| 320×240 framebuffer | 150 KB — DMA-flushed to display after render returns |
| 60 Hz target | ~7 ms budget for update + render combined |
Next steps
- Read No OS, No Linker, No Problem for the architecture behind the ABI
- Check
console32-gameson GitHub for Snake and Hello World as complete examples - Browse the API reference for the full display, input, audio, storage, and maths method signatures