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

Nethercore ZX API Reference

Nethercore 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)
Resolution960×540 (fixed, upscaled to display)
Color depthRGBA8
Tick rate24, 30, 60 (default), 120 fps
ROM (Cartridge)16MB (WASM code + data pack assets)
RAM4MB (WASM linear memory for game state)
VRAM4MB (GPU textures and mesh buffers)
Compute budgetWASM GAS metering
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

Nethercore ZX uses a 16MB ROM + 4MB RAM memory model:

  • ROM (16MB): 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.

Coordinate System

Nethercore ZX uses standard graphics conventions with wgpu as the rendering backend.

Screen Space (2D)

All 2D drawing functions (draw_sprite, draw_rect, draw_text, etc.) use screen coordinates:

PropertyValue
Resolution960×540 pixels (fixed, 16:9 aspect)
OriginTop-left corner (0, 0)
X-axisIncreases rightward (0 → 960)
Y-axisIncreases downward (0 → 540)
Sprite anchorTop-left corner of sprite
(0,0) ────────────────────► X (960)
  │
  │     Screen Space
  │
  ▼
  Y (540)

World Space (3D)

For 3D rendering with camera_set() and draw_mesh():

PropertyValue
Coordinate systemRight-handed, Y-up
X-axisRight
Y-axisUp
Z-axisOut of screen (toward viewer)
HandednessRight-handed (cross X into Y to get Z)
        Y (up)
        │
        │
        │
        └──────► X (right)
       ╱
      ╱
     Z (toward viewer)

NDC (Normalized Device Coordinates)

The rendering pipeline uses wgpu’s standard NDC conventions:

PropertyValue
X-axis-1.0 (left) to +1.0 (right)
Y-axis-1.0 (bottom) to +1.0 (top)
Z-axis0.0 (near) to 1.0 (far)

Screen-space drawing functions automatically handle the conversion from screen pixels to NDC. For 3D, the view and projection matrices handle the transformation.

Texture Coordinates (UV)

PropertyValue
OriginTop-left (0, 0)
U-axis0 (left) to 1 (right)
V-axis0 (top) to 1 (bottom)

Matrix Conventions

All matrix functions use column-major order (standard for wgpu/WGSL):

| m0  m4  m8  m12 |    Column 0: m0, m1, m2, m3
| m1  m5  m9  m13 |    Column 1: m4, m5, m6, m7
| m2  m6  m10 m14 |    Column 2: m8, m9, m10, m11
| m3  m7  m11 m15 |    Column 3: m12, m13, m14, m15

Transformations use column vectors: v' = M × v

Default Projection

When using camera_set() and camera_fov():

PropertyValue
TypePerspective
Default FOV60° (vertical)
Aspect ratio16:9 (fixed)
Near plane0.1 units
Far plane1000 units
Functionperspective_rh (right-handed)

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
Environment (EPU)Procedural environments
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: your Nethercore data directory under screenshots/
  • GIFs: your Nethercore data directory under gifs/

Filenames include game name and timestamp (e.g., platformer_screenshot_2025-01-15_14-30-45.png).

Configuration (config.toml in your platform-specific config directory):

[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 mdbook-tabs

# 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 Nethercore ZX, you’ll need to set up your development environment.

Choose Your Language

Nethercore ZX games are compiled to WebAssembly. You can write games in several languages:

LanguageBest For
RustFull ecosystem support, best tooling
C/C++Existing codebases, familiar to game devs
ZigModern systems programming, C interop

This guide shows setup for each language. Pick one and follow its setup instructions.

Language Setup

Install Rust

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.

Add WebAssembly Target

After installing Rust, add the WASM compilation target:

rustup target add wasm32-unknown-unknown

Verify Installation

# Check Rust version
rustc --version

# Check WASM target is installed
rustup target list --installed | grep wasm32

You should see:

  • a rustc ... version line
  • wasm32-unknown-unknown in the installed target list

Any text editor works, but we recommend one with language support:

  • VS Code with rust-analyzer extension
  • RustRover (JetBrains IDE for Rust)
  • Neovim with rust-analyzer LSP

Optional: Nethercore CLI

The nether CLI tool provides convenient commands for building and running games:

cargo install --path tools/nether-cli

This gives you commands like:

  • nether build - Compile your game
  • nether run - Run your game in the player
  • nether 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 Nethercore game development.

Create the Project

cargo new --lib my-first-game
cd my-first-game

Configure Your Build

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:

#![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 Nethercore 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 Nethercore!";
        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() {
}

Nethercore games run in a minimal WebAssembly environment without the Rust standard library. This keeps binaries small and avoids OS dependencies.

FFI Imports

Functions are imported from the Nethercore 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/global variables. This is intentional - the Nethercore 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 Nethercore player:

nether run target/wasm32-unknown-unknown/release/my_first_game.wasm

Or load the .wasm file directly in the Nethercore Library application.

What You’ve Learned

  • Setting up a project for WASM compilation
  • The minimal/freestanding 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 Nethercore 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() (or random_u32() in C) 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
        let score = b"Score: ";
        draw_text(score.as_ptr(), score.len() as u32, 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

Nethercore’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/global 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()f32/floatSeconds since last tick (fixed)
elapsed_time()f32/floatTotal seconds since game start
tick_count()u64/uint64_tNumber of ticks since start
random() / random_u32()u32/uint32_tDeterministic 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 Nethercore’s rollback netcode

Paddle sprite Ball sprite

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, nether 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:

nethercore/examples/7-games/paddle/
├── Cargo.toml
├── nether.toml
└── src/
    └── lib.rs

You can build and run it with:

cd examples/7-games/paddle
cargo build --target wasm32-unknown-unknown --release
nether 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 Nethercore 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 Nethercore runtime
#[link(wasm_import_module = "env")]
extern "C" {
    fn set_clear_color(color: u32);
    fn set_color(color: u32);
    fn draw_rect(x: f32, y: f32, w: f32, h: f32);
}

#[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;

        set_color(COLOR_GRAY);
        let mut y = 10.0;
        while y < SCREEN_HEIGHT - 10.0 {
            draw_rect(center_x, y, dash_width, dash_height);
            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;

        set_color(COLOR_GRAY);
        let mut y = 10.0;
        while y < SCREEN_HEIGHT - 10.0 {
            draw_rect(center_x, y, dash_width, dash_height);
            y += dash_height + dash_gap;
        }

        // Draw paddle 1 (left, blue)
        set_color(COLOR_PLAYER1);
        draw_rect(
            PADDLE_MARGIN,
            PADDLE1_Y,
            PADDLE_WIDTH,
            PADDLE_HEIGHT,
        );

        // Draw paddle 2 (right, red)
        set_color(COLOR_PLAYER2);
        draw_rect(
            SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH,
            PADDLE2_Y,
            PADDLE_WIDTH,
            PADDLE_HEIGHT,
        );
    }
}
}

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
        set_color(COLOR_WHITE);
        draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE);
}

Build and Test

cargo build --target wasm32-unknown-unknown --release
nether 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 set_color(color: u32);
    fn draw_rect(x: f32, y: f32, w: f32, h: f32);
}

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;
        set_color(COLOR_GRAY);
        let mut y = 10.0;
        while y < SCREEN_HEIGHT - 10.0 {
            draw_rect(center_x, y, dash_width, dash_height);
            y += dash_height + dash_gap;
        }

        // Paddles
        set_color(COLOR_PLAYER1);
        draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT);
        set_color(COLOR_PLAYER2);
        draw_rect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y,
                  PADDLE_WIDTH, PADDLE_HEIGHT);

        // Ball
        set_color(COLOR_WHITE);
        draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE);
    }
}
}

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 set_color(color: u32);
    fn draw_rect(x: f32, y: f32, w: f32, h: f32);

    // 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
nether 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 set_color(color: u32);
    fn draw_rect(x: f32, y: f32, w: f32, h: f32);
    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;
        set_color(COLOR_GRAY);
        let mut y = 10.0;
        while y < SCREEN_HEIGHT - 10.0 {
            draw_rect(center_x, y, dash_width, dash_height);
            y += dash_height + dash_gap;
        }

        // Paddles
        set_color(COLOR_PLAYER1);
        draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT);
        set_color(COLOR_PLAYER2);
        draw_rect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y,
                  PADDLE_WIDTH, PADDLE_HEIGHT);

        // Ball
        set_color(COLOR_WHITE);
        draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE);
    }
}
}

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
nether 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 set_color(color: u32);
    fn draw_text(ptr: *const u8, len: u32, x: f32, y: f32, size: f32);
}
}

Add to render():

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn render() {
    unsafe {
        // ... existing drawing code ...

        // Show mode indicator
        set_color(COLOR_GRAY);
        if IS_TWO_PLAYER {
            let text = b"2 PLAYERS";
            draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0);
        } else {
            let text = b"vs AI";
            draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0);
        }
    }
}
}

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 Nethercore’s netcode, player_count() increases and they take control of paddle 2.

Build and Test

cargo build --target wasm32-unknown-unknown --release
nether 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 Nethercore’s magic happens. Our Paddle game already supports online multiplayer - and we didn’t write any networking code!

What You’ll Learn

  • How Nethercore’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.

Nethercore 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, Nethercore 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 Nethercore 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 NetcodeNethercore 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: Nethercore 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) {
    unsafe {
        draw_text(text.as_ptr(), text.len() as u32, x, y, size);
    }
}
}

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
        set_color(COLOR_PLAYER1);
        draw_text(&[score1_char], 1, SCREEN_WIDTH / 4.0, 30.0, 48.0);
        set_color(COLOR_PLAYER2);
        draw_text(&[score2_char], 1, SCREEN_WIDTH * 3.0 / 4.0, 30.0, 48.0);
    }
}
}

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 {
        set_color(COLOR_WHITE);
        draw_text_bytes(b"PADDLE", SCREEN_WIDTH / 2.0 - 100.0, 150.0, 64.0);

        if IS_TWO_PLAYER {
            draw_text_bytes(b"2 PLAYER MODE", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0);
        } else {
            draw_text_bytes(b"1 PLAYER VS AI", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0);
        }

        set_color(COLOR_GRAY);
        draw_text_bytes(b"Press A to Start", SCREEN_WIDTH / 2.0 - 120.0, 350.0, 24.0);
    }
}

fn render_game_over() {
    unsafe {
        // Dark overlay
        set_color(0x000000CC);
        draw_rect(SCREEN_WIDTH / 4.0, SCREEN_HEIGHT / 3.0,
                  SCREEN_WIDTH / 2.0, SCREEN_HEIGHT / 3.0);

        // 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)
        };

        set_color(color);
        draw_text(text.as_ptr(), text.len() as u32,
                  SCREEN_WIDTH / 2.0 - 120.0, SCREEN_HEIGHT / 2.0 - 20.0, 32.0);

        set_color(COLOR_GRAY);
        draw_text_bytes(b"Press A to Play Again",
                       SCREEN_WIDTH / 2.0 - 140.0, SCREEN_HEIGHT / 2.0 + 30.0, 20.0);
    }
}

fn render_mode_indicator() {
    unsafe {
        set_color(COLOR_GRAY);
        if IS_TWO_PLAYER {
            draw_text_bytes(b"2P", 10.0, 10.0, 16.0);
        } else {
            draw_text_bytes(b"vs AI", 10.0, 10.0, 16.0);
        }
    }
}
}

Build and Test

cargo build --target wasm32-unknown-unknown --release
nether 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 Nethercore’s asset pipeline comes in.

What You’ll Learn

  • Setting up an assets folder
  • Creating nether.toml to bundle assets
  • Using nether 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
nether 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 nether 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
├── nether.toml          ← We'll create this next
├── assets/
│   ├── hit.wav
│   ├── score.wav
│   └── win.wav
└── src/
    └── lib.rs

Create nether.toml

Create nether.toml in your project root. This manifest tells Nethercore 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 nether.toml

Build with nether build

Now use nether build instead of cargo build:

nether 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.nczx ROM file

You’ll see output like:

Building paddle...
  Compiling paddle v0.1.0
  Converting hit.wav → hit.nczxsnd
  Converting score.wav → score.nczxsnd
  Converting win.wav → win.nczxsnd
  Packing paddle.nczx (28 KB)
Done!

Run Your Game

Now run the ROM:

nether run paddle.nczx

Or just:

nether 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 nether.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

Nethercore uses these audio settings:

PropertyValue
Sample rate22050 Hz
Format16-bit mono PCM
ChannelsStereo output

The nether 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:

nether build
nether 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 nether.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 set_color(0xRRGGBBAA) before draw_sprite() 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 --releasenether build
nether run target/.../paddle.wasmnether run
No assets neededAssets bundled in ROM

From now on, just use nether build and nether 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 Nethercore Archive.

What You’ll Learn

  • Adding control hints
  • Final nether.toml configuration
  • Building a release ROM
  • Publishing to nethercore.systems

Add Control Hints

Let’s add helpful text on the title screen:

#![allow(unused)]
fn main() {
fn render_title() {
    unsafe {
        // Title
        set_color(COLOR_WHITE);
        draw_text_bytes(b"PADDLE", SCREEN_WIDTH / 2.0 - 100.0, 150.0, 64.0);

        // Mode indicator
        if IS_TWO_PLAYER {
            draw_text_bytes(b"2 PLAYER MODE", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0);
        } else {
            draw_text_bytes(b"1 PLAYER VS AI", SCREEN_WIDTH / 2.0 - 100.0, 250.0, 24.0);
        }

        // Start prompt
        set_color(COLOR_GRAY);
        draw_text_bytes(b"Press A to Start", SCREEN_WIDTH / 2.0 - 120.0, 350.0, 24.0);

        // Controls hint
        draw_text_bytes(b"Controls: Left Stick or D-Pad Up/Down",
                       250.0, 450.0, 18.0);
    }
}
}

Complete nether.toml

In Part 7, we created our nether.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
├── nether.toml
├── assets/
│   ├── hit.wav
│   ├── score.wav
│   ├── win.wav
│   ├── paddle.png    (optional)
│   └── ball.png      (optional)
└── src/
    └── lib.rs

Build for Release

Using nether build

Build your game with all assets bundled:

nether build

This creates a .nczx ROM file containing:

  • Your compiled WASM code
  • All converted and compressed assets
  • Game metadata

Verify the Build

Check your ROM was created:

ls -la *.nczx

You should see something like:

-rw-r--r-- 1 user user 45678 Dec 20 12:00 paddle.nczx

Test Your Release Build

Run the final ROM:

nether run paddle.nczx

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 Nethercore Archive

1. Create an Account

Visit nethercore.systems/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 nethercore.systems
  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 .nczx ROM file
  6. Add your icon and screenshots
  7. Click “Publish”

4. Share Your Game

Once published, your game has a unique page at:

nethercore.systems/game/paddle

Share this link! Anyone with the Nethercore 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 nether 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:

nethercore/examples/7-games/paddle/

You can compare your code or use it as a reference.


Summary

In this tutorial, you learned:

  1. Setup — Creating a Nethercore 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 nether.toml and nether build for sounds
  9. Audio — Loading and playing sound effects from ROM
  10. Publishing — Sharing your game with the world

You’re now a Nethercore game developer!

Render Modes Guide

Nethercore ZX supports 4 rendering modes, each with different lighting and material features.

Overview

ModeNameLightingBest For
0LambertSimple diffuseFlat colors, UI, retro 2D
1MatcapPre-bakedStylized, toon, sculpted look
2Metallic-RoughnessPBR-style Blinn-PhongRealistic materials
3Specular-ShininessTraditional Blinn-PhongClassic 3D, arcade

Set the mode in your nether.toml:

[game]
id = "my-game"
title = "My Game"
author = "Developer"
version = "1.0.0"
render_mode = 2  # 0=Lambert, 1=Matcap, 2=Metallic-Roughness, 3=Specular-Shininess

If not specified, defaults to mode 0 (Lambert). The render mode cannot be changed at runtime.


Mode 0: Lambert

Supports both flat shading (without normals) and Lambert diffuse shading (with normals). Without normals, colors come directly from textures and set_color(). With normals, applies Lambert lighting from environment and dynamic lights.

Features:

  • Fastest rendering
  • Flat, solid colors
  • No shadows or highlights
  • Perfect for 2D sprites, UI, or intentionally flat aesthetics

Example:

#![allow(unused)]
fn main() {
// nether.toml: 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() {
// nether.toml: render_mode = 1

fn init() {
    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() {
// nether.toml: 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() {
// nether.toml: 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, simple lighting0 (Lambert)
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 environment and light setup. See the EPU Environments Guide for details on the new instruction-based EPU API.

#![allow(unused)]
fn main() {
// nether.toml: render_mode = 1, 2, or 3

// 8 x [hi, lo] = 128 bytes (16 x u64).
// For presets and packing helpers, see `examples/3-inspectors/epu-showcase/`.
static ENV: [[u64; 2]; 8] = [
    [0, 0], [0, 0], [0, 0], [0, 0],
    [0, 0], [0, 0], [0, 0], [0, 0],
];

fn render() {
    // Set environment config.
    unsafe { epu_set(ENV.as_ptr().cast()); }

    // Main directional light
    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...

    // Draw environment background last (fills only background pixels).
    unsafe { draw_epu(); }
}
}

EPU Environments

The Environment Processing Unit (EPU) is ZX’s GPU-driven procedural background and ambient environment system.

  • It renders an infinite environment when you call draw_epu() after providing a config with epu_set(config_ptr) (packed 128-byte config).
  • The same environment is sampled by lit shaders for ambient/reflection lighting.

For exact FFI signatures and instruction encoding, see the Environment (EPU) API.

For the full specification (opcode catalog, packing rules, WGSL details), see:

  • nethercore-design/specs/epu-feature-catalog.md
  • nethercore/include/zx.rs (canonical ABI docs)
  • nethercore/nethercore-zx/shaders/epu/ (shader sources)

Quick Start

  1. Create a packed EPU config: 8 × 128-bit instructions (stored as 16 u64 values as 8 [hi, lo] pairs).
  2. Call epu_set(config_ptr) near the start of render(), then call draw_epu() after your 3D geometry so the environment fills only background pixels.

Determinism note: The EPU has no host-managed time. To animate an environment, keep a deterministic u8 phase in your game state (e.g. phase = phase.wrapping_add(1) each frame), write it into the opcode parameter you want to drive (commonly param_d), and call epu_set(...) again with the updated config.

// 8 x [hi, lo]
static ENV: [[u64; 2]; 8] = [
    [0, 0], [0, 0], [0, 0], [0, 0],
    [0, 0], [0, 0], [0, 0], [0, 0],
];

fn render() {
    unsafe {
        epu_set(ENV.as_ptr().cast()); // Set environment config
        // ... draw scene geometry
        draw_epu(); // Draw environment background
    }
}

Reference presets and packing helpers:

  • nethercore/examples/3-inspectors/epu-showcase/src/presets.rs
  • nethercore/examples/3-inspectors/epu-showcase/src/constants.rs

Architecture Overview

The EPU uses a 128-byte instruction-based configuration:

SlotKindRecommended Use
0–3BoundsRAMP + optional bounds ops (0x02..0x07)
4–7RadianceDECAL/GRID/SCATTER/FLOW + radiance ops (0x0C..0x13)

Bounds defines the low-frequency envelope and region weights (sky/walls/floor).

Radiance adds higher-frequency motifs (decals, grids, stars, clouds, etc.).


Opcode Overview

OpcodeNameBest ForNotes
0x01RAMPBase boundsOften used first to explicitly set up/ceil/floor/softness, but any bounds opcode can be layer 0.
0x02SECTOROpening wedge / interior cuesBounds modifier
0x03SILHOUETTESkyline / horizon cutoutBounds modifier
0x04SPLITGeometric divisionsBounds
0x08DECALSun disks, signage, portalsRadiance
0x09GRIDPanels, architectural linesRadiance
0x0ASCATTERStars, dust, particlesRadiance
0x0BFLOWClouds, rain, causticsRadiance
0x12LOBESun glow, lamps, neon spillRadiance
0x13BANDHorizon bands / ringsRadiance

Authoring Workflow

  • Start from a known-good preset (epu-showcase).
  • Use the epu-showcase debug panel (F4) to iterate on one layer at a time (opcode + params).
  • Copy the resulting packed 8-layer config into your game, call epu_set(config_ptr), then call draw_epu().

Slot Conventions

SlotKindRecommended Use
0-3BoundsAny bounds opcode (0x01..0x07). Common convention is RAMP first, not a requirement.
4-7RadianceDECAL / GRID / SCATTER / FLOW + radiance ops (0x0C..0x13)

Bounds/Feature Cadence (Don't Waste Slots)

Bounds opcodes don't just draw color; they also rewrite the region weights (SKY/WALLS/FLOOR) that later feature opcodes use for masking.

  • Avoid stacking multiple “plain bounds” layers back-to-back (e.g. RAMP -> SILHOUETTE) unless you immediately exploit the new regions with feature layers.
  • Prefer a cadence like: BOUNDS (define/reshape regions) -> FEATURES (use regions) -> BOUNDS (carve/retag: APERTURE/SPLIT) -> FEATURES (decorate + animate).
  • If you insert a bounds opcode later in the 8-layer program, it only affects features after it (it cannot retroactively re-mask earlier features).

meta5 Behavior

  • meta5 encodes (domain_id << 3) | variant_id for opcodes that support domain/variant selection.
  • For opcodes that do not use domain/variant, set meta5 = 0.

Split-Screen / Multiple Viewports

Call viewport(...) and then draw_epu() per viewport/pass where you want an environment background.


See Also

Rollback Safety Guide

Writing deterministic code for Nethercore’s rollback netcode.

How Rollback Works

Nethercore 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. Nethercore 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

Nethercore 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

Nethercore Asset Pipeline

Convert 3D models, textures, and audio into optimized Nethercore formats.


Quick Start

Getting assets into a Nethercore 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:

nether-export build assets.toml
#![allow(unused)]
fn main() {
static PLAYER_MESH: &[u8] = include_bytes!("assets/player.nczxmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.nczxtex");

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
nether-export build assets.toml

# Validate manifest without building
nether-export check assets.toml

# Convert individual files
nether-export mesh player.gltf -o player.nczxmesh
nether-export texture grass.png -o grass.nczxtex
nether-export audio jump.wav -o jump.nczxsnd

Output Files

Running nether-export build assets.toml generates binary asset files:

  • player.nczxmesh, enemy.nczxmesh, level.nczxmesh
  • player_diffuse.nczxtex
  • jump.nczxsnd

Output File Formats

NetherZXMesh (.nczxmesh)

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.

NetherZTexture (.nczxtex)

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 .nczxtex files, regenerate them with:
    nether-export texture <source.png> -o <output.nczxtex>
    
  • Symptom of old format: “invalid dimensions” error during load

NetherZSound (.nczxsnd)

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 Nethercore runtime supports 16 vertex format combinations, controlled by format flags.

#![allow(unused)]
fn main() {
const FORMAT_UV: u8 = 1;      // Texture coordinates
const FORMAT_COLOR: u8 = 2;   // Per-vertex color
const FORMAT_NORMAL: u8 = 4;  // Surface normals
const FORMAT_SKINNED: u8 = 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

nether-export

The asset conversion CLI tool.

Build from manifest:

nether-export build assets.toml           # Build all assets
nether-export check assets.toml           # Validate only

Convert individual files:

# Meshes
nether-export mesh player.gltf -o player.nczxmesh
nether-export mesh level.obj -o level.nczxmesh --format POS_UV_NORMAL

# Textures
nether-export texture grass.png -o grass.nczxtex

# Audio
nether-export audio jump.wav -o jump.nczxsnd

Loading Assets (FFI)

The simplest way to load assets is using the NetherZ 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.nczxmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.nczxtex");
static JUMP_SFX: &[u8] = include_bytes!("assets/jump.nczxsnd");

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 NetherZ format and provide raw data directly:

Convenience API (f32 input, auto-packed):

#![allow(unused)]
fn main() {
extern "C" {
    fn load_mesh(data_ptr: u32, vertex_count: u32, format: u8) -> u32;
    fn load_mesh_indexed(data_ptr: u32, vertex_count: u32, index_ptr: u32, index_count: u32, format: u8) -> u32;
}
}

Power User API (pre-packed data):

#![allow(unused)]
fn main() {
extern "C" {
    fn load_mesh_packed(data_ptr: u32, vertex_count: u32, format: u8) -> u32;
    fn load_mesh_indexed_packed(data_ptr: u32, vertex_count: u32, index_ptr: u32, index_count: u32, format: u8) -> u32;
}
}

Constraints

Nethercore enforces these limits:

ResourceLimit
ROM size16 MB
VRAM4 MB
Bones per skeleton256

All assets are packaged into the ROM at build time. There is no runtime filesystem/network 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);
    }
}
}

nether.toml vs include_bytes!()

There are two ways to include assets in your game:

Method 1: nether.toml + ROM Packing

Best for: Production games with many assets

# nether.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 .nczx 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.nczxtex");

// 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 assetsnether.toml + ROM
Games with large texturesnether.toml (compression)

Planned Features

The following features are planned but not yet implemented:

  • Font conversion - TTF/OTF to bitmap font atlas (.nczxfont)
  • Watch mode - nether-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 Nethercore 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 nethercore.systems
  5. Share with the world

Building for Release

The nether CLI handles compilation and packaging:

# Build WASM
nether build

# Package into ROM
nether pack

# Or do both
nether build && nether 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 (nether.toml)

Create nether.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.nczxmesh"

[[assets.sounds]]
id = "jump"
path = "assets/jump.wav"

ROM File Format

Nethercore ROMs (.nczx 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

Texture Compression

ZX supports BC7 texture compression (4:1 ratio) for render modes 1-3 (Matcap/PBR/Hybrid).

Enabling Compression

Add to your nether.toml:

[game]
# ... other fields ...
compress_textures = true  # Enable BC7 compression (default: false)

When to Use Compression

Use compression when:

  • Using Matcap (mode 1), PBR (mode 2), or Hybrid (mode 3) render modes
  • ROM size is a concern (compression reduces size by ~75%)
  • Shipping production builds

Don’t use compression when:

  • Using Lambert (mode 0) render mode - works best with RGBA8
  • Rapid prototyping (faster build times without compression)
  • You need pixel-perfect uncompressed textures

Note: The packer will warn you if your compress_textures setting doesn’t match your render mode.

Testing Your Build

Always test the final build:

# Test the WASM directly
nether run target/wasm32-unknown-unknown/release/my_game.wasm

# Or test the packed ROM
nether run my_game.nczx

Verify:

  • Game starts correctly
  • All assets load
  • No console errors
  • Multiplayer works (test with two controllers)

Upload Requirements

Required Files

FileFormatDescription
Game.wasm or .nczxYour 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 nethercore.systems/register

2. Access Dashboard

Log in and go to nethercore.systems/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:

nethercore.systems/game/your-game-id

Updating Your Game

To release an update:

  1. Bump version in nether.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 nether.toml are relative to the project root.

Verify:

# Check if file exists
ls assets/player.png

“ROM too large”

Nethercore has size limits for fair distribution.

Reduce size:

  • Compress textures - Add compress_textures = true to nether.toml (see Texture Compression section)
  • 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 nethercore.systems, you can distribute:

Direct Download

Share the .wasm or .nczx file directly. Players load it in the Nethercore player.

GitHub Releases

Host on GitHub as release artifacts.

itch.io

Upload as a downloadable file with instructions.


Ready to publish? Head to nethercore.systems and share your creation with the world!

Cheat Sheet

All Nethercore 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 u32
random_range(min, max) -> i32          // Random i32 in [min, max)
random_f32() -> f32                    // Random f32 in [0.0, 1.0)
random_f32_range(min, max) -> f32      // Random f32 in [min, max)
player_count() -> u32                  // Number of players (1-4)
local_player_mask() -> u32             // Bitmask of local players
}

Screen Constants: screen::WIDTH=960, screen::HEIGHT=540


Configuration (Init-Only)

#![allow(unused)]
fn main() {
set_tick_rate(fps)                     // 0=24, 1=30, 2=60, 3=120
set_clear_color(0xRRGGBBAA)            // Background color
// render_mode set via nether.toml     // 0=Lambert, 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
cull_mode(mode)                        // 0=none (default), 1=back, 2=front
texture_filter(filter)                 // 0=nearest, 1=linear
uniform_alpha(level)                   // 0-15 dither alpha
dither_offset(x, y)                    // 0-3 pattern offset
z_index(n)                             // 2D ordering within pass (0=back, higher=front)
}

Render Passes (Execution Barriers)

#![allow(unused)]
fn main() {
begin_pass(clear_depth)                // New pass with optional depth clear
begin_pass_stencil_write(ref_val, clear_depth)  // Create stencil mask
begin_pass_stencil_test(ref_val, clear_depth)   // Render inside mask
begin_pass_full(...)                   // Full control (8 params)
}

Use Cases:

  • FPS viewmodels: begin_pass(1) clears depth, gun renders on top
  • Portals: begin_pass_stencil_write(1,0) then begin_pass_stencil_test(1,1)

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, TANGENT=16 (combine with NORMAL)


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
material_normal(texture)               // Bind normal map to slot 3

// Mode 3 (Specular-Shininess)
material_shininess(value)              // 0.0-1.0 → 1-256
material_specular(0xRRGGBBAA)          // Specular color

// Override flags
use_uniform_color(enabled)
use_uniform_metallic(enabled)
use_uniform_roughness(enabled)
use_uniform_emissive(enabled)
skip_normal_map(skip)                  // 0=use normal map, 1=use vertex normal
}

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)
}

Environment Processing Unit (EPU)

The EPU renders procedural environment backgrounds and provides ambient/reflection lighting via a packed 128-byte configuration (8 x 128-bit instructions).

Push API

#![allow(unused)]
fn main() {
fn environment_index(env_id: u32);
fn epu_set(config_ptr: *const u64);
fn draw_epu();
}

Config Layout

  • 16 x u64 (128 bytes total): hi0, lo0, hi1, lo1, ... hi7, lo7

Instruction Layout (128-bit per layer)

u64 hi [bits 127..64]:
  [127:123] opcode     (5)
  [122:120] region     (3)  - SKY=4, WALLS=2, FLOOR=1, ALL=7
  [119:117] blend      (3)  - ADD=0, MULTIPLY=1, MAX=2, LERP=3, SCREEN=4, HSV_MOD=5, MIN=6, OVERLAY=7
  [116:112] meta5      (5)  - (domain_id<<3)|variant_id; use 0 when unused
  [111:88]  color_a    (24) - RGB24 primary color
  [87:64]   color_b    (24) - RGB24 secondary color

u64 lo [bits 63..0]:
  [63:56]   intensity  (8)
  [55:48]   param_a    (8)
  [47:40]   param_b    (8)
  [39:32]   param_c    (8)
  [31:24]   param_d    (8)
  [23:8]    direction  (16) - octahedral encoded (u8,u8)
  [7:4]     alpha_a    (4)
  [3:0]     alpha_b    (4)

Opcodes (current shaders)

NOP=0x00, RAMP=0x01, SECTOR=0x02, SILHOUETTE=0x03, SPLIT=0x04, CELL=0x05, PATCHES=0x06, APERTURE=0x07, DECAL=0x08, GRID=0x09, SCATTER=0x0A, FLOW=0x0B, TRACE=0x0C, VEIL=0x0D, ATMOSPHERE=0x0E, PLANE=0x0F, CELESTIAL=0x10, PORTAL=0x11, LOBE=0x12, BAND=0x13.

See EPU API Reference and EPU Feature Catalog.


2D Drawing

Note: Use set_color(0xRRGGBBAA) before drawing to set the tint color. Source coordinates (src_*) are UV values (0.0-1.0), not pixels.

#![allow(unused)]
fn main() {
// Sprites (use set_color() for tinting)
draw_sprite(x, y, w, h)
draw_sprite_region(x, y, w, h, src_x, src_y, src_w, src_h)  // UV coords (0.0-1.0)
draw_sprite_ex(x, y, w, h, src_x, src_y, src_w, src_h, ox, oy, angle)

// Primitives (use set_color() for color)
draw_rect(x, y, w, h)
draw_line(x1, y1, x2, y2, thickness)
draw_circle(x, y, radius)                      // Filled, 16 segments
draw_circle_outline(x, y, radius, thickness)

// Text (use set_color() for color)
draw_text(ptr, len, x, y, size)
text_width(ptr, len, size) -> f32              // Measure text width
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

Note: Use set_color(0xRRGGBBAA) before drawing to set the tint color. UV coordinates are 0.0-1.0.

#![allow(unused)]
fn main() {
draw_billboard(w, h, mode)             // mode: 1=sphere, 2=cylY, 3=cylX, 4=cylZ
draw_billboard_region(w, h, sx, sy, sw, sh, mode)  // UV coords (0.0-1.0)
}

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 (SFX)

#![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)
}

Unified Music API (PCM + XM Tracker)

Works with both PCM sounds and XM tracker modules. Handle type detected by bit 31 (0=PCM, 1=tracker).

#![allow(unused)]
fn main() {
// Loading (init-only)
rom_tracker(id_ptr, id_len) -> u32     // Load XM from ROM (returns tracker handle)
load_tracker(data_ptr, len) -> u32     // Load XM from raw data

// Playback (works for both PCM and tracker)
music_play(handle, volume, looping)    // Start playing (auto-stops other type)
music_stop()                            // Stop all music
music_pause(paused)                     // 0=resume, 1=pause (tracker only)
music_set_volume(volume)                // 0.0-1.0
music_is_playing() -> u32               // 1 if playing
music_type() -> u32                     // 0=none, 1=PCM, 2=tracker

// Position (tracker-specific, no-op for PCM)
music_jump(order, row)                  // Jump to position
music_position() -> u32                 // Tracker: (order << 16) | row, PCM: sample pos
music_length(handle) -> u32             // Tracker: orders, PCM: samples
music_set_speed(speed)                  // Ticks per row (1-31)
music_set_tempo(bpm)                    // BPM (32-255)

// Query
music_info(handle) -> u32               // Tracker: (ch<<24)|(pat<<16)|(inst<<8)|len
music_name(handle, out_ptr, max) -> u32 // Tracker only (returns 0 for PCM)
}

Note: PCM and tracker music are mutually exclusive. Starting one stops the other. Load samples via rom_sound() before rom_tracker() to map tracker instruments.


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_tracker(id_ptr, id_len) -> u32     // Load XM tracker
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: F2=settings, F3=stats, F4=inspector, F5=pause, F6=step, F7/F8=time scale, `=EPU panel (ZX), F12=network

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 Nethercore 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.


random_range

Returns a random integer in range [min, max).

Signature:

#![allow(unused)]
fn main() {
fn random_range(min: i32, max: i32) -> i32
}

Parameters:

NameTypeDescription
mini32Minimum value (inclusive)
maxi32Maximum value (exclusive)

Returns: Random integer in range [min, max)

Example:

#![allow(unused)]
fn main() {
let spawn_x = random_range(0, 960);  // 0 to 959
let damage = random_range(10, 21);   // 10 to 20
}

random_f32

Returns a random float in range [0.0, 1.0).

Signature:

#![allow(unused)]
fn main() {
fn random_f32() -> f32
}

Returns: Random float in range [0.0, 1.0)

Example:

#![allow(unused)]
fn main() {
let t = random_f32();  // 0.0 to 0.999...
let color_variation = random_f32() * 0.2 - 0.1;  // -0.1 to +0.1
}

random_f32_range

Returns a random float in range [min, max).

Signature:

#![allow(unused)]
fn main() {
fn random_f32_range(min: f32, max: f32) -> f32
}

Parameters:

NameTypeDescription
minf32Minimum value (inclusive)
maxf32Maximum value (exclusive)

Returns: Random float in range [min, max)

Example:

#![allow(unused)]
fn main() {
let speed = random_f32_range(5.0, 15.0);  // 5.0 to 14.999...
let angle = random_f32_range(0.0, 6.28);  // 0 to 2π
}

Screen Constants

Fixed screen dimensions for the ZX console (540p resolution).

#![allow(unused)]
fn main() {
screen::WIDTH   // 960
screen::HEIGHT  // 540
}

Example:

#![allow(unused)]
fn main() {
// Center something on screen
let center_x = screen::WIDTH as f32 / 2.0;
let center_y = screen::HEIGHT as f32 / 2.0;
}

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

Nethercore 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

Nethercore 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_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
0LambertSimple diffuse shading
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 (Lambert).

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);
}
}

cull_mode

Sets face culling mode.

Signature:

#![allow(unused)]
fn main() {
fn cull_mode(mode: u32)
}

Parameters:

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

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);
    set_color(0xFFFFFFFF);
    draw_sprite(0.0, 0.0, 64.0, 64.0);

    // 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);
}
}

Render Passes (Execution Barriers)

Render passes provide execution barriers with configurable depth and stencil state. Commands in pass N are guaranteed to complete before commands in pass N+1 begin.

begin_pass

Starts a new render pass with standard depth testing (depth enabled, compare LESS, write ON).

Signature:

#![allow(unused)]
fn main() {
fn begin_pass(clear_depth: u32)
}

Parameters:

NameTypeDescription
clear_depthu321 to clear depth buffer, 0 to preserve

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Draw world normally
    draw_mesh(world);

    // Start new pass, clear depth to draw gun on top
    begin_pass(1);
    draw_mesh(fps_gun);
}
}

begin_pass_stencil_write

Starts a stencil write pass for mask creation. Depth is disabled, stencil writes reference value on pass.

Signature:

#![allow(unused)]
fn main() {
fn begin_pass_stencil_write(ref_value: u32, clear_depth: u32)
}

Parameters:

NameTypeDescription
ref_valueu32Stencil reference value to write (0-255)
clear_depthu321 to clear depth buffer, 0 to preserve

Example: See Portal Effect below.


begin_pass_stencil_test

Starts a stencil test pass to render only where stencil equals reference. Depth testing is enabled.

Signature:

#![allow(unused)]
fn main() {
fn begin_pass_stencil_test(ref_value: u32, clear_depth: u32)
}

Parameters:

NameTypeDescription
ref_valueu32Stencil reference value to test against (0-255)
clear_depthu321 to clear depth buffer (for portal interiors), 0 to preserve

Example: See Portal Effect below.


begin_pass_full

Starts a pass with full control over depth and stencil state.

Signature:

#![allow(unused)]
fn main() {
fn begin_pass_full(
    depth_compare: u32,
    depth_write: u32,
    clear_depth: u32,
    stencil_compare: u32,
    stencil_ref: u32,
    stencil_pass_op: u32,
    stencil_fail_op: u32,
    stencil_depth_fail_op: u32,
)
}

Compare Function Constants:

ConstantValueDescription
compare::NEVER1Never pass
compare::LESS2Pass if src < dst
compare::EQUAL3Pass if src == dst
compare::LESS_EQUAL4Pass if src <= dst
compare::GREATER5Pass if src > dst
compare::NOT_EQUAL6Pass if src != dst
compare::GREATER_EQUAL7Pass if src >= dst
compare::ALWAYS8Always pass

Stencil Operation Constants:

ConstantValueDescription
stencil_op::KEEP0Keep current value
stencil_op::ZERO1Set to zero
stencil_op::REPLACE2Replace with ref value
stencil_op::INCREMENT_CLAMP3Increment, clamp to max
stencil_op::DECREMENT_CLAMP4Decrement, clamp to 0
stencil_op::INVERT5Bitwise invert
stencil_op::INCREMENT_WRAP6Increment, wrap to 0
stencil_op::DECREMENT_WRAP7Decrement, wrap to max

z_index

Sets the Z-order index for 2D draw ordering within a pass. Higher values draw on top.

Signature:

#![allow(unused)]
fn main() {
fn z_index(n: u32)
}

Parameters:

NameTypeDescription
nu32Z-order index (0-255)

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Background (lowest)
    z_index(0);
    set_color(bg_color);
    draw_sprite(bg_x, bg_y, bg_w, bg_h);

    // Game objects
    z_index(1);
    set_color(obj_color);
    draw_sprite(obj_x, obj_y, obj_w, obj_h);

    // UI on top
    z_index(2);
    set_color(ui_color);
    draw_sprite(ui_x, ui_y, ui_w, ui_h);
}
}

Viewport

Functions for split-screen rendering. Each player can have their own viewport region.

viewport

Set the viewport for subsequent draw calls.

Signature:

#![allow(unused)]
fn main() {
fn viewport(x: u32, y: u32, width: u32, height: u32)
}

Parameters:

NameTypeDescription
xu32Left edge of viewport in screen pixels
yu32Top edge of viewport in screen pixels
widthu32Width of viewport in screen pixels
heightu32Height of viewport in screen pixels

Behavior:

  • Camera aspect ratio automatically adjusts to viewport dimensions
  • 2D coordinates (draw_sprite, draw_text, etc.) become viewport-relative
  • Native resolution is 960×540

Example:

#![allow(unused)]
fn main() {
fn render() {
    // 2-player horizontal split

    // Left half (player 1)
    viewport(0, 0, 480, 540);
    camera_position(p1.x, p1.y, p1.z);
    draw_mesh(world);
    set_color(0xFFFFFFFF);
    draw_text_str("P1", 10.0, 10.0, 16.0);

    // Right half (player 2)
    viewport(480, 0, 480, 540);
    camera_position(p2.x, p2.y, p2.z);
    draw_mesh(world);
    set_color(0xFFFFFFFF);
    draw_text_str("P2", 10.0, 10.0, 16.0);

    // Reset to fullscreen for shared UI
    viewport_clear();
    set_color(0xFFFFFFFF);
    draw_text_str("SCORE: 1000", 400.0, 10.0, 16.0);
}
}

viewport_clear

Reset viewport to fullscreen (960×540).

Signature:

#![allow(unused)]
fn main() {
fn viewport_clear()
}

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Draw player viewports...
    viewport(0, 0, 480, 540);
    // ... render player 1 ...

    viewport(480, 0, 480, 540);
    // ... render player 2 ...

    // Back to fullscreen for UI overlay
    viewport_clear();
    z_index(10);
    set_color(0x00000080);
    draw_rect(0.0, 500.0, 960.0, 40.0);  // Bottom bar
}
}

Stencil Portal Example

#![allow(unused)]
fn main() {
fn render() {
    // 1. Draw main world
    draw_mesh(main_world);

    // 2. Write portal shape to stencil buffer (invisible)
    begin_pass_stencil_write(1, 0);
    draw_mesh(portal_quad);

    // 3. Draw portal interior (only where stencil == 1, clear depth)
    begin_pass_stencil_test(1, 1);
    draw_mesh(other_world);

    // 4. Return to normal rendering
    begin_pass(0);
    draw_mesh(portal_frame);
}
}

Complete Example

#![allow(unused)]
fn main() {
fn init() {
    // Configure console
    set_tick_rate(2);         // 60 fps
    set_clear_color(0x1a1a2eFF);
    render_mode(2);           // PBR lighting
}

fn render() {
    // Draw 3D scene (depth testing is enabled by default)
    cull_mode(1);  // Enable back-face culling for performance
    texture_filter(1);

    set_color(0xFFFFFFFF);
    draw_mesh(level);
    draw_mesh(player);

    // Draw semi-transparent water using dithering
    uniform_alpha(8);  // 50% alpha via ordered dithering
    set_color(0x4080FFFF);
    draw_mesh(water);
    uniform_alpha(15);  // Reset to fully opaque

    // Draw UI (2D draws are always on top via z_index)
    texture_filter(0);
    z_index(1);
    set_color(0xFFFFFFFF);
    draw_sprite(10.0, 10.0, 200.0, 50.0);
}
}

Camera Functions

Camera position, target, and projection control.

Coordinate System

The camera uses a right-handed, Y-up coordinate system:

  • +X points right
  • +Y points up
  • +Z points toward the viewer (out of the screen)

This matches standard OpenGL/wgpu conventions. When using camera_set(), positions and targets are specified in world space using these axes.

Default projection settings:

PropertyValue
FOV60° vertical
Aspect16:9 (960×540 fixed resolution)
Near plane0.1 units
Far plane1000 units

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);
}
}

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);
}
}

material_normal

Binds a normal map texture to slot 3. Requires mesh to have tangent data (FORMAT_TANGENT).

Signature:

#![allow(unused)]
fn main() {
fn material_normal(texture: u32)
}

Parameters:

NameTypeDescription
textureu32Texture handle for normal map (BC5 RG format recommended)

Normal Map Format:

  • BC5 RG: 2-channel compressed format (8 bytes/16 pixels). Blue channel reconstructed from RG.
  • RGBA8: Also supported, but wastes storage. Only RG channels are used.

Developer-Friendly Default: When a mesh has tangent data, normal mapping is enabled automatically. Use skip_normal_map(1) to opt out if needed.

Example:

#![allow(unused)]
fn main() {
fn render() {
    material_albedo(brick_albedo);
    material_mre(brick_mre);
    material_normal(brick_normal);  // Adds surface detail
    draw_mesh(wall);
}
}

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)
}

skip_normal_map

Skip normal map sampling and use vertex normals instead.

Signature:

#![allow(unused)]
fn main() {
fn skip_normal_map(skip: u32)
}

Parameters:

NameTypeDescription
skipu320 = use normal map (default when tangent data exists), 1 = skip normal map

Use Cases:

  • Debugging: See the raw vertex normals without normal map perturbation
  • Artistic control: Prefer smooth vertex normals for certain materials
  • Performance: Skip texture sampling when normal detail isn’t needed

Note: This flag only affects meshes with tangent data. Meshes without tangent data always use vertex normals regardless of this setting.

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Use normal map for detailed brick wall
    skip_normal_map(0);
    material_normal(brick_normal);
    draw_mesh(wall);

    // Skip normal map for smooth character skin
    skip_normal_map(1);
    draw_mesh(character_face);
}
}

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() {
    // Main directional light
    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 environment 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
  • Ambient comes from the procedural environment automatically
  • Works only in Mode 2 (Metallic-Roughness) and Mode 3 (Specular-Shininess)

See Also: Environment (EPU), 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.nczxskel");

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.

Asset Configuration

Configure animations in nether.toml:

Explicit Import (Single Animation)

[[assets.animations]]
id = "walk"
path = "models/character.glb"
animation_name = "WalkCycle"
skin_name = "Armature"

Bulk Import (All Animations from GLB)

Import all animations from a single GLB file with one entry:

[[assets.animations]]
path = "models/character.glb"
skin_name = "Armature"
id_prefix = "char_"  # Optional: creates char_Walk, char_Run, etc.

When both id and animation_name are omitted, ALL animations from the GLB are imported using their original Blender names as asset IDs. Use id_prefix to prevent collisions when importing from multiple GLB files.


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.nczxanim");
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 bone_count × 40 bytes)

Output Format (TRS, 40 bytes per bone):

Per-bone layout:
  [0..16]  rotation: [f32; 4]  (quaternion x, y, z, w)
  [16..28] position: [f32; 3]  (translation x, y, z)
  [28..40] scale:    [f32; 3]  (scale x, y, z)

Note: This returns TRS (Translation-Rotation-Scale) format, NOT matrices. Use this for CPU-side interpolation/blending. For stamp animation without blending, use keyframe_bind() which uses pre-converted matrices on GPU.

Example:

#![allow(unused)]
fn main() {
const BONE_COUNT: usize = 64;
const TRS_SIZE: usize = 40; // 10 floats per bone (quat[4] + pos[3] + scale[3])

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 TRS frames for interpolation
        let mut buf_a = [0u8; BONE_COUNT * TRS_SIZE];
        let mut buf_b = [0u8; BONE_COUNT * TRS_SIZE];

        keyframe_read(WALK_ANIM, frame_a, buf_a.as_mut_ptr());
        keyframe_read(WALK_ANIM, frame_b, buf_b.as_mut_ptr());

        // Interpolate TRS on CPU (SLERP quaternions, LERP position/scale)
        let blended_trs = interpolate_trs(&buf_a, &buf_b, blend);

        // Convert TRS to matrices and upload
        let matrices = trs_to_matrices(&blended_trs, BONE_COUNT);
        set_bones_4x4(matrices.as_ptr(), BONE_COUNT as u32);
        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.

Color: Use set_color(0xRRGGBBAA) before drawing to set the tint color. Default is white (0xFFFFFFFF).

Screen Coordinate System

All 2D drawing uses screen-space coordinates with the following conventions:

PropertyValue
Resolution960×540 pixels (fixed)
OriginTop-left corner (0, 0)
X-axisIncreases rightward (0 to 960)
Y-axisIncreases downward (0 to 540)
Anchor pointTop-left corner of sprite/rect
(0,0) ───────────────────────────► X (960)
  │
  │   ┌────────┐
  │   │ sprite │  x,y is top-left corner
  │   │  w×h   │
  │   └────────┘
  ▼
  Y (540)

The screen is always rendered at 960×540 internally and scaled to fit the window. This ensures pixel-perfect positioning regardless of window size.

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)
}

Parameters:

NameTypeDescription
x, yf32Screen position (top-left corner)
w, hf32Size in pixels

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Draw full texture
    texture_bind(player_sprite);
    draw_sprite(100.0, 100.0, 64.0, 64.0);

    // Tinted sprite
    set_color(0xFF8080FF);
    draw_sprite(200.0, 100.0, 64.0, 64.0);
}
}

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
)
}

Parameters:

NameTypeDescription
x, yf32Screen position
w, hf32Destination size in pixels
src_x, src_yf32Source UV position (0.0-1.0)
src_w, src_hf32Source UV size (0.0-1.0)

Example:

#![allow(unused)]
fn main() {
// Sprite sheet: 4x4 grid of 32x32 sprites (128x128 texture)
fn draw_frame(frame: u32) {
    let col = frame % 4;
    let row = frame / 4;
    // UV coordinates: frame size / texture size
    let uv_size = 32.0 / 128.0;  // = 0.25
    draw_sprite_region(
        100.0, 100.0, 64.0, 64.0,           // Destination (scaled 2x)
        col as f32 * uv_size, row as f32 * uv_size, uv_size, uv_size  // Source UV
    );
}
}

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
)
}

Parameters:

NameTypeDescription
x, yf32Screen position
w, hf32Destination size
src_x, src_y, src_w, src_hf32Source UV region (0.0-1.0)
origin_x, origin_yf32Rotation origin (0-1 normalized)
angle_degf32Rotation angle in degrees

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, 1.0, 1.0,          // Full texture UV
        0.5, 0.5,                     // Center origin
        elapsed_time() * 90.0         // Rotation (90 deg/sec)
    );

    // Rotating around bottom-center (like a pendulum)
    draw_sprite_ex(
        300.0, 200.0, 64.0, 64.0,
        0.0, 0.0, 1.0, 1.0,
        0.5, 1.0,                     // Bottom-center origin
        (elapsed_time() * 2.0).sin() * 30.0
    );
}
}

Rectangles

draw_rect

Draws a solid color rectangle.

Signature:

#![allow(unused)]
fn main() {
fn draw_rect(x: f32, y: f32, w: f32, h: f32)
}

Parameters:

NameTypeDescription
x, yf32Screen position (top-left)
w, hf32Size in pixels

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Health bar background
    set_color(0x333333FF);
    draw_rect(10.0, 10.0, 100.0, 20.0);

    // Health bar fill
    set_color(0x00FF00FF);
    let health_width = (health / max_health) * 96.0;
    draw_rect(12.0, 12.0, health_width, 16.0);

    // Semi-transparent overlay
    set_color(0x00000080);
    draw_rect(0.0, 0.0, 960.0, 540.0);
}
}

Lines & Circles

draw_line

Draws a line between two points.

Signature:

#![allow(unused)]
fn main() {
fn draw_line(x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32)
}

Parameters:

NameTypeDescription
x1, y1f32Start point in screen pixels
x2, y2f32End point in screen pixels
thicknessf32Line thickness in pixels

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Diagonal line
    set_color(0xFFFFFFFF);
    draw_line(100.0, 100.0, 300.0, 200.0, 2.0);

    // Box outline
    set_color(0x00FF00FF);
    draw_line(50.0, 50.0, 150.0, 50.0, 1.0);
    draw_line(150.0, 50.0, 150.0, 150.0, 1.0);
    draw_line(150.0, 150.0, 50.0, 150.0, 1.0);
    draw_line(50.0, 150.0, 50.0, 50.0, 1.0);
}
}

draw_circle

Draws a filled circle.

Signature:

#![allow(unused)]
fn main() {
fn draw_circle(x: f32, y: f32, radius: f32)
}

Parameters:

NameTypeDescription
x, yf32Center position in screen pixels
radiusf32Circle radius in pixels

Notes: Rendered as a 16-segment approximation.

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Player indicator
    set_color(0x00FF00FF);
    draw_circle(player_x, player_y, 10.0);

    // Semi-transparent area effect
    set_color(0xFF000040);
    draw_circle(500.0, 300.0, 50.0);
}
}

draw_circle_outline

Draws a circle outline.

Signature:

#![allow(unused)]
fn main() {
fn draw_circle_outline(x: f32, y: f32, radius: f32, thickness: f32)
}

Parameters:

NameTypeDescription
x, yf32Center position in screen pixels
radiusf32Circle radius in pixels
thicknessf32Line thickness in pixels

Notes: Rendered as 16 line segments.

Example:

#![allow(unused)]
fn main() {
fn render() {
    // Selection ring
    set_color(0xFFFF00FF);
    draw_circle_outline(selected_x, selected_y, 20.0, 2.0);

    // Range indicator
    set_color(0x00FF0080);
    draw_circle_outline(tower_x, tower_y, attack_range, 1.0);
}
}

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)
}

Parameters:

NameTypeDescription
ptr*const u8Pointer to UTF-8 string
lenu32String length in bytes
x, yf32Screen position
sizef32Font size in pixels

Note: Use set_color(0xRRGGBBAA) before calling draw_text() to set the text color.

Example:

#![allow(unused)]
fn main() {
fn render() {
    let text = b"SCORE: 12345";
    set_color(0xFFFFFFFF);
    draw_text(text.as_ptr(), text.len() as u32, 10.0, 10.0, 16.0);

    let title = b"GAME OVER";
    set_color(0xFF0000FF);
    draw_text(title.as_ptr(), title.len() as u32, 400.0, 270.0, 48.0);
}
}

text_width

Measures the width of text when rendered.

Signature:

#![allow(unused)]
fn main() {
fn text_width(ptr: *const u8, len: u32, size: f32) -> f32
}

Parameters:

NameTypeDescription
ptr*const u8Pointer to UTF-8 string
lenu32String length in bytes
sizef32Font size in pixels

Returns: Width in pixels that the text would occupy when rendered.

Example:

#![allow(unused)]
fn main() {
fn render() {
    let text = b"CENTERED";
    let width = text_width(text.as_ptr(), text.len() as u32, 32.0);

    // Center text on screen
    let x = (screen::WIDTH as f32 - width) / 2.0;
    set_color(0xFFFFFFFF);
    draw_text(text.as_ptr(), text.len() as u32, x, 270.0, 32.0);
}
}

See Also: draw_text


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);
    set_color(0xFFFFFFFF);
    draw_text(b"Custom Text".as_ptr(), 11, 10.0, 10.0, 16.0);

    // Switch to different font
    font_bind(TITLE_FONT);
    set_color(0xFFD700FF);
    draw_text(b"Title".as_ptr(), 5, 100.0, 50.0, 32.0);
}
}

Complete Example

#![allow(unused)]
fn main() {
static mut UI_FONT: u32 = 0;
static mut ICON_SHEET: u32 = 0;

// Icon sheet is 64x32 pixels with 16x16 icons
const ICON_SIZE: f32 = 16.0;
const SHEET_W: f32 = 64.0;
const SHEET_H: f32 = 32.0;
const ICON_UV_W: f32 = ICON_SIZE / SHEET_W;  // 0.25
const ICON_UV_H: f32 = ICON_SIZE / SHEET_H;  // 0.5

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
        set_color(0x00000099);
        draw_rect(5.0, 5.0, 200.0, 80.0);

        // Health bar background
        set_color(0x333333FF);
        draw_rect(10.0, 10.0, 102.0, 12.0);

        // Health bar fill
        set_color(0x00FF00FF);
        draw_rect(11.0, 11.0, health as f32, 10.0);

        // Health icon (first icon in sheet)
        texture_bind(ICON_SHEET);
        set_color(0xFFFFFFFF);
        draw_sprite_region(
            10.0, 25.0, 16.0, 16.0,      // Position and size
            0.0, 0.0, ICON_UV_W, ICON_UV_H  // UV region (0.0-1.0)
        );

        // Score text
        font_bind(UI_FONT);
        set_color(0xFFFFFFFF);
        let score_text = b"SCORE: 12345";
        draw_text(score_text.as_ptr(), score_text.len() as u32,
                  30.0, 25.0, 12.0);

        // Animated coin icon (4 frames in bottom row)
        let frame = ((elapsed_time() * 8.0) as u32) % 4;
        set_color(0xFFD700FF);
        draw_sprite_region(
            10.0, 45.0, 16.0, 16.0,
            frame as f32 * ICON_UV_W, 0.5, ICON_UV_W, ICON_UV_H  // UV coords
        );

        // 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)
}

Parameters:

NameTypeDescription
wf32Width in world units
hf32Height in world units
modeu32Billboard mode (1-4)

Note: Use set_color(0xRRGGBBAA) before calling to set the billboard tint.

Example:

#![allow(unused)]
fn main() {
fn render() {
    texture_bind(tree_sprite);

    // Trees with cylindrical Y billboards
    set_color(0xFFFFFFFF);
    for tree in &trees {
        push_identity();
        push_translate(tree.x, tree.y, tree.z);
        draw_billboard(2.0, 4.0, 2);
    }

    // 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);
        set_color(particle.color);
        draw_billboard(0.5, 0.5, 1);
    }
}
}

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
)
}

Parameters:

NameTypeDescription
w, hf32Size in world units
src_x, src_yf32UV origin in texture (0.0-1.0)
src_w, src_hf32UV size in texture (0.0-1.0)
modeu32Billboard mode (1-4)

Note: Use set_color(0xRRGGBBAA) before calling to set the billboard tint.

Example:

#![allow(unused)]
fn main() {
// Sprite sheet: 128x32 pixels, 4 frames of 32x32 each
const FRAME_UV_W: f32 = 32.0 / 128.0;  // 0.25
const FRAME_UV_H: f32 = 32.0 / 32.0;   // 1.0

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);
    set_color(0xFFFFFFFF);
    draw_billboard_region(
        2.0, 2.0,                                    // Size
        frame as f32 * FRAME_UV_W, 0.0, FRAME_UV_W, FRAME_UV_H,  // UV coords
        2                                            // Cylindrical Y
    );
}
}

Use Cases

Trees and Vegetation

#![allow(unused)]
fn main() {
// Vegetation atlas: 256x128 pixels, 4 tree types of 64x128 each
const TREE_UV_W: f32 = 64.0 / 256.0;   // 0.25
const TREE_UV_H: f32 = 128.0 / 128.0;  // 1.0

fn render() {
    texture_bind(vegetation_atlas);
    blend_mode(1); // Alpha blend for transparency
    cull_mode(0);  // Double-sided
    set_color(0xFFFFFFFF);

    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 as f32 * TREE_UV_W;
        draw_billboard_region(
            tree.width, tree.height,
            src_x, 0.0, TREE_UV_W, TREE_UV_H,
            2  // Cylindrical Y - always upright
        );
    }
}
}

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;
        set_color(color);
        draw_billboard(particle.size, particle.size, 1);
    }
}
}

NPCs and Enemies

#![allow(unused)]
fn main() {
// NPC sheet: 128x128 pixels, 4x4 grid of 32x32 frames
const FRAME_UV_SIZE: f32 = 32.0 / 128.0;  // 0.25

fn render() {
    texture_bind(npc_sheet);
    blend_mode(1);
    set_color(0xFFFFFFFF);

    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) as f32 * FRAME_UV_SIZE,
            (frame / 4) as f32 * FRAME_UV_SIZE,
            FRAME_UV_SIZE, FRAME_UV_SIZE,
            2  // Cylindrical Y
        );
    }
}
}

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
            set_color(0x333333FF);
            draw_billboard(1.0, 0.1, 1);

            // Health fill
            let ratio = enemy.health / enemy.max_health;
            push_scale(ratio, 1.0, 1.0);
            set_color(0x00FF00FF);
            draw_billboard(1.0, 0.1, 1);
        }
    }

    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);
        set_color(0xFFFFFFFF);

        push_identity();
        push_translate(5.0, 2.0, -5.0);
        draw_billboard(2.0, 4.0, 2);

        push_identity();
        push_translate(-3.0, 1.5, -8.0);
        draw_billboard(1.5, 3.0, 2);

        // 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;
                set_color(0xFFAA00FF & (0xFFFFFF00 | alpha));
                draw_billboard(p.size, p.size, 1);
            }
        }

        blend_mode(0);
        cull_mode(1);
    }
}
}

See Also: Textures, Transforms

Environment Processing Unit (EPU)

The Environment Processing Unit (EPU) is ZX’s instruction-based procedural environment system. You provide a packed 128-byte configuration (8 × 128-bit instructions) and use epu_set(config_ptr) + draw_epu() to:

  • Render the environment background
  • Drive ambient + reflection lighting for lit materials (computed on the GPU)

For canonical ABI docs, see nethercore/include/zx.rs. For the opcode catalog/spec, see nethercore-design/specs/epu-feature-catalog.md.


FFI

environment_index

Select which EPU environment (env_id) subsequent draw calls will sample for ambient + reflections.

/// Select the EPU environment ID for subsequent draws (0..255).
fn environment_index(env_id: u32);

epu_set

Store the environment config for the currently selected environment_index(...) (no background draw).

To configure multiple environments in the same frame, call environment_index(env_id) then epu_set(config_ptr) for each env_id you use.

/// Store the EPU config for the current environment_index(...).
///
/// config_ptr points to 16 u64 values (128 bytes):
/// 8 instructions × (hi u64, lo u64)
fn epu_set(config_ptr: *const u64);

draw_epu

Draw the environment background for the current viewport/pass.

/// Draw the EPU background for the current viewport/pass.
fn draw_epu();

Call draw_epu() after your 3D geometry so the environment only fills background pixels.

Notes:

  • For split-screen, set viewport(...) and call draw_epu() per viewport.
  • The EPU compute pass runs automatically before rendering.
  • Ambient lighting is computed and applied entirely on the GPU; there is no CPU ambient query.
  • epu_set(...) stores a config for the currently selected environment_index(...).

Configuration Layout

Each environment is exactly 8 × 128-bit instructions (128 bytes total). In memory, that’s 16 u64 values laid out as 8 [hi, lo] pairs.

SlotKindRecommended Use
0–3BoundsAny bounds opcode (0x01..0x07). Common convention: start with RAMP to explicitly set up/ceil/floor/softness, then add SECTOR/SILHOUETTE/etc., but it is not required.
4–7RadianceDECAL / GRID / SCATTER / FLOW + radiance ops (0x0C..0x13)

Instruction Bit Layout (128-bit)

Each instruction is packed as two u64 values:

High Word (bits 127..64)

bits 127..123: opcode     (5)  - Which algorithm to run
bits 122..120: region     (3)  - Bitfield: SKY=0b100, WALLS=0b010, FLOOR=0b001
bits 119..117: blend      (3)  - How to combine layer output (8 modes)
bits 116..112: meta5      (5)  - (domain_id<<3)|variant_id; use 0 when unused
bits 111..88:  color_a    (24) - RGB24 primary color
bits 87..64:   color_b    (24) - RGB24 secondary color

Low Word (bits 63..0)

bits 63..56:   intensity  (8)  - Layer brightness
bits 55..48:   param_a    (8)  - Opcode-specific
bits 47..40:   param_b    (8)  - Opcode-specific
bits 39..32:   param_c    (8)  - Opcode-specific
bits 31..24:   param_d    (8)  - Opcode-specific
bits 23..8:    direction  (16) - Octahedral-encoded direction (u8,u8)
bits 7..4:     alpha_a    (4)  - color_a alpha (0=transparent, 15=opaque)
bits 3..0:     alpha_b    (4)  - color_b alpha (0=transparent, 15=opaque)

Determinism (No Host Time)

The EPU has no host-managed time input. Any temporal variation (scrolling, pulsing, drifting, twinkling, etc.) must be driven explicitly by the game by changing instruction parameters as part of deterministic simulation.

In practice this usually means incrementing an opcode-specific phase parameter (often param_d, see the opcode catalog) each frame: 0, 1, 2, 3, … (wrapping at 255), and re-calling epu_set(...) with the updated config.


Opcode Map (current shaders)

This is the opcode number. Some opcodes use meta5 for domain/variant selection; when unused, set meta5 = 0.

CodeNameNotes
0x00NOPDisable layer
0x01RAMPBounds gradient
0x02SECTORBounds modifier
0x03SILHOUETTEBounds modifier
0x04SPLITBounds
0x05CELLBounds
0x06PATCHESBounds
0x07APERTUREBounds
0x08DECALRadiance
0x09GRIDRadiance
0x0ASCATTERRadiance
0x0BFLOWRadiance
0x0CTRACERadiance
0x0DVEILRadiance
0x0EATMOSPHERERadiance
0x0FPLANERadiance
0x10CELESTIALRadiance
0x11PORTALRadiance
0x12LOBERadiance
0x13BANDRadiance

For full per-opcode packing/algorithm details, see:

  • nethercore-design/specs/epu-feature-catalog.md
  • nethercore/nethercore-zx/shaders/epu/

Region Mask (3-bit bitfield)

Regions are combinable using bitwise OR:

ValueBinaryNameMeaning
70b111ALLApply to sky + walls + floor
40b100SKYSky/ceiling only
20b010WALLSWall/horizon belt only
10b001FLOORFloor/ground only
60b110SKY_WALLSSky + walls
50b101SKY_FLOORSky + floor
30b011WALLS_FLOORWalls + floor
00b000NONELayer disabled

The region mask is consumed by feature/radiance opcodes: their contribution is multiplied by region_weight(current_regions, mask).

current_regions comes from the most recent bounds opcode; every bounds opcode outputs updated RegionWeights for subsequent layers. (Bounds opcodes do not use the region mask.)


Blend Modes (3-bit, 8 modes)

ValueNameFormula
0ADDdst + src * a
1MULTIPLYdst * mix(1, src, a)
2MAXmax(dst, src * a)
3LERPmix(dst, src, a)
4SCREEN1 - (1-dst)*(1-src*a)
5HSV_MODHSV shift dst by src
6MINmin(dst, src * a)
7OVERLAYPhotoshop-style overlay

meta5

The 5-bit meta5 field (hi bits 116..112) is interpreted as:

  • meta5 = (domain_id << 3) | variant_id
  • domain_id = (meta5 >> 3) & 0b11
  • variant_id = meta5 & 0b111

Quick Start

The easiest reference implementation is the EPU showcase presets:

  • nethercore/examples/3-inspectors/epu-showcase/src/presets.rs
  • nethercore/examples/3-inspectors/epu-showcase/src/constants.rs
// 8 x [hi, lo]
static ENV: [[u64; 2]; 8] = [
    [0, 0], [0, 0], [0, 0], [0, 0],
    [0, 0], [0, 0], [0, 0], [0, 0],
];

fn render() {
    unsafe {
        epu_set(ENV.as_ptr().cast());
        // ... draw scene geometry
        draw_epu();
    }
}

See Also

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;
    }
}
}

Unified Music API

A unified API for playing both PCM music and XM tracker modules. The handle type is detected automatically:

  • PCM handles (from load_sound/rom_sound) have bit 31 = 0
  • Tracker handles (from load_tracker/rom_tracker) have bit 31 = 1

Starting one type automatically stops the other (mutually exclusive). All music functions support rollback netcode.

rom_tracker

Loads an XM tracker module from the ROM data pack.

Signature:

#![allow(unused)]
fn main() {
fn rom_tracker(id_ptr: *const u8, id_len: u32) -> u32
}

Parameters:

NameTypeDescription
id_ptr*const u8Pointer to tracker ID string
id_lenu32Length of tracker ID string

Returns: Tracker handle (with bit 31 set) on success, 0 on failure.

Constraints: Init-only. Load instrument samples via rom_sound() before loading the tracker.


load_tracker

Loads an XM tracker module from WASM memory.

Signature:

#![allow(unused)]
fn main() {
fn load_tracker(data_ptr: u32, data_len: u32) -> u32
}

Parameters:

NameTypeDescription
data_ptru32Pointer to XM file data
data_lenu32Length in bytes

Returns: Tracker handle (with bit 31 set) on success, 0 on failure.

Constraints: Init-only.


music_play

Plays music (PCM sound or XM tracker module).

Signature:

#![allow(unused)]
fn main() {
fn music_play(handle: u32, volume: f32, looping: u32)
}

Parameters:

NameTypeDescription
handleu32Sound handle (from load_sound) or tracker handle (from rom_tracker)
volumef32Volume (0.0-1.0)
loopingu321 = loop, 0 = play once

Behavior: Automatically stops any currently playing music of the other type.

Example:

#![allow(unused)]
fn main() {
// PCM music
let bgm = rom_sound(b"menu_bgm".as_ptr(), 8);
music_play(bgm, 0.7, 1); // Loop

// XM tracker music
let tracker = rom_tracker(b"level1".as_ptr(), 6);
music_play(tracker, 0.8, 1); // Loop
}

music_stop

Stops currently playing music (both PCM and tracker).

Signature:

#![allow(unused)]
fn main() {
fn music_stop()
}

music_pause

Pauses or resumes music playback (tracker only, no-op for PCM).

Signature:

#![allow(unused)]
fn main() {
fn music_pause(paused: u32)
}

Parameters:

NameTypeDescription
pausedu321 = pause, 0 = resume

music_set_volume

Sets the music volume (works for both PCM and tracker).

Signature:

#![allow(unused)]
fn main() {
fn music_set_volume(volume: f32)
}

Parameters:

NameTypeDescription
volumef32Volume (0.0-1.0)

music_is_playing

Checks if music is currently playing.

Signature:

#![allow(unused)]
fn main() {
fn music_is_playing() -> u32
}

Returns: 1 if playing (and not paused), 0 otherwise.


music_type

Gets the current music type.

Signature:

#![allow(unused)]
fn main() {
fn music_type() -> u32
}

Returns:

  • 0 = none
  • 1 = PCM
  • 2 = tracker

music_jump

Jumps to a specific position (tracker only, no-op for PCM).

Signature:

#![allow(unused)]
fn main() {
fn music_jump(order: u32, row: u32)
}

Parameters:

NameTypeDescription
orderu32Order position (0-based)
rowu32Row within pattern (0-based)

music_position

Gets the current music position.

Signature:

#![allow(unused)]
fn main() {
fn music_position() -> u32
}

Returns:

  • For tracker: (order << 16) | row
  • For PCM: sample position

music_length

Gets the music length.

Signature:

#![allow(unused)]
fn main() {
fn music_length(handle: u32) -> u32
}

Parameters:

NameTypeDescription
handleu32Music handle (PCM or tracker)

Returns:

  • For tracker: number of orders
  • For PCM: number of samples

music_set_speed

Sets tracker speed (tracker only, ticks per row).

Signature:

#![allow(unused)]
fn main() {
fn music_set_speed(speed: u32)
}

Parameters:

NameTypeDescription
speedu321-31 (XM default is 6)

music_set_tempo

Sets tracker tempo (tracker only, BPM).

Signature:

#![allow(unused)]
fn main() {
fn music_set_tempo(bpm: u32)
}

Parameters:

NameTypeDescription
bpmu3232-255 (XM default is 125)

music_info

Gets music info.

Signature:

#![allow(unused)]
fn main() {
fn music_info(handle: u32) -> u32
}

Parameters:

NameTypeDescription
handleu32Music handle (PCM or tracker)

Returns:

  • For tracker: (num_channels << 24) | (num_patterns << 16) | (num_instruments << 8) | song_length
  • For PCM: (sample_rate << 16) | (channels << 8) | bits_per_sample

music_name

Gets the music name (tracker only, returns 0 for PCM).

Signature:

#![allow(unused)]
fn main() {
fn music_name(handle: u32, out_ptr: *mut u8, max_len: u32) -> u32
}

Parameters:

NameTypeDescription
handleu32Music handle
out_ptr*mut u8Output buffer pointer
max_lenu32Maximum bytes to write

Returns: Actual length written (0 if PCM or invalid handle).


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, 1); // 1 = loop
        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 (4 slots, 64KB each).

Overview

  • 4 save slots (indices 0-3)
  • 64KB maximum per slot
  • Persistent data is stored locally per-game
  • Netplay safety: only local session slots persist; remote session slots never overwrite your saves

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-3)
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-3)
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-3)

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..4 {
            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 {
        // Auto-save overwrites the controller 0 slot
        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(0, 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 (nether.toml)

Assets are bundled using the nether.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.nczxmesh"

[[assets.meshes]]
id = "player_model"
path = "assets/player.nczxmesh"

[[assets.skeletons]]
id = "player_rig"
path = "assets/player.nczxskel"

[[assets.animations]]
id = "walk"
path = "assets/walk.nczxanim"

[[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.nczxfont"

[[assets.data]]
id = "level1"
path = "assets/levels/level1.bin"

Build with:

nether build
nether pack  # Creates .nczx ROM file

Memory Model

ROM (16MB)          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 F4 Debug Inspector.

Overview

The debug system allows you to register game variables for live editing and monitoring. Press F4 to open the Debug Inspector 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();
    }
}
}

Debug Actions

Actions create buttons in the debug panel that trigger game functions when clicked. They’re useful for testing scenarios, spawning entities, or triggering events during development.

debug_register_action

Registers a simple action button with no parameters.

Signature:

#![allow(unused)]
fn main() {
fn debug_register_action(
    name_ptr: *const u8, name_len: u32,
    func_name_ptr: *const u8, func_name_len: u32
)
}

Parameters:

NameTypeDescription
name_ptr*const u8Pointer to button label string
name_lenu32Length of button label
func_name_ptr*const u8Pointer to exported WASM function name
func_name_lenu32Length of function name

Example:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn spawn_enemy() {
    // Called when button is clicked
    unsafe {
        ENEMY_COUNT += 1;
    }
}

#[no_mangle]
pub extern "C" fn reset_game() {
    // Reset all game state
    unsafe {
        PLAYER_X = 0.0;
        PLAYER_Y = 0.0;
        ENEMY_COUNT = 0;
    }
}

fn init() {
    unsafe {
        debug_group_begin(b"Actions".as_ptr(), 7);
        debug_register_action(
            b"Spawn Enemy".as_ptr(), 11,
            b"spawn_enemy".as_ptr(), 11
        );
        debug_register_action(
            b"Reset Game".as_ptr(), 10,
            b"reset_game".as_ptr(), 10
        );
        debug_group_end();
    }
}
}

debug_action_begin

Begins building an action with parameters. Use with debug_action_param_* functions and debug_action_end() to create actions with input fields.

Signature:

#![allow(unused)]
fn main() {
fn debug_action_begin(
    name_ptr: *const u8, name_len: u32,
    func_name_ptr: *const u8, func_name_len: u32
)
}

Parameters:

NameTypeDescription
name_ptr*const u8Pointer to button label string
name_lenu32Length of button label
func_name_ptr*const u8Pointer to exported WASM function name
func_name_lenu32Length of function name

debug_action_param_i32

Adds an i32 parameter input field to the pending action.

Signature:

#![allow(unused)]
fn main() {
fn debug_action_param_i32(name_ptr: *const u8, name_len: u32, default_value: i32)
}

Parameters:

NameTypeDescription
name_ptr*const u8Pointer to parameter label string
name_lenu32Length of parameter label
default_valuei32Default value shown in input field

debug_action_param_f32

Adds an f32 parameter input field to the pending action.

Signature:

#![allow(unused)]
fn main() {
fn debug_action_param_f32(name_ptr: *const u8, name_len: u32, default_value: f32)
}

Parameters:

NameTypeDescription
name_ptr*const u8Pointer to parameter label string
name_lenu32Length of parameter label
default_valuef32Default value shown in input field

debug_action_end

Completes the action registration started with debug_action_begin().

Signature:

#![allow(unused)]
fn main() {
fn debug_action_end()
}

Complete Action with Parameters Example:

#![allow(unused)]
fn main() {
// Function called when action button is clicked
// Parameters are passed in the order they were registered
#[no_mangle]
pub extern "C" fn spawn_enemies_at(count: i32, x: f32, y: f32) {
    for _ in 0..count {
        unsafe {
            // Spawn enemy at specified position
            spawn_enemy(x, y);
        }
    }
}

#[no_mangle]
pub extern "C" fn teleport_player(x: f32, y: f32) {
    unsafe {
        PLAYER_X = x;
        PLAYER_Y = y;
    }
}

fn init() {
    unsafe {
        debug_group_begin(b"Actions".as_ptr(), 7);

        // Action with parameters
        debug_action_begin(
            b"Spawn Enemies".as_ptr(), 13,
            b"spawn_enemies_at".as_ptr(), 16
        );
        debug_action_param_i32(b"Count".as_ptr(), 5, 3);
        debug_action_param_f32(b"X".as_ptr(), 1, 100.0);
        debug_action_param_f32(b"Y".as_ptr(), 1, 100.0);
        debug_action_end();

        // Another parameterized action
        debug_action_begin(
            b"Teleport Player".as_ptr(), 15,
            b"teleport_player".as_ptr(), 15
        );
        debug_action_param_f32(b"X".as_ptr(), 1, 480.0);
        debug_action_param_f32(b"Y".as_ptr(), 1, 270.0);
        debug_action_end();

        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 Runtime Stats Panel
F4Toggle Debug Inspector
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

EPU Architecture Overview

The Environment Processing Unit (EPU) is Nethercore ZX’s GPU-driven, fully procedural environment system. This page provides an architectural overview.

For the complete specification (opcode catalog, packing rules, and shader implementations), see:

  • nethercore-design/specs/epu-feature-catalog.md
  • nethercore/include/zx.rs
  • nethercore/nethercore-zx/shaders/epu/

For the current API reference and quick-start guide, see:


Introduction

The EPU provides a universal, stylized environment system that:

  • Renders backgrounds (sky/walls/void) with strong, art-directable motifs
  • Provides lighting data for objects (diffuse ambient + reflection color)

The system is designed around these hard constraints:

ConstraintValue
Config size128 bytes per environment state
Layer count8 instructions (4 Bounds + 4 Radiance)
Instruction size128 bits (two u64 values)
CubemapsNone (fully procedural octahedral maps)
MipmapsYes (compute-generated downsample pyramid)
Color modelDirect RGB24 x 2 per layer
AestheticPS1/PS2-era stylized, quantized params

System Diagram

CPU (game)                                         GPU
---------                                         ---
Call environment_index(...)+epu_set(...) during render()   --->   [Compute] EPU_Build(configs)
Call draw_epu() to request a background draw            - Evaluate 8-layer microprogram into EnvRadiance (mip 0)
Capture (viewport, pass) draw requests                  - Generate mip pyramid from EnvRadiance mip 0
                                                    - Extract SH9 from a coarse mip (e.g. 16x16)

Main render (background + objects)          --->   [Render] Sample prebuilt results
                                                  - Background: EPU environment draw per viewport/pass
                                                  - Specular:   EnvRadiance sampled by roughness (LOD)
                                                  - Diffuse:    SH9 evaluated at the shading normal

Radiance Flow

The EPU produces a single directional radiance signal per environment (EnvRadiance, mip 0). From that radiance, the runtime builds a downsample mip pyramid used for continuous roughness-based reflections, and extracts SH9 coefficients for diffuse ambient.


Data Model

PackedEnvironmentState (128 bytes)

Each environment is exactly 8 x 128-bit instructions:

SlotKindRecommended Use
0-3BoundsAny bounds opcode (0x01..0x07). Any bounds opcode can be first; each bounds layer outputs RegionWeights consumed by later feature/radiance layers.
4-7RadianceDECAL / GRID / SCATTER / FLOW + radiance ops (0x0C..0x13)

Implementation note: in the shaders, bounds opcodes return (sample, regions). Dispatch updates regions after every bounds layer, and feature layers apply region masking using the current regions.

Instruction Bit Layout (128-bit)

Each instruction is packed as two u64 values:

High word (bits 127..64):

bits 127..123: opcode     (5)  - 32 opcodes available
bits 122..120: region     (3)  - Bitfield: SKY=0b100, WALLS=0b010, FLOOR=0b001
bits 119..117: blend      (3)  - 8 blend modes
bits 116..112: meta5      (5)  - (domain_id<<3)|variant_id; use 0 when unused
bits 111..88:  color_a    (24) - RGB24 primary color
bits 87..64:   color_b    (24) - RGB24 secondary color

Low word (bits 63..0):

bits 63..56:   intensity  (8)  - Layer brightness
bits 55..48:   param_a    (8)  - Opcode-specific
bits 47..40:   param_b    (8)  - Opcode-specific
bits 39..32:   param_c    (8)  - Opcode-specific
bits 31..24:   param_d    (8)  - Opcode-specific
bits 23..8:    direction  (16) - Octahedral-encoded direction (u8,u8)
bits 7..4:     alpha_a    (4)  - color_a alpha (0-15)
bits 3..0:     alpha_b    (4)  - color_b alpha (0-15)

Opcodes

OpcodeNameKindPurpose
0x00NOPAnyDisable layer
0x01RAMPBoundsBounds gradient (sky/walls/floor)
0x02SECTORBoundsAzimuthal opening wedge modifier
0x03SILHOUETTEBoundsSkyline/horizon cutout modifier
0x04SPLITBoundsGeometric divisions
0x05CELLBoundsVoronoi/mosaic cells
0x06PATCHESBoundsNoise patches
0x07APERTUREBoundsShaped opening/viewport
0x08DECALRadianceSharp SDF shape (disk/ring/rect/line)
0x09GRIDRadianceRepeating lines/panels
0x0ASCATTERRadiancePoint field (stars/dust/bubbles)
0x0BFLOWRadianceAnimated noise/streaks/caustics
0x0CTRACERadianceLine/crack patterns
0x0DVEILRadianceCurtain/ribbon effects
0x0EATMOSPHERERadianceAtmospheric absorption + scattering
0x0FPLANERadianceGround/surface textures
0x10CELESTIALRadianceMoon/sun/planet bodies
0x11PORTALRadiancePortal/vortex effects
0x12LOBERadianceRegion-masked directional glow
0x13BANDRadianceRegion-masked horizon band

Blend Modes (8 modes)

ValueNameFormula
0ADDdst + src * a
1MULTIPLYdst * mix(1, src, a)
2MAXmax(dst, src * a)
3LERPmix(dst, src, a)
4SCREEN1 - (1-dst)*(1-src*a)
5HSV_MODHSV shift dst by src
6MINmin(dst, src * a)
7OVERLAYPhotoshop-style overlay

Compute Pipeline

Implementation note: Internally, the runtime stores outputs in arrays indexed by env_id. Games can provide configs for one or more env_ids by setting environment_index(env_id) then calling epu_set(config_ptr). Any env_id without an explicit config falls back to env_id = 0, and then to the built-in default config.

The EPU runtime maintains these outputs per env_id:

OutputTypePurpose
EnvRadiance[env_id]mip-mapped octahedral 2D arrayBackground + roughness-based reflections
SH9[env_id]storage bufferL2 diffuse irradiance (spherical harmonics)

Frame Execution Order

  1. Capture EPU draw requests (per viewport/pass) and determine active environment states
  2. Deduplicate env_id list, cap to MAX_ACTIVE_ENVS
  3. Determine which env_ids are dirty (hash)
  4. Dispatch compute passes:
    • Environment evaluation (build EnvRadiance mip 0)
    • Mip pyramid generation (2x2 downsample chain)
    • Irradiance extraction (SH9)
  5. Barrier: compute to render
  6. Render background + objects (sampling by env_id)

Render Integration

Background Sampling

Render sky/background by evaluating the EPU directly per pixel (L_hi(dir)), not by sampling EnvRadiance. This guarantees the sky is never limited by the EnvRadiance base resolution.

Reflection Sampling

Sample EnvRadiance with a continuous roughness-to-LOD mapping across mip levels. A common mapping is:

  • lod = (roughness^2) * (mip_count - 1)

Then sample at that LOD (trilinear) or lerp between floor(lod) and ceil(lod).

To avoid hard cutoffs while still preserving mirror-quality reflections, add a high-frequency residual term that fades out with roughness:

  • alpha = roughness^2
  • L_spec = L_lp + (1 - alpha) * (L_hi - L0)
    • L_hi is procedural EPU evaluation at the reflection direction
    • L0 is EnvRadiance sampled at mip 0
    • L_lp is EnvRadiance sampled at the roughness-derived LOD

Ambient Lighting

Diffuse ambient is evaluated from SH9 coefficients at the shading normal n.


Multiple Environments

The EPU supports multiple environments per frame through texture array indexing:

  • All outputs are stored in array layers indexed by env_id
  • Renderers pass env_id per draw/instance (internal)
  • No per-draw rebinding required
ConstantTypical Value
MAX_ENV_STATES256
MAX_ACTIVE_ENVS32
EPU_MAP_SIZE128 (default; override via NETHERCORE_EPU_MAP_SIZE)
EPU_MIN_MIP_SIZE4 (default; override via NETHERCORE_EPU_MIN_MIP_SIZE)
EPU_IRRAD_TARGET_SIZE16

Dirty-State Caching

For environments, the EPU tracks:

  • state_hash: Hash of the 128-byte config
  • valid: Whether the cached entry has been initialized

Update policy:

ConditionAction
Unused this frameSkip
Used + unchangedSkip
Used + changedRebuild, then update state_hash

Format Summary

AspectValue
Instruction size128-bit
Environment size128 bytes
Opcode bits5-bit (32 opcodes)
Region3-bit mask (combinable)
Blend modes8 modes
ColorRGB24 × 2 per layer
EmissiveReserved (future use)
Alpha4-bit × 2 (per-color)
Parameters4 (+param_d)

Full Specification

For complete details including:

  • WGSL shader implementations
  • Per-opcode parameter tables
  • Example configurations
  • Performance considerations

See:

Dither Patterns Reference

Quick reference for Bayer dithering matrices. Currently Nethercore 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

NethercoreXboxPlayStationNintendo
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 Nethercore repository includes 46 working examples organized into 8 categories. Each example is a complete, buildable project (Rust, C, or Zig).

Location

Examples are organized by category:

examples/
├── 1-getting-started/   (4 examples)
├── 2-graphics/          (6 examples)
├── 3-inspectors/        (13 examples)
├── 4-animation/         (6 examples)
├── 5-audio/             (5 examples)
├── 6-assets/            (7 examples)
├── 7-games/             (2 examples)
├── 8-advanced/          (3 examples)
└── examples-common/     (support library)

Learning Path

New to Nethercore? 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. procedural-shapes - Procedural meshes with texture toggle
  5. paddle - Complete game with the tutorial
  6. platformer - Advanced example with physics, billboards, UI

By Category

1. Getting Started

ExampleDescription
hello-worldBasic 2D drawing, text, input handling
hello-world-cSame example in C (demonstrates C FFI)
hello-world-zigSame example in Zig (demonstrates Zig FFI)
triangleMinimal 3D rendering

2. Graphics & Rendering

ExampleDescription
textured-quadTexture loading and sprite rendering
procedural-shapesBuilt-in mesh generators with texture toggle (B button)
lightingPBR rendering with 4 dynamic lights
billboardGPU-instanced billboards
dither-demoPS1-style dithering effects
material-overridePer-draw material properties

3. Inspectors (Mode & Environment)

ExampleDescription
debug-demoDebug inspection system (F4 panel)
mode0-inspectorInteractive Mode 0 (Lambert) explorer
mode1-inspectorInteractive Mode 1 (Matcap) explorer
mode2-inspectorInteractive Mode 2 (PBR) explorer
mode3-inspectorInteractive Mode 3 (Blinn-Phong) explorer
epu-showcaseCurated preset environments + interactive layer controls (F4)

4. Animation & Skinning

ExampleDescription
skinned-meshGPU skeletal animation basics
animation-demoKeyframe playback from ROM
ik-demoInverse kinematics
multi-skinned-proceduralMultiple animated characters (procedural)
multi-skinned-romMultiple animated characters (ROM data)
skeleton-stress-testPerformance testing with many skeletons

5. Audio

ExampleDescription
audio-demoSound effects, panning, channels, looping
tracker-demo-xmXM tracker music playback
tracker-demo-xm-splitXM tracker music with split sample workflow
tracker-demo-itIT tracker music playback
tracker-demo-it-splitIT tracker demo with separate sample assets

6. Asset Loading

ExampleDescription
datapack-demoFull ROM asset workflow (textures, meshes, sounds)
font-demoCustom font loading with rom_font
level-loaderLevel data loading with rom_data
asset-testPre-converted asset testing (.nczxmesh, .nczxtex)
gltf-testGLTF import pipeline validation (mesh, skeleton, animation)
glb-inlineDirect .glb references with multiple animations
glb-rigidRigid transform animation imported from GLB

7. Complete Games

ExampleDescription
paddleClassic 2-player paddle game with AI and rollback netcode
platformerFull mini-game with 2D gameplay, physics, collision, UI

8. Advanced Rendering

ExampleDescription
stencil-demoAll 4 stencil masking modes
viewport-testSplit-screen rendering (2P, 4P)
rear-mirrorRear-view mirror using viewport

Support Library

LibraryDescription
examples-commonReusable utilities (DebugCamera, StickControl, math helpers)

Building Examples

Many examples include a nether.toml manifest:

cd examples/7-games/paddle
nether build   # Build WASM and create .nczx ROM
nether run     # Build and launch in emulator

Building All Examples (For Nethercore Contributors)

To build and install all examples at once:

# From nethercore repository root
cargo xtask build-examples

This builds the Cargo-based examples and installs them into your Nethercore data directory under games/ (the command prints the exact path).

Example Structure

Most Rust examples follow this pattern:

category/example-name/
├── Cargo.toml       # Project config
├── nether.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 navigate to examples/ in your local clone.