Emberware ZX API Reference
Emberware ZX is a 5th-generation fantasy console targeting PS1/N64/Saturn aesthetics with modern conveniences like deterministic rollback netcode.
Console Specs
| Spec | Value |
|---|---|
| Aesthetic | PS1/N64/Saturn (5th gen) |
| Resolution | 360p, 540p (default), 720p, 1080p |
| Color depth | RGBA8 |
| Tick rate | 24, 30, 60 (default), 120 fps |
| ROM (Cartridge) | 12MB (WASM code + data pack assets) |
| RAM | 4MB (WASM linear memory for game state) |
| VRAM | 4MB (GPU textures and mesh buffers) |
| CPU budget | 4ms per tick (at 60fps) |
| Netcode | Deterministic rollback via GGRS |
| Max players | 4 (any mix of local + remote) |
Game Lifecycle
Games export three functions:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
// Called once at startup
// Load resources, configure console settings
}
#[no_mangle]
pub extern "C" fn update() {
// Called every tick (deterministic for rollback)
// Update game state, handle input
}
#[no_mangle]
pub extern "C" fn render() {
// Called every frame (skipped during rollback replay)
// Draw to screen
}
}
Memory Model
Emberware ZX uses a 12MB ROM + 4MB RAM memory model:
- ROM (12MB): WASM bytecode + data pack (textures, meshes, sounds)
- RAM (4MB): WASM linear memory for game state
- VRAM (4MB): GPU textures and mesh buffers
Assets loaded via rom_* functions go directly to VRAM/audio memory, keeping RAM free for game state.
API Categories
| Category | Description |
|---|---|
| System | Time, logging, random, session info |
| Input | Buttons, sticks, triggers |
| Graphics | Resolution, render mode, state |
| Camera | View and projection |
| Transforms | Matrix stack operations |
| Textures | Loading and binding textures |
| Meshes | Loading and drawing meshes |
| Materials | PBR and Blinn-Phong properties |
| Lighting | Directional and point lights |
| Skinning | Skeletal animation |
| Animation | Keyframe playback |
| Procedural | Generated primitives |
| 2D Drawing | Sprites, text, rectangles |
| Billboards | Camera-facing quads |
| Sky | Procedural sky rendering |
| Audio | Sound effects and music |
| Save Data | Persistent storage |
| ROM Loading | Data pack access |
| Debug | Runtime value inspection |
Screen Capture
The host application includes screenshot and GIF recording capabilities:
| Key | Default | Action |
|---|---|---|
| Screenshot | F9 | Save PNG to screenshots folder |
| GIF Toggle | F10 | Start/stop GIF recording |
Files are saved to:
- Screenshots:
~/.emberware/Emberware/screenshots/ - GIFs:
~/.emberware/Emberware/gifs/
Filenames include game name and timestamp (e.g., platformer_screenshot_2025-01-15_14-30-45.png).
Configuration (~/.emberware/config.toml):
[capture]
screenshot = "F9"
gif_toggle = "F10"
gif_fps = 30 # GIF framerate
gif_max_seconds = 60 # Max duration
Quick Links
- Cheat Sheet - All functions on one page
- Getting Started - Your first game
- Render Modes - Mode 0-3 explained
- Rollback Safety - Writing deterministic code
Building These Docs
These docs are built with mdBook.
# Install mdBook
cargo install mdbook
# Build static HTML (outputs to docs/book/book/)
cd docs/book
mdbook build
# Or serve locally with live reload
mdbook serve
Prerequisites
Before you start building games for Emberware ZX, you’ll need to set up your development environment.
Required Tools
1. Rust
Emberware games are written in Rust and compiled to WebAssembly. Install Rust using rustup:
Windows/macOS/Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Or visit rustup.rs for platform-specific installers.
2. WebAssembly Target
After installing Rust, add the WASM compilation target:
rustup target add wasm32-unknown-unknown
3. Code Editor (Optional but Recommended)
Any text editor works, but we recommend one with Rust support:
- VS Code with rust-analyzer extension
- RustRover (JetBrains IDE for Rust)
- Neovim with rust-analyzer LSP
Verify Installation
Check that everything is installed correctly:
# Check Rust version
rustc --version
# Check Cargo version
cargo --version
# Check WASM target is installed
rustup target list --installed | grep wasm32
You should see output similar to:
rustc 1.75.0 (82e1608df 2023-12-21)
cargo 1.75.0 (1d8b05cdd 2023-11-20)
wasm32-unknown-unknown
Optional: Emberware CLI
The ember CLI tool provides convenient commands for building and running games:
cargo install --path tools/ember-cli
This gives you commands like:
ember build- Compile your gameember run- Run your game in the playerember pack- Package your game into a ROM file
Next: Your First Game
Your First Game
Let’s create a simple game that draws a colored square and responds to input. This will introduce you to the core concepts of Emberware game development.
Create the Project
cargo new --lib my-first-game
cd my-first-game
Configure Cargo.toml
Replace the contents of Cargo.toml with:
[package]
name = "my-first-game"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
Key settings:
crate-type = ["cdylib"]- Builds a C-compatible dynamic library (required for WASM)opt-level = "s"- Optimize for small binary sizelto = true- Link-time optimization for even smaller binaries
Write Your Game
Replace src/lib.rs with the following code:
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
// Panic handler required for no_std
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// FFI imports from the Emberware runtime
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn button_pressed(player: u32, button: u32) -> u32;
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32);
fn draw_text(ptr: *const u8, len: u32, x: f32, y: f32, size: f32, color: u32);
}
// Game state - stored in static variables for rollback safety
static mut SQUARE_Y: f32 = 200.0;
// Button constants
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_A: u32 = 4;
#[no_mangle]
pub extern "C" fn init() {
unsafe {
// Set the background color (dark blue)
set_clear_color(0x1a1a2eFF);
}
}
#[no_mangle]
pub extern "C" fn update() {
unsafe {
// Move square with D-pad
if button_pressed(0, BUTTON_UP) != 0 {
SQUARE_Y -= 10.0;
}
if button_pressed(0, BUTTON_DOWN) != 0 {
SQUARE_Y += 10.0;
}
// Reset position with A button
if button_pressed(0, BUTTON_A) != 0 {
SQUARE_Y = 200.0;
}
// Keep square on screen
SQUARE_Y = SQUARE_Y.clamp(20.0, 450.0);
}
}
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Draw title text
let title = b"Hello Emberware!";
draw_text(
title.as_ptr(),
title.len() as u32,
80.0,
50.0,
32.0,
0xFFFFFFFF,
);
// Draw the moving square
draw_rect(200.0, SQUARE_Y, 80.0, 80.0, 0xFF6B6BFF);
// Draw instructions
let hint = b"D-pad: Move A: Reset";
draw_text(
hint.as_ptr(),
hint.len() as u32,
60.0,
500.0,
18.0,
0x888888FF,
);
}
}
}
Understanding the Code
No Standard Library
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
}
Emberware games run in a minimal WebAssembly environment without the Rust standard library. This keeps binaries small and avoids OS dependencies.
FFI Imports
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
// ...
}
}
Functions are imported from the Emberware runtime. See the Cheat Sheet for all available functions.
Static Game State
#![allow(unused)]
fn main() {
static mut SQUARE_Y: f32 = 200.0;
}
All game state lives in static mut variables. This is intentional - the Emberware runtime automatically snapshots all WASM memory for rollback netcode. No manual state serialization needed!
Colors
Colors are 32-bit RGBA values in hexadecimal:
0xFFFFFFFF= White (R=255, G=255, B=255, A=255)0xFF6B6BFF= Salmon red (R=255, G=107, B=107, A=255)0x1a1a2eFF= Dark blue (R=26, G=26, B=46, A=255)
Build and Run
Build the WASM file:
cargo build --target wasm32-unknown-unknown --release
Output: target/wasm32-unknown-unknown/release/my_first_game.wasm
Run in the Emberware player:
ember run target/wasm32-unknown-unknown/release/my_first_game.wasm
Or load the .wasm file directly in the Emberware Library application.
What You’ve Learned
- Setting up a Rust project for WASM
- The
#![no_std]environment - Importing FFI functions from the runtime
- The three lifecycle functions:
init(),update(),render() - Drawing 2D graphics with
draw_rect()anddraw_text() - Handling input with
button_pressed() - Using static variables for game state
Next: Understanding the Game Loop
Understanding the Game Loop
Every Emberware game implements three core functions that the runtime calls at specific times. Understanding this lifecycle is key to building robust, multiplayer-ready games.
The Three Functions
init() - Called Once at Startup
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
// Load resources
// Configure graphics settings
// Initialize game state
}
}
Purpose: Set up your game. This runs once when the game starts.
Common uses:
- Set resolution and tick rate
- Configure render mode
- Load textures from ROM or create procedural ones
- Initialize game state to starting values
- Set clear color
Example:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_resolution(1); // 540p
set_tick_rate(2); // 60 FPS
set_clear_color(0x000000FF);
render_mode(2); // PBR lighting
// Load a texture
PLAYER_TEXTURE = load_texture(8, 8, PIXELS.as_ptr());
}
}
}
update() - Called Every Tick
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
// Read input
// Update game logic
// Handle physics
// Check collisions
}
}
Purpose: Update your game state. This runs at a fixed rate (default 60 times per second).
Critical for multiplayer: The update() function must be deterministic. Given the same inputs, it must produce exactly the same results every time. This is how rollback netcode works.
Rules for deterministic code:
- Use
random()for randomness (seeded by the runtime) - Don’t use system time or external state
- All game logic goes here, not in
render()
Example:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
let dt = delta_time();
// Read input
let move_x = left_stick_x(0);
let jump = button_pressed(0, BUTTON_A) != 0;
// Update physics
PLAYER_VY -= GRAVITY;
PLAYER_X += move_x * SPEED * dt;
PLAYER_Y += PLAYER_VY * dt;
// Handle jump
if jump && ON_GROUND {
PLAYER_VY = JUMP_FORCE;
}
}
}
}
render() - Called Every Frame
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
// Set up camera
// Draw game objects
// Draw UI
}
}
Purpose: Draw your game. This runs every frame (may be more often than update() for smooth visuals).
Important:
- This function is skipped during rollback
- Don’t modify game state here
- Use state from
update()to determine what to draw
Example:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Set camera
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Draw player
push_identity();
push_translate(PLAYER_X, PLAYER_Y, 0.0);
texture_bind(PLAYER_TEXTURE);
draw_mesh(PLAYER_MESH);
// Draw UI
draw_text(b"Score: ".as_ptr(), 7, 10.0, 10.0, 20.0, 0xFFFFFFFF);
}
}
}
Tick Rate vs Frame Rate
| Concept | Default | Purpose |
|---|---|---|
| Tick Rate | 60 Hz | How often update() runs. Fixed for determinism. |
| Frame Rate | Variable | How often render() runs. Matches display refresh. |
You can change the tick rate in init():
#![allow(unused)]
fn main() {
set_tick_rate(0); // 24 ticks per second (cinematic)
set_tick_rate(1); // 30 ticks per second
set_tick_rate(2); // 60 ticks per second (default)
set_tick_rate(3); // 120 ticks per second (fighting games)
}
The Rollback System
Emberware’s killer feature is automatic rollback netcode. Here’s how it works:
- Snapshot: The runtime snapshots all WASM memory after each
update() - Predict: When waiting for remote player input, the game predicts and continues
- Rollback: When real input arrives, the game rolls back and replays
- Skip render: During rollback replay,
render()is not called
Why this matters:
- All your game state must be in WASM memory (static variables)
update()must be deterministicrender()should only read state, never modify it
init() ← Run once
│
▼
┌─────────────────┐
│ update() ←────┼── Runs at fixed tick rate
│ (snapshot) │ Rollback replays from here
└────────┬────────┘
│
▼
┌─────────────────┐
│ render() ←────┼── Runs every frame
│ (skipped │ Skipped during rollback
│ on rollback) │
└────────┬────────┘
│
└── Loop back to update()
Helpful Functions
| Function | Returns | Description |
|---|---|---|
delta_time() | f32 | Seconds since last tick |
elapsed_time() | f32 | Total seconds since game start |
tick_count() | u64 | Number of ticks since start |
random() | u32 | Deterministic random number |
Common Patterns
Game State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq)]
enum GameState {
Title,
Playing,
Paused,
GameOver,
}
static mut STATE: GameState = GameState::Title;
#[no_mangle]
pub extern "C" fn update() {
unsafe {
match STATE {
GameState::Title => update_title(),
GameState::Playing => update_gameplay(),
GameState::Paused => update_pause(),
GameState::GameOver => update_game_over(),
}
}
}
}
Delta Time for Smooth Movement
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
let dt = delta_time();
// Movement is frame-rate independent
PLAYER_X += SPEED * dt;
}
}
}
You’re ready to build real games!
Continue to the Build Paddle tutorial to create your first complete game, or explore the API Reference to see all available functions.
Build Paddle
In this tutorial, you’ll build a complete Paddle game from scratch. By the end, you’ll have a fully playable game with:
- Two paddles (player-controlled or AI)
- Ball physics with collision detection
- Score tracking and win conditions
- Sound effects loaded from assets
- Title screen and game over states
- Automatic online multiplayer via Emberware’s rollback netcode

What You’ll Learn
| Part | Topics |
|---|---|
| Part 1: Setup & Drawing | Project creation, FFI imports, draw_rect() |
| Part 2: Paddle Movement | Input handling, game state |
| Part 3: Ball Physics | Velocity, collision detection |
| Part 4: AI Opponent | Simple AI for single-player |
| Part 5: Multiplayer | The magic of rollback netcode |
| Part 6: Scoring & Win States | Game logic, state machine |
| Part 7: Sound Effects | Assets, ember build, audio playback |
| Part 8: Polish & Publishing | Title screen, publishing to archive |
Prerequisites
Before starting this tutorial, you should have:
- Completed Your First Game
- Rust and WASM target installed (Prerequisites)
- Basic understanding of the game loop
Final Code
The complete source code for this tutorial is available in the examples:
emberware/examples/paddle/
├── Cargo.toml
├── ember.toml
└── src/
└── lib.rs
You can build and run it with:
cd examples/paddle
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
Time Investment
Each part takes about 10-15 minutes to complete. The full tutorial can be finished in about 2 hours.
Ready? Let’s start with Part 1: Setup & Drawing.
Part 1: Setup & Drawing
In this part, you’ll set up your Paddle project and draw the basic game elements: the court and paddles.
What You’ll Learn
- Creating a new Emberware game project
- Importing FFI functions
- Drawing rectangles with
draw_rect() - Using colors in RGBA hex format
Create the Project
cargo new --lib paddle
cd paddle
Configure Cargo.toml
[package]
name = "paddle"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
libm = "0.2"
[profile.release]
opt-level = "s"
lto = true
We include libm for math functions like sqrt() that we’ll need later.
Write the Basic Structure
Create src/lib.rs:
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// FFI imports from the Emberware runtime
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32);
}
#[no_mangle]
pub extern "C" fn init() {
unsafe {
// Dark background
set_clear_color(0x1a1a2eFF);
}
}
#[no_mangle]
pub extern "C" fn update() {
// Game logic will go here
}
#[no_mangle]
pub extern "C" fn render() {
// Drawing will go here
}
}
Define Constants
Add these constants after the FFI imports:
#![allow(unused)]
fn main() {
// Screen dimensions (540p default resolution)
const SCREEN_WIDTH: f32 = 960.0;
const SCREEN_HEIGHT: f32 = 540.0;
// Paddle dimensions
const PADDLE_WIDTH: f32 = 15.0;
const PADDLE_HEIGHT: f32 = 80.0;
const PADDLE_MARGIN: f32 = 30.0; // Distance from edge
// Ball size
const BALL_SIZE: f32 = 15.0;
// Colors
const COLOR_WHITE: u32 = 0xFFFFFFFF;
const COLOR_GRAY: u32 = 0x666666FF;
const COLOR_PLAYER1: u32 = 0x4a9fffFF; // Blue
const COLOR_PLAYER2: u32 = 0xff6b6bFF; // Red
}
Draw the Court
Let’s draw a dashed center line. Update render():
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Draw center line (dashed)
let dash_height = 20.0;
let dash_gap = 15.0;
let dash_width = 4.0;
let center_x = SCREEN_WIDTH / 2.0 - dash_width / 2.0;
let mut y = 10.0;
while y < SCREEN_HEIGHT - 10.0 {
draw_rect(center_x, y, dash_width, dash_height, COLOR_GRAY);
y += dash_height + dash_gap;
}
}
}
}
Draw the Paddles
Add paddle state and drawing:
#![allow(unused)]
fn main() {
// Add after constants
static mut PADDLE1_Y: f32 = 0.0;
static mut PADDLE2_Y: f32 = 0.0;
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
// Center paddles vertically
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
}
}
}
Update render() to draw the paddles:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Draw center line (dashed)
let dash_height = 20.0;
let dash_gap = 15.0;
let dash_width = 4.0;
let center_x = SCREEN_WIDTH / 2.0 - dash_width / 2.0;
let mut y = 10.0;
while y < SCREEN_HEIGHT - 10.0 {
draw_rect(center_x, y, dash_width, dash_height, COLOR_GRAY);
y += dash_height + dash_gap;
}
// Draw paddle 1 (left, blue)
draw_rect(
PADDLE_MARGIN,
PADDLE1_Y,
PADDLE_WIDTH,
PADDLE_HEIGHT,
COLOR_PLAYER1,
);
// Draw paddle 2 (right, red)
draw_rect(
SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH,
PADDLE2_Y,
PADDLE_WIDTH,
PADDLE_HEIGHT,
COLOR_PLAYER2,
);
}
}
}
Draw the Ball
Add ball state:
#![allow(unused)]
fn main() {
static mut BALL_X: f32 = 0.0;
static mut BALL_Y: f32 = 0.0;
}
Initialize it in init():
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
// Center the ball
BALL_X = SCREEN_WIDTH / 2.0 - BALL_SIZE / 2.0;
BALL_Y = SCREEN_HEIGHT / 2.0 - BALL_SIZE / 2.0;
}
}
}
Draw it in render() (add after paddles):
#![allow(unused)]
fn main() {
// Draw ball
draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE, COLOR_WHITE);
}
Build and Test
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
You should see:
- Dark blue background
- Dashed white center line
- Blue paddle on the left
- Red paddle on the right
- White ball in the center
Complete Code So Far
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32);
}
const SCREEN_WIDTH: f32 = 960.0;
const SCREEN_HEIGHT: f32 = 540.0;
const PADDLE_WIDTH: f32 = 15.0;
const PADDLE_HEIGHT: f32 = 80.0;
const PADDLE_MARGIN: f32 = 30.0;
const BALL_SIZE: f32 = 15.0;
const COLOR_WHITE: u32 = 0xFFFFFFFF;
const COLOR_GRAY: u32 = 0x666666FF;
const COLOR_PLAYER1: u32 = 0x4a9fffFF;
const COLOR_PLAYER2: u32 = 0xff6b6bFF;
static mut PADDLE1_Y: f32 = 0.0;
static mut PADDLE2_Y: f32 = 0.0;
static mut BALL_X: f32 = 0.0;
static mut BALL_Y: f32 = 0.0;
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
BALL_X = SCREEN_WIDTH / 2.0 - BALL_SIZE / 2.0;
BALL_Y = SCREEN_HEIGHT / 2.0 - BALL_SIZE / 2.0;
}
}
#[no_mangle]
pub extern "C" fn update() {}
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Center line
let dash_height = 20.0;
let dash_gap = 15.0;
let dash_width = 4.0;
let center_x = SCREEN_WIDTH / 2.0 - dash_width / 2.0;
let mut y = 10.0;
while y < SCREEN_HEIGHT - 10.0 {
draw_rect(center_x, y, dash_width, dash_height, COLOR_GRAY);
y += dash_height + dash_gap;
}
// Paddles
draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER1);
draw_rect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y,
PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER2);
// Ball
draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE, COLOR_WHITE);
}
}
}
Next: Part 2: Paddle Movement — Make the paddles respond to input.
Part 2: Paddle Movement
Now let’s make the paddles respond to player input.
What You’ll Learn
- Reading input with
button_held()andleft_stick_y() - Clamping values to keep paddles on screen
- The difference between
button_pressed()andbutton_held()
Add Input FFI Functions
Update your FFI imports:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32);
// Input functions
fn left_stick_y(player: u32) -> f32;
fn button_held(player: u32, button: u32) -> u32;
}
}
Add Constants for Input
#![allow(unused)]
fn main() {
// Button constants
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
// Movement speed
const PADDLE_SPEED: f32 = 8.0;
}
Implement Paddle Movement
Update the update() function:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
// Player 1 (left paddle)
update_paddle(&mut PADDLE1_Y, 0);
// Player 2 (right paddle) - we'll add AI later
update_paddle(&mut PADDLE2_Y, 1);
}
}
fn update_paddle(paddle_y: &mut f32, player: u32) {
unsafe {
// Read analog stick (Y axis is inverted: up is negative)
let stick_y = left_stick_y(player);
// Read D-pad buttons
let up = button_held(player, BUTTON_UP) != 0;
let down = button_held(player, BUTTON_DOWN) != 0;
// Calculate movement
let mut movement = -stick_y * PADDLE_SPEED; // Invert stick
if up {
movement -= PADDLE_SPEED;
}
if down {
movement += PADDLE_SPEED;
}
// Apply movement
*paddle_y += movement;
// Clamp to screen bounds
*paddle_y = clamp(*paddle_y, 0.0, SCREEN_HEIGHT - PADDLE_HEIGHT);
}
}
// Helper function
fn clamp(v: f32, min: f32, max: f32) -> f32 {
if v < min { min } else if v > max { max } else { v }
}
}
Understanding Input
Analog Stick
left_stick_y(player) returns a value from -1.0 to 1.0:
- -1.0 = stick pushed fully up
- 0.0 = stick at center
- 1.0 = stick pushed fully down
We invert this because screen Y coordinates increase downward.
D-Pad Buttons
There are two ways to read buttons:
| Function | Behavior |
|---|---|
button_pressed(player, button) | Returns 1 only on the frame the button is first pressed |
button_held(player, button) | Returns 1 every frame the button is held down |
For continuous movement like paddles, use button_held().
Button Constants
#![allow(unused)]
fn main() {
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const BUTTON_X: u32 = 6;
const BUTTON_Y: u32 = 7;
const BUTTON_LB: u32 = 8;
const BUTTON_RB: u32 = 9;
const BUTTON_START: u32 = 12;
const BUTTON_SELECT: u32 = 13;
}
Build and Test
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
Both paddles should now respond to input. Use:
- Player 1: Left stick or D-pad on controller 1
- Player 2: Left stick or D-pad on controller 2 (if connected)
If you only have one controller, player 2’s paddle won’t move yet - we’ll add AI in Part 4.
Complete Code So Far
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32);
fn left_stick_y(player: u32) -> f32;
fn button_held(player: u32, button: u32) -> u32;
}
const SCREEN_WIDTH: f32 = 960.0;
const SCREEN_HEIGHT: f32 = 540.0;
const PADDLE_WIDTH: f32 = 15.0;
const PADDLE_HEIGHT: f32 = 80.0;
const PADDLE_MARGIN: f32 = 30.0;
const PADDLE_SPEED: f32 = 8.0;
const BALL_SIZE: f32 = 15.0;
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const COLOR_WHITE: u32 = 0xFFFFFFFF;
const COLOR_GRAY: u32 = 0x666666FF;
const COLOR_PLAYER1: u32 = 0x4a9fffFF;
const COLOR_PLAYER2: u32 = 0xff6b6bFF;
static mut PADDLE1_Y: f32 = 0.0;
static mut PADDLE2_Y: f32 = 0.0;
static mut BALL_X: f32 = 0.0;
static mut BALL_Y: f32 = 0.0;
fn clamp(v: f32, min: f32, max: f32) -> f32 {
if v < min { min } else if v > max { max } else { v }
}
fn update_paddle(paddle_y: &mut f32, player: u32) {
unsafe {
let stick_y = left_stick_y(player);
let up = button_held(player, BUTTON_UP) != 0;
let down = button_held(player, BUTTON_DOWN) != 0;
let mut movement = -stick_y * PADDLE_SPEED;
if up { movement -= PADDLE_SPEED; }
if down { movement += PADDLE_SPEED; }
*paddle_y += movement;
*paddle_y = clamp(*paddle_y, 0.0, SCREEN_HEIGHT - PADDLE_HEIGHT);
}
}
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
BALL_X = SCREEN_WIDTH / 2.0 - BALL_SIZE / 2.0;
BALL_Y = SCREEN_HEIGHT / 2.0 - BALL_SIZE / 2.0;
}
}
#[no_mangle]
pub extern "C" fn update() {
unsafe {
update_paddle(&mut PADDLE1_Y, 0);
update_paddle(&mut PADDLE2_Y, 1);
}
}
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// Center line
let dash_height = 20.0;
let dash_gap = 15.0;
let dash_width = 4.0;
let center_x = SCREEN_WIDTH / 2.0 - dash_width / 2.0;
let mut y = 10.0;
while y < SCREEN_HEIGHT - 10.0 {
draw_rect(center_x, y, dash_width, dash_height, COLOR_GRAY);
y += dash_height + dash_gap;
}
// Paddles
draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER1);
draw_rect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y,
PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER2);
// Ball
draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE, COLOR_WHITE);
}
}
}
Next: Part 3: Ball Physics - Make the ball move and bounce.
Part 3: Ball Physics
Time to make the ball move! We’ll add velocity, wall bouncing, and paddle collision.
What You’ll Learn
- Ball velocity and movement
- Wall collision (top and bottom)
- Paddle collision with spin
- AABB collision detection
Add Ball Velocity
Update your ball state to include velocity:
#![allow(unused)]
fn main() {
static mut BALL_X: f32 = 0.0;
static mut BALL_Y: f32 = 0.0;
static mut BALL_VX: f32 = 0.0; // Horizontal velocity
static mut BALL_VY: f32 = 0.0; // Vertical velocity
}
Add ball speed constants:
#![allow(unused)]
fn main() {
const BALL_SPEED_INITIAL: f32 = 5.0;
const BALL_SPEED_MAX: f32 = 12.0;
const BALL_SPEED_INCREMENT: f32 = 0.5;
}
Add Random FFI
We need randomness to vary the ball’s starting angle:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
// ... existing imports ...
fn random() -> u32;
}
}
Reset Ball Function
Create a function to reset the ball position and give it a random velocity:
#![allow(unused)]
fn main() {
fn reset_ball(direction: i32) {
unsafe {
// Center the ball
BALL_X = SCREEN_WIDTH / 2.0 - BALL_SIZE / 2.0;
BALL_Y = SCREEN_HEIGHT / 2.0 - BALL_SIZE / 2.0;
// Random vertical angle (-0.25 to 0.25)
let rand = random() % 100;
let angle = ((rand as f32 / 100.0) - 0.5) * 0.5;
// Set velocity
BALL_VX = BALL_SPEED_INITIAL * direction as f32;
BALL_VY = BALL_SPEED_INITIAL * angle;
}
}
}
Update init() to use it:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
reset_ball(-1); // Start moving toward player 1
}
}
}
Ball Movement and Wall Bounce
Create an update_ball() function:
#![allow(unused)]
fn main() {
fn update_ball() {
unsafe {
// Move ball
BALL_X += BALL_VX;
BALL_Y += BALL_VY;
// Bounce off top wall
if BALL_Y <= 0.0 {
BALL_Y = 0.0;
BALL_VY = -BALL_VY;
}
// Bounce off bottom wall
if BALL_Y >= SCREEN_HEIGHT - BALL_SIZE {
BALL_Y = SCREEN_HEIGHT - BALL_SIZE;
BALL_VY = -BALL_VY;
}
}
}
}
Call it from update():
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
update_paddle(&mut PADDLE1_Y, 0);
update_paddle(&mut PADDLE2_Y, 1);
update_ball();
}
}
}
Paddle Collision
Now add collision detection with the paddles. Update update_ball():
#![allow(unused)]
fn main() {
fn update_ball() {
unsafe {
// Move ball
BALL_X += BALL_VX;
BALL_Y += BALL_VY;
// Bounce off top wall
if BALL_Y <= 0.0 {
BALL_Y = 0.0;
BALL_VY = -BALL_VY;
}
// Bounce off bottom wall
if BALL_Y >= SCREEN_HEIGHT - BALL_SIZE {
BALL_Y = SCREEN_HEIGHT - BALL_SIZE;
BALL_VY = -BALL_VY;
}
// Paddle 1 (left) collision
if BALL_VX < 0.0 { // Ball moving left
let paddle_x = PADDLE_MARGIN;
let paddle_right = paddle_x + PADDLE_WIDTH;
if BALL_X <= paddle_right
&& BALL_X + BALL_SIZE >= paddle_x
&& BALL_Y + BALL_SIZE >= PADDLE1_Y
&& BALL_Y <= PADDLE1_Y + PADDLE_HEIGHT
{
// Bounce
BALL_X = paddle_right;
BALL_VX = -BALL_VX;
// Add spin based on where ball hit paddle
let paddle_center = PADDLE1_Y + PADDLE_HEIGHT / 2.0;
let ball_center = BALL_Y + BALL_SIZE / 2.0;
let offset = (ball_center - paddle_center) / (PADDLE_HEIGHT / 2.0);
BALL_VY += offset * 2.0;
// Speed up (makes game more exciting)
speed_up_ball();
}
}
// Paddle 2 (right) collision
if BALL_VX > 0.0 { // Ball moving right
let paddle_x = SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH;
if BALL_X + BALL_SIZE >= paddle_x
&& BALL_X <= paddle_x + PADDLE_WIDTH
&& BALL_Y + BALL_SIZE >= PADDLE2_Y
&& BALL_Y <= PADDLE2_Y + PADDLE_HEIGHT
{
// Bounce
BALL_X = paddle_x - BALL_SIZE;
BALL_VX = -BALL_VX;
// Add spin
let paddle_center = PADDLE2_Y + PADDLE_HEIGHT / 2.0;
let ball_center = BALL_Y + BALL_SIZE / 2.0;
let offset = (ball_center - paddle_center) / (PADDLE_HEIGHT / 2.0);
BALL_VY += offset * 2.0;
speed_up_ball();
}
}
// Ball goes off screen (scoring - we'll handle this properly later)
if BALL_X < -BALL_SIZE || BALL_X > SCREEN_WIDTH {
reset_ball(if BALL_X < 0.0 { 1 } else { -1 });
}
}
}
fn speed_up_ball() {
unsafe {
let speed = libm::sqrtf(BALL_VX * BALL_VX + BALL_VY * BALL_VY);
if speed < BALL_SPEED_MAX {
let factor = (speed + BALL_SPEED_INCREMENT) / speed;
BALL_VX *= factor;
BALL_VY *= factor;
}
}
}
}
Understanding the Collision
AABB (Axis-Aligned Bounding Box)
The collision check uses AABB overlap testing:
Ball overlaps Paddle if:
ball.left < paddle.right AND
ball.right > paddle.left AND
ball.top < paddle.bottom AND
ball.bottom > paddle.top
Spin System
When the ball hits the paddle:
- Hit the top of the paddle → ball goes up more
- Hit the center → ball goes straight
- Hit the bottom → ball goes down more
This gives players control over the ball direction.
Build and Test
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
The ball should now:
- Move across the screen
- Bounce off top and bottom walls
- Bounce off paddles with spin
- Reset when it goes off screen
Next: Part 4: AI Opponent - Add an AI for single-player mode.
Part 4: AI Opponent
Let’s add an AI opponent so single players can enjoy the game.
What You’ll Learn
- Simple AI that follows the ball
- Checking player count with
player_count() - Making AI beatable (not perfect)
Add Player Count FFI
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
// ... existing imports ...
fn player_count() -> u32;
}
}
Track Game Mode
Add a state variable to track whether we’re in two-player mode:
#![allow(unused)]
fn main() {
static mut IS_TWO_PLAYER: bool = false;
}
Simple AI Logic
Create an AI update function:
#![allow(unused)]
fn main() {
fn update_ai(paddle_y: &mut f32) {
unsafe {
// AI follows the ball
let paddle_center = *paddle_y + PADDLE_HEIGHT / 2.0;
let ball_center = BALL_Y + BALL_SIZE / 2.0;
let diff = ball_center - paddle_center;
// Only move if difference is significant (dead zone)
if diff.abs() > 5.0 {
// AI moves slower than max speed to be beatable
let ai_speed = PADDLE_SPEED * 0.7;
if diff > 0.0 {
*paddle_y += ai_speed;
} else {
*paddle_y -= ai_speed;
}
}
// Clamp to screen bounds
*paddle_y = clamp(*paddle_y, 0.0, SCREEN_HEIGHT - PADDLE_HEIGHT);
}
}
}
You’ll also need this helper:
#![allow(unused)]
fn main() {
fn abs(v: f32) -> f32 {
if v < 0.0 { -v } else { v }
}
}
Update the Game Loop
Modify update() to use AI when appropriate:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
// Check if a second player is connected
IS_TWO_PLAYER = player_count() >= 2;
// Player 1 always uses input
update_paddle(&mut PADDLE1_Y, 0);
// Player 2: human or AI
if IS_TWO_PLAYER {
update_paddle(&mut PADDLE2_Y, 1);
} else {
update_ai(&mut PADDLE2_Y);
}
update_ball();
}
}
}
Show Game Mode
Let’s display the current mode. Add text FFI:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
// ... existing imports ...
fn draw_text(ptr: *const u8, len: u32, x: f32, y: f32, size: f32, color: u32);
}
}
Add to render():
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
unsafe {
// ... existing drawing code ...
// Show mode indicator
if IS_TWO_PLAYER {
let text = b"2 PLAYERS";
draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0, COLOR_GRAY);
} else {
let text = b"vs AI";
draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0, COLOR_GRAY);
}
}
}
}
How the AI Works
The AI is intentionally imperfect:
- Follows the ball - Moves toward where the ball is
- Slower speed - Only 70% of max paddle speed
- Dead zone - Doesn’t jitter when ball is near center
- No prediction - Doesn’t anticipate where ball will go
This makes the AI beatable but still challenging.
Making AI Harder or Easier
#![allow(unused)]
fn main() {
// Easier AI (50% speed)
let ai_speed = PADDLE_SPEED * 0.5;
// Harder AI (90% speed)
let ai_speed = PADDLE_SPEED * 0.9;
// Perfect AI (instant tracking) - not fun!
*paddle_y = ball_center - PADDLE_HEIGHT / 2.0;
}
The Magic: Automatic Multiplayer
Here’s the key insight: when a second player connects, the game automatically becomes two-player. You don’t need to do anything special!
This works because:
- We check
player_count()every frame - Player 2 input is always read (even if unused)
- The switch from AI to human is seamless
When a friend joins your game online via Emberware’s netcode, player_count() increases and they take control of paddle 2.
Build and Test
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
With one controller:
- You control player 1
- AI controls player 2
- “vs AI” appears in corner
Connect a second controller:
- Both players are human
- “2 PLAYERS” appears
Next: Part 5: Multiplayer - Understanding the rollback netcode magic.
Part 5: Multiplayer
This is where Emberware’s magic happens. Our Paddle game already supports online multiplayer - and we didn’t write any networking code!
What You’ll Learn
- How Emberware’s rollback netcode works
- Why all game state must be in static variables
- Rules for deterministic code
- What happens during a rollback
The Magic
Here’s a surprising fact: your Paddle game already works online.
When two players connect over the internet:
- Player 1’s inputs are sent to Player 2’s game
- Player 2’s inputs are sent to Player 1’s game
- Both games run the same
update()function with the same inputs - Both games show the same result
You didn’t write a single line of networking code.
How It Works
Rollback Netcode
Traditional netcode waits for the other player’s input before advancing. This causes lag.
Emberware uses rollback netcode:
- Predict: Don’t have remote input? Guess it (usually “same as last frame”)
- Continue: Run the game with the prediction
- Correct: When real input arrives, if it differs from prediction:
- Roll back to the snapshot
- Replay with correct input
- Catch up to present
Automatic Snapshots
Every frame, Emberware snapshots your entire WASM memory:
Frame 1: [snapshot] → update() → render()
Frame 2: [snapshot] → update() → render()
Frame 3: [snapshot] → update() → render()
↑
If rollback needed, restore this and replay
This is why all game state must be in static mut variables - they live in WASM memory and get snapshotted automatically.
The Rules for Rollback-Safe Code
Rule 1: All State in WASM Memory
✅ Good - State in static variables:
#![allow(unused)]
fn main() {
static mut PLAYER_X: f32 = 0.0;
static mut SCORE: u32 = 0;
}
❌ Bad - State outside WASM (if this were possible):
#![allow(unused)]
fn main() {
// Don't try to use external state!
// (Rust's no_std prevents most of this anyway)
}
Rule 2: Deterministic Update
Given the same inputs, update() must produce the same results.
✅ Good - Use random() for randomness:
#![allow(unused)]
fn main() {
let rand = random(); // Deterministic, seeded by runtime
}
❌ Bad - Use system time (if this were possible):
#![allow(unused)]
fn main() {
// let time = get_system_time(); // Non-deterministic!
}
Rule 3: No State Changes in Render
The render() function is skipped during rollback. Never modify game state there.
✅ Good:
#![allow(unused)]
fn main() {
fn render() {
// Only READ state
draw_rect(BALL_X, BALL_Y, ...);
}
}
❌ Bad:
#![allow(unused)]
fn main() {
fn render() {
ANIMATION_FRAME += 1; // This won't replay during rollback!
}
}
Our Paddle Game Follows the Rules
Let’s verify our code is rollback-safe:
✅ All State in Statics
#![allow(unused)]
fn main() {
static mut PADDLE1_Y: f32 = 0.0;
static mut PADDLE2_Y: f32 = 0.0;
static mut BALL_X: f32 = 0.0;
static mut BALL_Y: f32 = 0.0;
static mut BALL_VX: f32 = 0.0;
static mut BALL_VY: f32 = 0.0;
}
✅ Deterministic Randomness
#![allow(unused)]
fn main() {
fn reset_ball(direction: i32) {
let rand = random() % 100; // Uses runtime's seeded RNG
// ...
}
}
✅ Update Reads Input, Render Just Draws
#![allow(unused)]
fn main() {
fn update() {
// Read input
let stick_y = left_stick_y(player);
// Modify state
PADDLE1_Y += movement;
}
fn render() {
// Only draw, never modify state
draw_rect(PADDLE_MARGIN, PADDLE1_Y, ...);
}
}
Testing Multiplayer Locally
To test multiplayer on your local machine:
- Start the game
- Connect a second controller
- Both players can play!
The player_count() function automatically detects connected players.
Testing Online Multiplayer
Online play is handled by the Emberware runtime:
- Player 1 hosts a game
- Player 2 joins via game code or direct connect
- The runtime handles all networking
- Your game code doesn’t change at all!
What Rollback Looks Like
During normal play:
You press A → Your game shows jump immediately
(predicting remote player holds same buttons)
50ms later → Remote input arrives, matches prediction
Nothing changes, smooth gameplay!
When prediction is wrong:
You press A → Your game shows jump immediately
(predicting remote player holds same buttons)
50ms later → Remote input arrives: they pressed B!
Game rolls back to frame N-3
Replays frames N-3, N-2, N-1 with correct input
Catches up to present frame N
Visual "correction" happens in ~1-2 frames
With good connections, predictions are usually correct and rollbacks are rare.
Summary
| Traditional Netcode | Emberware Rollback |
|---|---|
| Wait for input → lag | Predict input → smooth |
| Manual state sync | Automatic snapshots |
| You write network code | You write game code |
| State can be anywhere | State must be in WASM |
The key insight: Emberware handles multiplayer complexity so you can focus on making your game fun.
Next: Part 6: Scoring & Win States - Add scoring and game flow.
Part 6: Scoring & Win States
Let’s add proper scoring, win conditions, and a game state machine.
What You’ll Learn
- Game state machines (Title, Playing, GameOver)
- Tracking and displaying scores
- Win conditions
- Using
button_pressed()for menu navigation
Add Game State
Create a state enum and related variables:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq)]
enum GameState {
Title,
Playing,
GameOver,
}
static mut STATE: GameState = GameState::Title;
static mut SCORE1: u32 = 0;
static mut SCORE2: u32 = 0;
static mut WINNER: u32 = 0; // 1 or 2
const WIN_SCORE: u32 = 5;
}
Add Button Constants
We need the A button for starting/restarting:
#![allow(unused)]
fn main() {
const BUTTON_A: u32 = 4;
}
Add to FFI imports:
#![allow(unused)]
fn main() {
fn button_pressed(player: u32, button: u32) -> u32;
}
Reset Game Function
Create a function to reset the entire game:
#![allow(unused)]
fn main() {
fn reset_game() {
unsafe {
// Reset paddles
PADDLE1_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
PADDLE2_Y = SCREEN_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
// Reset scores
SCORE1 = 0;
SCORE2 = 0;
WINNER = 0;
// Check player count
IS_TWO_PLAYER = player_count() >= 2;
// Reset ball
reset_ball(-1);
}
}
}
Update Scoring Logic
Modify the ball update to handle scoring:
#![allow(unused)]
fn main() {
fn update_ball() {
unsafe {
// ... existing movement and collision code ...
// Ball goes off left side - Player 2 scores
if BALL_X < -BALL_SIZE {
SCORE2 += 1;
if SCORE2 >= WIN_SCORE {
WINNER = 2;
STATE = GameState::GameOver;
} else {
reset_ball(-1); // Serve toward player 1
}
}
// Ball goes off right side - Player 1 scores
if BALL_X > SCREEN_WIDTH {
SCORE1 += 1;
if SCORE1 >= WIN_SCORE {
WINNER = 1;
STATE = GameState::GameOver;
} else {
reset_ball(1); // Serve toward player 2
}
}
}
}
}
State Machine in Update
Restructure update() to handle game states:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
// Always check player count
IS_TWO_PLAYER = player_count() >= 2;
match STATE {
GameState::Title => {
// Press A to start
if button_pressed(0, BUTTON_A) != 0 {
reset_game();
STATE = GameState::Playing;
}
}
GameState::Playing => {
// Normal gameplay
update_paddle(&mut PADDLE1_Y, 0);
if IS_TWO_PLAYER {
update_paddle(&mut PADDLE2_Y, 1);
} else {
update_ai(&mut PADDLE2_Y);
}
update_ball();
}
GameState::GameOver => {
// Press A to restart
if button_pressed(0, BUTTON_A) != 0 || button_pressed(1, BUTTON_A) != 0 {
reset_game();
STATE = GameState::Playing;
}
}
}
}
}
}
Update Init
Start on title screen:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
reset_game();
STATE = GameState::Title;
}
}
}
Render Scores
Add a helper for drawing text:
#![allow(unused)]
fn main() {
fn draw_text_bytes(text: &[u8], x: f32, y: f32, size: f32, color: u32) {
unsafe {
draw_text(text.as_ptr(), text.len() as u32, x, y, size, color);
}
}
}
Add score display in render:
#![allow(unused)]
fn main() {
fn render_scores() {
unsafe {
// Convert scores to single digits
let score1_char = b'0' + (SCORE1 % 10) as u8;
let score2_char = b'0' + (SCORE2 % 10) as u8;
// Draw scores
draw_text(&[score1_char], 1, SCREEN_WIDTH / 4.0, 30.0, 48.0, COLOR_PLAYER1);
draw_text(&[score2_char], 1, SCREEN_WIDTH * 3.0 / 4.0, 30.0, 48.0, COLOR_PLAYER2);
}
}
}
Render States
Update render() to show different screens:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
unsafe {
match STATE {
GameState::Title => {
render_court();
render_title();
}
GameState::Playing => {
render_court();
render_scores();
render_paddles();
render_ball();
render_mode_indicator();
}
GameState::GameOver => {
render_court();
render_scores();
render_paddles();
render_ball();
render_game_over();
}
}
}
}
fn render_title() {
unsafe {
draw_text_bytes(b"PADDLE", SCREEN_WIDTH / 2.0 - 100.0, 150.0, 64.0, COLOR_WHITE);
if IS_TWO_PLAYER {
draw_text_bytes(b"2 PLAYER MODE", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0, COLOR_WHITE);
} else {
draw_text_bytes(b"1 PLAYER VS AI", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0, COLOR_WHITE);
}
draw_text_bytes(b"Press A to Start", SCREEN_WIDTH / 2.0 - 120.0, 350.0, 24.0, COLOR_GRAY);
}
}
fn render_game_over() {
unsafe {
// Dark overlay
draw_rect(SCREEN_WIDTH / 4.0, SCREEN_HEIGHT / 3.0,
SCREEN_WIDTH / 2.0, SCREEN_HEIGHT / 3.0, 0x000000CC);
// Winner text
let (text, color) = if WINNER == 1 {
(b"PLAYER 1 WINS!" as &[u8], COLOR_PLAYER1)
} else if IS_TWO_PLAYER {
(b"PLAYER 2 WINS!" as &[u8], COLOR_PLAYER2)
} else {
(b"AI WINS!" as &[u8], COLOR_PLAYER2)
};
draw_text(text.as_ptr(), text.len() as u32,
SCREEN_WIDTH / 2.0 - 120.0, SCREEN_HEIGHT / 2.0 - 20.0, 32.0, color);
draw_text_bytes(b"Press A to Play Again",
SCREEN_WIDTH / 2.0 - 140.0, SCREEN_HEIGHT / 2.0 + 30.0, 20.0, COLOR_GRAY);
}
}
fn render_mode_indicator() {
unsafe {
if IS_TWO_PLAYER {
draw_text_bytes(b"2P", 10.0, 10.0, 16.0, COLOR_GRAY);
} else {
draw_text_bytes(b"vs AI", 10.0, 10.0, 16.0, COLOR_GRAY);
}
}
}
}
Build and Test
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
The game now has:
- Title screen with mode indicator
- Score display during play
- Game over screen with winner
- Press A to start or restart
- First to 5 points wins
Next: Part 7: Sound Effects - Add audio feedback.
Part 7: Sound Effects
Games feel incomplete without audio. So far we’ve been using draw_rect() for everything—but you can’t draw a sound! This is where Emberware’s asset pipeline comes in.
What You’ll Learn
- Setting up an assets folder
- Creating
ember.tomlto bundle assets - Using
ember buildinstead ofcargo build - Loading sounds with
rom_sound() - Playing sounds with
play_sound()and stereo panning
Why Assets Now?
Up until now, we’ve built and tested like this:
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
This works great for graphics—draw_rect() handles everything. But sounds need actual audio files. That’s where ember build comes in: it bundles your code and assets into a single ROM file.
Create the Assets Folder
Create an assets/ folder in your project:
mkdir assets
Get Sound Files
You need three WAV files for the game:
| Sound | Description | Duration |
|---|---|---|
hit.wav | Quick beep for paddle/wall hits | ~0.1s |
score.wav | Descending tone when someone scores | ~0.2s |
win.wav | Victory fanfare when game ends | ~0.5s |
Download sample sounds from the tutorial assets, or create your own with:
- JSFXR — Generate retro sound effects in your browser
- Freesound.org — CC-licensed sounds
- Audacity — Record and edit audio
Put them in your assets/ folder:
paddle/
├── Cargo.toml
├── ember.toml ← We'll create this next
├── assets/
│ ├── hit.wav
│ ├── score.wav
│ └── win.wav
└── src/
└── lib.rs
Create ember.toml
Create ember.toml in your project root. This manifest tells Emberware about your game and its assets:
[game]
id = "paddle"
title = "Paddle"
author = "Your Name"
version = "0.1.0"
# Sound assets
[[assets.sounds]]
id = "hit"
path = "assets/hit.wav"
[[assets.sounds]]
id = "score"
path = "assets/score.wav"
[[assets.sounds]]
id = "win"
path = "assets/win.wav"
Each asset has:
- id — The name you’ll use to load it in code
- path — File location relative to
ember.toml
Build with ember build
Now use ember build instead of cargo build:
ember build
This command:
- Compiles your Rust code to WASM
- Converts WAV files to the optimized format (22050 Hz mono)
- Bundles everything into a
paddle.ewzxROM file
You’ll see output like:
Building paddle...
Compiling paddle v0.1.0
Converting hit.wav → hit.ewzsnd
Converting score.wav → score.ewzsnd
Converting win.wav → win.ewzsnd
Packing paddle.ewzx (28 KB)
Done!
Run Your Game
Now run the ROM:
ember run paddle.ewzx
Or just:
ember run
This builds and runs in one step.
Add Audio FFI
Add the audio functions to your FFI imports:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
// ... existing imports ...
// ROM loading
fn rom_sound(id_ptr: *const u8, id_len: u32) -> u32;
// Audio playback
fn play_sound(sound: u32, volume: f32, pan: f32);
}
}
Sound Handles
Add static variables to store sound handles:
#![allow(unused)]
fn main() {
static mut SFX_HIT: u32 = 0;
static mut SFX_SCORE: u32 = 0;
static mut SFX_WIN: u32 = 0;
}
Load Sounds in init()
Update init() to load sounds from the ROM:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
unsafe {
set_clear_color(0x1a1a2eFF);
// Load sounds from ROM
SFX_HIT = rom_sound(b"hit".as_ptr(), 3);
SFX_SCORE = rom_sound(b"score".as_ptr(), 5);
SFX_WIN = rom_sound(b"win".as_ptr(), 3);
reset_game();
STATE = GameState::Title;
}
}
}
The rom_sound() function loads the sound directly from the bundled ROM—the string IDs match what you put in ember.toml.
Play Sounds
Now add sound effects to game events:
Wall Bounce
#![allow(unused)]
fn main() {
// In update_ball(), after wall bounce:
if BALL_Y <= 0.0 {
BALL_Y = 0.0;
BALL_VY = -BALL_VY;
play_sound(SFX_HIT, 0.3, 0.0); // Center pan
}
if BALL_Y >= SCREEN_HEIGHT - BALL_SIZE {
BALL_Y = SCREEN_HEIGHT - BALL_SIZE;
BALL_VY = -BALL_VY;
play_sound(SFX_HIT, 0.3, 0.0);
}
}
Paddle Hit
#![allow(unused)]
fn main() {
// In paddle 1 collision:
play_sound(SFX_HIT, 0.5, -0.5); // Pan left
// In paddle 2 collision:
play_sound(SFX_HIT, 0.5, 0.5); // Pan right
}
Scoring
#![allow(unused)]
fn main() {
// When player 2 scores (ball exits left):
SCORE2 += 1;
play_sound(SFX_SCORE, 0.6, 0.5); // Pan right (scorer's side)
// When player 1 scores (ball exits right):
SCORE1 += 1;
play_sound(SFX_SCORE, 0.6, -0.5); // Pan left (scorer's side)
}
Win
#![allow(unused)]
fn main() {
// When either player wins:
if SCORE1 >= WIN_SCORE || SCORE2 >= WIN_SCORE {
STATE = GameState::GameOver;
play_sound(SFX_WIN, 0.8, 0.0); // Center, louder
}
}
Understanding play_sound()
#![allow(unused)]
fn main() {
fn play_sound(sound: u32, volume: f32, pan: f32);
}
| Parameter | Range | Description |
|---|---|---|
sound | Handle | Sound handle from rom_sound() |
volume | 0.0 - 1.0 | 0 = silent, 1 = full volume |
pan | -1.0 - 1.0 | -1 = left, 0 = center, 1 = right |
Audio Specs
Emberware uses these audio settings:
| Property | Value |
|---|---|
| Sample rate | 22050 Hz |
| Format | 16-bit mono PCM |
| Channels | Stereo output |
The ember build command automatically converts your WAV files to this format.
Sound Design Tips
- Keep sounds short — 0.1 to 0.5 seconds is plenty for effects
- Use panning — Stereo positioning helps players track action
- Vary volume — Important sounds louder, ambient sounds quieter
- Match your aesthetic — Simple sounds fit retro games
Build and Test
Rebuild with your sound assets:
ember build
ember run
The game now has:
- “Ping” sound when ball hits walls or paddles
- Different panning for left/right paddle hits
- Descending “whomp” when someone scores
- Victory fanfare when a player wins
Bonus: Sprite Graphics
Now that we have the asset pipeline set up, we can also use image sprites instead of draw_rect(). This is optional—the game works fine with rectangles—but sprites look nicer!
Add Texture Assets
Download paddle.png and ball.png from the tutorial assets, then add them to ember.toml:
# Texture assets
[[assets.textures]]
id = "paddle"
path = "assets/paddle.png"
[[assets.textures]]
id = "ball"
path = "assets/ball.png"
Add Texture FFI
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
// ... existing imports ...
// Texture loading and drawing
fn rom_texture(id_ptr: *const u8, id_len: u32) -> u32;
fn texture_bind(texture: u32);
fn draw_sprite(x: f32, y: f32, w: f32, h: f32);
}
}
Load Textures
Add handles and load in init():
#![allow(unused)]
fn main() {
static mut TEX_PADDLE: u32 = 0;
static mut TEX_BALL: u32 = 0;
// In init():
TEX_PADDLE = rom_texture(b"paddle".as_ptr(), 6);
TEX_BALL = rom_texture(b"ball".as_ptr(), 4);
}
Draw Sprites
Replace draw_rect() calls in render():
#![allow(unused)]
fn main() {
// Instead of: draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER1);
texture_bind(TEX_PADDLE);
draw_sprite(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT);
// Second paddle
draw_sprite(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y, PADDLE_WIDTH, PADDLE_HEIGHT);
// Instead of: draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE, COLOR_WHITE);
texture_bind(TEX_BALL);
draw_sprite(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE);
}
The sprite will be tinted by the bound texture. You can also use draw_sprite_colored() if you want to tint sprites with different colors per player.
New Workflow Summary
| Before (Parts 1-6) | Now (Part 7+) |
|---|---|
cargo build --target wasm32-unknown-unknown --release | ember build |
ember run target/.../paddle.wasm | ember run |
| No assets needed | Assets bundled in ROM |
From now on, just use ember build and ember run!
Next: Part 8: Polish & Publishing — Final touches and releasing your game.
Part 8: Polish & Publishing
Your Paddle game is complete! Let’s add some final polish and publish it to the Emberware Archive.
What You’ll Learn
- Adding control hints
- Final ember.toml configuration
- Building a release ROM
- Publishing to emberware.io
Add Control Hints
Let’s add helpful text on the title screen:
#![allow(unused)]
fn main() {
fn render_title() {
unsafe {
// Title
draw_text_bytes(b"PADDLE", SCREEN_WIDTH / 2.0 - 100.0, 150.0, 64.0, COLOR_WHITE);
// Mode indicator
if IS_TWO_PLAYER {
draw_text_bytes(b"2 PLAYER MODE", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0, COLOR_WHITE);
} else {
draw_text_bytes(b"1 PLAYER VS AI", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0, COLOR_WHITE);
}
// Start prompt
draw_text_bytes(b"Press A to Start", SCREEN_WIDTH / 2.0 - 120.0, 350.0, 24.0, COLOR_GRAY);
// Controls hint
draw_text_bytes(b"Controls: Left Stick or D-Pad Up/Down",
250.0, 450.0, 18.0, COLOR_GRAY);
}
}
}
Complete ember.toml
In Part 7, we created our ember.toml. Here’s the complete version with all metadata for publishing:
[game]
id = "paddle"
title = "Paddle"
author = "Your Name"
version = "1.0.0"
description = "Classic Paddle game with AI and multiplayer support"
# Sound assets
[[assets.sounds]]
id = "hit"
path = "assets/hit.wav"
[[assets.sounds]]
id = "score"
path = "assets/score.wav"
[[assets.sounds]]
id = "win"
path = "assets/win.wav"
# Texture assets (optional - for sprite graphics)
[[assets.textures]]
id = "paddle"
path = "assets/paddle.png"
[[assets.textures]]
id = "ball"
path = "assets/ball.png"
Project Structure
Your final project should look like:
paddle/
├── Cargo.toml
├── ember.toml
├── assets/
│ ├── hit.wav
│ ├── score.wav
│ ├── win.wav
│ ├── paddle.png (optional)
│ └── ball.png (optional)
└── src/
└── lib.rs
Build for Release
Using ember build
Build your game with all assets bundled:
ember build
This creates a .ewzx ROM file containing:
- Your compiled WASM code
- All converted and compressed assets
- Game metadata
Verify the Build
Check your ROM was created:
ls -la *.ewzx
You should see something like:
-rw-r--r-- 1 user user 45678 Dec 20 12:00 paddle.ewzx
Test Your Release Build
Run the final ROM:
ember run paddle.ewzx
Final Checklist
Before publishing, verify:
- Title screen displays correctly
- Both players can control paddles
- AI works when only one player
- Ball bounces correctly off walls and paddles
- Scores track correctly
- Game ends at 5 points
- Victory screen shows correct winner
- All sound effects play with proper panning
- Game restarts correctly
Publishing to Emberware Archive
1. Create an Account
Visit emberware.io/register to create your developer account.
2. Prepare Assets
You’ll need:
- Icon (64×64 PNG) — Shows in the game library
- Screenshot(s) (optional) — Shows on your game’s page
3. Upload Your Game
- Log in to emberware.io
- Go to your Dashboard
- Click “Upload New Game”
- Fill in the details:
- Title: “Paddle”
- Description: Your game description
- Category: Arcade
- Upload your
.ewzxROM file - Add your icon and screenshots
- Click “Publish”
4. Share Your Game
Once published, your game has a unique page at:
emberware.io/game/paddle
Share this link! Anyone with the Emberware player can play your game.
What You’ve Built
Congratulations! Your Paddle game includes:
| Feature | Implementation |
|---|---|
| Graphics | Court, paddles, ball with draw_rect() |
| Input | Analog stick and D-pad with left_stick_y(), button_held() |
| Physics | Ball movement, wall bouncing, paddle collision |
| AI | Simple ball-following AI opponent |
| Multiplayer | Automatic online play via rollback netcode |
| Game Flow | Title, Playing, GameOver states |
| Scoring | Point tracking, win conditions |
| Audio | Sound effects loaded from ROM with stereo panning |
| Assets | Sounds bundled with ember build |
What’s Next?
Enhance Your Paddle Game
Ideas to try:
- Add ball speed increase after each hit
- Create power-ups that spawn randomly
- Add particle effects when scoring
- Implement 4-player mode
- Use sprite textures for paddles and ball
Build More Games
Check out these resources:
- Example Games — 28+ examples
- API Reference — All available functions
- Asset Pipeline — Advanced asset workflows
- Render Modes Guide — 3D graphics
Join the Community
- Share your game in GitHub Discussions
- Report bugs or request features
- Help other developers
Complete Source Code
The final source code is available at:
emberware/examples/paddle/
You can compare your code or use it as a reference.
Summary
In this tutorial, you learned:
- Setup — Creating an Emberware project
- Drawing — Using
draw_rect()for 2D graphics - Input — Reading sticks and buttons
- Physics — Ball movement and collision
- AI — Simple opponent behavior
- Multiplayer — How rollback netcode “just works”
- Game Flow — State machines for menus
- Assets — Using
ember.tomlandember buildfor sounds - Audio — Loading and playing sound effects from ROM
- Publishing — Sharing your game with the world
You’re now an Emberware game developer!
Render Modes Guide
Emberware ZX supports 4 rendering modes, each with different lighting and material features.
Overview
| Mode | Name | Lighting | Best For |
|---|---|---|---|
| 0 | Unlit | None | Flat colors, UI, retro 2D |
| 1 | Matcap | Pre-baked | Stylized, toon, sculpted look |
| 2 | Metallic-Roughness | PBR-style Blinn-Phong | Realistic materials |
| 3 | Specular-Shininess | Traditional Blinn-Phong | Classic 3D, arcade |
Set the mode once in init():
#![allow(unused)]
fn main() {
fn init() {
render_mode(2); // PBR-style lighting
}
}
Mode 0: Unlit
No lighting calculations. Colors come directly from textures and set_color().
Features:
- Fastest rendering
- Flat, solid colors
- No shadows or highlights
- Perfect for 2D sprites, UI, or intentionally flat aesthetics
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(0);
}
fn render() {
// Color comes purely from texture + set_color tint
texture_bind(sprite_tex);
set_color(0xFFFFFFFF);
draw_mesh(quad);
}
}
Use cases:
- 2D games with sprite-based graphics
- UI elements
- Retro flat-shaded PS1 style
- Unlit portions of scenes (skyboxes, emissive objects)
Mode 1: Matcap
Uses matcap textures for pre-baked lighting. Fast and stylized.
Features:
- Lighting baked into matcap textures
- No dynamic lights
- Great for stylized/toon looks
- Multiple matcaps can be layered
Texture Slots:
| Slot | Purpose | Blend Mode |
|---|---|---|
| 0 | Albedo (UV-mapped) | Base color |
| 1-3 | Matcap (normal-mapped) | Configurable |
Matcap Blend Modes:
- 0 (Multiply): Darkens (shadows, AO)
- 1 (Add): Brightens (highlights, rim)
- 2 (HSV Modulate): Hue/saturation shift
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(1);
SHADOW_MATCAP = rom_texture(b"matcap_shadow".as_ptr(), 13);
HIGHLIGHT_MATCAP = rom_texture(b"matcap_highlight".as_ptr(), 16);
}
fn render() {
texture_bind(character_albedo);
matcap_set(1, SHADOW_MATCAP);
matcap_blend_mode(1, 0); // Multiply
matcap_set(2, HIGHLIGHT_MATCAP);
matcap_blend_mode(2, 1); // Add
draw_mesh(character);
}
}
Use cases:
- Stylized/cartoon characters
- Sculpt-like rendering
- Fast mobile-friendly lighting
- Consistent lighting regardless of scene
Mode 2: Metallic-Roughness
PBR-inspired Blinn-Phong with metallic/roughness workflow.
Features:
- Up to 4 dynamic lights
- Metallic/roughness material properties
- MRE texture support (Metallic/Roughness/Emissive)
- Rim lighting
- Procedural sky ambient
- Energy-conserving Gotanda normalization
Texture Slots:
| Slot | Purpose | Channels |
|---|---|---|
| 0 | Albedo | RGB: Diffuse color |
| 1 | MRE | R: Metallic, G: Roughness, B: Emissive |
Material Functions:
#![allow(unused)]
fn main() {
material_metallic(0.0); // 0 = dielectric, 1 = metal
material_roughness(0.5); // 0 = mirror, 1 = rough
material_emissive(0.0); // Self-illumination
material_rim(0.2, 0.15); // Rim light intensity and power
}
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(2);
}
fn render() {
// Set up lighting
light_set(0, 0.5, -0.7, 0.5);
light_color(0, 0xFFF2E6FF);
light_enable(0);
// Shiny metal
material_metallic(1.0);
material_roughness(0.2);
material_rim(0.1, 0.2);
texture_bind(sword_tex);
draw_mesh(sword);
// Rough stone
material_metallic(0.0);
material_roughness(0.9);
material_rim(0.0, 0.0);
texture_bind(stone_tex);
draw_mesh(wall);
}
}
Use cases:
- Realistic materials (metal, plastic, wood)
- PBR asset pipelines
- Games requiring material variety
- Modern 3D aesthetics
Mode 3: Specular-Shininess
Traditional Blinn-Phong with direct specular color control.
Features:
- Up to 4 dynamic lights
- Shininess-based specular
- Direct specular color control
- Rim lighting
- Energy-conserving Gotanda normalization
Texture Slots:
| Slot | Purpose | Channels |
|---|---|---|
| 0 | Albedo | RGB: Diffuse color |
| 1 | SSE | R: Specular intensity, G: Shininess, B: Emissive |
| 2 | Specular | RGB: Specular highlight color |
Material Functions:
#![allow(unused)]
fn main() {
material_shininess(0.7); // 0-1 → maps to 1-256
material_specular(0xFFD700FF); // Specular highlight color
material_emissive(0.0); // Self-illumination
material_rim(0.2, 0.15); // Rim light
}
Shininess Values:
| Value | Shininess | Appearance |
|---|---|---|
| 0.0-0.2 | 1-52 | Very soft (cloth, skin) |
| 0.2-0.4 | 52-103 | Broad (leather, wood) |
| 0.4-0.6 | 103-154 | Medium (plastic) |
| 0.6-0.8 | 154-205 | Tight (polished metal) |
| 0.8-1.0 | 205-256 | Mirror (chrome, glass) |
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(3);
}
fn render() {
// Gold armor
set_color(0xE6B84DFF);
material_shininess(0.8);
material_specular(0xFFD700FF);
material_rim(0.2, 0.15);
draw_mesh(armor);
// Wet skin
set_color(0xD9B399FF);
material_shininess(0.7);
material_specular(0xFFFFFFFF);
material_rim(0.3, 0.25);
draw_mesh(character);
}
}
Use cases:
- Classic 3D game aesthetics
- Colored specular highlights (metals)
- Artist-friendly workflow
- Fighting games, action games
Choosing a Mode
| If you need… | Use Mode |
|---|---|
| Fastest rendering, no lighting | 0 (Unlit) |
| Stylized, consistent lighting | 1 (Matcap) |
| PBR workflow with MRE textures | 2 (Metallic-Roughness) |
| Colored specular, artist control | 3 (Specular-Shininess) |
Performance: All lit modes (1-3) have similar performance. Mode 0 is fastest.
Compatibility: All modes work with procedural meshes and skeletal animation.
Common Setup
All lit modes benefit from proper sky and light setup:
#![allow(unused)]
fn main() {
fn init() {
render_mode(2); // or 1 or 3
// Sky provides ambient light
sky_set_colors(0xB2D8F2FF, 0x3366B2FF);
sky_set_sun(0.5, 0.7, 0.5, 0xFFF2E6FF, 0.95);
}
fn render() {
draw_sky();
// Main light (match sun direction)
light_set(0, 0.5, -0.7, 0.5);
light_color(0, 0xFFF2E6FF);
light_intensity(0, 1.0);
light_enable(0);
// Fill light
light_set(1, -0.8, -0.3, 0.0);
light_color(1, 0x8899BBFF);
light_intensity(1, 0.3);
light_enable(1);
// Draw scene...
}
}
Rollback Safety Guide
Writing deterministic code for Emberware’s rollback netcode.
How Rollback Works
Emberware uses GGRS for deterministic rollback netcode:
- Every tick, your
update()receives inputs from all players - GGRS synchronizes inputs across the network
- On misprediction, the game state is restored from a snapshot and replayed
For this to work, your update() must be deterministic: same inputs → same state.
The Golden Rules
1. Use random() for All Randomness
#![allow(unused)]
fn main() {
// GOOD - Deterministic
let spawn_x = (random() % 320) as f32;
let damage = 10 + (random() % 5) as i32;
// BAD - Non-deterministic
let spawn_x = system_time_nanos() % 320; // Different on each client!
let damage = 10 + (thread_rng().next_u32() % 5); // Different seeds!
}
The random() function returns values from a synchronized seed, ensuring all clients get the same sequence.
2. Keep State in Static Variables
All game state must live in WASM linear memory (global statics):
#![allow(unused)]
fn main() {
// GOOD - State in WASM memory (snapshotted)
static mut PLAYER_X: f32 = 0.0;
static mut ENEMIES: [Enemy; 10] = [Enemy::new(); 10];
// BAD - State outside WASM memory
// (external systems, thread-locals, etc. are not snapshotted)
}
3. Same Inputs = Same State
Your update() must produce identical results given identical inputs:
#![allow(unused)]
fn main() {
fn update() {
// All calculations based only on:
// - Current state (in WASM memory)
// - Player inputs (from GGRS)
// - delta_time() / elapsed_time() / tick_count() (synchronized)
// - random() (synchronized)
let dt = delta_time();
for p in 0..player_count() {
if button_pressed(p, BUTTON_A) != 0 {
players[p].jump();
}
players[p].x += left_stick_x(p) * SPEED * dt;
}
}
}
4. Render is Skipped During Rollback
render() is not called during rollback replay. Don’t put game logic in render():
#![allow(unused)]
fn main() {
// GOOD - Logic in update()
fn update() {
ANIMATION_FRAME = (tick_count() as u32 / 6) % 4;
}
fn render() {
// Just draw, no state changes
draw_sprite_region(..., ANIMATION_FRAME as f32 * 32.0, ...);
}
// BAD - Logic in render()
fn render() {
ANIMATION_FRAME += 1; // Skipped during rollback = desynced!
draw_sprite_region(...);
}
}
Common Pitfalls
Floating Point Non-Determinism
Floating point operations can vary across CPUs. Emberware handles most cases, but be careful with:
#![allow(unused)]
fn main() {
// Potentially problematic
let angle = (y / x).atan(); // atan can differ slightly
// Safer alternatives
// - Use integer math where possible
// - Use lookup tables for trig
// - Accept small visual differences (for rendering only)
}
Order-Dependent Iteration
HashMap iteration order is non-deterministic:
#![allow(unused)]
fn main() {
// BAD - Non-deterministic order
for (id, enemy) in enemies.iter() {
enemy.update(); // Order matters for collisions!
}
// GOOD - Fixed order
for i in 0..enemies.len() {
enemies[i].update();
}
}
External State
Never read from external sources in update():
#![allow(unused)]
fn main() {
// BAD
let now = SystemTime::now(); // Different on each client
let file = read_file("data.txt"); // Files can differ
let response = http_get("api.com"); // Network varies
// GOOD - All data from ROM or synchronized state
let data = rom_data(b"level".as_ptr(), 5, ...);
}
Audio and Visual Effects
Audio and particles are often non-critical for gameplay:
#![allow(unused)]
fn main() {
fn update() {
// Core gameplay - must be deterministic
if player_hit_enemy() {
ENEMY_HEALTH -= DAMAGE;
// Audio/VFX triggers are fine here
// (they'll replay during rollback, but that's OK)
play_sound(HIT_SFX, 1.0, 0.0);
}
}
fn render() {
// Visual-only effects
spawn_particles(PLAYER_X, PLAYER_Y); // Not critical
}
}
Memory Snapshotting
Emberware automatically snapshots your WASM linear memory:
What's Snapshotted (RAM): What's NOT Snapshotted:
├── Static variables ├── GPU textures (VRAM)
├── Heap allocations ├── Audio buffers
├── Stack (function locals) ├── Mesh data
└── Resource handles (u32s) └── Resource data
Tip: Keep your game state small for faster snapshots. Only handles (u32) live in RAM; actual texture/mesh/audio data stays in host memory.
Testing Determinism
Local Testing
Run the same inputs twice and compare state:
#![allow(unused)]
fn main() {
fn update() {
// After each update, log state hash
let hash = calculate_state_hash();
log_fmt(b"Tick {} hash: {}", tick_count(), hash);
}
}
Multiplayer Testing
- Start a local game with 2 players
- Give identical inputs
- Verify states match
Debug Checklist
If you see desync:
- Check
random()usage - All randomness fromrandom()? - Check iteration order - Using fixed-order arrays?
- Check floating point - Sensitive calculations reproducible?
- Check
render()logic - Any state changes in render? - Check external reads - System time, files, network?
- Check audio timing - Audio triggering consistent?
Example: Deterministic Enemy AI
#![allow(unused)]
fn main() {
static mut ENEMIES: [Enemy; 10] = [Enemy::new(); 10];
static mut ENEMY_COUNT: usize = 0;
#[derive(Clone, Copy)]
struct Enemy {
x: f32,
y: f32,
health: i32,
ai_state: u8,
ai_timer: u32,
}
impl Enemy {
const fn new() -> Self {
Self { x: 0.0, y: 0.0, health: 100, ai_state: 0, ai_timer: 0 }
}
fn update(&mut self, player_x: f32, player_y: f32) {
match self.ai_state {
0 => {
// Idle - random chance to start patrol
if random() % 100 < 5 { // 5% chance per tick
self.ai_state = 1;
self.ai_timer = 60 + (random() % 60); // 1-2 seconds
}
}
1 => {
// Patrol - move toward random target
self.ai_timer -= 1;
if self.ai_timer == 0 {
self.ai_state = 0;
}
// Movement...
}
_ => {}
}
}
}
fn update() {
unsafe {
let px = PLAYER_X;
let py = PLAYER_Y;
// Fixed iteration order
for i in 0..ENEMY_COUNT {
ENEMIES[i].update(px, py);
}
}
}
}
This AI is deterministic because:
random()is synchronized- Array iteration has fixed order
- All state is in WASM memory
- No external dependencies
Emberware Asset Pipeline
Convert 3D models, textures, and audio into optimized Emberware formats.
Quick Start
Getting assets into an Emberware game is 3 steps:
1. Export from your 3D tool (Blender, Maya, etc.) as glTF, GLB, or OBJ
2. Create assets.toml:
[output]
dir = "assets/"
[meshes]
player = "models/player.gltf"
enemy = "models/enemy.glb"
[textures]
grass = "textures/grass.png"
[sounds]
jump = "audio/jump.wav"
3. Build and use:
ember-export build assets.toml
#![allow(unused)]
fn main() {
static PLAYER_MESH: &[u8] = include_bytes!("assets/player.ewzmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.ewztex");
fn init() {
let player = load_zmesh(PLAYER_MESH.as_ptr() as u32, PLAYER_MESH.len() as u32);
let grass = load_ztex(GRASS_TEX.as_ptr() as u32, GRASS_TEX.len() as u32);
}
}
One manifest, one command, simple FFI calls.
Supported Input Formats
3D Models
| Format | Extension | Status |
|---|---|---|
| glTF 2.0 | .gltf, .glb | Implemented |
| OBJ | .obj | Implemented |
Recommendation: Use glTF for new projects. It’s the “JPEG of 3D” - efficient, well-documented, and supported everywhere.
Textures
| Format | Status |
|---|---|
| PNG | Implemented |
| JPG | Implemented |
Audio
| Format | Status |
|---|---|
| WAV | Implemented |
Fonts
| Format | Status |
|---|---|
| TTF | Planned |
Manifest-Based Asset Pipeline
Define all your game assets in a single assets.toml file, then build everything with one command.
assets.toml Reference
# Output configuration
[output]
dir = "assets/" # Output directory for converted files
# rust = "src/assets.rs" # Planned: Generated Rust module
# 3D Models
[meshes]
player = "models/player.gltf" # Simple: just the path
enemy = "models/enemy.glb"
level = { path = "models/level.obj", format = "POS_UV_NORMAL" } # With options
# Textures
[textures]
player_diffuse = "textures/player.png"
# Audio
[sounds]
jump = "audio/jump.wav"
# Fonts (planned)
# [fonts]
# ui = { path = "fonts/roboto.ttf", size = 16 }
Build Commands
# Build all assets from manifest
ember-export build assets.toml
# Validate manifest without building
ember-export check assets.toml
# Convert individual files
ember-export mesh player.gltf -o player.ewzmesh
ember-export texture grass.png -o grass.ewztex
ember-export audio jump.wav -o jump.ewzsnd
Output Files
Running ember-export build assets.toml generates binary asset files:
player.ewzmesh,enemy.ewzmesh,level.ewzmeshplayer_diffuse.ewztexjump.ewzsnd
Output File Formats
EmberZMesh (.ewzmesh)
Binary format for 3D meshes with GPU-optimized packed vertex data. POD format (no magic bytes).
Header (12 bytes):
Offset | Type | Description
-------|------|----------------------------------
0x00 | u32 | Vertex count
0x04 | u32 | Index count
0x08 | u8 | Vertex format flags (0-15)
0x09 | u8 | Reserved (padding)
0x0A | u16 | Reserved (padding)
0x0C | data | Vertex data (vertex_count * stride bytes)
0x?? | u16[]| Index data (index_count * 2 bytes)
Stride is calculated from the format flags at runtime.
EmberZTexture (.ewztex)
Binary format for textures. POD format (no magic bytes).
Current Header (4 bytes):
Offset | Type | Description
-------|------|----------------------------------
0x00 | u16 | Width in pixels (max 65535)
0x02 | u16 | Height in pixels (max 65535)
0x04 | u8[] | Pixel data (RGBA8, width * height * 4 bytes)
⚠️ Format Change (Dec 12, 2024):
- Old format (before commit 3ed67ef): 8-byte header with
u32 width + u32 height - Current format: 4-byte header with
u16 width + u16 height - If you have old
.ewztexfiles, regenerate them with:ember-export texture <source.png> -o <output.ewztex> - Symptom of old format: “invalid dimensions” error during load
EmberZSound (.ewzsnd)
Binary format for audio. POD format (no magic bytes).
Header (4 bytes):
Offset | Type | Description
-------|-------|----------------------------------
0x00 | u32 | Sample count
0x04 | i16[] | PCM samples (22050Hz mono)
Vertex Formats
The Emberware runtime supports 16 vertex format combinations, controlled by format flags.
#![allow(unused)]
fn main() {
FORMAT_UV = 1 // Texture coordinates
FORMAT_COLOR = 2 // Per-vertex color
FORMAT_NORMAL = 4 // Surface normals
FORMAT_SKINNED = 8 // Bone weights for skeletal animation
}
All 16 Formats
| Format | Name | Packed Stride |
|---|---|---|
| 0 | POS | 8 bytes |
| 1 | POS_UV | 12 bytes |
| 2 | POS_COLOR | 12 bytes |
| 3 | POS_UV_COLOR | 16 bytes |
| 4 | POS_NORMAL | 12 bytes |
| 5 | POS_UV_NORMAL | 16 bytes |
| 6 | POS_COLOR_NORMAL | 16 bytes |
| 7 | POS_UV_COLOR_NORMAL | 20 bytes |
| 8 | POS_SKINNED | 16 bytes |
| 9 | POS_UV_SKINNED | 20 bytes |
| 10 | POS_COLOR_SKINNED | 20 bytes |
| 11 | POS_UV_COLOR_SKINNED | 24 bytes |
| 12 | POS_NORMAL_SKINNED | 20 bytes |
| 13 | POS_UV_NORMAL_SKINNED | 24 bytes |
| 14 | POS_COLOR_NORMAL_SKINNED | 24 bytes |
| 15 | POS_UV_COLOR_NORMAL_SKINNED | 28 bytes |
Common formats:
- Format 5 (POS_UV_NORMAL): Most common for textured, lit meshes
- Format 13 (POS_UV_NORMAL_SKINNED): Animated characters
Packed Vertex Data
The runtime automatically packs vertex data using GPU-optimized formats for smaller memory footprint and faster uploads.
Attribute Encoding
| Attribute | Packed Format | Size | Notes |
|---|---|---|---|
| Position | Float16x4 | 8 bytes | x, y, z, w=1.0 |
| UV | Unorm16x2 | 4 bytes | 65536 values in [0,1], better precision than f16 |
| Color | Unorm8x4 | 4 bytes | RGBA, alpha=255 if not provided |
| Normal | Octahedral u32 | 4 bytes | ~0.02° angular precision |
| Bone Indices | Uint8x4 | 4 bytes | Up to 256 bones |
| Bone Weights | Unorm8x4 | 4 bytes | Normalized to [0,255] |
Octahedral Normal Encoding
Normals use octahedral encoding for uniform angular precision with 66% size reduction:
Standard normal: 3 floats × 4 bytes = 12 bytes
Octahedral: 1 u32 = 4 bytes
How it works:
- Project 3D unit vector onto octahedron surface
- Unfold octahedron to 2D square [-1, 1]²
- Pack as 2× snorm16 into single u32
Precision: ~0.02° worst-case angular error - uniform across the entire sphere.
The vertex shader decodes the normal automatically.
Memory Savings
| Format | Unpacked | Packed | Savings |
|---|---|---|---|
| POS_UV_NORMAL | 32 bytes | 16 bytes | 50% |
| POS_UV_NORMAL_SKINNED | 52 bytes | 24 bytes | 54% |
| Full format (15) | 64 bytes | 28 bytes | 56% |
Skeletal Animation
Vertex Skinning Data
Each skinned vertex stores:
- Bone Indices: Uint8x4 (4 bytes) - which bones affect this vertex (0-255)
- Bone Weights: Unorm8x4 (4 bytes) - influence weights, normalized
Bone Matrices
Bone transforms use 3×4 affine matrices (not 4×4):
set_bones(matrices_ptr, count)
3×4 Matrix Layout (column-major, 12 floats per bone):
[m00, m10, m20] ← column 0 (X basis)
[m01, m11, m21] ← column 1 (Y basis)
[m02, m12, m22] ← column 2 (Z basis)
[m03, m13, m23] ← column 3 (translation)
The bottom row [0, 0, 0, 1] is implicit (affine transform).
Limits:
- Maximum 256 bones per skeleton
- 48 bytes per bone (vs 64 bytes for 4×4) - 25% memory savings
Tool Reference
ember-export
The asset conversion CLI tool.
Build from manifest:
ember-export build assets.toml # Build all assets
ember-export check assets.toml # Validate only
Convert individual files:
# Meshes
ember-export mesh player.gltf -o player.ewzmesh
ember-export mesh level.obj -o level.ewzmesh --format POS_UV_NORMAL
# Textures
ember-export texture grass.png -o grass.ewztex
# Audio
ember-export audio jump.wav -o jump.ewzsnd
Loading Assets (FFI)
EmberZ Format Loading (Recommended)
The simplest way to load assets is using the EmberZ format functions. These parse the POD headers host-side and upload to GPU.
#![allow(unused)]
fn main() {
// FFI declarations
extern "C" {
fn load_zmesh(data_ptr: u32, data_len: u32) -> u32;
fn load_ztex(data_ptr: u32, data_len: u32) -> u32;
fn load_zsound(data_ptr: u32, data_len: u32) -> u32;
}
// Embed assets at compile time
static PLAYER_MESH: &[u8] = include_bytes!("assets/player.ewzmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.ewztex");
static JUMP_SFX: &[u8] = include_bytes!("assets/jump.ewzsnd");
fn init() {
let player = load_zmesh(PLAYER_MESH.as_ptr() as u32, PLAYER_MESH.len() as u32);
let grass = load_ztex(GRASS_TEX.as_ptr() as u32, GRASS_TEX.len() as u32);
let jump = load_zsound(JUMP_SFX.as_ptr() as u32, JUMP_SFX.len() as u32);
}
}
Raw Data Loading (Advanced)
For fine-grained control, you can bypass the EmberZ format and provide raw data directly:
Convenience API (f32 input, auto-packed):
#![allow(unused)]
fn main() {
load_mesh(data_ptr, vertex_count, format) -> u32
load_mesh_indexed(data_ptr, vertex_count, index_ptr, index_count, format) -> u32
}
Power User API (pre-packed data):
#![allow(unused)]
fn main() {
load_mesh_packed(data_ptr, vertex_count, format) -> u32
load_mesh_indexed_packed(data_ptr, vertex_count, index_ptr, index_count, format) -> u32
}
Constraints
Emberware enforces these limits:
| Resource | Limit |
|---|---|
| ROM size | 12 MB |
| VRAM | 4 MB |
| Bones per skeleton | 256 |
All assets are embedded in the WASM binary at compile time. There is no runtime asset loading - this ensures deterministic builds required for rollback netcode.
Starter Assets
Don’t have assets yet? Here are some ready-to-use procedural assets you can copy directly into your game.
Procedural Textures
Checkerboard (8x8)
#![allow(unused)]
fn main() {
const CHECKERBOARD: [u8; 256] = {
let mut pixels = [0u8; 256];
let white = [0xFF, 0xFF, 0xFF, 0xFF];
let gray = [0x88, 0x88, 0x88, 0xFF];
let mut y = 0;
while y < 8 {
let mut x = 0;
while x < 8 {
let idx = (y * 8 + x) * 4;
let color = if (x + y) % 2 == 0 { white } else { gray };
pixels[idx] = color[0];
pixels[idx + 1] = color[1];
pixels[idx + 2] = color[2];
pixels[idx + 3] = color[3];
x += 1;
}
y += 1;
}
pixels
};
}
Player Sprite (8x8)
#![allow(unused)]
fn main() {
const PLAYER_SPRITE: [u8; 256] = {
let mut pixels = [0u8; 256];
let white = [0xFF, 0xFF, 0xFF, 0xFF];
let trans = [0x00, 0x00, 0x00, 0x00];
let pattern: [[u8; 8]; 8] = [
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
];
let mut y = 0;
while y < 8 {
let mut x = 0;
while x < 8 {
let idx = (y * 8 + x) * 4;
let color = if pattern[y][x] == 1 { white } else { trans };
pixels[idx] = color[0];
pixels[idx + 1] = color[1];
pixels[idx + 2] = color[2];
pixels[idx + 3] = color[3];
x += 1;
}
y += 1;
}
pixels
};
}
Coin/Collectible (8x8)
#![allow(unused)]
fn main() {
const COIN_SPRITE: [u8; 256] = {
let mut pixels = [0u8; 256];
let gold = [0xFF, 0xD7, 0x00, 0xFF];
let shine = [0xFF, 0xFF, 0x88, 0xFF];
let trans = [0x00, 0x00, 0x00, 0x00];
let pattern: [[u8; 8]; 8] = [
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 2, 2, 1, 1, 1, 0],
[1, 2, 2, 1, 1, 1, 1, 1],
[1, 2, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
];
let mut y = 0;
while y < 8 {
let mut x = 0;
while x < 8 {
let idx = (y * 8 + x) * 4;
let color = match pattern[y][x] {
0 => trans, 1 => gold, _ => shine,
};
pixels[idx] = color[0];
pixels[idx + 1] = color[1];
pixels[idx + 2] = color[2];
pixels[idx + 3] = color[3];
x += 1;
}
y += 1;
}
pixels
};
}
Procedural Sounds
Beep (short hit sound)
#![allow(unused)]
fn main() {
fn generate_beep() -> [i16; 2205] {
let mut samples = [0i16; 2205]; // 0.1 sec @ 22050 Hz
for i in 0..2205 {
let t = i as f32 / 22050.0;
let envelope = 1.0 - (i as f32 / 2205.0);
let value = libm::sinf(2.0 * core::f32::consts::PI * 440.0 * t) * envelope;
samples[i] = (value * 32767.0 * 0.3) as i16;
}
samples
}
}
Jump sound (ascending)
#![allow(unused)]
fn main() {
fn generate_jump() -> [i16; 4410] {
let mut samples = [0i16; 4410]; // 0.2 sec
for i in 0..4410 {
let t = i as f32 / 22050.0;
let progress = i as f32 / 4410.0;
let freq = 200.0 + (400.0 * progress); // 200 → 600 Hz
let envelope = 1.0 - progress;
let value = libm::sinf(2.0 * core::f32::consts::PI * freq * t) * envelope;
samples[i] = (value * 32767.0 * 0.3) as i16;
}
samples
}
}
Coin collect (sparkle)
#![allow(unused)]
fn main() {
fn generate_collect() -> [i16; 6615] {
let mut samples = [0i16; 6615]; // 0.3 sec
for i in 0..6615 {
let t = i as f32 / 22050.0;
let progress = i as f32 / 6615.0;
// Two frequencies for shimmer effect
let f1 = 880.0;
let f2 = 1320.0; // Perfect fifth
let envelope = 1.0 - progress;
let v1 = libm::sinf(2.0 * core::f32::consts::PI * f1 * t);
let v2 = libm::sinf(2.0 * core::f32::consts::PI * f2 * t);
let value = (v1 + v2 * 0.5) * envelope;
samples[i] = (value * 32767.0 * 0.2) as i16;
}
samples
}
}
Using Starter Assets
Load procedural assets in your init():
#![allow(unused)]
fn main() {
static mut PLAYER_TEX: u32 = 0;
static mut JUMP_SFX: u32 = 0;
#[no_mangle]
pub extern "C" fn init() {
unsafe {
// Load texture
PLAYER_TEX = load_texture(8, 8, PLAYER_SPRITE.as_ptr());
texture_filter(0); // Nearest-neighbor for crisp pixels
// Load sound
let jump = generate_jump();
JUMP_SFX = load_sound(jump.as_ptr(), (jump.len() * 2) as u32);
}
}
}
ember.toml vs include_bytes!()
There are two ways to include assets in your game:
Method 1: ember.toml + ROM Packing
Best for: Production games with many assets
# ember.toml
[game]
id = "my-game"
title = "My Game"
[[assets.textures]]
id = "player"
path = "assets/player.png"
[[assets.sounds]]
id = "jump"
path = "assets/jump.wav"
Load with ROM functions:
#![allow(unused)]
fn main() {
let player_tex = rom_texture(b"player".as_ptr(), 6);
let jump_sfx = rom_sound(b"jump".as_ptr(), 4);
}
Benefits:
- Assets are pre-processed and compressed
- Single
.ewzxROM file - Automatic GPU format conversion
Method 2: include_bytes!() + Procedural
Best for: Small games, prototyping, tutorials
#![allow(unused)]
fn main() {
// Compile-time embedding
static TEXTURE_DATA: &[u8] = include_bytes!("../assets/player.ewztex");
// Or generate at runtime
const PIXELS: [u8; 256] = generate_pixels();
}
Benefits:
- Simple, no build step
- Good for procedural content
- Self-contained WASM file
Which Should I Use?
| Scenario | Recommendation |
|---|---|
| Learning/prototyping | include_bytes!() or procedural |
| Simple arcade games | Either works |
| Complex games with many assets | ember.toml + ROM |
| Games with large textures | ember.toml (compression) |
Planned Features
The following features are planned but not yet implemented:
- Font conversion - TTF/OTF to bitmap font atlas (.ewzfont)
- Watch mode -
ember-export build --watchfor auto-rebuild on changes - Rust code generation - Auto-generated asset loading module
Publishing Your Game
This guide covers everything you need to know about packaging and publishing your Emberware game.
Overview
The publishing process:
- Build your game (compile to WASM)
- Pack assets into a ROM file (optional)
- Test the final build
- Upload to emberware.io
- Share with the world
Building for Release
Using ember-cli (Recommended)
The ember CLI handles compilation and packaging:
# Build WASM
ember build
# Package into ROM
ember pack
# Or do both
ember build && ember pack
Manual Build
If you prefer manual control:
# Build optimized WASM
cargo build --target wasm32-unknown-unknown --release
# Your WASM file
ls target/wasm32-unknown-unknown/release/your_game.wasm
Game Manifest (ember.toml)
Create ember.toml in your project root:
[game]
id = "my-game" # Unique identifier (lowercase, hyphens ok)
title = "My Game" # Display name
author = "Your Name" # Creator credit
version = "1.0.0" # Semantic version
description = "A fun game" # Short description
[build]
script = "cargo build --target wasm32-unknown-unknown --release"
wasm = "target/wasm32-unknown-unknown/release/my_game.wasm"
# Optional: Assets to include in ROM
[[assets.textures]]
id = "player"
path = "assets/player.png"
[[assets.meshes]]
id = "level"
path = "assets/level.ewzmesh"
[[assets.sounds]]
id = "jump"
path = "assets/jump.wav"
ROM File Format
Emberware ROMs (.ewzx files) bundle:
- Your compiled WASM game
- Pre-processed assets (textures, meshes, sounds)
- Game metadata
Benefits of ROM packaging:
- Faster loading - Assets are already GPU-ready
- Single file - Easy to distribute
- Verified - Content integrity checked
Testing Your Build
Always test the final build:
# Test the WASM directly
ember run target/wasm32-unknown-unknown/release/my_game.wasm
# Or test the packed ROM
ember run my_game.ewzx
Verify:
- Game starts correctly
- All assets load
- No console errors
- Multiplayer works (test with two controllers)
Upload Requirements
Required Files
| File | Format | Description |
|---|---|---|
| Game | .wasm or .ewzx | Your compiled game |
| Icon | 64×64 PNG | Library thumbnail |
Optional Files
| File | Format | Description |
|---|---|---|
| Screenshots | PNG | Game page gallery (up to 5) |
| Banner | 1280×720 PNG | Featured games banner |
Metadata
- Title - Your game’s name
- Description - What your game is about (Markdown supported)
- Category - Arcade, Puzzle, Action, etc.
- Tags - Searchable keywords
Publishing Process
1. Create Developer Account
Visit emberware.io/register
2. Access Dashboard
Log in and go to emberware.io/dashboard
3. Upload Game
- Click “Upload New Game”
- Fill in title and description
- Select category and tags
- Upload your game file
- Upload icon (required) and screenshots (optional)
- Click “Publish”
4. Game Page
Your game gets a public page:
emberware.io/game/your-game-id
Updating Your Game
To release an update:
- Bump version in
ember.toml - Build and test new version
- Go to Dashboard → Your Game → Edit
- Upload new game file
- Update version number
- Save changes
Players with old versions will be prompted to update.
Content Guidelines
Games must:
- Be appropriate for all ages
- Not contain malware or harmful code
- Not violate copyright
- Actually be playable
Games must NOT:
- Contain excessive violence or adult content
- Harvest user data
- Attempt to break out of the sandbox
- Impersonate other developers’ games
Troubleshooting
“WASM validation failed”
Your WASM file may be corrupted or built incorrectly.
Fix:
# Clean build
cargo clean
cargo build --target wasm32-unknown-unknown --release
“Asset not found”
Asset paths in ember.toml are relative to the project root.
Verify:
# Check if file exists
ls assets/player.png
“ROM too large”
Emberware has size limits for fair distribution.
Reduce size:
- Compress textures
- Use smaller audio sample rates
- Remove unused assets
“Game crashes on load”
Usually a panic in init().
Debug:
- Test locally first
- Check console for error messages
- Simplify
init()to isolate the issue
Best Practices
- Test thoroughly before publishing
- Write a good description - help players find your game
- Create an appealing icon - first impressions matter
- Include screenshots - show off your game
- Respond to feedback - engage with players
- Update regularly - fix bugs, add features
Distribution Alternatives
Besides emberware.io, you can distribute:
Direct Download
Share the .wasm or .ewzx file directly. Players load it in the Emberware player.
GitHub Releases
Host on GitHub as release artifacts.
itch.io
Upload as a downloadable file with instructions.
Ready to publish? Head to emberware.io and share your creation with the world!
Cheat Sheet
All Emberware ZX FFI functions on one page.
System
#![allow(unused)]
fn main() {
delta_time() -> f32 // Seconds since last tick
elapsed_time() -> f32 // Total seconds since start
tick_count() -> u64 // Current tick number
log(ptr, len) // Log message to console
quit() // Exit to library
random() -> u32 // Deterministic random
player_count() -> u32 // Number of players (1-4)
local_player_mask() -> u32 // Bitmask of local players
}
Configuration (Init-Only)
#![allow(unused)]
fn main() {
set_resolution(res) // 0=360p, 1=540p, 2=720p, 3=1080p
set_tick_rate(fps) // 0=24, 1=30, 2=60, 3=120
set_clear_color(0xRRGGBBAA) // Background color
render_mode(mode) // 0=Unlit, 1=Matcap, 2=MR, 3=SS
}
Input
#![allow(unused)]
fn main() {
// Buttons (player: 0-3, button: 0-13)
button_held(player, button) -> u32 // 1 if held
button_pressed(player, button) -> u32 // 1 if just pressed
button_released(player, button) -> u32 // 1 if just released
buttons_held(player) -> u32 // Bitmask of held
buttons_pressed(player) -> u32 // Bitmask of pressed
buttons_released(player) -> u32 // Bitmask of released
// Sticks (-1.0 to 1.0)
left_stick_x(player) -> f32
left_stick_y(player) -> f32
right_stick_x(player) -> f32
right_stick_y(player) -> f32
left_stick(player, &mut x, &mut y) // Both axes
right_stick(player, &mut x, &mut y)
// Triggers (0.0 to 1.0)
trigger_left(player) -> f32
trigger_right(player) -> f32
}
Button Constants: UP=0, DOWN=1, LEFT=2, RIGHT=3, A=4, B=5, X=6, Y=7, LB=8, RB=9, L3=10, R3=11, START=12, SELECT=13
Camera
#![allow(unused)]
fn main() {
camera_set(x, y, z, target_x, target_y, target_z)
camera_fov(degrees) // Default: 60
push_view_matrix(m0..m15) // Custom 4x4 view matrix
push_projection_matrix(m0..m15) // Custom 4x4 projection
}
Transforms
#![allow(unused)]
fn main() {
push_identity() // Reset to identity
transform_set(matrix_ptr) // Set from 4x4 matrix
push_translate(x, y, z)
push_rotate_x(degrees)
push_rotate_y(degrees)
push_rotate_z(degrees)
push_rotate(degrees, axis_x, axis_y, axis_z)
push_scale(x, y, z)
push_scale_uniform(s)
}
Render State
#![allow(unused)]
fn main() {
set_color(0xRRGGBBAA) // Tint color
depth_test(enabled) // 0=off, 1=on
cull_mode(mode) // 0=none, 1=back, 2=front
blend_mode(mode) // 0=none, 1=alpha, 2=add, 3=mul
texture_filter(filter) // 0=nearest, 1=linear
uniform_alpha(level) // 0-15 dither alpha
dither_offset(x, y) // 0-3 pattern offset
}
Textures
#![allow(unused)]
fn main() {
load_texture(w, h, pixels_ptr) -> u32 // Init-only, returns handle
texture_bind(handle) // Bind to slot 0
texture_bind_slot(handle, slot) // Bind to slot 0-3
matcap_blend_mode(slot, mode) // 0=mul, 1=add, 2=hsv
}
Meshes
#![allow(unused)]
fn main() {
// Retained (init-only)
load_mesh(data_ptr, vertex_count, format) -> u32
load_mesh_indexed(data_ptr, vcount, idx_ptr, icount, fmt) -> u32
load_mesh_packed(data_ptr, vertex_count, format) -> u32
load_mesh_indexed_packed(data_ptr, vcount, idx_ptr, icount, fmt) -> u32
draw_mesh(handle)
// Immediate
draw_triangles(data_ptr, vertex_count, format)
draw_triangles_indexed(data_ptr, vcount, idx_ptr, icount, fmt)
}
Vertex Formats: POS=0, UV=1, COLOR=2, UV_COLOR=3, NORMAL=4, UV_NORMAL=5, COLOR_NORMAL=6, UV_COLOR_NORMAL=7, +SKINNED=8
Procedural Meshes (Init-Only)
#![allow(unused)]
fn main() {
cube(sx, sy, sz) -> u32
sphere(radius, segments, rings) -> u32
cylinder(r_bot, r_top, height, segments) -> u32
plane(sx, sz, subdiv_x, subdiv_z) -> u32
torus(major_r, minor_r, major_seg, minor_seg) -> u32
capsule(radius, height, segments, rings) -> u32
// With explicit UV naming (same behavior)
cube_uv, sphere_uv, cylinder_uv, plane_uv, torus_uv, capsule_uv
}
Materials
#![allow(unused)]
fn main() {
// Mode 2 (Metallic-Roughness)
material_metallic(value) // 0.0-1.0
material_roughness(value) // 0.0-1.0
material_emissive(value) // Glow intensity
material_rim(intensity, power) // Rim light
material_albedo(texture) // Bind to slot 0
material_mre(texture) // Bind MRE to slot 1
// Mode 3 (Specular-Shininess)
material_shininess(value) // 0.0-1.0 → 1-256
material_specular(0xRRGGBBAA) // Specular color
material_specular_color(r, g, b) // RGB floats
material_specular_damping(value)
// Override flags
use_uniform_color(enabled)
use_uniform_metallic(enabled)
use_uniform_roughness(enabled)
use_uniform_emissive(enabled)
use_uniform_specular(enabled)
use_matcap_reflection(enabled)
}
Lighting
#![allow(unused)]
fn main() {
// Directional lights (index 0-3)
light_set(index, dir_x, dir_y, dir_z)
light_color(index, 0xRRGGBBAA)
light_intensity(index, intensity) // 0.0-8.0
light_enable(index)
light_disable(index)
// Point lights
light_set_point(index, x, y, z)
light_range(index, range)
}
Sky & Matcap
#![allow(unused)]
fn main() {
sky_set_colors(horizon, zenith) // 0xRRGGBBAA colors
sky_set_sun(dx, dy, dz, color, sharpness)
draw_sky() // Call first in render()
matcap_set(slot, texture) // Slot 1-3
}
2D Drawing
#![allow(unused)]
fn main() {
draw_sprite(x, y, w, h, color)
draw_sprite_region(x, y, w, h, src_x, src_y, src_w, src_h, color)
draw_sprite_ex(x, y, w, h, src_x, src_y, src_w, src_h, ox, oy, angle, color)
draw_rect(x, y, w, h, color)
draw_text(ptr, len, x, y, size, color)
load_font(tex, char_w, char_h, first_cp, count) -> u32
load_font_ex(tex, widths_ptr, char_h, first_cp, count) -> u32
font_bind(handle)
}
Billboards
#![allow(unused)]
fn main() {
draw_billboard(w, h, mode, color) // mode: 1=sphere, 2=cylY, 3=cylX, 4=cylZ
draw_billboard_region(w, h, sx, sy, sw, sh, mode, color)
}
Skinning
#![allow(unused)]
fn main() {
load_skeleton(inverse_bind_ptr, bone_count) -> u32 // Init-only
skeleton_bind(skeleton) // 0 to disable
set_bones(matrices_ptr, count) // 12 floats per bone (3x4)
set_bones_4x4(matrices_ptr, count) // 16 floats per bone (4x4)
}
Animation
#![allow(unused)]
fn main() {
keyframes_load(data_ptr, byte_size) -> u32 // Init-only
rom_keyframes(id_ptr, id_len) -> u32 // Init-only
keyframes_bone_count(handle) -> u32
keyframes_frame_count(handle) -> u32
keyframe_bind(handle, frame_index) // GPU-side, no CPU decode
keyframe_read(handle, frame_index, out_ptr) // Read to WASM for blending
}
Audio
#![allow(unused)]
fn main() {
load_sound(data_ptr, byte_len) -> u32 // Init-only, 22kHz 16-bit mono
play_sound(sound, volume, pan) // Auto-select channel
channel_play(ch, sound, vol, pan, loop)
channel_set(ch, volume, pan)
channel_stop(ch)
music_play(sound, volume)
music_stop()
music_set_volume(volume)
}
Save Data
#![allow(unused)]
fn main() {
save(slot, data_ptr, data_len) -> u32 // 0=ok, 1=bad slot, 2=too big
load(slot, data_ptr, max_len) -> u32 // Returns bytes read
delete(slot) -> u32 // 0=ok, 1=bad slot
}
ROM Loading (Init-Only)
#![allow(unused)]
fn main() {
rom_texture(id_ptr, id_len) -> u32
rom_mesh(id_ptr, id_len) -> u32
rom_skeleton(id_ptr, id_len) -> u32
rom_font(id_ptr, id_len) -> u32
rom_sound(id_ptr, id_len) -> u32
rom_keyframes(id_ptr, id_len) -> u32
rom_data_len(id_ptr, id_len) -> u32
rom_data(id_ptr, id_len, out_ptr, max_len) -> u32
}
Debug
#![allow(unused)]
fn main() {
// Registration (init-only)
debug_register_i8/i16/i32(name_ptr, name_len, ptr)
debug_register_u8/u16/u32(name_ptr, name_len, ptr)
debug_register_f32(name_ptr, name_len, ptr)
debug_register_bool(name_ptr, name_len, ptr)
debug_register_i32_range(name_ptr, name_len, ptr, min, max)
debug_register_f32_range(name_ptr, name_len, ptr, min, max)
debug_register_u8_range/u16_range/i16_range(...)
debug_register_vec2/vec3/rect/color(name_ptr, name_len, ptr)
debug_register_fixed_i16_q8/i32_q8/i32_q16/i32_q24(...)
// Watch (read-only)
debug_watch_i8/i16/i32/u8/u16/u32/f32/bool(name_ptr, name_len, ptr)
debug_watch_vec2/vec3/rect/color(name_ptr, name_len, ptr)
// Groups
debug_group_begin(name_ptr, name_len)
debug_group_end()
// Frame control
debug_is_paused() -> i32 // 1 if paused
debug_get_time_scale() -> f32 // 1.0 = normal
}
Keyboard: F3=panel, F5=pause, F6=step, F7/F8=time scale
System Functions
Core system functions for time, logging, randomness, and session management.
Time Functions
delta_time
Returns the time elapsed since the last tick in seconds.
Signature:
#![allow(unused)]
fn main() {
fn delta_time() -> f32
}
Returns: Time in seconds since last tick (typically 1/60 = 0.0167 at 60fps)
Example:
#![allow(unused)]
fn main() {
fn update() {
// Frame-rate independent movement
position.x += velocity.x * delta_time();
position.y += velocity.y * delta_time();
}
}
See Also: elapsed_time, tick_count
elapsed_time
Returns total elapsed time since game start in seconds.
Signature:
#![allow(unused)]
fn main() {
fn elapsed_time() -> f32
}
Returns: Total seconds since init() was called
Example:
#![allow(unused)]
fn main() {
fn render() {
// Pulsing effect
let pulse = (elapsed_time() * 2.0).sin() * 0.5 + 0.5;
set_color(rgba(255, 255, 255, (pulse * 255.0) as u8));
}
}
See Also: delta_time, tick_count
tick_count
Returns the current tick number (frame count).
Signature:
#![allow(unused)]
fn main() {
fn tick_count() -> u64
}
Returns: Number of ticks since game start
Example:
#![allow(unused)]
fn main() {
fn update() {
// Every second at 60fps
if tick_count() % 60 == 0 {
spawn_enemy();
}
// Every other tick
if tick_count() % 2 == 0 {
animate_water();
}
}
}
See Also: delta_time, elapsed_time
Logging
log
Outputs a message to the console for debugging.
Signature:
#![allow(unused)]
fn main() {
fn log(ptr: *const u8, len: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| ptr | *const u8 | Pointer to UTF-8 string data |
| len | u32 | Length of the string in bytes |
Example:
#![allow(unused)]
fn main() {
fn init() {
let msg = b"Game initialized!";
log(msg.as_ptr(), msg.len() as u32);
}
fn update() {
if player_died {
let msg = b"Player died";
log(msg.as_ptr(), msg.len() as u32);
}
}
}
Control Flow
quit
Exits the game and returns to the Emberware library.
Signature:
#![allow(unused)]
fn main() {
fn quit()
}
Example:
#![allow(unused)]
fn main() {
fn update() {
// Quit on Start + Select held for 60 frames
if buttons_held(0) & ((1 << BUTTON_START) | (1 << BUTTON_SELECT)) != 0 {
quit_timer += 1;
if quit_timer >= 60 {
quit();
}
} else {
quit_timer = 0;
}
}
}
Randomness
random
Returns a deterministic random number from the host’s seeded RNG.
Signature:
#![allow(unused)]
fn main() {
fn random() -> u32
}
Returns: A random u32 value (0 to 4,294,967,295)
Constraints: Must use this for all randomness to maintain rollback determinism.
Example:
#![allow(unused)]
fn main() {
fn update() {
// Random integer in range [0, 320)
let spawn_x = (random() % 320) as f32;
// Random float 0.0 to 1.0
let rf = (random() as f32) / (u32::MAX as f32);
// Random bool
let coin_flip = random() & 1 == 0;
// Random float in range [min, max]
let min = 10.0;
let max = 50.0;
let rf = (random() as f32) / (u32::MAX as f32);
let value = min + rf * (max - min);
}
}
Warning: Never use external random sources (system time, etc.) — this breaks rollback determinism.
Session Functions
player_count
Returns the number of players in the current session.
Signature:
#![allow(unused)]
fn main() {
fn player_count() -> u32
}
Returns: Number of players (1-4)
Example:
#![allow(unused)]
fn main() {
fn update() {
// Process all players
for p in 0..player_count() {
process_player_input(p);
update_player_state(p);
}
}
fn render() {
// Draw viewport split for multiplayer
match player_count() {
1 => draw_fullscreen_viewport(0),
2 => {
draw_half_viewport(0, 0); // Left half
draw_half_viewport(1, 1); // Right half
}
_ => draw_quad_viewports(),
}
}
}
See Also: local_player_mask
local_player_mask
Returns a bitmask indicating which players are local to this client.
Signature:
#![allow(unused)]
fn main() {
fn local_player_mask() -> u32
}
Returns: Bitmask where bit N is set if player N is local
Example:
#![allow(unused)]
fn main() {
fn render() {
let mask = local_player_mask();
// Check if specific player is local
let p0_local = (mask & 1) != 0; // Player 0
let p1_local = (mask & 2) != 0; // Player 1
let p2_local = (mask & 4) != 0; // Player 2
let p3_local = (mask & 8) != 0; // Player 3
// Only show local player's UI
for p in 0..player_count() {
if (mask & (1 << p)) != 0 {
draw_player_ui(p);
}
}
}
}
Multiplayer Model
Emberware supports up to 4 players in any combination:
- 4 local players (couch co-op)
- 1 local + 3 remote (online)
- 2 local + 2 remote (mixed)
All inputs are synchronized via GGRS rollback netcode. Your update() processes all players uniformly — the host handles synchronization automatically.
#![allow(unused)]
fn main() {
fn update() {
// This code works for any local/remote mix
for p in 0..player_count() {
let input = get_player_input(p);
update_player(p, input);
}
}
}
Input Functions
Controller input handling for buttons, analog sticks, and triggers.
Controller Layout
Emberware ZX uses a modern PS2/Xbox-style controller:
[LB] [RB]
[LT] [RT]
+-----------------------------+
| [^] [Y] |
| [<][>] [=][=] [X] [B] |
| [v] [A] |
| [SELECT] [START] |
| [L3] [R3] |
+-----------------------------+
Left Right
Stick Stick
- D-Pad: 4 directions (digital)
- Face buttons: A, B, X, Y (digital)
- Shoulder bumpers: LB, RB (digital)
- Triggers: LT, RT (analog 0.0-1.0)
- Sticks: Left + Right (analog -1.0 to 1.0, clickable L3/R3)
- Menu: Start, Select (digital)
Button Constants
#![allow(unused)]
fn main() {
// D-Pad
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
// Face buttons
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const BUTTON_X: u32 = 6;
const BUTTON_Y: u32 = 7;
// Shoulder bumpers
const BUTTON_LB: u32 = 8;
const BUTTON_RB: u32 = 9;
// Stick clicks
const BUTTON_L3: u32 = 10;
const BUTTON_R3: u32 = 11;
// Menu
const BUTTON_START: u32 = 12;
const BUTTON_SELECT: u32 = 13;
}
Individual Button Queries
button_held
Check if a button is currently held down.
Signature:
#![allow(unused)]
fn main() {
fn button_held(player: u32, button: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if held, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Continuous movement while held
if button_held(0, BUTTON_RIGHT) != 0 {
player.x += MOVE_SPEED * delta_time();
}
if button_held(0, BUTTON_LEFT) != 0 {
player.x -= MOVE_SPEED * delta_time();
}
}
}
See Also: button_pressed, button_released
button_pressed
Check if a button was just pressed this tick (edge detection).
Signature:
#![allow(unused)]
fn main() {
fn button_pressed(player: u32, button: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if just pressed this tick, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Jump only triggers once per press
if button_pressed(0, BUTTON_A) != 0 && player.on_ground {
player.velocity_y = JUMP_VELOCITY;
play_sound(jump_sfx, 1.0, 0.0);
}
// Cycle weapons
if button_pressed(0, BUTTON_RB) != 0 {
current_weapon = (current_weapon + 1) % NUM_WEAPONS;
}
}
}
See Also: button_held, button_released
button_released
Check if a button was just released this tick.
Signature:
#![allow(unused)]
fn main() {
fn button_released(player: u32, button: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if just released this tick, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Variable jump height (release early = smaller jump)
if button_released(0, BUTTON_A) != 0 && player.velocity_y < 0.0 {
player.velocity_y *= 0.5; // Cut upward velocity
}
// Charged attack
if button_released(0, BUTTON_X) != 0 {
let power = charge_time.min(MAX_CHARGE);
fire_charged_attack(power);
charge_time = 0.0;
}
}
}
See Also: button_held, button_pressed
Bulk Button Queries
For better performance when checking multiple buttons, use bulk queries to reduce FFI overhead.
buttons_held
Get a bitmask of all currently held buttons.
Signature:
#![allow(unused)]
fn main() {
fn buttons_held(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N is held
Example:
#![allow(unused)]
fn main() {
fn update() {
let held = buttons_held(0);
// Check multiple buttons efficiently
if held & (1 << BUTTON_A) != 0 { /* A held */ }
if held & (1 << BUTTON_B) != 0 { /* B held */ }
// Check for combo (A + B held together)
let combo = (1 << BUTTON_A) | (1 << BUTTON_B);
if held & combo == combo {
perform_combo_attack();
}
}
}
See Also: buttons_pressed, buttons_released
buttons_pressed
Get a bitmask of all buttons pressed this tick.
Signature:
#![allow(unused)]
fn main() {
fn buttons_pressed(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N was just pressed
Example:
#![allow(unused)]
fn main() {
fn update() {
let pressed = buttons_pressed(0);
// Check if any face button pressed
let face_buttons = (1 << BUTTON_A) | (1 << BUTTON_B) |
(1 << BUTTON_X) | (1 << BUTTON_Y);
if pressed & face_buttons != 0 {
// Handle menu selection
}
}
}
buttons_released
Get a bitmask of all buttons released this tick.
Signature:
#![allow(unused)]
fn main() {
fn buttons_released(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N was just released
Analog Sticks
left_stick_x
Get the left stick horizontal axis.
Signature:
#![allow(unused)]
fn main() {
fn left_stick_x(player: u32) -> f32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Value from -1.0 (left) to 1.0 (right), 0.0 at center
Example:
#![allow(unused)]
fn main() {
fn update() {
let stick_x = left_stick_x(0);
// Apply deadzone
let deadzone = 0.15;
if stick_x.abs() > deadzone {
player.x += stick_x * MOVE_SPEED * delta_time();
}
}
}
left_stick_y
Get the left stick vertical axis.
Signature:
#![allow(unused)]
fn main() {
fn left_stick_y(player: u32) -> f32
}
Returns: Value from -1.0 (down) to 1.0 (up), 0.0 at center
right_stick_x
Get the right stick horizontal axis.
Signature:
#![allow(unused)]
fn main() {
fn right_stick_x(player: u32) -> f32
}
Returns: Value from -1.0 (left) to 1.0 (right)
Example:
#![allow(unused)]
fn main() {
fn update() {
// Camera control with right stick
camera_yaw += right_stick_x(0) * CAMERA_SPEED * delta_time();
}
}
right_stick_y
Get the right stick vertical axis.
Signature:
#![allow(unused)]
fn main() {
fn right_stick_y(player: u32) -> f32
}
Returns: Value from -1.0 (down) to 1.0 (up)
left_stick
Get both left stick axes in a single FFI call (more efficient).
Signature:
#![allow(unused)]
fn main() {
fn left_stick(player: u32, out_x: *mut f32, out_y: *mut f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| out_x | *mut f32 | Pointer to write X value |
| out_y | *mut f32 | Pointer to write Y value |
Example:
#![allow(unused)]
fn main() {
fn update() {
let mut x: f32 = 0.0;
let mut y: f32 = 0.0;
left_stick(0, &mut x, &mut y);
// Calculate magnitude for circular deadzone
let mag = (x * x + y * y).sqrt();
if mag > 0.15 {
let nx = x / mag;
let ny = y / mag;
player.x += nx * MOVE_SPEED * delta_time();
player.y += ny * MOVE_SPEED * delta_time();
}
}
}
right_stick
Get both right stick axes in a single FFI call.
Signature:
#![allow(unused)]
fn main() {
fn right_stick(player: u32, out_x: *mut f32, out_y: *mut f32)
}
Analog Triggers
trigger_left
Get the left trigger (LT) value.
Signature:
#![allow(unused)]
fn main() {
fn trigger_left(player: u32) -> f32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Value from 0.0 (released) to 1.0 (fully pressed)
Example:
#![allow(unused)]
fn main() {
fn update() {
let lt = trigger_left(0);
// Brake with analog pressure
if lt > 0.1 {
vehicle.speed *= 1.0 - (lt * BRAKE_FORCE * delta_time());
}
}
}
trigger_right
Get the right trigger (RT) value.
Signature:
#![allow(unused)]
fn main() {
fn trigger_right(player: u32) -> f32
}
Returns: Value from 0.0 (released) to 1.0 (fully pressed)
Example:
#![allow(unused)]
fn main() {
fn update() {
let rt = trigger_right(0);
// Accelerate with analog pressure
if rt > 0.1 {
vehicle.speed += rt * ACCEL_FORCE * delta_time();
}
// Aiming zoom
let zoom = 1.0 + rt * 2.0; // 1x to 3x zoom
camera_fov(60.0 / zoom);
}
}
Complete Input Example
#![allow(unused)]
fn main() {
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const MOVE_SPEED: f32 = 100.0;
const DEADZONE: f32 = 0.15;
static mut PLAYER_X: f32 = 0.0;
static mut PLAYER_Y: f32 = 0.0;
static mut ON_GROUND: bool = true;
static mut VEL_Y: f32 = 0.0;
#[no_mangle]
pub extern "C" fn update() {
unsafe {
let dt = delta_time();
// Movement with left stick
let mut sx: f32 = 0.0;
let mut sy: f32 = 0.0;
left_stick(0, &mut sx, &mut sy);
if sx.abs() > DEADZONE {
PLAYER_X += sx * MOVE_SPEED * dt;
}
// Jump with A button
if button_pressed(0, BUTTON_A) != 0 && ON_GROUND {
VEL_Y = -300.0;
ON_GROUND = false;
}
// Gravity
VEL_Y += 800.0 * dt;
PLAYER_Y += VEL_Y * dt;
// Ground collision
if PLAYER_Y >= 200.0 {
PLAYER_Y = 200.0;
VEL_Y = 0.0;
ON_GROUND = true;
}
}
}
}
Graphics Configuration
Console configuration and render state functions.
Configuration (Init-Only)
These functions must be called in init() and cannot be changed at runtime.
set_resolution
Sets the render resolution.
Signature:
#![allow(unused)]
fn main() {
fn set_resolution(res: u32)
}
Parameters:
| Value | Resolution |
|---|---|
| 0 | 360p (640x360) |
| 1 | 540p (960x540) - default |
| 2 | 720p (1280x720) |
| 3 | 1080p (1920x1080) |
Constraints: Init-only. Cannot be changed after init() returns.
Example:
#![allow(unused)]
fn main() {
fn init() {
set_resolution(2); // 720p
}
}
set_tick_rate
Sets the game’s tick rate (updates per second).
Signature:
#![allow(unused)]
fn main() {
fn set_tick_rate(fps: u32)
}
Parameters:
| Value | Tick Rate |
|---|---|
| 0 | 24 fps |
| 1 | 30 fps |
| 2 | 60 fps - default |
| 3 | 120 fps |
Constraints: Init-only. Affects GGRS synchronization.
Example:
#![allow(unused)]
fn main() {
fn init() {
set_tick_rate(2); // 60 fps
}
}
set_clear_color
Sets the background clear color.
Signature:
#![allow(unused)]
fn main() {
fn set_clear_color(color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| color | u32 | RGBA color as 0xRRGGBBAA |
Constraints: Init-only. Default is 0x000000FF (black).
Example:
#![allow(unused)]
fn main() {
fn init() {
set_clear_color(0x1a1a2eFF); // Dark blue
set_clear_color(0x87CEEBFF); // Sky blue
}
}
render_mode
Sets the rendering mode (shader pipeline).
Signature:
#![allow(unused)]
fn main() {
fn render_mode(mode: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | Unlit | Flat colors, no lighting |
| 1 | Matcap | Pre-baked lighting via matcap textures |
| 2 | Metallic-Roughness | PBR-style Blinn-Phong with MRE textures |
| 3 | Specular-Shininess | Traditional Blinn-Phong |
Constraints: Init-only. Default is mode 0 (Unlit).
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(2); // PBR-style lighting
}
}
See Also: Render Modes Guide
Render State
These functions can be called anytime during render() to change draw state.
set_color
Sets the uniform tint color for subsequent draws.
Signature:
#![allow(unused)]
fn main() {
fn set_color(color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| color | u32 | RGBA color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// White (no tint)
set_color(0xFFFFFFFF);
draw_mesh(model);
// Red tint
set_color(0xFF0000FF);
draw_mesh(enemy);
// 50% transparent
set_color(0xFFFFFF80);
draw_mesh(ghost);
}
}
depth_test
Enables or disables depth testing.
Signature:
#![allow(unused)]
fn main() {
fn depth_test(enabled: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| enabled | u32 | 1 to enable, 0 to disable |
Example:
#![allow(unused)]
fn main() {
fn render() {
// 3D scene with depth
depth_test(1);
draw_mesh(level);
draw_mesh(player);
// UI overlay without depth
depth_test(0);
draw_sprite(0.0, 0.0, 100.0, 50.0, 0xFFFFFFFF);
}
}
cull_mode
Sets face culling mode.
Signature:
#![allow(unused)]
fn main() {
fn cull_mode(mode: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | None | Draw both sides |
| 1 | Back | Cull back faces (default) |
| 2 | Front | Cull front faces |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Normal geometry
cull_mode(1); // Back-face culling
draw_mesh(solid_object);
// Skybox (inside-out)
cull_mode(2); // Front-face culling
draw_mesh(skybox);
// Double-sided foliage
cull_mode(0); // No culling
draw_mesh(leaves);
}
}
blend_mode
Sets the alpha blending mode.
Signature:
#![allow(unused)]
fn main() {
fn blend_mode(mode: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | None | No blending (opaque) |
| 1 | Alpha | Standard transparency |
| 2 | Additive | Add colors (glow effects) |
| 3 | Multiply | Multiply colors (shadows) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Opaque geometry first
blend_mode(0);
draw_mesh(level);
draw_mesh(player);
// Transparent objects (sorted back-to-front)
blend_mode(1);
draw_mesh(window);
// Additive glow effects
blend_mode(2);
draw_mesh(fire_particles);
draw_mesh(laser_beam);
}
}
texture_filter
Sets texture filtering mode.
Signature:
#![allow(unused)]
fn main() {
fn texture_filter(filter: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | Nearest | Pixelated (retro look) |
| 1 | Linear | Smooth (modern look) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Pixel art sprites
texture_filter(0);
draw_sprite(0.0, 0.0, 64.0, 64.0, 0xFFFFFFFF);
// Photo textures
texture_filter(1);
draw_mesh(realistic_model);
}
}
uniform_alpha
Sets the dither alpha level for PS1-style transparency.
Signature:
#![allow(unused)]
fn main() {
fn uniform_alpha(level: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| level | u32 | Alpha level 0-15 (0 = invisible, 15 = opaque) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Fade in effect
let alpha = (fade_progress * 15.0) as u32;
uniform_alpha(alpha);
draw_mesh(fading_object);
// Reset to fully opaque
uniform_alpha(15);
}
}
See Also: dither_offset
dither_offset
Sets the dither pattern offset for animated dithering.
Signature:
#![allow(unused)]
fn main() {
fn dither_offset(x: u32, y: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | u32 | X offset 0-3 |
| y | u32 | Y offset 0-3 |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Animate dither pattern for shimmer effect
let frame = tick_count() as u32;
dither_offset(frame % 4, (frame / 4) % 4);
}
}
Complete Example
#![allow(unused)]
fn main() {
fn init() {
// Configure console
set_resolution(1); // 540p
set_tick_rate(2); // 60 fps
set_clear_color(0x1a1a2eFF);
render_mode(2); // PBR lighting
}
fn render() {
// Draw 3D scene
depth_test(1);
cull_mode(1);
blend_mode(0);
texture_filter(1);
set_color(0xFFFFFFFF);
draw_mesh(level);
draw_mesh(player);
// Draw transparent water
blend_mode(1);
set_color(0x4080FF80);
draw_mesh(water);
// Draw UI (no depth, alpha blending)
depth_test(0);
texture_filter(0);
draw_sprite(10.0, 10.0, 200.0, 50.0, 0xFFFFFFFF);
}
}
Camera Functions
Camera position, target, and projection control.
Camera Setup
camera_set
Sets the camera position and look-at target.
Signature:
#![allow(unused)]
fn main() {
fn camera_set(x: f32, y: f32, z: f32, target_x: f32, target_y: f32, target_z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x, y, z | f32 | Camera position in world space |
| target_x, target_y, target_z | f32 | Point the camera looks at |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Fixed camera looking at origin
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Third-person follow camera
camera_set(
player.x,
player.y + 3.0,
player.z + 8.0,
player.x,
player.y + 1.0,
player.z
);
}
}
camera_fov
Sets the camera field of view.
Signature:
#![allow(unused)]
fn main() {
fn camera_fov(fov_degrees: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| fov_degrees | f32 | Vertical FOV in degrees (1-179) |
Default: 60 degrees
Example:
#![allow(unused)]
fn main() {
fn render() {
// Normal gameplay
camera_fov(60.0);
// Zoom in for aiming
if aiming {
camera_fov(30.0);
}
// Wide angle for racing
camera_fov(90.0);
}
}
Custom Matrices
For advanced camera control, you can set the view and projection matrices directly.
push_view_matrix
Sets a custom view matrix (camera transform).
Signature:
#![allow(unused)]
fn main() {
fn push_view_matrix(
m0: f32, m1: f32, m2: f32, m3: f32,
m4: f32, m5: f32, m6: f32, m7: f32,
m8: f32, m9: f32, m10: f32, m11: f32,
m12: f32, m13: f32, m14: f32, m15: f32
)
}
Parameters: 16 floats representing a 4x4 column-major matrix.
Matrix Layout (column-major):
| m0 m4 m8 m12 |
| m1 m5 m9 m13 |
| m2 m6 m10 m14 |
| m3 m7 m11 m15 |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam for matrix math
let eye = Vec3::new(0.0, 5.0, 10.0);
let target = Vec3::new(0.0, 0.0, 0.0);
let up = Vec3::Y;
let view = Mat4::look_at_rh(eye, target, up);
let cols = view.to_cols_array();
push_view_matrix(
cols[0], cols[1], cols[2], cols[3],
cols[4], cols[5], cols[6], cols[7],
cols[8], cols[9], cols[10], cols[11],
cols[12], cols[13], cols[14], cols[15]
);
}
}
push_projection_matrix
Sets a custom projection matrix.
Signature:
#![allow(unused)]
fn main() {
fn push_projection_matrix(
m0: f32, m1: f32, m2: f32, m3: f32,
m4: f32, m5: f32, m6: f32, m7: f32,
m8: f32, m9: f32, m10: f32, m11: f32,
m12: f32, m13: f32, m14: f32, m15: f32
)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Custom perspective projection
let aspect = 16.0 / 9.0;
let fov = 60.0_f32.to_radians();
let near = 0.1;
let far = 1000.0;
let proj = Mat4::perspective_rh(fov, aspect, near, far);
let cols = proj.to_cols_array();
push_projection_matrix(
cols[0], cols[1], cols[2], cols[3],
cols[4], cols[5], cols[6], cols[7],
cols[8], cols[9], cols[10], cols[11],
cols[12], cols[13], cols[14], cols[15]
);
// Orthographic projection for 2D
let ortho = Mat4::orthographic_rh(0.0, 960.0, 540.0, 0.0, -1.0, 1.0);
// ... push_projection_matrix with ortho values
}
}
Camera Patterns
Orbiting Camera
#![allow(unused)]
fn main() {
static mut ORBIT_ANGLE: f32 = 0.0;
static mut ORBIT_DISTANCE: f32 = 10.0;
static mut ORBIT_HEIGHT: f32 = 5.0;
fn update() {
unsafe {
// Rotate with right stick
ORBIT_ANGLE += right_stick_x(0) * 2.0 * delta_time();
// Zoom with triggers
ORBIT_DISTANCE -= trigger_right(0) * 5.0 * delta_time();
ORBIT_DISTANCE += trigger_left(0) * 5.0 * delta_time();
ORBIT_DISTANCE = ORBIT_DISTANCE.clamp(5.0, 20.0);
}
}
fn render() {
unsafe {
let cam_x = ORBIT_ANGLE.cos() * ORBIT_DISTANCE;
let cam_z = ORBIT_ANGLE.sin() * ORBIT_DISTANCE;
camera_set(cam_x, ORBIT_HEIGHT, cam_z, 0.0, 0.0, 0.0);
}
}
}
First-Person Camera
#![allow(unused)]
fn main() {
static mut CAM_X: f32 = 0.0;
static mut CAM_Y: f32 = 1.7; // Eye height
static mut CAM_Z: f32 = 0.0;
static mut CAM_YAW: f32 = 0.0;
static mut CAM_PITCH: f32 = 0.0;
fn update() {
unsafe {
// Look with right stick
CAM_YAW += right_stick_x(0) * 3.0 * delta_time();
CAM_PITCH -= right_stick_y(0) * 2.0 * delta_time();
CAM_PITCH = CAM_PITCH.clamp(-1.4, 1.4); // Limit look up/down
// Move with left stick
let forward_x = CAM_YAW.sin();
let forward_z = CAM_YAW.cos();
let right_x = forward_z;
let right_z = -forward_x;
let speed = 5.0 * delta_time();
CAM_X += left_stick_y(0) * forward_x * speed;
CAM_Z += left_stick_y(0) * forward_z * speed;
CAM_X += left_stick_x(0) * right_x * speed;
CAM_Z += left_stick_x(0) * right_z * speed;
}
}
fn render() {
unsafe {
let look_x = CAM_X + CAM_YAW.sin() * CAM_PITCH.cos();
let look_y = CAM_Y + CAM_PITCH.sin();
let look_z = CAM_Z + CAM_YAW.cos() * CAM_PITCH.cos();
camera_set(CAM_X, CAM_Y, CAM_Z, look_x, look_y, look_z);
}
}
}
Split-Screen Cameras
#![allow(unused)]
fn main() {
fn render() {
let count = player_count();
for p in 0..count {
// Set viewport (would need custom projection)
setup_viewport_for_player(p, count);
// Each player's camera follows them
camera_set(
players[p].x,
players[p].y + 5.0,
players[p].z + 10.0,
players[p].x,
players[p].y,
players[p].z
);
draw_scene();
}
}
}
Transform Functions
Matrix stack operations for positioning, rotating, and scaling objects.
Conventions
- Y-up right-handed coordinate system
- Column-major matrix storage (wgpu/WGSL compatible)
- Column vectors:
v' = M * v - Angles in degrees for FFI (converted to radians internally)
Transform Stack
push_identity
Resets the current transform to identity (no transformation).
Signature:
#![allow(unused)]
fn main() {
fn push_identity()
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Reset before drawing each object
push_identity();
draw_mesh(object_a);
push_identity();
push_translate(10.0, 0.0, 0.0);
draw_mesh(object_b);
}
}
transform_set
Sets the current transform from a 4x4 matrix.
Signature:
#![allow(unused)]
fn main() {
fn transform_set(matrix_ptr: *const f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrix_ptr | *const f32 | Pointer to 16 floats (4x4 column-major) |
Matrix Layout (column-major, 16 floats):
[col0.x, col0.y, col0.z, col0.w,
col1.x, col1.y, col1.z, col1.w,
col2.x, col2.y, col2.z, col2.w,
col3.x, col3.y, col3.z, col3.w]
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam
let transform = Mat4::from_scale_rotation_translation(
Vec3::ONE,
Quat::from_rotation_y(angle),
Vec3::new(x, y, z)
);
let cols = transform.to_cols_array();
transform_set(cols.as_ptr());
draw_mesh(model);
}
}
Translation
push_translate
Applies a translation to the current transform.
Signature:
#![allow(unused)]
fn main() {
fn push_translate(x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | f32 | X offset (right is positive) |
| y | f32 | Y offset (up is positive) |
| z | f32 | Z offset (toward camera is positive) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Position object at (10, 5, 0)
push_identity();
push_translate(10.0, 5.0, 0.0);
draw_mesh(object);
// Stack translations (additive)
push_identity();
push_translate(5.0, 0.0, 0.0); // Move right 5
push_translate(0.0, 3.0, 0.0); // Then move up 3
draw_mesh(object); // At (5, 3, 0)
}
}
Rotation
push_rotate_x
Rotates around the X axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_x(angle_deg: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| angle_deg | f32 | Rotation angle in degrees |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_x(45.0); // Tilt forward 45 degrees
draw_mesh(object);
}
}
push_rotate_y
Rotates around the Y axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_y(angle_deg: f32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_y(elapsed_time() * 90.0); // Spin 90 deg/sec
draw_mesh(spinning_object);
}
}
push_rotate_z
Rotates around the Z axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_z(angle_deg: f32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_z(45.0); // Roll 45 degrees
draw_mesh(object);
}
}
push_rotate
Rotates around an arbitrary axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate(angle_deg: f32, axis_x: f32, axis_y: f32, axis_z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| angle_deg | f32 | Rotation angle in degrees |
| axis_x, axis_y, axis_z | f32 | Rotation axis (will be normalized) |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
// Rotate around diagonal axis
push_rotate(45.0, 1.0, 1.0, 0.0);
draw_mesh(object);
}
}
Scale
push_scale
Applies non-uniform scaling.
Signature:
#![allow(unused)]
fn main() {
fn push_scale(x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | f32 | Scale factor on X axis |
| y | f32 | Scale factor on Y axis |
| z | f32 | Scale factor on Z axis |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_scale(2.0, 1.0, 1.0); // Stretch horizontally
draw_mesh(object);
push_identity();
push_scale(1.0, 0.5, 1.0); // Squash vertically
draw_mesh(squashed);
}
}
push_scale_uniform
Applies uniform scaling (same factor on all axes).
Signature:
#![allow(unused)]
fn main() {
fn push_scale_uniform(s: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| s | f32 | Uniform scale factor |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_scale_uniform(2.0); // Double size
draw_mesh(big_object);
push_identity();
push_scale_uniform(0.5); // Half size
draw_mesh(small_object);
}
}
Transform Order
Transforms are applied in reverse order of function calls (right-to-left matrix multiplication).
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_translate(5.0, 0.0, 0.0); // Applied LAST
push_rotate_y(45.0); // Applied SECOND
push_scale_uniform(2.0); // Applied FIRST
draw_mesh(object);
// Equivalent to: Translate * Rotate * Scale * vertex
// Object is: 1) scaled, 2) rotated, 3) translated
}
}
Common Patterns
Object at position with rotation:
#![allow(unused)]
fn main() {
push_identity();
push_translate(obj.x, obj.y, obj.z); // Position
push_rotate_y(obj.rotation); // Then rotate
draw_mesh(obj.mesh);
}
Hierarchical transforms (parent-child):
#![allow(unused)]
fn main() {
fn render() {
// Tank body
push_identity();
push_translate(tank.x, tank.y, tank.z);
push_rotate_y(tank.body_angle);
draw_mesh(tank_body);
// Turret (inherits body transform, then adds its own)
push_translate(0.0, 1.0, 0.0); // Offset from body
push_rotate_y(tank.turret_angle); // Independent rotation
draw_mesh(tank_turret);
// Barrel (inherits turret transform)
push_translate(0.0, 0.5, 2.0);
push_rotate_x(tank.barrel_pitch);
draw_mesh(tank_barrel);
}
}
Rotating around a pivot point:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_translate(pivot_x, pivot_y, pivot_z); // Move to pivot
push_rotate_y(angle); // Rotate
push_translate(-pivot_x, -pivot_y, -pivot_z); // Move back
draw_mesh(object);
}
}
Complete Example
#![allow(unused)]
fn main() {
static mut ANGLE: f32 = 0.0;
fn update() {
unsafe {
ANGLE += 90.0 * delta_time(); // 90 degrees per second
}
}
fn render() {
unsafe {
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Spinning cube at origin
push_identity();
push_rotate_y(ANGLE);
draw_mesh(cube);
// Orbiting cube
push_identity();
push_rotate_y(ANGLE * 0.5); // Orbital rotation
push_translate(5.0, 0.0, 0.0); // Distance from center
push_rotate_y(ANGLE * 2.0); // Spin on own axis
push_scale_uniform(0.5);
draw_mesh(cube);
// Static cube for reference
push_identity();
push_translate(-5.0, 0.0, 0.0);
draw_mesh(cube);
}
}
}
Texture Functions
Loading, binding, and configuring textures.
Loading Textures
load_texture
Loads an RGBA8 texture from WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn load_texture(width: u32, height: u32, pixels: *const u8) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| width | u32 | Texture width in pixels |
| height | u32 | Texture height in pixels |
| pixels | *const u8 | Pointer to RGBA8 pixel data (4 bytes per pixel) |
Returns: Texture handle (non-zero on success)
Constraints: Init-only. Must be called in init().
Example:
#![allow(unused)]
fn main() {
static mut PLAYER_TEX: u32 = 0;
// Embedded pixel data (8x8 checkerboard)
const CHECKER: [u8; 8 * 8 * 4] = {
let mut pixels = [0u8; 256];
let mut i = 0;
while i < 64 {
let x = i % 8;
let y = i / 8;
let white = ((x + y) % 2) == 0;
let idx = i * 4;
pixels[idx] = if white { 255 } else { 0 }; // R
pixels[idx + 1] = if white { 255 } else { 0 }; // G
pixels[idx + 2] = if white { 255 } else { 0 }; // B
pixels[idx + 3] = 255; // A
i += 1;
}
pixels
};
fn init() {
unsafe {
PLAYER_TEX = load_texture(8, 8, CHECKER.as_ptr());
}
}
}
Note: Prefer rom_texture() for assets bundled in the ROM data pack.
See Also: rom_texture
Binding Textures
texture_bind
Binds a texture to slot 0 (albedo/diffuse).
Signature:
#![allow(unused)]
fn main() {
fn texture_bind(handle: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Texture handle from load_texture() or rom_texture() |
Example:
#![allow(unused)]
fn main() {
fn render() {
texture_bind(player_tex);
draw_mesh(player_model);
texture_bind(enemy_tex);
draw_mesh(enemy_model);
}
}
texture_bind_slot
Binds a texture to a specific slot.
Signature:
#![allow(unused)]
fn main() {
fn texture_bind_slot(handle: u32, slot: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Texture handle |
| slot | u32 | Texture slot (0-3) |
Texture Slots:
| Slot | Purpose |
|---|---|
| 0 | Albedo/diffuse texture |
| 1 | MRE texture (Mode 2) or Specular (Mode 3) |
| 2 | Reserved |
| 3 | Reserved |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Bind albedo to slot 0
texture_bind_slot(albedo_tex, 0);
// Bind MRE (Metallic/Roughness/Emissive) to slot 1
texture_bind_slot(mre_tex, 1);
draw_mesh(pbr_model);
}
}
Matcap Textures
matcap_blend_mode
Sets the blend mode for a matcap texture slot.
Signature:
#![allow(unused)]
fn main() {
fn matcap_blend_mode(slot: u32, mode: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Matcap slot (1-3) |
| mode | u32 | Blend mode |
Blend Modes:
| Value | Mode | Description |
|---|---|---|
| 0 | Multiply | Darkens (shadows, ambient occlusion) |
| 1 | Add | Brightens (highlights, rim light) |
| 2 | HSV Modulate | Hue/saturation shift |
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(1); // Matcap mode
}
fn render() {
// Dark matcap for shadows (multiply)
matcap_set(1, shadow_matcap);
matcap_blend_mode(1, 0);
// Bright matcap for highlights (add)
matcap_set(2, highlight_matcap);
matcap_blend_mode(2, 1);
texture_bind(albedo_tex);
draw_mesh(character);
}
}
See Also: matcap_set, Render Modes Guide
Texture Formats
RGBA8
Standard 8-bit RGBA format. 4 bytes per pixel.
#![allow(unused)]
fn main() {
// Pixel layout: [R, G, B, A, R, G, B, A, ...]
let pixels: [u8; 4 * 4 * 4] = [
255, 0, 0, 255, // Red pixel
0, 255, 0, 255, // Green pixel
0, 0, 255, 255, // Blue pixel
255, 255, 255, 128, // Semi-transparent white
// ... more pixels
];
}
Texture Tips
- Power-of-two dimensions recommended (8, 16, 32, 64, 128, 256, 512)
- Texture atlases reduce bind calls and improve batching
- Use
rom_texture()for large textures (bypasses WASM memory) - Use
load_texture()only for small procedural/runtime textures
Complete Example
#![allow(unused)]
fn main() {
static mut CHECKER_TEX: u32 = 0;
static mut GRADIENT_TEX: u32 = 0;
// Generate checkerboard at compile time
const CHECKER_PIXELS: [u8; 16 * 16 * 4] = {
let mut pixels = [0u8; 16 * 16 * 4];
let mut i = 0;
while i < 256 {
let x = i % 16;
let y = i / 16;
let white = ((x / 2 + y / 2) % 2) == 0;
let idx = i * 4;
let c = if white { 200 } else { 50 };
pixels[idx] = c;
pixels[idx + 1] = c;
pixels[idx + 2] = c;
pixels[idx + 3] = 255;
i += 1;
}
pixels
};
// Generate gradient at compile time
const GRADIENT_PIXELS: [u8; 8 * 8 * 4] = {
let mut pixels = [0u8; 8 * 8 * 4];
let mut i = 0;
while i < 64 {
let x = i % 8;
let y = i / 8;
let idx = i * 4;
pixels[idx] = (x * 32) as u8; // R increases right
pixels[idx + 1] = (y * 32) as u8; // G increases down
pixels[idx + 2] = 128; // B constant
pixels[idx + 3] = 255;
i += 1;
}
pixels
};
fn init() {
unsafe {
CHECKER_TEX = load_texture(16, 16, CHECKER_PIXELS.as_ptr());
GRADIENT_TEX = load_texture(8, 8, GRADIENT_PIXELS.as_ptr());
}
}
fn render() {
unsafe {
// Draw floor with checker texture
texture_bind(CHECKER_TEX);
texture_filter(0); // Nearest for crisp pixels
push_identity();
push_scale(10.0, 1.0, 10.0);
draw_mesh(plane);
// Draw object with gradient
texture_bind(GRADIENT_TEX);
texture_filter(1); // Linear for smooth
push_identity();
push_translate(0.0, 1.0, 0.0);
draw_mesh(cube);
}
}
}
Mesh Functions
Loading and drawing 3D meshes.
Retained Meshes
Retained meshes are loaded once in init() and drawn multiple times in render().
load_mesh
Loads a non-indexed mesh from vertex data.
Signature:
#![allow(unused)]
fn main() {
fn load_mesh(data_ptr: *const u8, vertex_count: u32, format: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices |
| format | u32 | Vertex format flags |
Returns: Mesh handle (non-zero on success)
Constraints: Init-only.
load_mesh_indexed
Loads an indexed mesh (more efficient for shared vertices).
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_indexed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices |
| index_ptr | *const u16 | Pointer to u16 index data |
| index_count | u32 | Number of indices |
| format | u32 | Vertex format flags |
Returns: Mesh handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut CUBE_MESH: u32 = 0;
// Cube with 8 vertices, 36 indices (12 triangles)
const CUBE_VERTS: [f32; 8 * 6] = [
// Position (xyz) + Normal (xyz)
-1.0, -1.0, -1.0, 0.0, 0.0, -1.0,
1.0, -1.0, -1.0, 0.0, 0.0, -1.0,
// ... more vertices
];
const CUBE_INDICES: [u16; 36] = [
0, 1, 2, 2, 3, 0, // Front face
// ... more indices
];
fn init() {
unsafe {
CUBE_MESH = load_mesh_indexed(
CUBE_VERTS.as_ptr() as *const u8,
8,
CUBE_INDICES.as_ptr(),
36,
4 // FORMAT_POS_NORMAL
);
}
}
}
load_mesh_packed
Loads a packed mesh with half-precision floats (smaller memory footprint).
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_packed(data_ptr: *const u8, vertex_count: u32, format: u32) -> u32
}
Constraints: Init-only. Uses f16 for positions and snorm16 for normals.
load_mesh_indexed_packed
Loads an indexed packed mesh.
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_indexed_packed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
) -> u32
}
Constraints: Init-only.
draw_mesh
Draws a retained mesh with the current transform and render state.
Signature:
#![allow(unused)]
fn main() {
fn draw_mesh(handle: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Mesh handle from load_mesh*() or procedural generators |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Draw at origin
push_identity();
draw_mesh(cube);
// Draw at different position
push_identity();
push_translate(5.0, 0.0, 0.0);
draw_mesh(cube);
// Draw with different color
set_color(0xFF0000FF);
push_identity();
push_translate(-5.0, 0.0, 0.0);
draw_mesh(cube);
}
}
Immediate Mode Drawing
For dynamic geometry that changes every frame.
draw_triangles
Draws non-indexed triangles immediately (not retained).
Signature:
#![allow(unused)]
fn main() {
fn draw_triangles(data_ptr: *const u8, vertex_count: u32, format: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices (must be multiple of 3) |
| format | u32 | Vertex format flags |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Dynamic triangle
let verts: [f32; 18] = [
// Position (xyz) + Color (rgb)
0.0, 1.0, 0.0, 1.0, 0.0, 0.0, // Top (red)
-1.0, -1.0, 0.0, 0.0, 1.0, 0.0, // Left (green)
1.0, -1.0, 0.0, 0.0, 0.0, 1.0, // Right (blue)
];
push_identity();
draw_triangles(verts.as_ptr() as *const u8, 3, 2); // FORMAT_POS_COLOR
}
}
draw_triangles_indexed
Draws indexed triangles immediately.
Signature:
#![allow(unused)]
fn main() {
fn draw_triangles_indexed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
)
}
Vertex Formats
Vertex format is specified as a bitmask of flags:
| Flag | Value | Components | Bytes |
|---|---|---|---|
| Position | 0 | xyz (3 floats) | 12 |
| UV | 1 | uv (2 floats) | 8 |
| Color | 2 | rgb (3 floats) | 12 |
| Normal | 4 | xyz (3 floats) | 12 |
| Skinned | 8 | bone indices + weights | 16 |
Common Combinations:
| Format | Value | Components | Stride |
|---|---|---|---|
| POS | 0 | Position only | 12 bytes |
| POS_UV | 1 | Position + UV | 20 bytes |
| POS_COLOR | 2 | Position + Color | 24 bytes |
| POS_UV_COLOR | 3 | Position + UV + Color | 32 bytes |
| POS_NORMAL | 4 | Position + Normal | 24 bytes |
| POS_UV_NORMAL | 5 | Position + UV + Normal | 32 bytes |
| POS_COLOR_NORMAL | 6 | Position + Color + Normal | 36 bytes |
| POS_UV_COLOR_NORMAL | 7 | Position + UV + Color + Normal | 44 bytes |
With Skinning (add 8):
| Format | Value | Stride |
|---|---|---|
| POS_NORMAL_SKINNED | 12 | 40 bytes |
| POS_UV_NORMAL_SKINNED | 13 | 48 bytes |
Vertex Data Layout
Data is laid out per-vertex in this order:
- Position (xyz) - 3 floats
- UV (uv) - 2 floats (if enabled)
- Color (rgb) - 3 floats (if enabled)
- Normal (xyz) - 3 floats (if enabled)
- Skinning (indices + weights) - 4 bytes + 4 bytes (if enabled)
Example: POS_UV_NORMAL (format 5)
#![allow(unused)]
fn main() {
// Each vertex: 8 floats (32 bytes)
let vertex: [f32; 8] = [
0.0, 1.0, 0.0, // Position
0.5, 1.0, // UV
0.0, 1.0, 0.0, // Normal
];
}
Complete Example
#![allow(unused)]
fn main() {
static mut TRIANGLE: u32 = 0;
static mut QUAD: u32 = 0;
// Triangle with position + color
const TRI_VERTS: [f32; 3 * 6] = [
// pos xyz, color rgb
0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
-1.0, -1.0, 0.0, 0.0, 1.0, 0.0,
1.0, -1.0, 0.0, 0.0, 0.0, 1.0,
];
// Quad with position + UV + normal (indexed)
const QUAD_VERTS: [f32; 4 * 8] = [
// pos xyz, uv, normal xyz
-1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
1.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0,
1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0,
-1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
];
const QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 3, 0];
fn init() {
unsafe {
// Non-indexed triangle
TRIANGLE = load_mesh(
TRI_VERTS.as_ptr() as *const u8,
3,
2 // POS_COLOR
);
// Indexed quad
QUAD = load_mesh_indexed(
QUAD_VERTS.as_ptr() as *const u8,
4,
QUAD_INDICES.as_ptr(),
6,
5 // POS_UV_NORMAL
);
}
}
fn render() {
unsafe {
camera_set(0.0, 0.0, 5.0, 0.0, 0.0, 0.0);
// Draw triangle
push_identity();
push_translate(-2.0, 0.0, 0.0);
draw_mesh(TRIANGLE);
// Draw textured quad
texture_bind(my_texture);
push_identity();
push_translate(2.0, 0.0, 0.0);
draw_mesh(QUAD);
}
}
}
See Also: Procedural Meshes, rom_mesh
Material Functions
Material properties for PBR (Mode 2) and Blinn-Phong (Mode 3) rendering.
Mode 2: Metallic-Roughness (PBR)
material_metallic
Sets the metallic value for PBR rendering.
Signature:
#![allow(unused)]
fn main() {
fn material_metallic(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Metallic value (0.0 = dielectric, 1.0 = metal) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Non-metallic plastic
material_metallic(0.0);
draw_mesh(plastic_toy);
// Full metal
material_metallic(1.0);
draw_mesh(sword);
// Partially metallic (worn paint on metal)
material_metallic(0.3);
draw_mesh(rusty_barrel);
}
}
material_roughness
Sets the roughness value for PBR rendering.
Signature:
#![allow(unused)]
fn main() {
fn material_roughness(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Roughness value (0.0 = smooth/mirror, 1.0 = rough/matte) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Mirror-like chrome
material_roughness(0.1);
draw_mesh(chrome_bumper);
// Rough stone
material_roughness(0.9);
draw_mesh(stone_wall);
// Smooth plastic
material_roughness(0.4);
draw_mesh(toy);
}
}
material_emissive
Sets the emissive (self-illumination) intensity.
Signature:
#![allow(unused)]
fn main() {
fn material_emissive(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Emissive intensity (0.0 = none, 1.0+ = glowing) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Glowing lava
set_color(0xFF4400FF);
material_emissive(2.0);
draw_mesh(lava);
// Neon sign
set_color(0x00FFFFFF);
material_emissive(1.5);
draw_mesh(neon_tube);
// Normal object (no glow)
material_emissive(0.0);
draw_mesh(normal_object);
}
}
material_rim
Sets rim lighting parameters.
Signature:
#![allow(unused)]
fn main() {
fn material_rim(intensity: f32, power: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| intensity | f32 | Rim light intensity (0.0-1.0) |
| power | f32 | Rim light falloff power (0.0-1.0, maps to 0-32 internally) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Subtle rim for characters
material_rim(0.2, 0.15);
draw_mesh(character);
// Strong backlighting effect
material_rim(0.5, 0.3);
draw_mesh(silhouette_enemy);
// No rim lighting
material_rim(0.0, 0.0);
draw_mesh(ground);
}
}
Mode 3: Specular-Shininess (Blinn-Phong)
material_shininess
Sets the shininess for specular highlights (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn material_shininess(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Shininess (0.0-1.0, maps to 1-256 internally) |
Shininess Guide:
| Value | Internal | Visual | Use For |
|---|---|---|---|
| 0.0-0.2 | 1-52 | Very soft, broad | Cloth, skin, rough stone |
| 0.2-0.4 | 52-103 | Broad | Leather, wood, rubber |
| 0.4-0.6 | 103-154 | Medium | Plastic, painted metal |
| 0.6-0.8 | 154-205 | Tight | Polished metal, wet surfaces |
| 0.8-1.0 | 205-256 | Very tight | Chrome, mirrors, glass |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Matte cloth
material_shininess(0.1);
draw_mesh(cloth);
// Polished armor
material_shininess(0.8);
draw_mesh(armor);
// Chrome
material_shininess(0.95);
draw_mesh(chrome_sphere);
}
}
material_specular
Sets the specular highlight color (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn material_specular(color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| color | u32 | Specular color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// White specular (default, most materials)
material_specular(0xFFFFFFFF);
draw_mesh(plastic);
// Gold specular
material_specular(0xFFD700FF);
draw_mesh(gold_ring);
// Copper specular
material_specular(0xB87333FF);
draw_mesh(copper_pot);
}
}
material_specular_color
Sets the specular highlight color as RGB floats (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn material_specular_color(r: f32, g: f32, b: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| r, g, b | f32 | Specular color components (0.0-1.0) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Gold specular
material_specular_color(1.0, 0.84, 0.0);
draw_mesh(gold);
// Tinted specular
material_specular_color(0.8, 0.9, 1.0);
draw_mesh(ice);
}
}
material_specular_damping
Sets specular damping (Mode 3, alias for metallic behavior).
Signature:
#![allow(unused)]
fn main() {
fn material_specular_damping(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Damping value (0.0-1.0) |
Texture Slots
material_albedo
Binds an albedo (diffuse) texture to slot 0.
Signature:
#![allow(unused)]
fn main() {
fn material_albedo(texture: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Texture handle |
Note: Equivalent to texture_bind_slot(texture, 0).
material_mre
Binds an MRE (Metallic/Roughness/Emissive) texture to slot 1 (Mode 2).
Signature:
#![allow(unused)]
fn main() {
fn material_mre(texture: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Texture handle for MRE map |
MRE Texture Channels:
- R: Metallic (0-255 maps to 0.0-1.0)
- G: Roughness (0-255 maps to 0.0-1.0)
- B: Emissive (0-255 maps to emissive intensity)
Example:
#![allow(unused)]
fn main() {
fn render() {
material_albedo(character_albedo);
material_mre(character_mre);
draw_mesh(character);
}
}
Override Flags
These functions enable uniform values instead of texture sampling.
use_uniform_color
Use uniform color instead of albedo texture.
Signature:
#![allow(unused)]
fn main() {
fn use_uniform_color(enabled: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Use texture
use_uniform_color(0);
texture_bind(wood_tex);
draw_mesh(table);
// Use uniform color
use_uniform_color(1);
set_color(0xFF0000FF);
draw_mesh(red_cube);
}
}
use_uniform_metallic
Use uniform metallic value instead of MRE texture.
Signature:
#![allow(unused)]
fn main() {
fn use_uniform_metallic(enabled: u32)
}
use_uniform_roughness
Use uniform roughness value instead of MRE texture.
Signature:
#![allow(unused)]
fn main() {
fn use_uniform_roughness(enabled: u32)
}
use_uniform_emissive
Use uniform emissive value instead of MRE texture.
Signature:
#![allow(unused)]
fn main() {
fn use_uniform_emissive(enabled: u32)
}
use_uniform_specular
Use uniform specular color instead of specular texture (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn use_uniform_specular(enabled: u32)
}
use_matcap_reflection
Use matcap for environmental reflection (Mode 1).
Signature:
#![allow(unused)]
fn main() {
fn use_matcap_reflection(enabled: u32)
}
Complete Examples
PBR Material (Mode 2)
#![allow(unused)]
fn main() {
fn init() {
render_mode(2); // Metallic-Roughness
}
fn render() {
// Shiny metal sword
material_albedo(sword_albedo);
material_mre(sword_mre);
material_rim(0.15, 0.2);
push_identity();
push_translate(player.x, player.y, player.z);
draw_mesh(sword);
// Simple colored object (no textures)
use_uniform_color(1);
use_uniform_metallic(1);
use_uniform_roughness(1);
set_color(0x4080FFFF);
material_metallic(0.0);
material_roughness(0.3);
push_identity();
draw_mesh(magic_orb);
}
}
Blinn-Phong Material (Mode 3)
#![allow(unused)]
fn main() {
fn init() {
render_mode(3); // Specular-Shininess
}
fn render() {
// Gold armor
set_color(0xE6B84DFF); // Gold base color
material_shininess(0.8);
material_specular(0xFFD700FF); // Gold specular
material_rim(0.2, 0.15);
material_emissive(0.0);
draw_mesh(armor);
// Glowing crystal
set_color(0x4D99E6FF); // Blue crystal
material_shininess(0.75);
material_specular(0xFFFFFFFF);
material_rim(0.4, 0.18);
material_emissive(0.3); // Self-illumination
draw_mesh(crystal);
// Wet skin
set_color(0xD9B399FF);
material_shininess(0.7);
material_specular(0xFFFFFFFF);
material_rim(0.3, 0.25);
material_emissive(0.0);
draw_mesh(character_skin);
}
}
See Also: Render Modes Guide, Textures, Lighting
Lighting Functions
Dynamic lighting for Modes 2 and 3 (up to 4 lights).
Directional Lights
light_set
Sets a directional light direction.
Signature:
#![allow(unused)]
fn main() {
fn light_set(index: u32, x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| x, y, z | f32 | Light direction (from light, will be normalized) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Sun from upper right
light_set(0, 0.5, -0.7, 0.5);
light_enable(0);
// Fill light from left
light_set(1, -0.8, -0.2, 0.0);
light_enable(1);
}
}
light_color
Sets a light’s color.
Signature:
#![allow(unused)]
fn main() {
fn light_color(index: u32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| color | u32 | Light color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Warm sunlight
light_color(0, 0xFFF2E6FF);
// Cool fill light
light_color(1, 0xB3D9FFFF);
// Red emergency light
light_color(2, 0xFF3333FF);
}
}
light_intensity
Sets a light’s intensity.
Signature:
#![allow(unused)]
fn main() {
fn light_intensity(index: u32, intensity: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| intensity | f32 | Light intensity (0.0-8.0, default 1.0) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Bright main light
light_intensity(0, 1.2);
// Dim fill light
light_intensity(1, 0.3);
// Flickering torch
let flicker = 0.8 + (elapsed_time() * 10.0).sin() * 0.2;
light_intensity(2, flicker);
}
}
light_enable
Enables a light.
Signature:
#![allow(unused)]
fn main() {
fn light_enable(index: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Enable lights 0 and 1
light_enable(0);
light_enable(1);
}
}
light_disable
Disables a light.
Signature:
#![allow(unused)]
fn main() {
fn light_disable(index: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Disable light 2 when entering dark area
if in_dark_zone {
light_disable(2);
}
}
}
Point Lights
light_set_point
Sets a point light position.
Signature:
#![allow(unused)]
fn main() {
fn light_set_point(index: u32, x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| x, y, z | f32 | World position of the light |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Torch at fixed position
light_set_point(0, 5.0, 2.0, 3.0);
light_color(0, 0xFFAA66FF);
light_range(0, 10.0);
light_enable(0);
// Light following player
light_set_point(1, player.x, player.y + 1.0, player.z);
light_enable(1);
}
}
light_range
Sets a point light’s falloff range.
Signature:
#![allow(unused)]
fn main() {
fn light_range(index: u32, range: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| range | f32 | Maximum range/falloff distance |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Small candle
light_set_point(0, candle_x, candle_y, candle_z);
light_range(0, 3.0);
light_intensity(0, 0.5);
// Large bonfire
light_set_point(1, fire_x, fire_y, fire_z);
light_range(1, 15.0);
light_intensity(1, 2.0);
}
}
Standard Lighting Setups
Three-Point Lighting
#![allow(unused)]
fn main() {
fn setup_lighting() {
// Key light (main light source)
light_set(0, 0.5, -0.7, 0.5);
light_color(0, 0xFFF2E6FF); // Warm white
light_intensity(0, 1.0);
light_enable(0);
// Fill light (soften shadows)
light_set(1, -0.8, -0.3, 0.2);
light_color(1, 0xB3D9FFFF); // Cool blue
light_intensity(1, 0.3);
light_enable(1);
// Rim/back light (separation from background)
light_set(2, 0.0, -0.2, -1.0);
light_color(2, 0xFFFFFFFF);
light_intensity(2, 0.5);
light_enable(2);
}
}
Outdoor Sunlight
#![allow(unused)]
fn main() {
fn render() {
// Configure sun (matches sky_set_sun direction)
light_set(0, 0.3, -0.8, 0.5);
light_color(0, 0xFFF8E6FF); // Warm sunlight
light_intensity(0, 1.2);
light_enable(0);
// Ambient comes from sky automatically
}
}
Indoor Point Lights
#![allow(unused)]
fn main() {
fn render() {
// Overhead lamp
light_set_point(0, room_center_x, ceiling_y - 0.5, room_center_z);
light_color(0, 0xFFE6B3FF);
light_range(0, 8.0);
light_intensity(0, 1.0);
light_enable(0);
// Desk lamp
light_set_point(1, desk_x, desk_y + 0.5, desk_z);
light_color(1, 0xFFFFE6FF);
light_range(1, 3.0);
light_intensity(1, 0.8);
light_enable(1);
}
}
Dynamic Torch Effect
#![allow(unused)]
fn main() {
static mut TORCH_FLICKER: f32 = 0.0;
fn update() {
unsafe {
// Randomized flicker
let r = (random() % 1000) as f32 / 1000.0;
TORCH_FLICKER = 0.7 + r * 0.3;
}
}
fn render() {
unsafe {
light_set_point(0, torch_x, torch_y, torch_z);
light_color(0, 0xFF8833FF);
light_range(0, 6.0 + TORCH_FLICKER);
light_intensity(0, TORCH_FLICKER);
light_enable(0);
}
}
}
Lighting Notes
- Maximum 4 lights (indices 0-3)
- Directional lights have no position, only direction
- Point lights have position and range falloff
- Sun lighting comes from
sky_set_sun()in addition to explicit lights - Ambient comes from the procedural sky automatically
- Works only in Mode 2 (Metallic-Roughness) and Mode 3 (Specular-Shininess)
See Also: Sky Functions, Materials, Render Modes Guide
Skeletal Animation Functions
GPU-based skeletal animation with bone transforms.
Skeleton Loading
load_skeleton
Loads inverse bind matrices for a skeleton.
Signature:
#![allow(unused)]
fn main() {
fn load_skeleton(inverse_bind_ptr: *const f32, bone_count: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| inverse_bind_ptr | *const f32 | Pointer to 3x4 matrices (12 floats each, column-major) |
| bone_count | u32 | Number of bones (max 256) |
Returns: Skeleton handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static INVERSE_BIND: &[u8] = include_bytes!("skeleton.ewzskel");
fn init() {
unsafe {
// Parse bone count from header
let bone_count = u32::from_le_bytes([
INVERSE_BIND[0], INVERSE_BIND[1],
INVERSE_BIND[2], INVERSE_BIND[3]
]);
// Matrix data starts after 8-byte header
let matrices_ptr = INVERSE_BIND[8..].as_ptr() as *const f32;
SKELETON = load_skeleton(matrices_ptr, bone_count);
}
}
}
skeleton_bind
Binds a skeleton for inverse bind mode rendering.
Signature:
#![allow(unused)]
fn main() {
fn skeleton_bind(skeleton: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| skeleton | u32 | Skeleton handle, or 0 to disable inverse bind mode |
Skinning Modes:
skeleton_bind() | set_bones() receives | GPU applies |
|---|---|---|
0 or not called | Final skinning matrices | Nothing extra |
| Valid handle | Model-space bone transforms | bone × inverse_bind |
Example:
#![allow(unused)]
fn main() {
fn render() {
unsafe {
// Enable inverse bind mode
skeleton_bind(SKELETON);
// Upload model-space transforms (GPU applies inverse bind)
set_bones(animation_bones.as_ptr(), bone_count);
draw_mesh(character_mesh);
// Disable for other meshes
skeleton_bind(0);
}
}
}
Bone Transforms
set_bones
Uploads bone transforms as 3x4 matrices.
Signature:
#![allow(unused)]
fn main() {
fn set_bones(matrices_ptr: *const f32, count: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrices_ptr | *const f32 | Pointer to array of 3x4 matrices (12 floats each) |
| count | u32 | Number of bones (max 256) |
3x4 Matrix Layout (column-major, 12 floats):
[col0.x, col0.y, col0.z, // X axis
col1.x, col1.y, col1.z, // Y axis
col2.x, col2.y, col2.z, // Z axis
tx, ty, tz] // Translation
// Implicit 4th row: [0, 0, 0, 1]
Example:
#![allow(unused)]
fn main() {
static mut BONE_MATRICES: [f32; 64 * 12] = [0.0; 64 * 12]; // 64 bones max
fn update() {
unsafe {
// Update bone transforms from animation
for i in 0..BONE_COUNT {
let offset = i * 12;
// Set identity with translation
BONE_MATRICES[offset + 0] = 1.0; // col0.x
BONE_MATRICES[offset + 4] = 1.0; // col1.y
BONE_MATRICES[offset + 8] = 1.0; // col2.z
BONE_MATRICES[offset + 9] = bone_positions[i].x;
BONE_MATRICES[offset + 10] = bone_positions[i].y;
BONE_MATRICES[offset + 11] = bone_positions[i].z;
}
}
}
fn render() {
unsafe {
set_bones(BONE_MATRICES.as_ptr(), BONE_COUNT as u32);
draw_mesh(SKINNED_MESH);
}
}
}
set_bones_4x4
Uploads bone transforms as 4x4 matrices (converted to 3x4 internally).
Signature:
#![allow(unused)]
fn main() {
fn set_bones_4x4(matrices_ptr: *const f32, count: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrices_ptr | *const f32 | Pointer to array of 4x4 matrices (16 floats each) |
| count | u32 | Number of bones (max 256) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam Mat4 arrays
let mut bone_mats: [Mat4; 64] = [Mat4::IDENTITY; 64];
// Animate bones
for i in 0..bone_count {
bone_mats[i] = compute_bone_transform(i);
}
// Upload (host converts 4x4 → 3x4)
set_bones_4x4(bone_mats.as_ptr() as *const f32, bone_count);
draw_mesh(skinned_mesh);
}
}
Skinned Vertex Format
Add FORMAT_SKINNED (8) to your vertex format for skinned meshes:
#![allow(unused)]
fn main() {
const FORMAT_SKINNED: u32 = 8;
// Common skinned formats
const FORMAT_SKINNED_UV_NORMAL: u32 = FORMAT_SKINNED | FORMAT_UV | FORMAT_NORMAL; // 13
}
Skinned vertex data layout:
position (3 floats)
uv (2 floats, if FORMAT_UV)
color (3 floats, if FORMAT_COLOR)
normal (3 floats, if FORMAT_NORMAL)
bone_indices (4 u8, packed as 4 bytes)
bone_weights (4 floats)
Example vertex (FORMAT_SKINNED_UV_NORMAL):
#![allow(unused)]
fn main() {
// 52 bytes per vertex: 3 + 2 + 3 + 4bytes + 4 floats
let vertex = [
0.0, 1.0, 0.0, // position
0.5, 0.5, // uv
0.0, 1.0, 0.0, // normal
// bone_indices: [0, 1, 255, 255] as 4 bytes
// bone_weights: [0.7, 0.3, 0.0, 0.0] as 4 floats
];
}
Complete Example
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static mut CHARACTER_MESH: u32 = 0;
static mut BONE_MATRICES: [f32; 32 * 12] = [0.0; 32 * 12];
const BONE_COUNT: usize = 32;
fn init() {
unsafe {
// Load skeleton
SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
// Load skinned mesh
CHARACTER_MESH = rom_mesh(b"player".as_ptr(), 6);
// Initialize bones to identity
for i in 0..BONE_COUNT {
let o = i * 12;
BONE_MATRICES[o + 0] = 1.0;
BONE_MATRICES[o + 4] = 1.0;
BONE_MATRICES[o + 8] = 1.0;
}
}
}
fn update() {
unsafe {
// Animate bones (your animation logic here)
animate_walk_cycle(&mut BONE_MATRICES, elapsed_time());
}
}
fn render() {
unsafe {
// Bind skeleton for inverse bind mode
skeleton_bind(SKELETON);
// Upload bone transforms
set_bones(BONE_MATRICES.as_ptr(), BONE_COUNT as u32);
// Draw character
texture_bind(character_texture);
push_identity();
push_translate(player_x, player_y, player_z);
draw_mesh(CHARACTER_MESH);
// Unbind skeleton
skeleton_bind(0);
}
}
}
See Also: Animation Functions, rom_skeleton
Keyframe Animation Functions
GPU-optimized keyframe animation system for skeletal animation.
Loading Keyframes
keyframes_load
Loads keyframes from WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn keyframes_load(data_ptr: *const u8, byte_size: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to keyframe data |
| byte_size | u32 | Size of data in bytes |
Returns: Keyframe collection handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static WALK_DATA: &[u8] = include_bytes!("walk.ewzanim");
static mut WALK_ANIM: u32 = 0;
fn init() {
unsafe {
WALK_ANIM = keyframes_load(WALK_DATA.as_ptr(), WALK_DATA.len() as u32);
}
}
}
rom_keyframes
Loads keyframes from ROM data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_keyframes(id_ptr: *const u8, id_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID string |
| id_len | u32 | Length of asset ID |
Returns: Keyframe collection handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut ATTACK_ANIM: u32 = 0;
fn init() {
unsafe {
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
ATTACK_ANIM = rom_keyframes(b"attack".as_ptr(), 6);
}
}
}
Querying Keyframes
keyframes_bone_count
Gets the bone count for a keyframe collection.
Signature:
#![allow(unused)]
fn main() {
fn keyframes_bone_count(handle: u32) -> u32
}
Returns: Number of bones in the animation
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
let bones = keyframes_bone_count(WALK_ANIM);
log_fmt(b"Walk animation has {} bones", bones);
}
}
}
keyframes_frame_count
Gets the frame count for a keyframe collection.
Signature:
#![allow(unused)]
fn main() {
fn keyframes_frame_count(handle: u32) -> u32
}
Returns: Number of frames in the animation
Example:
#![allow(unused)]
fn main() {
fn render() {
unsafe {
let frame_count = keyframes_frame_count(WALK_ANIM);
let current_frame = (ANIM_TIME as u32) % frame_count;
keyframe_bind(WALK_ANIM, current_frame);
}
}
}
Using Keyframes
keyframe_bind
Binds a keyframe directly from GPU buffer (zero CPU overhead).
Signature:
#![allow(unused)]
fn main() {
fn keyframe_bind(handle: u32, index: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Keyframe collection handle |
| index | u32 | Frame index (0 to frame_count-1) |
Example:
#![allow(unused)]
fn main() {
static mut ANIM_FRAME: f32 = 0.0;
fn update() {
unsafe {
ANIM_FRAME += delta_time() * 30.0; // 30 FPS animation
}
}
fn render() {
unsafe {
let frame_count = keyframes_frame_count(WALK_ANIM);
let frame = (ANIM_FRAME as u32) % frame_count;
// Bind frame - GPU reads directly, no CPU decode!
keyframe_bind(WALK_ANIM, frame);
draw_mesh(CHARACTER_MESH);
}
}
}
keyframe_read
Reads a keyframe to WASM memory for CPU-side blending.
Signature:
#![allow(unused)]
fn main() {
fn keyframe_read(handle: u32, index: u32, out_ptr: *mut u8)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Keyframe collection handle |
| index | u32 | Frame index |
| out_ptr | *mut u8 | Destination buffer (must be large enough for all bone matrices) |
Example:
#![allow(unused)]
fn main() {
fn render() {
unsafe {
let frame_count = keyframes_frame_count(WALK_ANIM);
let frame_a = (ANIM_TIME as u32) % frame_count;
let frame_b = (frame_a + 1) % frame_count;
let blend = ANIM_TIME.fract();
// Read frames for interpolation
let mut buf_a = [0u8; 64 * 12 * 4]; // 64 bones × 12 floats × 4 bytes
let mut buf_b = [0u8; 64 * 12 * 4];
keyframe_read(WALK_ANIM, frame_a, buf_a.as_mut_ptr());
keyframe_read(WALK_ANIM, frame_b, buf_b.as_mut_ptr());
// Interpolate on CPU
let blended = interpolate_bones(&buf_a, &buf_b, blend);
// Upload blended result
set_bones(blended.as_ptr(), bone_count);
draw_mesh(CHARACTER_MESH);
}
}
}
Animation Paths
| Path | Function | Use Case | Performance |
|---|---|---|---|
| Static | keyframe_bind() | Pre-baked ROM animations | Zero CPU work |
| Immediate | set_bones() | Procedural, IK, blended | Minimal overhead |
Static keyframes: Data uploaded to GPU once in init(). keyframe_bind() just sets buffer offset.
Immediate bones: Matrices appended to per-frame buffer, uploaded before rendering.
Complete Example
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static mut CHARACTER: u32 = 0;
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut ANIM_TIME: f32 = 0.0;
static mut IS_WALKING: bool = false;
fn init() {
unsafe {
SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
CHARACTER = rom_mesh(b"player".as_ptr(), 6);
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
}
}
fn update() {
unsafe {
// Check movement input
let stick_x = left_stick_x(0);
let stick_y = left_stick_y(0);
IS_WALKING = stick_x.abs() > 0.1 || stick_y.abs() > 0.1;
// Advance animation
let anim_speed = if IS_WALKING { 30.0 } else { 15.0 };
ANIM_TIME += delta_time() * anim_speed;
}
}
fn render() {
unsafe {
skeleton_bind(SKELETON);
// Choose animation
let anim = if IS_WALKING { WALK_ANIM } else { IDLE_ANIM };
let frame_count = keyframes_frame_count(anim);
let frame = (ANIM_TIME as u32) % frame_count;
// Bind keyframe (GPU-side, no CPU decode)
keyframe_bind(anim, frame);
// Draw character
texture_bind(player_texture);
push_identity();
push_translate(player_x, player_y, player_z);
draw_mesh(CHARACTER);
skeleton_bind(0);
}
}
}
See Also: Skinning Functions, rom_keyframes
Procedural Mesh Functions
Generate common 3D primitives at runtime.
All procedural meshes use vertex format 5 (POS_UV_NORMAL): 8 floats per vertex. Works with all render modes (0-3).
Constraints: All functions are init-only. Call in init().
Basic Primitives
cube
Generates a box mesh.
Signature:
#![allow(unused)]
fn main() {
fn cube(size_x: f32, size_y: f32, size_z: f32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| size_x | f32 | Half-width (total width = 2 × size_x) |
| size_y | f32 | Half-height (total height = 2 × size_y) |
| size_z | f32 | Half-depth (total depth = 2 × size_z) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
UNIT_CUBE = cube(0.5, 0.5, 0.5); // 1×1×1 cube
TALL_BOX = cube(1.0, 3.0, 1.0); // 2×6×2 tall box
FLAT_TILE = cube(2.0, 0.1, 2.0); // 4×0.2×4 tile
}
}
}
sphere
Generates a UV sphere mesh.
Signature:
#![allow(unused)]
fn main() {
fn sphere(radius: f32, segments: u32, rings: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius | f32 | Sphere radius |
| segments | u32 | Horizontal divisions (3-256) |
| rings | u32 | Vertical divisions (2-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
LOW_POLY_SPHERE = sphere(1.0, 8, 6); // 48 triangles
SMOOTH_SPHERE = sphere(1.0, 32, 16); // 960 triangles
PLANET = sphere(100.0, 64, 32); // Large, detailed
}
}
}
cylinder
Generates a cylinder or cone mesh.
Signature:
#![allow(unused)]
fn main() {
fn cylinder(radius_bottom: f32, radius_top: f32, height: f32, segments: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius_bottom | f32 | Bottom cap radius |
| radius_top | f32 | Top cap radius (0 for cone) |
| height | f32 | Cylinder height |
| segments | u32 | Radial divisions (3-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PILLAR = cylinder(0.5, 0.5, 3.0, 12); // Uniform cylinder
CONE = cylinder(1.0, 0.0, 2.0, 16); // Cone
TAPERED = cylinder(1.0, 0.5, 2.0, 16); // Tapered cylinder
BARREL = cylinder(0.8, 0.6, 1.5, 24); // Barrel shape
}
}
}
plane
Generates a subdivided plane mesh (XZ plane, Y=0, facing up).
Signature:
#![allow(unused)]
fn main() {
fn plane(size_x: f32, size_z: f32, subdivisions_x: u32, subdivisions_z: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| size_x | f32 | Half-width |
| size_z | f32 | Half-depth |
| subdivisions_x | u32 | X divisions (1-256) |
| subdivisions_z | u32 | Z divisions (1-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
GROUND = plane(50.0, 50.0, 1, 1); // 100×100 simple quad
TERRAIN = plane(100.0, 100.0, 32, 32); // Subdivided for LOD
WATER = plane(20.0, 20.0, 16, 16); // Animated water
}
}
}
torus
Generates a torus (donut) mesh.
Signature:
#![allow(unused)]
fn main() {
fn torus(major_radius: f32, minor_radius: f32, major_segments: u32, minor_segments: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| major_radius | f32 | Distance from center to tube center |
| minor_radius | f32 | Tube thickness |
| major_segments | u32 | Segments around ring (3-256) |
| minor_segments | u32 | Segments around tube (3-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
DONUT = torus(2.0, 0.5, 32, 16); // Classic donut
RING = torus(3.0, 0.1, 48, 8); // Thin ring
TIRE = torus(1.5, 0.6, 24, 12); // Car tire
}
}
}
capsule
Generates a capsule (cylinder with hemispherical caps).
Signature:
#![allow(unused)]
fn main() {
fn capsule(radius: f32, height: f32, segments: u32, rings: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius | f32 | Capsule radius |
| height | f32 | Cylinder section height (total = height + 2×radius) |
| segments | u32 | Radial divisions (3-256) |
| rings | u32 | Hemisphere divisions (1-128) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PILL = capsule(0.5, 1.0, 16, 8); // Pill shape
CHARACTER_COLLIDER = capsule(0.4, 1.2, 8, 4); // Physics capsule
BULLET = capsule(0.1, 0.3, 12, 6); // Projectile
}
}
}
UV-Mapped Variants
These variants are identical but explicitly named for clarity.
cube_uv
#![allow(unused)]
fn main() {
fn cube_uv(size_x: f32, size_y: f32, size_z: f32) -> u32
}
Same as cube(). UV coordinates map 0-1 on each face.
sphere_uv
#![allow(unused)]
fn main() {
fn sphere_uv(radius: f32, segments: u32, rings: u32) -> u32
}
Same as sphere(). Equirectangular UV mapping.
cylinder_uv
#![allow(unused)]
fn main() {
fn cylinder_uv(radius_bottom: f32, radius_top: f32, height: f32, segments: u32) -> u32
}
Same as cylinder(). Radial unwrap for body, polar for caps.
plane_uv
#![allow(unused)]
fn main() {
fn plane_uv(size_x: f32, size_z: f32, subdivisions_x: u32, subdivisions_z: u32) -> u32
}
Same as plane(). Simple 0-1 grid UV mapping.
torus_uv
#![allow(unused)]
fn main() {
fn torus_uv(major_radius: f32, minor_radius: f32, major_segments: u32, minor_segments: u32) -> u32
}
Same as torus(). Wrapped UVs on both axes.
capsule_uv
#![allow(unused)]
fn main() {
fn capsule_uv(radius: f32, height: f32, segments: u32, rings: u32) -> u32
}
Same as capsule(). Radial for body, polar for hemispheres.
Complete Example
#![allow(unused)]
fn main() {
static mut GROUND: u32 = 0;
static mut SPHERE: u32 = 0;
static mut CUBE: u32 = 0;
static mut PILLAR: u32 = 0;
fn init() {
unsafe {
render_mode(2); // PBR lighting
// Generate primitives
GROUND = plane(20.0, 20.0, 1, 1);
SPHERE = sphere(1.0, 24, 12);
CUBE = cube(0.5, 0.5, 0.5);
PILLAR = cylinder(0.3, 0.3, 2.0, 16);
}
}
fn render() {
unsafe {
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Ground
material_roughness(0.9);
material_metallic(0.0);
set_color(0x556644FF);
push_identity();
draw_mesh(GROUND);
// Central sphere
material_roughness(0.3);
material_metallic(1.0);
set_color(0xFFD700FF);
push_identity();
push_translate(0.0, 1.0, 0.0);
draw_mesh(SPHERE);
// Pillars
set_color(0x888888FF);
material_metallic(0.0);
for i in 0..4 {
let angle = (i as f32) * 1.57;
push_identity();
push_translate(angle.cos() * 5.0, 1.0, angle.sin() * 5.0);
draw_mesh(PILLAR);
}
// Floating cubes
set_color(0x4488FFFF);
for i in 0..8 {
let t = elapsed_time() + (i as f32) * 0.5;
push_identity();
push_translate(
(t * 0.5).cos() * 3.0,
2.0 + (t * 2.0).sin() * 0.5,
(t * 0.5).sin() * 3.0
);
push_rotate_y(t * 90.0);
draw_mesh(CUBE);
}
}
}
}
2D Drawing Functions
Screen-space sprites, rectangles, and text rendering.
Sprites
draw_sprite
Draws a textured quad at screen coordinates.
Signature:
#![allow(unused)]
fn main() {
fn draw_sprite(x: f32, y: f32, w: f32, h: f32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position (top-left corner) |
| w, h | f32 | Size in pixels |
| color | u32 | Tint color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Draw full texture
texture_bind(player_sprite);
draw_sprite(100.0, 100.0, 64.0, 64.0, 0xFFFFFFFF);
// Tinted sprite
draw_sprite(200.0, 100.0, 64.0, 64.0, 0xFF8080FF);
}
}
draw_sprite_region
Draws a region of a texture (sprite sheet).
Signature:
#![allow(unused)]
fn main() {
fn draw_sprite_region(
x: f32, y: f32, w: f32, h: f32,
src_x: f32, src_y: f32, src_w: f32, src_h: f32,
color: u32
)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position |
| w, h | f32 | Destination size in pixels |
| src_x, src_y | f32 | Source position in texture (pixels) |
| src_w, src_h | f32 | Source size in texture (pixels) |
| color | u32 | Tint color |
Example:
#![allow(unused)]
fn main() {
// Sprite sheet: 4x4 grid of 32x32 sprites
fn draw_frame(frame: u32) {
let col = frame % 4;
let row = frame / 4;
draw_sprite_region(
100.0, 100.0, 64.0, 64.0, // Destination (scaled 2x)
(col * 32) as f32, (row * 32) as f32, 32.0, 32.0, // Source
0xFFFFFFFF
);
}
}
draw_sprite_ex
Draws a sprite with rotation and custom origin.
Signature:
#![allow(unused)]
fn main() {
fn draw_sprite_ex(
x: f32, y: f32, w: f32, h: f32,
src_x: f32, src_y: f32, src_w: f32, src_h: f32,
origin_x: f32, origin_y: f32,
angle_deg: f32,
color: u32
)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position |
| w, h | f32 | Destination size |
| src_x, src_y, src_w, src_h | f32 | Source region |
| origin_x, origin_y | f32 | Rotation origin (0-1 normalized) |
| angle_deg | f32 | Rotation angle in degrees |
| color | u32 | Tint color |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Rotating sprite around center
draw_sprite_ex(
200.0, 200.0, 64.0, 64.0, // Position and size
0.0, 0.0, 32.0, 32.0, // Full texture
0.5, 0.5, // Center origin
elapsed_time() * 90.0, // Rotation (90 deg/sec)
0xFFFFFFFF
);
// Rotating around bottom-center (like a pendulum)
draw_sprite_ex(
300.0, 200.0, 64.0, 64.0,
0.0, 0.0, 32.0, 32.0,
0.5, 1.0, // Bottom-center origin
(elapsed_time() * 2.0).sin() * 30.0,
0xFFFFFFFF
);
}
}
Rectangles
draw_rect
Draws a solid color rectangle.
Signature:
#![allow(unused)]
fn main() {
fn draw_rect(x: f32, y: f32, w: f32, h: f32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position (top-left) |
| w, h | f32 | Size in pixels |
| color | u32 | Fill color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Health bar background
draw_rect(10.0, 10.0, 100.0, 20.0, 0x333333FF);
// Health bar fill
let health_width = (health / max_health) * 96.0;
draw_rect(12.0, 12.0, health_width, 16.0, 0x00FF00FF);
// Semi-transparent overlay
draw_rect(0.0, 0.0, 960.0, 540.0, 0x00000080);
}
}
Text
draw_text
Draws text using the bound font.
Signature:
#![allow(unused)]
fn main() {
fn draw_text(ptr: *const u8, len: u32, x: f32, y: f32, size: f32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| ptr | *const u8 | Pointer to UTF-8 string |
| len | u32 | String length in bytes |
| x, y | f32 | Screen position |
| size | f32 | Font size in pixels |
| color | u32 | Text color |
Example:
#![allow(unused)]
fn main() {
fn render() {
let text = b"SCORE: 12345";
draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0, 0xFFFFFFFF);
let title = b"GAME OVER";
draw_text(title.as_ptr(), title.len() as u32, 400.0, 270.0, 48.0, 0xFF0000FF);
}
}
Custom Fonts
load_font
Loads a fixed-width bitmap font.
Signature:
#![allow(unused)]
fn main() {
fn load_font(
texture: u32,
char_width: u32,
char_height: u32,
first_codepoint: u32,
char_count: u32
) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Font texture atlas handle |
| char_width | u32 | Width of each character in pixels |
| char_height | u32 | Height of each character in pixels |
| first_codepoint | u32 | First character code (usually 32 for space) |
| char_count | u32 | Number of characters in atlas |
Returns: Font handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
FONT_TEXTURE = load_texture(128, 64, FONT_PIXELS.as_ptr());
// 8x8 font starting at space (32), 96 characters
MY_FONT = load_font(FONT_TEXTURE, 8, 8, 32, 96);
}
}
}
load_font_ex
Loads a variable-width bitmap font.
Signature:
#![allow(unused)]
fn main() {
fn load_font_ex(
texture: u32,
widths_ptr: *const u8,
char_height: u32,
first_codepoint: u32,
char_count: u32
) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Font texture atlas handle |
| widths_ptr | *const u8 | Pointer to array of character widths |
| char_height | u32 | Height of each character |
| first_codepoint | u32 | First character code |
| char_count | u32 | Number of characters |
Returns: Font handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
// Width table for characters ' ' through '~'
static CHAR_WIDTHS: [u8; 96] = [
4, 2, 4, 6, 6, 6, 6, 2, 3, 3, 4, 6, 2, 4, 2, 4, // space to /
6, 4, 6, 6, 6, 6, 6, 6, 6, 6, 2, 2, 4, 6, 4, 6, // 0 to ?
// ... etc
];
fn init() {
unsafe {
PROP_FONT = load_font_ex(FONT_TEX, CHAR_WIDTHS.as_ptr(), 12, 32, 96);
}
}
}
font_bind
Binds a font for subsequent draw_text() calls.
Signature:
#![allow(unused)]
fn main() {
fn font_bind(font_handle: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Use custom font
font_bind(MY_FONT);
draw_text(b"Custom Text".as_ptr(), 11, 10.0, 10.0, 16.0, 0xFFFFFFFF);
// Switch to different font
font_bind(TITLE_FONT);
draw_text(b"Title".as_ptr(), 5, 100.0, 50.0, 32.0, 0xFFD700FF);
}
}
Complete Example
#![allow(unused)]
fn main() {
static mut UI_FONT: u32 = 0;
static mut ICON_SHEET: u32 = 0;
fn init() {
unsafe {
UI_FONT = rom_font(b"ui_font".as_ptr(), 7);
ICON_SHEET = rom_texture(b"icons".as_ptr(), 5);
}
}
fn render() {
unsafe {
// Disable depth for 2D overlay
depth_test(0);
blend_mode(1);
// Background panel
draw_rect(5.0, 5.0, 200.0, 80.0, 0x00000099);
// Health bar
draw_rect(10.0, 10.0, 102.0, 12.0, 0x333333FF);
draw_rect(11.0, 11.0, health as f32, 10.0, 0x00FF00FF);
// Health icon
texture_bind(ICON_SHEET);
draw_sprite_region(
10.0, 25.0, 16.0, 16.0, // Position
0.0, 0.0, 16.0, 16.0, // Heart icon
0xFFFFFFFF
);
// Score text
font_bind(UI_FONT);
let score_text = b"SCORE: 12345";
draw_text(score_text.as_ptr(), score_text.len() as u32,
30.0, 25.0, 12.0, 0xFFFFFFFF);
// Animated coin icon
let frame = ((elapsed_time() * 8.0) as u32) % 4;
draw_sprite_region(
10.0, 45.0, 16.0, 16.0,
(frame * 16) as f32, 16.0, 16.0, 16.0,
0xFFD700FF
);
// Re-enable depth for 3D
depth_test(1);
blend_mode(0);
}
}
}
Billboard Functions
Camera-facing quads for sprites in 3D space.
Billboard Modes
| Mode | Name | Description |
|---|---|---|
| 1 | Spherical | Always faces camera (all axes) |
| 2 | Cylindrical Y | Rotates around Y axis only (trees, NPCs) |
| 3 | Cylindrical X | Rotates around X axis only |
| 4 | Cylindrical Z | Rotates around Z axis only |
Functions
draw_billboard
Draws a camera-facing quad using the bound texture.
Signature:
#![allow(unused)]
fn main() {
fn draw_billboard(w: f32, h: f32, mode: u32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| w | f32 | Width in world units |
| h | f32 | Height in world units |
| mode | u32 | Billboard mode (1-4) |
| color | u32 | Tint color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
texture_bind(tree_sprite);
// Trees with cylindrical Y billboards
for tree in &trees {
push_identity();
push_translate(tree.x, tree.y, tree.z);
draw_billboard(2.0, 4.0, 2, 0xFFFFFFFF);
}
// Particles with spherical billboards
texture_bind(particle_sprite);
blend_mode(2); // Additive
for particle in &particles {
push_identity();
push_translate(particle.x, particle.y, particle.z);
draw_billboard(0.5, 0.5, 1, particle.color);
}
}
}
draw_billboard_region
Draws a billboard using a texture region (sprite sheet).
Signature:
#![allow(unused)]
fn main() {
fn draw_billboard_region(
w: f32, h: f32,
src_x: f32, src_y: f32, src_w: f32, src_h: f32,
mode: u32,
color: u32
)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| w, h | f32 | Size in world units |
| src_x, src_y | f32 | Source position in texture (pixels) |
| src_w, src_h | f32 | Source size in texture (pixels) |
| mode | u32 | Billboard mode (1-4) |
| color | u32 | Tint color |
Example:
#![allow(unused)]
fn main() {
fn render() {
texture_bind(enemy_sheet);
// Animated enemy sprite
let frame = ((elapsed_time() * 8.0) as u32) % 4;
push_identity();
push_translate(enemy.x, enemy.y + 1.0, enemy.z);
draw_billboard_region(
2.0, 2.0, // Size
(frame * 32) as f32, 0.0, 32.0, 32.0, // Animation frame
2, // Cylindrical Y
0xFFFFFFFF
);
}
}
Use Cases
Trees and Vegetation
#![allow(unused)]
fn main() {
fn render() {
texture_bind(vegetation_atlas);
blend_mode(1); // Alpha blend for transparency
cull_mode(0); // Double-sided
for tree in &trees {
push_identity();
push_translate(tree.x, tree.height * 0.5, tree.z);
// Different tree types from atlas
let src_x = (tree.type_id * 64) as f32;
draw_billboard_region(
tree.width, tree.height,
src_x, 0.0, 64.0, 128.0,
2, // Cylindrical Y - always upright
0xFFFFFFFF
);
}
}
}
Particle Effects
#![allow(unused)]
fn main() {
fn render() {
texture_bind(particle_texture);
blend_mode(2); // Additive for glow
depth_test(1);
for particle in &particles {
push_identity();
push_translate(particle.x, particle.y, particle.z);
// Spherical billboard - faces camera completely
let alpha = (particle.life * 255.0) as u32;
let color = (particle.color & 0xFFFFFF00) | alpha;
draw_billboard(particle.size, particle.size, 1, color);
}
}
}
NPCs and Enemies
#![allow(unused)]
fn main() {
fn render() {
texture_bind(npc_sheet);
blend_mode(1);
for npc in &npcs {
push_identity();
push_translate(npc.x, npc.y + 1.0, npc.z);
// Select animation frame based on direction and state
let frame = get_npc_frame(npc);
draw_billboard_region(
2.0, 2.0,
(frame % 4 * 32) as f32,
(frame / 4 * 32) as f32,
32.0, 32.0,
2, // Cylindrical Y
0xFFFFFFFF
);
}
}
}
Health Bars Above Enemies
#![allow(unused)]
fn main() {
fn render() {
// Draw enemies first
for enemy in &enemies {
draw_enemy(enemy);
}
// Then draw health bars as billboards
depth_test(0); // On top of everything
texture_bind(0); // No texture (solid color)
for enemy in &enemies {
if enemy.health < enemy.max_health {
push_identity();
push_translate(enemy.x, enemy.y + 2.5, enemy.z);
// Background
draw_billboard(1.0, 0.1, 1, 0x333333FF);
// Health fill
let ratio = enemy.health / enemy.max_health;
push_scale(ratio, 1.0, 1.0);
draw_billboard(1.0, 0.1, 1, 0x00FF00FF);
}
}
depth_test(1);
}
}
Complete Example
#![allow(unused)]
fn main() {
static mut TREE_TEX: u32 = 0;
static mut PARTICLE_TEX: u32 = 0;
struct Particle {
x: f32, y: f32, z: f32,
vx: f32, vy: f32, vz: f32,
life: f32,
size: f32,
}
static mut PARTICLES: [Particle; 100] = [Particle {
x: 0.0, y: 0.0, z: 0.0,
vx: 0.0, vy: 0.0, vz: 0.0,
life: 0.0, size: 0.0,
}; 100];
fn init() {
unsafe {
TREE_TEX = rom_texture(b"tree".as_ptr(), 4);
PARTICLE_TEX = rom_texture(b"spark".as_ptr(), 5);
}
}
fn update() {
unsafe {
let dt = delta_time();
for p in &mut PARTICLES {
if p.life > 0.0 {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 5.0 * dt; // Gravity
p.life -= dt;
}
}
}
}
fn render() {
unsafe {
// Trees - cylindrical billboards
texture_bind(TREE_TEX);
blend_mode(1);
cull_mode(0);
push_identity();
push_translate(5.0, 2.0, -5.0);
draw_billboard(2.0, 4.0, 2, 0xFFFFFFFF);
push_identity();
push_translate(-3.0, 1.5, -8.0);
draw_billboard(1.5, 3.0, 2, 0xFFFFFFFF);
// Particles - spherical billboards
texture_bind(PARTICLE_TEX);
blend_mode(2); // Additive
for p in &PARTICLES {
if p.life > 0.0 {
push_identity();
push_translate(p.x, p.y, p.z);
let alpha = (p.life.min(1.0) * 255.0) as u32;
draw_billboard(p.size, p.size, 1, 0xFFAA00FF & (0xFFFFFF00 | alpha));
}
}
blend_mode(0);
cull_mode(1);
}
}
}
See Also: Textures, Transforms
Sky Functions
Procedural sky rendering and environment lighting.
Sky Configuration
sky_set_colors
Sets the sky gradient colors.
Signature:
#![allow(unused)]
fn main() {
fn sky_set_colors(horizon_color: u32, zenith_color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| horizon_color | u32 | Color at horizon as 0xRRGGBBAA |
| zenith_color | u32 | Color at top of sky as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn init() {
// Bright day sky
sky_set_colors(0xB2D8F2FF, 0x3366B2FF);
}
fn render() {
// Dynamic time of day
let t = (elapsed_time() * 0.1) % 1.0;
if t < 0.5 {
// Day
sky_set_colors(0xB2D8F2FF, 0x3366B2FF);
} else {
// Sunset
sky_set_colors(0xFF804DFF, 0x4D1A80FF);
}
}
}
sky_set_sun
Configures the sun for sky rendering and lighting.
Signature:
#![allow(unused)]
fn main() {
fn sky_set_sun(dir_x: f32, dir_y: f32, dir_z: f32, color: u32, sharpness: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| dir_x, dir_y, dir_z | f32 | Sun direction (will be normalized) |
| color | u32 | Sun color as 0xRRGGBBAA |
| sharpness | f32 | Sun disc sharpness (0.0-1.0, higher = smaller sun) |
Example:
#![allow(unused)]
fn main() {
fn init() {
// Morning sun from the east
sky_set_sun(0.8, 0.3, 0.0, 0xFFE6B3FF, 0.95);
// Midday sun from above
sky_set_sun(0.0, 1.0, 0.0, 0xFFF2E6FF, 0.98);
// Evening sun from the west
sky_set_sun(-0.8, 0.2, 0.0, 0xFF9933FF, 0.90);
}
}
draw_sky
Renders the procedural sky as a background.
Signature:
#![allow(unused)]
fn main() {
fn draw_sky()
}
Important: Call first in your render() function, before any geometry.
Example:
#![allow(unused)]
fn main() {
fn render() {
// 1. Draw sky first (renders at far plane)
draw_sky();
// 2. Set up camera
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// 3. Draw scene (appears in front of sky)
draw_mesh(terrain);
draw_mesh(player);
}
}
Matcap Textures
matcap_set
Binds a matcap texture to a slot (Mode 1 only).
Signature:
#![allow(unused)]
fn main() {
fn matcap_set(slot: u32, texture: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Matcap slot (1-3) |
| texture | u32 | Texture handle |
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(1); // Matcap mode
// Load matcap textures
SHADOW_MATCAP = rom_texture(b"matcap_shadow".as_ptr(), 13);
HIGHLIGHT_MATCAP = rom_texture(b"matcap_highlight".as_ptr(), 16);
}
fn render() {
// Bind matcaps
matcap_set(1, SHADOW_MATCAP);
matcap_set(2, HIGHLIGHT_MATCAP);
// Configure blend modes
matcap_blend_mode(1, 0); // Multiply for shadows
matcap_blend_mode(2, 1); // Add for highlights
// Draw
texture_bind(character_albedo);
draw_mesh(character);
}
}
Sky Presets
Midday
#![allow(unused)]
fn main() {
fn setup_midday() {
sky_set_colors(0xB2CDE6FF, 0x4D80E6FF); // Light blue → mid blue
sky_set_sun(0.3, 0.8, 0.5, 0xFFF2E6FF, 0.98);
}
}
Sunset
#![allow(unused)]
fn main() {
fn setup_sunset() {
sky_set_colors(0xFF804DFF, 0x4D1A80FF); // Orange → purple
sky_set_sun(0.8, 0.2, 0.0, 0xFFE673FF, 0.95);
}
}
Overcast
#![allow(unused)]
fn main() {
fn setup_overcast() {
sky_set_colors(0x9999A6FF, 0x666673FF); // Gray gradient
sky_set_sun(0.0, 1.0, 0.0, 0x404040FF, 0.5); // Dim, diffuse
}
}
Night
#![allow(unused)]
fn main() {
fn setup_night() {
sky_set_colors(0x0D0D1AFF, 0x03030DFF); // Dark blue
sky_set_sun(0.5, 0.3, 0.0, 0x8888AAFF, 0.85); // Moon
}
}
Dawn
#![allow(unused)]
fn main() {
fn setup_dawn() {
sky_set_colors(0xFFB380FF, 0x4D6680FF); // Warm orange → cool blue
sky_set_sun(0.9, 0.1, 0.3, 0xFFCC99FF, 0.92);
}
}
Complete Example
#![allow(unused)]
fn main() {
static mut TIME_OF_DAY: f32 = 0.5; // 0.0 = midnight, 0.5 = noon, 1.0 = midnight
fn update() {
unsafe {
// Advance time
TIME_OF_DAY += delta_time() * 0.01; // 100 seconds per day
if TIME_OF_DAY >= 1.0 {
TIME_OF_DAY -= 1.0;
}
}
}
fn render() {
unsafe {
// Calculate sun position based on time
let sun_angle = TIME_OF_DAY * 6.28318; // Full rotation
let sun_y = sun_angle.sin();
let sun_x = sun_angle.cos();
// Interpolate sky colors based on time
let (horizon, zenith, sun_color) = if TIME_OF_DAY < 0.25 {
// Night to dawn
let t = TIME_OF_DAY / 0.25;
(
lerp_color(0x0D0D1AFF, 0xFFB380FF, t),
lerp_color(0x03030DFF, 0x4D6680FF, t),
lerp_color(0x333355FF, 0xFFCC99FF, t),
)
} else if TIME_OF_DAY < 0.5 {
// Dawn to noon
let t = (TIME_OF_DAY - 0.25) / 0.25;
(
lerp_color(0xFFB380FF, 0xB2D8F2FF, t),
lerp_color(0x4D6680FF, 0x3366B2FF, t),
lerp_color(0xFFCC99FF, 0xFFF2E6FF, t),
)
} else if TIME_OF_DAY < 0.75 {
// Noon to dusk
let t = (TIME_OF_DAY - 0.5) / 0.25;
(
lerp_color(0xB2D8F2FF, 0xFF804DFF, t),
lerp_color(0x3366B2FF, 0x4D1A80FF, t),
lerp_color(0xFFF2E6FF, 0xFFE673FF, t),
)
} else {
// Dusk to night
let t = (TIME_OF_DAY - 0.75) / 0.25;
(
lerp_color(0xFF804DFF, 0x0D0D1AFF, t),
lerp_color(0x4D1A80FF, 0x03030DFF, t),
lerp_color(0xFFE673FF, 0x333355FF, t),
)
};
sky_set_colors(horizon, zenith);
sky_set_sun(sun_x, sun_y.max(0.1), 0.3, sun_color, 0.95);
// Draw sky first
draw_sky();
// Set up camera and draw scene
camera_set(0.0, 5.0, 15.0, 0.0, 0.0, 0.0);
draw_mesh(terrain);
draw_mesh(buildings);
}
}
}
See Also: Lighting, Materials, Render Modes Guide
Audio Functions
Sound effects and music playback with 16 channels.
Loading Sounds
load_sound
Loads a sound from WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn load_sound(data_ptr: *const u8, byte_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to PCM audio data |
| byte_len | u32 | Size of data in bytes |
Returns: Sound handle (non-zero on success)
Audio Format: 22.05 kHz, 16-bit signed, mono PCM
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static JUMP_DATA: &[u8] = include_bytes!("jump.raw");
static mut JUMP_SFX: u32 = 0;
fn init() {
unsafe {
JUMP_SFX = load_sound(JUMP_DATA.as_ptr(), JUMP_DATA.len() as u32);
}
}
}
Note: Prefer rom_sound() for sounds bundled in the ROM data pack.
Sound Effects
play_sound
Plays a sound on the next available channel.
Signature:
#![allow(unused)]
fn main() {
fn play_sound(sound: u32, volume: f32, pan: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| sound | u32 | Sound handle |
| volume | f32 | Volume (0.0-1.0) |
| pan | f32 | Stereo pan (-1.0 = left, 0.0 = center, 1.0 = right) |
Example:
#![allow(unused)]
fn main() {
fn update() {
if button_pressed(0, BUTTON_A) != 0 {
play_sound(JUMP_SFX, 1.0, 0.0);
}
// Positional audio
let dx = enemy.x - player.x;
let pan = (dx / 20.0).clamp(-1.0, 1.0);
let dist = ((enemy.x - player.x).powi(2) + (enemy.z - player.z).powi(2)).sqrt();
let vol = (1.0 - dist / 50.0).max(0.0);
play_sound(ENEMY_GROWL, vol, pan);
}
}
channel_play
Plays a sound on a specific channel with loop control.
Signature:
#![allow(unused)]
fn main() {
fn channel_play(channel: u32, sound: u32, volume: f32, pan: f32, looping: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
| sound | u32 | Sound handle |
| volume | f32 | Volume (0.0-1.0) |
| pan | f32 | Stereo pan (-1.0 to 1.0) |
| looping | u32 | 1 to loop, 0 for one-shot |
Example:
#![allow(unused)]
fn main() {
fn update() {
// Engine sound on dedicated channel (looping)
if vehicle.engine_on && !ENGINE_PLAYING {
channel_play(0, ENGINE_SFX, 0.8, 0.0, 1);
ENGINE_PLAYING = true;
}
// Adjust engine pitch based on speed
if ENGINE_PLAYING {
let vol = 0.5 + vehicle.speed * 0.005;
channel_set(0, vol.min(1.0), 0.0);
}
}
}
channel_set
Updates volume and pan for a playing channel.
Signature:
#![allow(unused)]
fn main() {
fn channel_set(channel: u32, volume: f32, pan: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
| volume | f32 | New volume (0.0-1.0) |
| pan | f32 | New stereo pan |
Example:
#![allow(unused)]
fn main() {
fn update() {
// Fade out channel 0
if fading {
fade_vol -= delta_time() * 0.5;
if fade_vol <= 0.0 {
channel_stop(0);
fading = false;
} else {
channel_set(0, fade_vol, 0.0);
}
}
}
}
channel_stop
Stops playback on a channel.
Signature:
#![allow(unused)]
fn main() {
fn channel_stop(channel: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
Example:
#![allow(unused)]
fn main() {
fn update() {
if vehicle.engine_off {
channel_stop(0);
ENGINE_PLAYING = false;
}
}
}
Music
Music uses a dedicated stereo channel, separate from the 16 SFX channels.
music_play
Plays background music (looping).
Signature:
#![allow(unused)]
fn main() {
fn music_play(sound: u32, volume: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| sound | u32 | Sound handle |
| volume | f32 | Volume (0.0-1.0) |
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
MENU_MUSIC = rom_sound(b"menu_bgm".as_ptr(), 8);
GAME_MUSIC = rom_sound(b"game_bgm".as_ptr(), 8);
}
}
fn start_game() {
music_play(GAME_MUSIC, 0.7);
}
}
music_stop
Stops the currently playing music.
Signature:
#![allow(unused)]
fn main() {
fn music_stop()
}
Example:
#![allow(unused)]
fn main() {
fn game_over() {
music_stop();
play_sound(GAME_OVER_SFX, 1.0, 0.0);
}
}
music_set_volume
Changes the music volume.
Signature:
#![allow(unused)]
fn main() {
fn music_set_volume(volume: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| volume | f32 | New volume (0.0-1.0) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Duck music during dialogue
if dialogue_active {
music_set_volume(0.3);
} else {
music_set_volume(0.7);
}
}
}
Audio Architecture
- 16 SFX channels (0-15) for sound effects
- 1 Music channel (separate) for background music
- 22.05 kHz sample rate, 16-bit mono PCM
- Rollback-safe: Audio state is part of rollback snapshots
- Per-frame audio generation with ring buffer
Complete Example
#![allow(unused)]
fn main() {
static mut JUMP_SFX: u32 = 0;
static mut LAND_SFX: u32 = 0;
static mut COIN_SFX: u32 = 0;
static mut MUSIC: u32 = 0;
static mut AMBIENT: u32 = 0;
fn init() {
unsafe {
// Load sounds from ROM
JUMP_SFX = rom_sound(b"jump".as_ptr(), 4);
LAND_SFX = rom_sound(b"land".as_ptr(), 4);
COIN_SFX = rom_sound(b"coin".as_ptr(), 4);
MUSIC = rom_sound(b"level1".as_ptr(), 6);
AMBIENT = rom_sound(b"wind".as_ptr(), 4);
// Start music and ambient
music_play(MUSIC, 0.6);
channel_play(15, AMBIENT, 0.3, 0.0, 1); // Looping ambient
}
}
fn update() {
unsafe {
// Jump sound
if button_pressed(0, BUTTON_A) != 0 && player.on_ground {
play_sound(JUMP_SFX, 0.8, 0.0);
}
// Land sound
if player.just_landed {
play_sound(LAND_SFX, 0.6, 0.0);
}
// Coin pickup with positional audio
for coin in &coins {
if coin.just_collected {
let dx = coin.x - player.x;
let pan = (dx / 10.0).clamp(-1.0, 1.0);
play_sound(COIN_SFX, 1.0, pan);
}
}
// Pause menu - duck audio
if game_paused {
music_set_volume(0.2);
channel_set(15, 0.1, 0.0);
} else {
music_set_volume(0.6);
channel_set(15, 0.3, 0.0);
}
}
}
}
See Also: rom_sound
Save Data Functions
Persistent storage for game saves (8 slots, 64KB each).
Overview
- 8 save slots (indices 0-7)
- 64KB maximum per slot
- Data persists across sessions
- Stored locally per-game
Functions
save
Saves data to a slot.
Signature:
#![allow(unused)]
fn main() {
fn save(slot: u32, data_ptr: *const u8, data_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-7) |
| data_ptr | *const u8 | Pointer to data to save |
| data_len | u32 | Size of data in bytes |
Returns:
| Value | Meaning |
|---|---|
| 0 | Success |
| 1 | Invalid slot |
| 2 | Data too large (>64KB) |
Example:
#![allow(unused)]
fn main() {
fn save_game() {
unsafe {
let save_data = SaveData {
level: CURRENT_LEVEL,
score: SCORE,
health: PLAYER_HEALTH,
position_x: PLAYER_X,
position_y: PLAYER_Y,
checksum: 0,
};
// Calculate checksum
let bytes = &save_data as *const SaveData as *const u8;
let size = core::mem::size_of::<SaveData>();
let result = save(0, bytes, size as u32);
if result == 0 {
show_message(b"Game Saved!");
}
}
}
}
load
Loads data from a slot.
Signature:
#![allow(unused)]
fn main() {
fn load(slot: u32, data_ptr: *mut u8, max_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-7) |
| data_ptr | *mut u8 | Destination buffer |
| max_len | u32 | Maximum bytes to read |
Returns: Number of bytes read (0 if empty or error)
Example:
#![allow(unused)]
fn main() {
fn load_game() -> bool {
unsafe {
let mut save_data = SaveData::default();
let bytes = &mut save_data as *mut SaveData as *mut u8;
let size = core::mem::size_of::<SaveData>();
let read = load(0, bytes, size as u32);
if read == size as u32 {
// Validate checksum
if validate_checksum(&save_data) {
CURRENT_LEVEL = save_data.level;
SCORE = save_data.score;
PLAYER_HEALTH = save_data.health;
PLAYER_X = save_data.position_x;
PLAYER_Y = save_data.position_y;
return true;
}
}
false
}
}
}
delete
Deletes data in a save slot.
Signature:
#![allow(unused)]
fn main() {
fn delete(slot: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-7) |
Returns:
| Value | Meaning |
|---|---|
| 0 | Success |
| 1 | Invalid slot |
Example:
#![allow(unused)]
fn main() {
fn delete_save(slot: u32) {
unsafe {
if delete(slot) == 0 {
show_message(b"Save deleted");
}
}
}
}
Save Data Patterns
Simple Struct Save
#![allow(unused)]
fn main() {
#[repr(C)]
struct SaveData {
magic: u32, // Identify valid saves
version: u32, // Save format version
level: u32,
score: u32,
health: f32,
position: [f32; 3],
inventory: [u8; 64],
checksum: u32,
}
impl SaveData {
const MAGIC: u32 = 0x53415645; // "SAVE"
fn new() -> Self {
Self {
magic: Self::MAGIC,
version: 1,
level: 0,
score: 0,
health: 100.0,
position: [0.0; 3],
inventory: [0; 64],
checksum: 0,
}
}
fn calculate_checksum(&self) -> u32 {
// Simple checksum (XOR all bytes except checksum field)
let bytes = self as *const Self as *const u8;
let len = core::mem::size_of::<Self>() - 4; // Exclude checksum
let mut sum: u32 = 0;
for i in 0..len {
unsafe { sum ^= (*bytes.add(i) as u32) << ((i % 4) * 8); }
}
sum
}
fn is_valid(&self) -> bool {
self.magic == Self::MAGIC && self.checksum == self.calculate_checksum()
}
}
}
Multiple Save Slots UI
#![allow(unused)]
fn main() {
fn render_save_menu() {
unsafe {
draw_text(b"SAVE SLOTS".as_ptr(), 10, 200.0, 50.0, 24.0, 0xFFFFFFFF);
for slot in 0..8 {
let mut buffer = [0u8; 128];
let read = load(slot, buffer.as_mut_ptr(), 128);
let y = 100.0 + (slot as f32) * 40.0;
if read > 0 {
// Parse save info
let save = &*(buffer.as_ptr() as *const SaveData);
if save.is_valid() {
// Show save info
let text = format_save_info(slot, save.level, save.score);
draw_text(text.as_ptr(), text.len() as u32, 100.0, y, 16.0, 0xFFFFFFFF);
} else {
draw_text(b"[Corrupted]".as_ptr(), 11, 100.0, y, 16.0, 0xFF4444FF);
}
} else {
draw_text(b"[Empty]".as_ptr(), 7, 100.0, y, 16.0, 0x888888FF);
}
// Highlight selected slot
if slot == SELECTED_SLOT {
draw_rect(90.0, y - 5.0, 300.0, 30.0, 0xFFFFFF33);
}
}
}
}
}
Auto-Save
#![allow(unused)]
fn main() {
static mut LAST_SAVE_TIME: f32 = 0.0;
const AUTO_SAVE_INTERVAL: f32 = 60.0; // Every 60 seconds
fn update() {
unsafe {
// Auto-save every 60 seconds
if elapsed_time() - LAST_SAVE_TIME > AUTO_SAVE_INTERVAL {
auto_save();
LAST_SAVE_TIME = elapsed_time();
}
}
}
fn auto_save() {
unsafe {
// Use slot 7 for auto-save
let mut save = create_save_data();
save.checksum = save.calculate_checksum();
let bytes = &save as *const SaveData as *const u8;
let size = core::mem::size_of::<SaveData>();
save(7, bytes, size as u32);
}
}
}
Complete Example
#![allow(unused)]
fn main() {
#[repr(C)]
struct GameSave {
magic: u32,
version: u32,
level: u32,
score: u32,
lives: u32,
player_x: f32,
player_y: f32,
unlocked_levels: u32, // Bitmask
high_scores: [u32; 10],
checksum: u32,
}
static mut CURRENT_SAVE: GameSave = GameSave {
magic: 0x47414D45,
version: 1,
level: 1,
score: 0,
lives: 3,
player_x: 0.0,
player_y: 0.0,
unlocked_levels: 1,
high_scores: [0; 10],
checksum: 0,
};
fn save_to_slot(slot: u32) -> bool {
unsafe {
CURRENT_SAVE.checksum = calculate_checksum(&CURRENT_SAVE);
let bytes = &CURRENT_SAVE as *const GameSave as *const u8;
let size = core::mem::size_of::<GameSave>();
save(slot, bytes, size as u32) == 0
}
}
fn load_from_slot(slot: u32) -> bool {
unsafe {
let bytes = &mut CURRENT_SAVE as *mut GameSave as *mut u8;
let size = core::mem::size_of::<GameSave>();
let read = load(slot, bytes, size as u32);
if read == size as u32 {
let expected = calculate_checksum(&CURRENT_SAVE);
if CURRENT_SAVE.checksum == expected && CURRENT_SAVE.magic == 0x47414D45 {
return true;
}
}
// Reset to defaults on failure
CURRENT_SAVE = GameSave::default();
false
}
}
}
See Also: System Functions
ROM Data Pack Functions
Load assets from the ROM’s bundled data pack.
Overview
Assets loaded via rom_* functions go directly to VRAM/audio memory, bypassing WASM linear memory for efficient rollback. Only u32 handles are stored in your game’s RAM.
All rom_* functions are init-only — call in init(), not update() or render().
Asset Loading
rom_texture
Loads a texture from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_texture(id_ptr: *const u8, id_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID string |
| id_len | u32 | Length of asset ID |
Returns: Texture handle (non-zero on success, 0 if not found)
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PLAYER_TEX = rom_texture(b"player".as_ptr(), 6);
ENEMY_TEX = rom_texture(b"enemy_sheet".as_ptr(), 11);
TERRAIN_TEX = rom_texture(b"terrain".as_ptr(), 7);
}
}
}
rom_mesh
Loads a mesh from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_mesh(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
LEVEL_MESH = rom_mesh(b"level1".as_ptr(), 6);
PLAYER_MESH = rom_mesh(b"player_model".as_ptr(), 12);
ENEMY_MESH = rom_mesh(b"enemy".as_ptr(), 5);
}
}
}
rom_skeleton
Loads a skeleton (inverse bind matrices) from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_skeleton(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Skeleton handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PLAYER_SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
}
}
}
rom_font
Loads a bitmap font from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_font(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Font handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
UI_FONT = rom_font(b"ui_font".as_ptr(), 7);
TITLE_FONT = rom_font(b"title_font".as_ptr(), 10);
}
}
}
rom_sound
Loads a sound from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_sound(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Sound handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
JUMP_SFX = rom_sound(b"jump".as_ptr(), 4);
COIN_SFX = rom_sound(b"coin".as_ptr(), 4);
MUSIC = rom_sound(b"level1_bgm".as_ptr(), 10);
}
}
}
Raw Data Access
For custom data formats (level data, dialog scripts, etc.).
rom_data_len
Gets the size of raw data in the pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_data_len(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Size in bytes (0 if not found)
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
let len = rom_data_len(b"level1_map".as_ptr(), 10);
if len > 0 {
// Allocate buffer and load
}
}
}
}
rom_data
Copies raw data from the pack into WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn rom_data(id_ptr: *const u8, id_len: u32, out_ptr: *mut u8, max_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID |
| id_len | u32 | Length of asset ID |
| out_ptr | *mut u8 | Destination buffer in WASM memory |
| max_len | u32 | Maximum bytes to copy |
Returns: Bytes copied (0 if not found or buffer too small)
Example:
#![allow(unused)]
fn main() {
static mut LEVEL_DATA: [u8; 4096] = [0; 4096];
fn init() {
unsafe {
let len = rom_data_len(b"level1".as_ptr(), 6);
if len <= 4096 {
rom_data(b"level1".as_ptr(), 6, LEVEL_DATA.as_mut_ptr(), 4096);
parse_level(&LEVEL_DATA[..len as usize]);
}
}
}
}
Game Manifest (ember.toml)
Assets are bundled using the ember.toml manifest:
[game]
id = "my-game"
title = "My Awesome Game"
author = "Developer Name"
version = "1.0.0"
render_mode = 2
[[assets.textures]]
id = "player"
path = "assets/player.png"
[[assets.textures]]
id = "enemy_sheet"
path = "assets/enemies.png"
[[assets.meshes]]
id = "level1"
path = "assets/level1.ewzmesh"
[[assets.meshes]]
id = "player_model"
path = "assets/player.ewzmesh"
[[assets.skeletons]]
id = "player_rig"
path = "assets/player.ewzskel"
[[assets.animations]]
id = "walk"
path = "assets/walk.ewzanim"
[[assets.sounds]]
id = "jump"
path = "assets/sfx/jump.raw"
[[assets.sounds]]
id = "level1_bgm"
path = "assets/music/level1.raw"
[[assets.fonts]]
id = "ui_font"
path = "assets/fonts/ui.ewzfont"
[[assets.data]]
id = "level1"
path = "assets/levels/level1.bin"
Build with:
ember build
ember pack # Creates .ewz ROM file
Memory Model
ROM (12MB) RAM (4MB) VRAM (4MB)
┌────────────┐ ┌────────────┐ ┌────────────┐
│ WASM code │ │ Game state │ │ Textures │
│ (50-200KB) │ │ (handles) │ │ (from ROM) │
├────────────┤ │ │ ├────────────┤
│ Data Pack: │ │ u32 tex_id │─────▶│ Uploaded │
│ - textures │ │ u32 mesh_id│─────▶│ GPU data │
│ - meshes │ │ u32 snd_id │ └────────────┘
│ - sounds │ │ │
│ - fonts │ │ Level data │◀──── rom_data()
│ - data │ │ (copied) │ copies here
└────────────┘ └────────────┘
Key points:
rom_texture/mesh/sound/font→ Data stays in host memory, only handle in WASM RAMrom_data→ Data copied to WASM RAM (use sparingly for level data, etc.)- Only WASM RAM (4MB) is snapshotted for rollback
Complete Example
#![allow(unused)]
fn main() {
// Asset handles
static mut PLAYER_TEX: u32 = 0;
static mut PLAYER_MESH: u32 = 0;
static mut PLAYER_SKEL: u32 = 0;
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut JUMP_SFX: u32 = 0;
static mut MUSIC: u32 = 0;
static mut UI_FONT: u32 = 0;
// Level data (copied to WASM memory)
static mut LEVEL_DATA: [u8; 8192] = [0; 8192];
static mut LEVEL_SIZE: u32 = 0;
fn init() {
unsafe {
// Graphics assets → VRAM
PLAYER_TEX = rom_texture(b"player".as_ptr(), 6);
PLAYER_MESH = rom_mesh(b"player".as_ptr(), 6);
PLAYER_SKEL = rom_skeleton(b"player_rig".as_ptr(), 10);
// Animations → GPU storage
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
// Audio → Audio memory
JUMP_SFX = rom_sound(b"jump".as_ptr(), 4);
MUSIC = rom_sound(b"music".as_ptr(), 5);
// Font → VRAM
UI_FONT = rom_font(b"ui".as_ptr(), 2);
// Level data → WASM RAM (copied)
LEVEL_SIZE = rom_data_len(b"level1".as_ptr(), 6);
if LEVEL_SIZE <= 8192 {
rom_data(b"level1".as_ptr(), 6, LEVEL_DATA.as_mut_ptr(), 8192);
}
// Start music
music_play(MUSIC, 0.7);
}
}
fn render() {
unsafe {
// Use loaded assets
texture_bind(PLAYER_TEX);
skeleton_bind(PLAYER_SKEL);
keyframe_bind(WALK_ANIM, frame);
draw_mesh(PLAYER_MESH);
font_bind(UI_FONT);
draw_text(b"SCORE: 0".as_ptr(), 8, 10.0, 10.0, 16.0, 0xFFFFFFFF);
}
}
}
See Also: Textures, Meshes, Audio, Animation
Debug Functions
Runtime value inspection via the F3 debug panel.
Overview
The debug system allows you to register game variables for live editing and monitoring. Press F3 to open the debug panel during development.
Features:
- Live value editing (sliders, color pickers)
- Read-only watches
- Grouped organization
- Frame control (pause, step, time scale)
- Zero overhead in release builds
Value Registration
Register editable values in init(). The debug panel will show controls for these values.
debug_register_i32
Registers an editable 32-bit signed integer.
Signature:
#![allow(unused)]
fn main() {
fn debug_register_i32(name_ptr: *const u8, name_len: u32, ptr: *const i32)
}
Example:
#![allow(unused)]
fn main() {
static mut ENEMY_COUNT: i32 = 5;
fn init() {
unsafe {
debug_register_i32(b"Enemy Count".as_ptr(), 11, &ENEMY_COUNT);
}
}
}
debug_register_f32
Registers an editable 32-bit float.
Signature:
#![allow(unused)]
fn main() {
fn debug_register_f32(name_ptr: *const u8, name_len: u32, ptr: *const f32)
}
Example:
#![allow(unused)]
fn main() {
static mut GRAVITY: f32 = 9.8;
static mut JUMP_FORCE: f32 = 15.0;
fn init() {
unsafe {
debug_register_f32(b"Gravity".as_ptr(), 7, &GRAVITY);
debug_register_f32(b"Jump Force".as_ptr(), 10, &JUMP_FORCE);
}
}
}
debug_register_bool
Registers an editable boolean (checkbox).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_bool(name_ptr: *const u8, name_len: u32, ptr: *const u8)
}
Example:
#![allow(unused)]
fn main() {
static mut GOD_MODE: u8 = 0; // 0 = false, 1 = true
fn init() {
unsafe {
debug_register_bool(b"God Mode".as_ptr(), 8, &GOD_MODE);
}
}
}
debug_register_u8 / u16 / u32
Registers unsigned integers.
#![allow(unused)]
fn main() {
fn debug_register_u8(name_ptr: *const u8, name_len: u32, ptr: *const u8)
fn debug_register_u16(name_ptr: *const u8, name_len: u32, ptr: *const u16)
fn debug_register_u32(name_ptr: *const u8, name_len: u32, ptr: *const u32)
}
debug_register_i8 / i16
Registers signed integers.
#![allow(unused)]
fn main() {
fn debug_register_i8(name_ptr: *const u8, name_len: u32, ptr: *const i8)
fn debug_register_i16(name_ptr: *const u8, name_len: u32, ptr: *const i16)
}
Range-Constrained Registration
debug_register_i32_range
Registers an integer with min/max bounds (slider).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_i32_range(
name_ptr: *const u8, name_len: u32,
ptr: *const i32,
min: i32, max: i32
)
}
Example:
#![allow(unused)]
fn main() {
static mut DIFFICULTY: i32 = 2;
fn init() {
unsafe {
debug_register_i32_range(b"Difficulty".as_ptr(), 10, &DIFFICULTY, 1, 5);
}
}
}
debug_register_f32_range
Registers a float with min/max bounds.
Signature:
#![allow(unused)]
fn main() {
fn debug_register_f32_range(
name_ptr: *const u8, name_len: u32,
ptr: *const f32,
min: f32, max: f32
)
}
Example:
#![allow(unused)]
fn main() {
static mut PLAYER_SPEED: f32 = 5.0;
fn init() {
unsafe {
debug_register_f32_range(b"Speed".as_ptr(), 5, &PLAYER_SPEED, 0.0, 20.0);
}
}
}
debug_register_u8_range / u16_range / i16_range
#![allow(unused)]
fn main() {
fn debug_register_u8_range(name_ptr: *const u8, name_len: u32, ptr: *const u8, min: u32, max: u32)
fn debug_register_u16_range(name_ptr: *const u8, name_len: u32, ptr: *const u16, min: u32, max: u32)
fn debug_register_i16_range(name_ptr: *const u8, name_len: u32, ptr: *const i16, min: i32, max: i32)
}
Compound Types
debug_register_vec2
Registers a 2D vector (two f32s).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_vec2(name_ptr: *const u8, name_len: u32, ptr: *const f32)
}
Example:
#![allow(unused)]
fn main() {
static mut PLAYER_POS: [f32; 2] = [0.0, 0.0];
fn init() {
unsafe {
debug_register_vec2(b"Player Pos".as_ptr(), 10, PLAYER_POS.as_ptr());
}
}
}
debug_register_vec3
Registers a 3D vector (three f32s).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_vec3(name_ptr: *const u8, name_len: u32, ptr: *const f32)
}
debug_register_rect
Registers a rectangle (x, y, width, height as four f32s).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_rect(name_ptr: *const u8, name_len: u32, ptr: *const f32)
}
debug_register_color
Registers a color (RGBA as four u8s).
Signature:
#![allow(unused)]
fn main() {
fn debug_register_color(name_ptr: *const u8, name_len: u32, ptr: *const u8)
}
Example:
#![allow(unused)]
fn main() {
static mut TINT_COLOR: [u8; 4] = [255, 255, 255, 255];
fn init() {
unsafe {
debug_register_color(b"Tint".as_ptr(), 4, TINT_COLOR.as_ptr());
}
}
}
Fixed-Point Registration
For games using fixed-point math.
#![allow(unused)]
fn main() {
fn debug_register_fixed_i16_q8(name_ptr: *const u8, name_len: u32, ptr: *const i16)
fn debug_register_fixed_i32_q8(name_ptr: *const u8, name_len: u32, ptr: *const i32)
fn debug_register_fixed_i32_q16(name_ptr: *const u8, name_len: u32, ptr: *const i32)
fn debug_register_fixed_i32_q24(name_ptr: *const u8, name_len: u32, ptr: *const i32)
}
Watch Functions (Read-Only)
Watches display values without allowing editing.
#![allow(unused)]
fn main() {
fn debug_watch_i8(name_ptr: *const u8, name_len: u32, ptr: *const i8)
fn debug_watch_i16(name_ptr: *const u8, name_len: u32, ptr: *const i16)
fn debug_watch_i32(name_ptr: *const u8, name_len: u32, ptr: *const i32)
fn debug_watch_u8(name_ptr: *const u8, name_len: u32, ptr: *const u8)
fn debug_watch_u16(name_ptr: *const u8, name_len: u32, ptr: *const u16)
fn debug_watch_u32(name_ptr: *const u8, name_len: u32, ptr: *const u32)
fn debug_watch_f32(name_ptr: *const u8, name_len: u32, ptr: *const f32)
fn debug_watch_bool(name_ptr: *const u8, name_len: u32, ptr: *const u8)
fn debug_watch_vec2(name_ptr: *const u8, name_len: u32, ptr: *const f32)
fn debug_watch_vec3(name_ptr: *const u8, name_len: u32, ptr: *const f32)
fn debug_watch_rect(name_ptr: *const u8, name_len: u32, ptr: *const f32)
fn debug_watch_color(name_ptr: *const u8, name_len: u32, ptr: *const u8)
}
Example:
#![allow(unused)]
fn main() {
static mut FRAME_COUNT: u32 = 0;
static mut FPS: f32 = 0.0;
fn init() {
unsafe {
debug_watch_u32(b"Frame".as_ptr(), 5, &FRAME_COUNT);
debug_watch_f32(b"FPS".as_ptr(), 3, &FPS);
}
}
}
Grouping
debug_group_begin
Starts a collapsible group in the debug panel.
Signature:
#![allow(unused)]
fn main() {
fn debug_group_begin(name_ptr: *const u8, name_len: u32)
}
debug_group_end
Ends the current group.
Signature:
#![allow(unused)]
fn main() {
fn debug_group_end()
}
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
debug_group_begin(b"Player".as_ptr(), 6);
debug_register_vec3(b"Position".as_ptr(), 8, PLAYER_POS.as_ptr());
debug_register_f32(b"Health".as_ptr(), 6, &PLAYER_HEALTH);
debug_register_f32(b"Speed".as_ptr(), 5, &PLAYER_SPEED);
debug_group_end();
debug_group_begin(b"Physics".as_ptr(), 7);
debug_register_f32(b"Gravity".as_ptr(), 7, &GRAVITY);
debug_register_f32(b"Friction".as_ptr(), 8, &FRICTION);
debug_group_end();
}
}
}
Frame Control
debug_is_paused
Check if game is paused via debug panel.
Signature:
#![allow(unused)]
fn main() {
fn debug_is_paused() -> i32
}
Returns: 1 if paused, 0 otherwise
debug_get_time_scale
Get the current time scale.
Signature:
#![allow(unused)]
fn main() {
fn debug_get_time_scale() -> f32
}
Returns: Time scale (1.0 = normal, 0.5 = half speed, 2.0 = double)
Example:
#![allow(unused)]
fn main() {
fn update() {
unsafe {
if debug_is_paused() != 0 {
return; // Skip update when paused
}
let dt = delta_time() * debug_get_time_scale();
// Use scaled delta time
}
}
}
Debug Keyboard Shortcuts
| Key | Action |
|---|---|
| F3 | Toggle debug panel |
| F5 | Pause/unpause |
| F6 | Step one frame (while paused) |
| F7 | Decrease time scale |
| F8 | Increase time scale |
Complete Example
#![allow(unused)]
fn main() {
// Game state
static mut PLAYER_X: f32 = 0.0;
static mut PLAYER_Y: f32 = 0.0;
static mut PLAYER_VEL_X: f32 = 0.0;
static mut PLAYER_VEL_Y: f32 = 0.0;
static mut PLAYER_HEALTH: f32 = 100.0;
// Tuning parameters
static mut MOVE_SPEED: f32 = 5.0;
static mut JUMP_FORCE: f32 = 12.0;
static mut GRAVITY: f32 = 25.0;
static mut FRICTION: f32 = 0.9;
// Debug
static mut GOD_MODE: u8 = 0;
static mut SHOW_HITBOXES: u8 = 0;
static mut ENEMY_COUNT: i32 = 5;
fn init() {
unsafe {
// Player group
debug_group_begin(b"Player".as_ptr(), 6);
debug_watch_f32(b"X".as_ptr(), 1, &PLAYER_X);
debug_watch_f32(b"Y".as_ptr(), 1, &PLAYER_Y);
debug_watch_f32(b"Vel X".as_ptr(), 5, &PLAYER_VEL_X);
debug_watch_f32(b"Vel Y".as_ptr(), 5, &PLAYER_VEL_Y);
debug_register_f32_range(b"Health".as_ptr(), 6, &PLAYER_HEALTH, 0.0, 100.0);
debug_group_end();
// Physics group
debug_group_begin(b"Physics".as_ptr(), 7);
debug_register_f32_range(b"Move Speed".as_ptr(), 10, &MOVE_SPEED, 1.0, 20.0);
debug_register_f32_range(b"Jump Force".as_ptr(), 10, &JUMP_FORCE, 5.0, 30.0);
debug_register_f32_range(b"Gravity".as_ptr(), 7, &GRAVITY, 10.0, 50.0);
debug_register_f32_range(b"Friction".as_ptr(), 8, &FRICTION, 0.5, 1.0);
debug_group_end();
// Debug options
debug_group_begin(b"Debug".as_ptr(), 5);
debug_register_bool(b"God Mode".as_ptr(), 8, &GOD_MODE);
debug_register_bool(b"Show Hitboxes".as_ptr(), 13, &SHOW_HITBOXES);
debug_register_i32_range(b"Enemy Count".as_ptr(), 11, &ENEMY_COUNT, 0, 20);
debug_group_end();
}
}
fn update() {
unsafe {
// Respect debug pause
if debug_is_paused() != 0 {
return;
}
let dt = delta_time() * debug_get_time_scale();
// Use tunable values
PLAYER_VEL_Y += GRAVITY * dt;
PLAYER_VEL_X *= FRICTION;
if button_held(0, BUTTON_RIGHT) != 0 {
PLAYER_VEL_X = MOVE_SPEED;
}
if button_held(0, BUTTON_LEFT) != 0 {
PLAYER_VEL_X = -MOVE_SPEED;
}
if button_pressed(0, BUTTON_A) != 0 {
PLAYER_VEL_Y = -JUMP_FORCE;
}
PLAYER_X += PLAYER_VEL_X * dt;
PLAYER_Y += PLAYER_VEL_Y * dt;
}
}
}
See Also: System Functions
Dither Patterns Reference
Quick reference for Bayer dithering matrices. Currently Emberware ZX uses 4x4 only (compile-time), but these are available for future work.
2x2 Bayer Matrix (4 levels)
const BAYER_2X2: array<f32, 4> = array(
0.0/4.0, 2.0/4.0,
3.0/4.0, 1.0/4.0,
);
4x4 Bayer Matrix (16 levels) — Current Default
const BAYER_4X4: array<f32, 16> = array(
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0,
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0,
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0,
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0,
);
8x8 Bayer Matrix (64 levels)
const BAYER_8X8: array<f32, 64> = array(
0.0/64.0, 32.0/64.0, 8.0/64.0, 40.0/64.0, 2.0/64.0, 34.0/64.0, 10.0/64.0, 42.0/64.0,
48.0/64.0, 16.0/64.0, 56.0/64.0, 24.0/64.0, 50.0/64.0, 18.0/64.0, 58.0/64.0, 26.0/64.0,
12.0/64.0, 44.0/64.0, 4.0/64.0, 36.0/64.0, 14.0/64.0, 46.0/64.0, 6.0/64.0, 38.0/64.0,
60.0/64.0, 28.0/64.0, 52.0/64.0, 20.0/64.0, 62.0/64.0, 30.0/64.0, 54.0/64.0, 22.0/64.0,
3.0/64.0, 35.0/64.0, 11.0/64.0, 43.0/64.0, 1.0/64.0, 33.0/64.0, 9.0/64.0, 41.0/64.0,
51.0/64.0, 19.0/64.0, 59.0/64.0, 27.0/64.0, 49.0/64.0, 17.0/64.0, 57.0/64.0, 25.0/64.0,
15.0/64.0, 47.0/64.0, 7.0/64.0, 39.0/64.0, 13.0/64.0, 45.0/64.0, 5.0/64.0, 37.0/64.0,
63.0/64.0, 31.0/64.0, 55.0/64.0, 23.0/64.0, 61.0/64.0, 29.0/64.0, 53.0/64.0, 21.0/64.0,
);
16x16 Bayer Matrix (256 levels)
const BAYER_16X16: array<f32, 256> = array(
0.0/256.0, 128.0/256.0, 32.0/256.0, 160.0/256.0, 8.0/256.0, 136.0/256.0, 40.0/256.0, 168.0/256.0, 2.0/256.0, 130.0/256.0, 34.0/256.0, 162.0/256.0, 10.0/256.0, 138.0/256.0, 42.0/256.0, 170.0/256.0,
192.0/256.0, 64.0/256.0, 224.0/256.0, 96.0/256.0, 200.0/256.0, 72.0/256.0, 232.0/256.0, 104.0/256.0, 194.0/256.0, 66.0/256.0, 226.0/256.0, 98.0/256.0, 202.0/256.0, 74.0/256.0, 234.0/256.0, 106.0/256.0,
48.0/256.0, 176.0/256.0, 16.0/256.0, 144.0/256.0, 56.0/256.0, 184.0/256.0, 24.0/256.0, 152.0/256.0, 50.0/256.0, 178.0/256.0, 18.0/256.0, 146.0/256.0, 58.0/256.0, 186.0/256.0, 26.0/256.0, 154.0/256.0,
240.0/256.0, 112.0/256.0, 208.0/256.0, 80.0/256.0, 248.0/256.0, 120.0/256.0, 216.0/256.0, 88.0/256.0, 242.0/256.0, 114.0/256.0, 210.0/256.0, 82.0/256.0, 250.0/256.0, 122.0/256.0, 218.0/256.0, 90.0/256.0,
12.0/256.0, 140.0/256.0, 44.0/256.0, 172.0/256.0, 4.0/256.0, 132.0/256.0, 36.0/256.0, 164.0/256.0, 14.0/256.0, 142.0/256.0, 46.0/256.0, 174.0/256.0, 6.0/256.0, 134.0/256.0, 38.0/256.0, 166.0/256.0,
204.0/256.0, 76.0/256.0, 236.0/256.0, 108.0/256.0, 196.0/256.0, 68.0/256.0, 228.0/256.0, 100.0/256.0, 206.0/256.0, 78.0/256.0, 238.0/256.0, 110.0/256.0, 198.0/256.0, 70.0/256.0, 230.0/256.0, 102.0/256.0,
60.0/256.0, 188.0/256.0, 28.0/256.0, 156.0/256.0, 52.0/256.0, 180.0/256.0, 20.0/256.0, 148.0/256.0, 62.0/256.0, 190.0/256.0, 30.0/256.0, 158.0/256.0, 54.0/256.0, 182.0/256.0, 22.0/256.0, 150.0/256.0,
252.0/256.0, 124.0/256.0, 220.0/256.0, 92.0/256.0, 244.0/256.0, 116.0/256.0, 212.0/256.0, 84.0/256.0, 254.0/256.0, 126.0/256.0, 222.0/256.0, 94.0/256.0, 246.0/256.0, 118.0/256.0, 214.0/256.0, 86.0/256.0,
3.0/256.0, 131.0/256.0, 35.0/256.0, 163.0/256.0, 11.0/256.0, 139.0/256.0, 43.0/256.0, 171.0/256.0, 1.0/256.0, 129.0/256.0, 33.0/256.0, 161.0/256.0, 9.0/256.0, 137.0/256.0, 41.0/256.0, 169.0/256.0,
195.0/256.0, 67.0/256.0, 227.0/256.0, 99.0/256.0, 203.0/256.0, 75.0/256.0, 235.0/256.0, 107.0/256.0, 193.0/256.0, 65.0/256.0, 225.0/256.0, 97.0/256.0, 201.0/256.0, 73.0/256.0, 233.0/256.0, 105.0/256.0,
51.0/256.0, 179.0/256.0, 19.0/256.0, 147.0/256.0, 59.0/256.0, 187.0/256.0, 27.0/256.0, 155.0/256.0, 49.0/256.0, 177.0/256.0, 17.0/256.0, 145.0/256.0, 57.0/256.0, 185.0/256.0, 25.0/256.0, 153.0/256.0,
243.0/256.0, 115.0/256.0, 211.0/256.0, 83.0/256.0, 251.0/256.0, 123.0/256.0, 219.0/256.0, 91.0/256.0, 241.0/256.0, 113.0/256.0, 209.0/256.0, 81.0/256.0, 249.0/256.0, 121.0/256.0, 217.0/256.0, 89.0/256.0,
15.0/256.0, 143.0/256.0, 47.0/256.0, 175.0/256.0, 7.0/256.0, 135.0/256.0, 39.0/256.0, 167.0/256.0, 13.0/256.0, 141.0/256.0, 45.0/256.0, 173.0/256.0, 5.0/256.0, 133.0/256.0, 37.0/256.0, 165.0/256.0,
207.0/256.0, 79.0/256.0, 239.0/256.0, 111.0/256.0, 199.0/256.0, 71.0/256.0, 231.0/256.0, 103.0/256.0, 205.0/256.0, 77.0/256.0, 237.0/256.0, 109.0/256.0, 197.0/256.0, 69.0/256.0, 229.0/256.0, 101.0/256.0,
63.0/256.0, 191.0/256.0, 31.0/256.0, 159.0/256.0, 55.0/256.0, 183.0/256.0, 23.0/256.0, 151.0/256.0, 61.0/256.0, 189.0/256.0, 29.0/256.0, 157.0/256.0, 53.0/256.0, 181.0/256.0, 21.0/256.0, 149.0/256.0,
255.0/256.0, 127.0/256.0, 223.0/256.0, 95.0/256.0, 247.0/256.0, 119.0/256.0, 215.0/256.0, 87.0/256.0, 253.0/256.0, 125.0/256.0, 221.0/256.0, 93.0/256.0, 245.0/256.0, 117.0/256.0, 213.0/256.0, 85.0/256.0,
);
Pattern Comparison at 50% Alpha
2x2: 4x4: 8x8:
█░█░█░█░ █░█░ █░█░░█░█
░█░█░█░█ ░█░█ ░░█░█░░█
█░█░█░█░ █░█░ ░█░░█░█░
░█░█░█░█ ░█░█ █░░█░░█░
(obvious) (classic) (smoother)
Usage
fn get_bayer_threshold(frag_coord: vec2<f32>, size: u32) -> f32 {
let x = u32(frag_coord.x) % size;
let y = u32(frag_coord.y) % size;
return BAYER_MATRIX[y * size + x];
}
fn should_discard(alpha: f32, frag_coord: vec2<f32>) -> bool {
return alpha < get_bayer_threshold(frag_coord, 4u);
}
External References
Button Constants
Quick reference for all button constants used with button_pressed() and button_held().
Standard Layout
#![allow(unused)]
fn main() {
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
const BUTTON_A: u32 = 4; // Bottom face button (Xbox A, PlayStation X)
const BUTTON_B: u32 = 5; // Right face button (Xbox B, PlayStation O)
const BUTTON_X: u32 = 6; // Left face button (Xbox X, PlayStation Square)
const BUTTON_Y: u32 = 7; // Top face button (Xbox Y, PlayStation Triangle)
const BUTTON_LB: u32 = 8; // Left bumper
const BUTTON_RB: u32 = 9; // Right bumper
const BUTTON_LT: u32 = 10; // Left trigger (as button)
const BUTTON_RT: u32 = 11; // Right trigger (as button)
const BUTTON_START: u32 = 12; // Start / Options
const BUTTON_SELECT: u32 = 13; // Select / Share / Back
const BUTTON_L3: u32 = 14; // Left stick click
const BUTTON_R3: u32 = 15; // Right stick click
}
Controller Mapping
| Emberware | Xbox | PlayStation | Nintendo |
|---|---|---|---|
| A | A | X (Cross) | B |
| B | B | O (Circle) | A |
| X | X | Square | Y |
| Y | Y | Triangle | X |
| LB | LB | L1 | L |
| RB | RB | R1 | R |
| START | Menu | Options | + |
| SELECT | View | Share | - |
Input Functions
Checking Button State
#![allow(unused)]
fn main() {
// Returns 1 on the frame button is first pressed, 0 otherwise
fn button_pressed(player: u32, button: u32) -> u32;
// Returns 1 every frame the button is held, 0 otherwise
fn button_held(player: u32, button: u32) -> u32;
// Returns 1 on the frame button is released, 0 otherwise
fn button_released(player: u32, button: u32) -> u32;
}
Usage Examples
#![allow(unused)]
fn main() {
// Jump on button press
if button_pressed(0, BUTTON_A) != 0 {
player_jump();
}
// Continuous movement while held
if button_held(0, BUTTON_LEFT) != 0 {
player.x -= SPEED;
}
// Trigger on release (e.g., charge attack)
if button_released(0, BUTTON_X) != 0 {
release_charged_attack();
}
}
Analog Input
For smooth movement, use the analog sticks:
#![allow(unused)]
fn main() {
fn left_stick_x(player: u32) -> f32; // -1.0 to 1.0
fn left_stick_y(player: u32) -> f32; // -1.0 (up) to 1.0 (down)
fn right_stick_x(player: u32) -> f32;
fn right_stick_y(player: u32) -> f32;
fn left_trigger(player: u32) -> f32; // 0.0 to 1.0
fn right_trigger(player: u32) -> f32;
}
Multiple Players
All input functions take a player parameter (0-3):
#![allow(unused)]
fn main() {
// Player 1 (index 0)
let p1_x = left_stick_x(0);
// Player 2 (index 1)
let p2_x = left_stick_x(1);
// Check how many players are connected
let count = player_count();
}
Copy-Paste Template
#![allow(unused)]
fn main() {
// Button constants
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const BUTTON_X: u32 = 6;
const BUTTON_Y: u32 = 7;
const BUTTON_LB: u32 = 8;
const BUTTON_RB: u32 = 9;
const BUTTON_START: u32 = 12;
const BUTTON_SELECT: u32 = 13;
}
Example Games
The Emberware repository includes 28+ example games organized by category. Each example is a complete, buildable project.
Location
emberware/examples/
Learning Path
New to Emberware? Follow this progression:
- hello-world - 2D text and rectangles, basic input
- triangle - Your first 3D shape
- textured-quad - Loading and applying textures
- cube - Transforms and rotation
- paddle - Complete game with the tutorial
- platformer - Advanced example with physics, billboards, UI
By Category
Getting Started
| Example | Description |
|---|---|
| hello-world | Basic 2D drawing, text, input handling |
| triangle | Minimal 3D rendering |
| textured-quad | Texture loading and binding |
| cube | Rotating textured cube with transforms |
Graphics & Rendering
| Example | Description |
|---|---|
| lighting | PBR rendering with 4 dynamic lights |
| blinn-phong | Classic specular and rim lighting |
| billboard | GPU-instanced billboards |
| procedural-shapes | Built-in mesh generators |
| textured-procedural | Textured procedural meshes |
| dither-demo | PS1-style dithering effects |
| material-override | Per-draw material properties |
Render Mode Inspectors
| Example | Description |
|---|---|
| mode0-inspector | Interactive Mode 0 (Unlit) explorer |
| mode1-inspector | Interactive Mode 1 (Matcap) explorer |
| mode2-inspector | Interactive Mode 2 (PBR) explorer |
| mode3-inspector | Interactive Mode 3 (Hybrid) explorer |
Animation & Skinning
| Example | Description |
|---|---|
| skinned-mesh | GPU skeletal animation basics |
| animation-demo | Keyframe playback from ROM |
| ik-demo | Inverse kinematics |
| multi-skinned-procedural | Multiple animated characters |
| multi-skinned-rom | ROM-based animation data |
| skeleton-stress-test | Performance testing |
Complete Games
| Example | Description |
|---|---|
| paddle | Classic 2-player game with AI |
| platformer | Full mini-game with physics, UI, multiplayer |
Audio
| Example | Description |
|---|---|
| audio-demo | Sound effects, panning, channels |
Asset Loading
| Example | Description |
|---|---|
| datapack-demo | ROM asset workflow |
| font-demo | Custom font loading |
| level-loader | Level data from ROM |
| asset-test | Pre-converted asset testing |
Development Tools
| Example | Description |
|---|---|
| debug-demo | Debug inspection system |
Shared Libraries
| Example | Description |
|---|---|
| examples-common | Reusable utilities (DebugCamera, math helpers) |
Building Examples
Each example is a standalone Cargo project:
cd examples/paddle
cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm
Or build all examples:
cargo xtask build-examples
Example Structure
All examples follow this pattern:
example-name/
├── Cargo.toml # Project config
├── ember.toml # Game manifest (optional)
├── src/
│ └── lib.rs # Game code
└── assets/ # Assets (if needed)
Learning by Reading Code
Each example includes comments explaining key concepts:
#![allow(unused)]
fn main() {
//! Example Name
//!
//! Description of what this example demonstrates.
//!
//! Controls:
//! - ...
//!
//! Note: Rollback state is automatic.
}
Browse the source on GitHub or read locally in your clone.