Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

SpecValue
AestheticPS1/N64/Saturn (5th gen)
Resolution360p, 540p (default), 720p, 1080p
Color depthRGBA8
Tick rate24, 30, 60 (default), 120 fps
ROM (Cartridge)12MB (WASM code + data pack assets)
RAM4MB (WASM linear memory for game state)
VRAM4MB (GPU textures and mesh buffers)
CPU budget4ms per tick (at 60fps)
NetcodeDeterministic rollback via GGRS
Max players4 (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

CategoryDescription
SystemTime, logging, random, session info
InputButtons, sticks, triggers
GraphicsResolution, render mode, state
CameraView and projection
TransformsMatrix stack operations
TexturesLoading and binding textures
MeshesLoading and drawing meshes
MaterialsPBR and Blinn-Phong properties
LightingDirectional and point lights
SkinningSkeletal animation
AnimationKeyframe playback
ProceduralGenerated primitives
2D DrawingSprites, text, rectangles
BillboardsCamera-facing quads
SkyProcedural sky rendering
AudioSound effects and music
Save DataPersistent storage
ROM LoadingData pack access
DebugRuntime value inspection

Screen Capture

The host application includes screenshot and GIF recording capabilities:

KeyDefaultAction
ScreenshotF9Save PNG to screenshots folder
GIF ToggleF10Start/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

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

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 game
  • ember run - Run your game in the player
  • ember 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 size
  • lto = 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() and draw_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

ConceptDefaultPurpose
Tick Rate60 HzHow often update() runs. Fixed for determinism.
Frame RateVariableHow 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:

  1. Snapshot: The runtime snapshots all WASM memory after each update()
  2. Predict: When waiting for remote player input, the game predicts and continues
  3. Rollback: When real input arrives, the game rolls back and replays
  4. 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 deterministic
  • render() 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

FunctionReturnsDescription
delta_time()f32Seconds since last tick
elapsed_time()f32Total seconds since game start
tick_count()u64Number of ticks since start
random()u32Deterministic 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

Paddle game preview

What You’ll Learn

PartTopics
Part 1: Setup & DrawingProject creation, FFI imports, draw_rect()
Part 2: Paddle MovementInput handling, game state
Part 3: Ball PhysicsVelocity, collision detection
Part 4: AI OpponentSimple AI for single-player
Part 5: MultiplayerThe magic of rollback netcode
Part 6: Scoring & Win StatesGame logic, state machine
Part 7: Sound EffectsAssets, ember build, audio playback
Part 8: Polish & PublishingTitle screen, publishing to archive

Prerequisites

Before starting this tutorial, you should have:

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() and left_stick_y()
  • Clamping values to keep paddles on screen
  • The difference between button_pressed() and button_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:

FunctionBehavior
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:

  1. Follows the ball - Moves toward where the ball is
  2. Slower speed - Only 70% of max paddle speed
  3. Dead zone - Doesn’t jitter when ball is near center
  4. 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:

  1. We check player_count() every frame
  2. Player 2 input is always read (even if unused)
  3. 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:

  1. Player 1’s inputs are sent to Player 2’s game
  2. Player 2’s inputs are sent to Player 1’s game
  3. Both games run the same update() function with the same inputs
  4. 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:

  1. Predict: Don’t have remote input? Guess it (usually “same as last frame”)
  2. Continue: Run the game with the prediction
  3. 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:

  1. Start the game
  2. Connect a second controller
  3. Both players can play!

The player_count() function automatically detects connected players.

Testing Online Multiplayer

Online play is handled by the Emberware runtime:

  1. Player 1 hosts a game
  2. Player 2 joins via game code or direct connect
  3. The runtime handles all networking
  4. 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 NetcodeEmberware Rollback
Wait for input → lagPredict input → smooth
Manual state syncAutomatic snapshots
You write network codeYou write game code
State can be anywhereState 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.toml to bundle assets
  • Using ember build instead of cargo 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:

SoundDescriptionDuration
hit.wavQuick beep for paddle/wall hits~0.1s
score.wavDescending tone when someone scores~0.2s
win.wavVictory fanfare when game ends~0.5s

Download sample sounds from the tutorial assets, or create your own with:

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:

  1. Compiles your Rust code to WASM
  2. Converts WAV files to the optimized format (22050 Hz mono)
  3. Bundles everything into a paddle.ewzx ROM 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);
}
ParameterRangeDescription
soundHandleSound handle from rom_sound()
volume0.0 - 1.00 = silent, 1 = full volume
pan-1.0 - 1.0-1 = left, 0 = center, 1 = right

Audio Specs

Emberware uses these audio settings:

PropertyValue
Sample rate22050 Hz
Format16-bit mono PCM
ChannelsStereo output

The ember build command automatically converts your WAV files to this format.

Sound Design Tips

  1. Keep sounds short — 0.1 to 0.5 seconds is plenty for effects
  2. Use panning — Stereo positioning helps players track action
  3. Vary volume — Important sounds louder, ambient sounds quieter
  4. 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 --releaseember build
ember run target/.../paddle.wasmember run
No assets neededAssets 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

  1. Log in to emberware.io
  2. Go to your Dashboard
  3. Click “Upload New Game”
  4. Fill in the details:
    • Title: “Paddle”
    • Description: Your game description
    • Category: Arcade
  5. Upload your .ewzx ROM file
  6. Add your icon and screenshots
  7. 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:

FeatureImplementation
GraphicsCourt, paddles, ball with draw_rect()
InputAnalog stick and D-pad with left_stick_y(), button_held()
PhysicsBall movement, wall bouncing, paddle collision
AISimple ball-following AI opponent
MultiplayerAutomatic online play via rollback netcode
Game FlowTitle, Playing, GameOver states
ScoringPoint tracking, win conditions
AudioSound effects loaded from ROM with stereo panning
AssetsSounds 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:

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:

  1. Setup — Creating an Emberware project
  2. Drawing — Using draw_rect() for 2D graphics
  3. Input — Reading sticks and buttons
  4. Physics — Ball movement and collision
  5. AI — Simple opponent behavior
  6. Multiplayer — How rollback netcode “just works”
  7. Game Flow — State machines for menus
  8. Assets — Using ember.toml and ember build for sounds
  9. Audio — Loading and playing sound effects from ROM
  10. 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

ModeNameLightingBest For
0UnlitNoneFlat colors, UI, retro 2D
1MatcapPre-bakedStylized, toon, sculpted look
2Metallic-RoughnessPBR-style Blinn-PhongRealistic materials
3Specular-ShininessTraditional Blinn-PhongClassic 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:

SlotPurposeBlend Mode
0Albedo (UV-mapped)Base color
1-3Matcap (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:

SlotPurposeChannels
0AlbedoRGB: Diffuse color
1MRER: 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:

SlotPurposeChannels
0AlbedoRGB: Diffuse color
1SSER: Specular intensity, G: Shininess, B: Emissive
2SpecularRGB: 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:

ValueShininessAppearance
0.0-0.21-52Very soft (cloth, skin)
0.2-0.452-103Broad (leather, wood)
0.4-0.6103-154Medium (plastic)
0.6-0.8154-205Tight (polished metal)
0.8-1.0205-256Mirror (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 lighting0 (Unlit)
Stylized, consistent lighting1 (Matcap)
PBR workflow with MRE textures2 (Metallic-Roughness)
Colored specular, artist control3 (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:

  1. Every tick, your update() receives inputs from all players
  2. GGRS synchronizes inputs across the network
  3. 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

  1. Start a local game with 2 players
  2. Give identical inputs
  3. Verify states match

Debug Checklist

If you see desync:

  1. Check random() usage - All randomness from random()?
  2. Check iteration order - Using fixed-order arrays?
  3. Check floating point - Sensitive calculations reproducible?
  4. Check render() logic - Any state changes in render?
  5. Check external reads - System time, files, network?
  6. 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

FormatExtensionStatus
glTF 2.0.gltf, .glbImplemented
OBJ.objImplemented

Recommendation: Use glTF for new projects. It’s the “JPEG of 3D” - efficient, well-documented, and supported everywhere.

Textures

FormatStatus
PNGImplemented
JPGImplemented

Audio

FormatStatus
WAVImplemented

Fonts

FormatStatus
TTFPlanned

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.ewzmesh
  • player_diffuse.ewztex
  • jump.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 .ewztex files, 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

FormatNamePacked Stride
0POS8 bytes
1POS_UV12 bytes
2POS_COLOR12 bytes
3POS_UV_COLOR16 bytes
4POS_NORMAL12 bytes
5POS_UV_NORMAL16 bytes
6POS_COLOR_NORMAL16 bytes
7POS_UV_COLOR_NORMAL20 bytes
8POS_SKINNED16 bytes
9POS_UV_SKINNED20 bytes
10POS_COLOR_SKINNED20 bytes
11POS_UV_COLOR_SKINNED24 bytes
12POS_NORMAL_SKINNED20 bytes
13POS_UV_NORMAL_SKINNED24 bytes
14POS_COLOR_NORMAL_SKINNED24 bytes
15POS_UV_COLOR_NORMAL_SKINNED28 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

AttributePacked FormatSizeNotes
PositionFloat16x48 bytesx, y, z, w=1.0
UVUnorm16x24 bytes65536 values in [0,1], better precision than f16
ColorUnorm8x44 bytesRGBA, alpha=255 if not provided
NormalOctahedral u324 bytes~0.02° angular precision
Bone IndicesUint8x44 bytesUp to 256 bones
Bone WeightsUnorm8x44 bytesNormalized 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:

  1. Project 3D unit vector onto octahedron surface
  2. Unfold octahedron to 2D square [-1, 1]²
  3. 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

FormatUnpackedPackedSavings
POS_UV_NORMAL32 bytes16 bytes50%
POS_UV_NORMAL_SKINNED52 bytes24 bytes54%
Full format (15)64 bytes28 bytes56%

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)

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:

ResourceLimit
ROM size12 MB
VRAM4 MB
Bones per skeleton256

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 .ewzx ROM 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?

ScenarioRecommendation
Learning/prototypinginclude_bytes!() or procedural
Simple arcade gamesEither works
Complex games with many assetsember.toml + ROM
Games with large texturesember.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 --watch for 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:

  1. Build your game (compile to WASM)
  2. Pack assets into a ROM file (optional)
  3. Test the final build
  4. Upload to emberware.io
  5. Share with the world

Building for Release

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

FileFormatDescription
Game.wasm or .ewzxYour compiled game
Icon64×64 PNGLibrary thumbnail

Optional Files

FileFormatDescription
ScreenshotsPNGGame page gallery (up to 5)
Banner1280×720 PNGFeatured 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

  1. Click “Upload New Game”
  2. Fill in title and description
  3. Select category and tags
  4. Upload your game file
  5. Upload icon (required) and screenshots (optional)
  6. Click “Publish”

4. Game Page

Your game gets a public page:

emberware.io/game/your-game-id

Updating Your Game

To release an update:

  1. Bump version in ember.toml
  2. Build and test new version
  3. Go to Dashboard → Your Game → Edit
  4. Upload new game file
  5. Update version number
  6. 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:

  1. Test locally first
  2. Check console for error messages
  3. Simplify init() to isolate the issue

Best Practices

  1. Test thoroughly before publishing
  2. Write a good description - help players find your game
  3. Create an appealing icon - first impressions matter
  4. Include screenshots - show off your game
  5. Respond to feedback - engage with players
  6. 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:

NameTypeDescription
ptr*const u8Pointer to UTF-8 string data
lenu32Length 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:

NameTypeDescription
playeru32Player index (0-3)
buttonu32Button 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:

NameTypeDescription
playeru32Player index (0-3)
buttonu32Button 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:

NameTypeDescription
playeru32Player index (0-3)
buttonu32Button 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:

NameTypeDescription
playeru32Player 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:

NameTypeDescription
playeru32Player 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:

NameTypeDescription
playeru32Player 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:

NameTypeDescription
playeru32Player 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:

NameTypeDescription
playeru32Player index (0-3)
out_x*mut f32Pointer to write X value
out_y*mut f32Pointer 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:

NameTypeDescription
playeru32Player 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:

ValueResolution
0360p (640x360)
1540p (960x540) - default
2720p (1280x720)
31080p (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:

ValueTick Rate
024 fps
130 fps
260 fps - default
3120 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:

NameTypeDescription
coloru32RGBA 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:

ValueModeDescription
0UnlitFlat colors, no lighting
1MatcapPre-baked lighting via matcap textures
2Metallic-RoughnessPBR-style Blinn-Phong with MRE textures
3Specular-ShininessTraditional 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:

NameTypeDescription
coloru32RGBA 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:

NameTypeDescription
enabledu321 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:

ValueModeDescription
0NoneDraw both sides
1BackCull back faces (default)
2FrontCull 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:

ValueModeDescription
0NoneNo blending (opaque)
1AlphaStandard transparency
2AdditiveAdd colors (glow effects)
3MultiplyMultiply 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:

ValueModeDescription
0NearestPixelated (retro look)
1LinearSmooth (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:

NameTypeDescription
levelu32Alpha 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:

NameTypeDescription
xu32X offset 0-3
yu32Y 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:

NameTypeDescription
x, y, zf32Camera position in world space
target_x, target_y, target_zf32Point 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:

NameTypeDescription
fov_degreesf32Vertical 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:

NameTypeDescription
matrix_ptr*const f32Pointer 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:

NameTypeDescription
xf32X offset (right is positive)
yf32Y offset (up is positive)
zf32Z 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:

NameTypeDescription
angle_degf32Rotation 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:

NameTypeDescription
angle_degf32Rotation angle in degrees
axis_x, axis_y, axis_zf32Rotation 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:

NameTypeDescription
xf32Scale factor on X axis
yf32Scale factor on Y axis
zf32Scale 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:

NameTypeDescription
sf32Uniform 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:

NameTypeDescription
widthu32Texture width in pixels
heightu32Texture height in pixels
pixels*const u8Pointer 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:

NameTypeDescription
handleu32Texture 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:

NameTypeDescription
handleu32Texture handle
slotu32Texture slot (0-3)

Texture Slots:

SlotPurpose
0Albedo/diffuse texture
1MRE texture (Mode 2) or Specular (Mode 3)
2Reserved
3Reserved

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:

NameTypeDescription
slotu32Matcap slot (1-3)
modeu32Blend mode

Blend Modes:

ValueModeDescription
0MultiplyDarkens (shadows, ambient occlusion)
1AddBrightens (highlights, rim light)
2HSV ModulateHue/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:

NameTypeDescription
data_ptr*const u8Pointer to vertex data
vertex_countu32Number of vertices
formatu32Vertex 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:

NameTypeDescription
data_ptr*const u8Pointer to vertex data
vertex_countu32Number of vertices
index_ptr*const u16Pointer to u16 index data
index_countu32Number of indices
formatu32Vertex 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:

NameTypeDescription
handleu32Mesh 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:

NameTypeDescription
data_ptr*const u8Pointer to vertex data
vertex_countu32Number of vertices (must be multiple of 3)
formatu32Vertex 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:

FlagValueComponentsBytes
Position0xyz (3 floats)12
UV1uv (2 floats)8
Color2rgb (3 floats)12
Normal4xyz (3 floats)12
Skinned8bone indices + weights16

Common Combinations:

FormatValueComponentsStride
POS0Position only12 bytes
POS_UV1Position + UV20 bytes
POS_COLOR2Position + Color24 bytes
POS_UV_COLOR3Position + UV + Color32 bytes
POS_NORMAL4Position + Normal24 bytes
POS_UV_NORMAL5Position + UV + Normal32 bytes
POS_COLOR_NORMAL6Position + Color + Normal36 bytes
POS_UV_COLOR_NORMAL7Position + UV + Color + Normal44 bytes

With Skinning (add 8):

FormatValueStride
POS_NORMAL_SKINNED1240 bytes
POS_UV_NORMAL_SKINNED1348 bytes

Vertex Data Layout

Data is laid out per-vertex in this order:

  1. Position (xyz) - 3 floats
  2. UV (uv) - 2 floats (if enabled)
  3. Color (rgb) - 3 floats (if enabled)
  4. Normal (xyz) - 3 floats (if enabled)
  5. 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:

NameTypeDescription
valuef32Metallic 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:

NameTypeDescription
valuef32Roughness 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:

NameTypeDescription
valuef32Emissive 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:

NameTypeDescription
intensityf32Rim light intensity (0.0-1.0)
powerf32Rim 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:

NameTypeDescription
valuef32Shininess (0.0-1.0, maps to 1-256 internally)

Shininess Guide:

ValueInternalVisualUse For
0.0-0.21-52Very soft, broadCloth, skin, rough stone
0.2-0.452-103BroadLeather, wood, rubber
0.4-0.6103-154MediumPlastic, painted metal
0.6-0.8154-205TightPolished metal, wet surfaces
0.8-1.0205-256Very tightChrome, 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:

NameTypeDescription
coloru32Specular 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:

NameTypeDescription
r, g, bf32Specular 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:

NameTypeDescription
valuef32Damping 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:

NameTypeDescription
textureu32Texture 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:

NameTypeDescription
textureu32Texture 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:

NameTypeDescription
indexu32Light index (0-3)
x, y, zf32Light 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:

NameTypeDescription
indexu32Light index (0-3)
coloru32Light 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:

NameTypeDescription
indexu32Light index (0-3)
intensityf32Light 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:

NameTypeDescription
indexu32Light index (0-3)
x, y, zf32World 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:

NameTypeDescription
indexu32Light index (0-3)
rangef32Maximum 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:

NameTypeDescription
inverse_bind_ptr*const f32Pointer to 3x4 matrices (12 floats each, column-major)
bone_countu32Number 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:

NameTypeDescription
skeletonu32Skeleton handle, or 0 to disable inverse bind mode

Skinning Modes:

skeleton_bind()set_bones() receivesGPU applies
0 or not calledFinal skinning matricesNothing extra
Valid handleModel-space bone transformsbone × 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:

NameTypeDescription
matrices_ptr*const f32Pointer to array of 3x4 matrices (12 floats each)
countu32Number 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:

NameTypeDescription
matrices_ptr*const f32Pointer to array of 4x4 matrices (16 floats each)
countu32Number 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:

NameTypeDescription
data_ptr*const u8Pointer to keyframe data
byte_sizeu32Size 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:

NameTypeDescription
id_ptr*const u8Pointer to asset ID string
id_lenu32Length 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:

NameTypeDescription
handleu32Keyframe collection handle
indexu32Frame 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:

NameTypeDescription
handleu32Keyframe collection handle
indexu32Frame index
out_ptr*mut u8Destination 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

PathFunctionUse CasePerformance
Statickeyframe_bind()Pre-baked ROM animationsZero CPU work
Immediateset_bones()Procedural, IK, blendedMinimal 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:

NameTypeDescription
size_xf32Half-width (total width = 2 × size_x)
size_yf32Half-height (total height = 2 × size_y)
size_zf32Half-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:

NameTypeDescription
radiusf32Sphere radius
segmentsu32Horizontal divisions (3-256)
ringsu32Vertical 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:

NameTypeDescription
radius_bottomf32Bottom cap radius
radius_topf32Top cap radius (0 for cone)
heightf32Cylinder height
segmentsu32Radial 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:

NameTypeDescription
size_xf32Half-width
size_zf32Half-depth
subdivisions_xu32X divisions (1-256)
subdivisions_zu32Z 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:

NameTypeDescription
major_radiusf32Distance from center to tube center
minor_radiusf32Tube thickness
major_segmentsu32Segments around ring (3-256)
minor_segmentsu32Segments 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:

NameTypeDescription
radiusf32Capsule radius
heightf32Cylinder section height (total = height + 2×radius)
segmentsu32Radial divisions (3-256)
ringsu32Hemisphere 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);
        }
    }
}
}

See Also: Meshes, rom_mesh

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:

NameTypeDescription
x, yf32Screen position (top-left corner)
w, hf32Size in pixels
coloru32Tint 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:

NameTypeDescription
x, yf32Screen position
w, hf32Destination size in pixels
src_x, src_yf32Source position in texture (pixels)
src_w, src_hf32Source size in texture (pixels)
coloru32Tint 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:

NameTypeDescription
x, yf32Screen position
w, hf32Destination size
src_x, src_y, src_w, src_hf32Source region
origin_x, origin_yf32Rotation origin (0-1 normalized)
angle_degf32Rotation angle in degrees
coloru32Tint 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:

NameTypeDescription
x, yf32Screen position (top-left)
w, hf32Size in pixels
coloru32Fill 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:

NameTypeDescription
ptr*const u8Pointer to UTF-8 string
lenu32String length in bytes
x, yf32Screen position
sizef32Font size in pixels
coloru32Text 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:

NameTypeDescription
textureu32Font texture atlas handle
char_widthu32Width of each character in pixels
char_heightu32Height of each character in pixels
first_codepointu32First character code (usually 32 for space)
char_countu32Number 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:

NameTypeDescription
textureu32Font texture atlas handle
widths_ptr*const u8Pointer to array of character widths
char_heightu32Height of each character
first_codepointu32First character code
char_countu32Number 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);
    }
}
}

See Also: rom_font, Textures

Billboard Functions

Camera-facing quads for sprites in 3D space.

Billboard Modes

ModeNameDescription
1SphericalAlways faces camera (all axes)
2Cylindrical YRotates around Y axis only (trees, NPCs)
3Cylindrical XRotates around X axis only
4Cylindrical ZRotates 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:

NameTypeDescription
wf32Width in world units
hf32Height in world units
modeu32Billboard mode (1-4)
coloru32Tint 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:

NameTypeDescription
w, hf32Size in world units
src_x, src_yf32Source position in texture (pixels)
src_w, src_hf32Source size in texture (pixels)
modeu32Billboard mode (1-4)
coloru32Tint 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:

NameTypeDescription
horizon_coloru32Color at horizon as 0xRRGGBBAA
zenith_coloru32Color 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:

NameTypeDescription
dir_x, dir_y, dir_zf32Sun direction (will be normalized)
coloru32Sun color as 0xRRGGBBAA
sharpnessf32Sun 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:

NameTypeDescription
slotu32Matcap slot (1-3)
textureu32Texture 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:

NameTypeDescription
data_ptr*const u8Pointer to PCM audio data
byte_lenu32Size 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:

NameTypeDescription
soundu32Sound handle
volumef32Volume (0.0-1.0)
panf32Stereo 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:

NameTypeDescription
channelu32Channel index (0-15)
soundu32Sound handle
volumef32Volume (0.0-1.0)
panf32Stereo pan (-1.0 to 1.0)
loopingu321 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:

NameTypeDescription
channelu32Channel index (0-15)
volumef32New volume (0.0-1.0)
panf32New 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:

NameTypeDescription
channelu32Channel 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:

NameTypeDescription
soundu32Sound handle
volumef32Volume (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:

NameTypeDescription
volumef32New 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:

NameTypeDescription
slotu32Save slot (0-7)
data_ptr*const u8Pointer to data to save
data_lenu32Size of data in bytes

Returns:

ValueMeaning
0Success
1Invalid slot
2Data 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:

NameTypeDescription
slotu32Save slot (0-7)
data_ptr*mut u8Destination buffer
max_lenu32Maximum 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:

NameTypeDescription
slotu32Save slot (0-7)

Returns:

ValueMeaning
0Success
1Invalid 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:

NameTypeDescription
id_ptr*const u8Pointer to asset ID string
id_lenu32Length 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:

NameTypeDescription
id_ptr*const u8Pointer to asset ID
id_lenu32Length of asset ID
out_ptr*mut u8Destination buffer in WASM memory
max_lenu32Maximum 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 RAM
  • rom_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

KeyAction
F3Toggle debug panel
F5Pause/unpause
F6Step one frame (while paused)
F7Decrease time scale
F8Increase 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

EmberwareXboxPlayStationNintendo
AAX (Cross)B
BBO (Circle)A
XXSquareY
YYTriangleX
LBLBL1L
RBRBR1R
STARTMenuOptions+
SELECTViewShare-

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:

  1. hello-world - 2D text and rectangles, basic input
  2. triangle - Your first 3D shape
  3. textured-quad - Loading and applying textures
  4. cube - Transforms and rotation
  5. paddle - Complete game with the tutorial
  6. platformer - Advanced example with physics, billboards, UI

By Category

Getting Started

ExampleDescription
hello-worldBasic 2D drawing, text, input handling
triangleMinimal 3D rendering
textured-quadTexture loading and binding
cubeRotating textured cube with transforms

Graphics & Rendering

ExampleDescription
lightingPBR rendering with 4 dynamic lights
blinn-phongClassic specular and rim lighting
billboardGPU-instanced billboards
procedural-shapesBuilt-in mesh generators
textured-proceduralTextured procedural meshes
dither-demoPS1-style dithering effects
material-overridePer-draw material properties

Render Mode Inspectors

ExampleDescription
mode0-inspectorInteractive Mode 0 (Unlit) explorer
mode1-inspectorInteractive Mode 1 (Matcap) explorer
mode2-inspectorInteractive Mode 2 (PBR) explorer
mode3-inspectorInteractive Mode 3 (Hybrid) explorer

Animation & Skinning

ExampleDescription
skinned-meshGPU skeletal animation basics
animation-demoKeyframe playback from ROM
ik-demoInverse kinematics
multi-skinned-proceduralMultiple animated characters
multi-skinned-romROM-based animation data
skeleton-stress-testPerformance testing

Complete Games

ExampleDescription
paddleClassic 2-player game with AI
platformerFull mini-game with physics, UI, multiplayer

Audio

ExampleDescription
audio-demoSound effects, panning, channels

Asset Loading

ExampleDescription
datapack-demoROM asset workflow
font-demoCustom font loading
level-loaderLevel data from ROM
asset-testPre-converted asset testing

Development Tools

ExampleDescription
debug-demoDebug inspection system

Shared Libraries

ExampleDescription
examples-commonReusable 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.