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
| Spec | Value |
|---|---|
| Aesthetic | PS1/N64/Saturn (5th gen) |
| Resolution | 960×540 (fixed, upscaled to display) |
| Color depth | RGBA8 |
| Tick rate | 24, 30, 60 (default), 120 fps |
| ROM (Cartridge) | 16MB (WASM code + data pack assets) |
| RAM | 4MB (WASM linear memory for game state) |
| VRAM | 4MB (GPU textures and mesh buffers) |
| Compute budget | WASM GAS metering |
| Netcode | Deterministic rollback via GGRS |
| Max players | 4 (any mix of local + remote) |
Game Lifecycle
Games export three functions:
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
// Called once at startup
// Load resources, configure console settings
}
#[no_mangle]
pub extern "C" fn update() {
// Called every tick (deterministic for rollback)
// Update game state, handle input
}
#[no_mangle]
pub extern "C" fn render() {
// Called every frame (skipped during rollback replay)
// Draw to screen
}
}
Memory Model
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:
| Property | Value |
|---|---|
| Resolution | 960×540 pixels (fixed, 16:9 aspect) |
| Origin | Top-left corner (0, 0) |
| X-axis | Increases rightward (0 → 960) |
| Y-axis | Increases downward (0 → 540) |
| Sprite anchor | Top-left corner of sprite |
(0,0) ────────────────────► X (960)
│
│ Screen Space
│
▼
Y (540)
World Space (3D)
For 3D rendering with camera_set() and draw_mesh():
| Property | Value |
|---|---|
| Coordinate system | Right-handed, Y-up |
| X-axis | Right |
| Y-axis | Up |
| Z-axis | Out of screen (toward viewer) |
| Handedness | Right-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:
| Property | Value |
|---|---|
| X-axis | -1.0 (left) to +1.0 (right) |
| Y-axis | -1.0 (bottom) to +1.0 (top) |
| Z-axis | 0.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)
| Property | Value |
|---|---|
| Origin | Top-left (0, 0) |
| U-axis | 0 (left) to 1 (right) |
| V-axis | 0 (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():
| Property | Value |
|---|---|
| Type | Perspective |
| Default FOV | 60° (vertical) |
| Aspect ratio | 16:9 (fixed) |
| Near plane | 0.1 units |
| Far plane | 1000 units |
| Function | perspective_rh (right-handed) |
API Categories
| Category | Description |
|---|---|
| System | Time, logging, random, session info |
| Input | Buttons, sticks, triggers |
| Graphics | Resolution, render mode, state |
| Camera | View and projection |
| Transforms | Matrix stack operations |
| Textures | Loading and binding textures |
| Meshes | Loading and drawing meshes |
| Materials | PBR and Blinn-Phong properties |
| Lighting | Directional and point lights |
| Skinning | Skeletal animation |
| Animation | Keyframe playback |
| Procedural | Generated primitives |
| 2D Drawing | Sprites, text, rectangles |
| Billboards | Camera-facing quads |
| Environment (EPU) | Procedural environments |
| Audio | Sound effects and music |
| Save Data | Persistent storage |
| ROM Loading | Data pack access |
| Debug | Runtime value inspection |
Screen Capture
The host application includes screenshot and GIF recording capabilities:
| Key | Default | Action |
|---|---|---|
| Screenshot | F9 | Save PNG to screenshots folder |
| GIF Toggle | F10 | Start/stop GIF recording |
Files are saved to:
- Screenshots: 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
Quick Links
- Cheat Sheet - All functions on one page
- Getting Started - Your first game
- Render Modes - Mode 0-3 explained
- Rollback Safety - Writing deterministic code
Building These Docs
These docs are built with mdBook.
# Install mdBook
cargo install mdbook 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:
| Language | Best For |
|---|---|
| Rust | Full ecosystem support, best tooling |
| C/C++ | Existing codebases, familiar to game devs |
| Zig | Modern 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-unknownin the installed target list
Code Editor (Optional but Recommended)
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 gamenether run- Run your game in the playernether 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 sizelto = 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()anddraw_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()(orrandom_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
| Concept | Default | Purpose |
|---|---|---|
| Tick Rate | 60 Hz | How often update() runs. Fixed for determinism. |
| Frame Rate | Variable | How often render() runs. Matches display refresh. |
You can change the tick rate in init():
#![allow(unused)]
fn main() {
set_tick_rate(0); // 24 ticks per second (cinematic)
set_tick_rate(1); // 30 ticks per second
set_tick_rate(2); // 60 ticks per second (default)
set_tick_rate(3); // 120 ticks per second (fighting games)
}
The Rollback System
Nethercore’s killer feature is automatic rollback netcode. Here’s how it works:
- Snapshot: The runtime snapshots all WASM memory after each
update() - Predict: When waiting for remote player input, the game predicts and continues
- Rollback: When real input arrives, the game rolls back and replays
- Skip render: During rollback replay,
render()is not called
Why this matters:
- All your game state must be in WASM memory (static/global variables)
update()must be deterministicrender()should only read state, never modify it
init() ← Run once
│
▼
┌─────────────────┐
│ update() ←────┼── Runs at fixed tick rate
│ (snapshot) │ Rollback replays from here
└────────┬────────┘
│
▼
┌─────────────────┐
│ render() ←────┼── Runs every frame
│ (skipped │ Skipped during rollback
│ on rollback) │
└────────┬────────┘
│
└── Loop back to update()
Helpful Functions
| Function | Returns | Description |
|---|---|---|
delta_time() | f32/float | Seconds since last tick (fixed) |
elapsed_time() | f32/float | Total seconds since game start |
tick_count() | u64/uint64_t | Number of ticks since start |
random() / random_u32() | u32/uint32_t | Deterministic random number |
Common Patterns
Game State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq)]
enum GameState {
Title,
Playing,
Paused,
GameOver,
}
static mut STATE: GameState = GameState::Title;
#[no_mangle]
pub extern "C" fn update() {
unsafe {
match STATE {
GameState::Title => update_title(),
GameState::Playing => update_gameplay(),
GameState::Paused => update_pause(),
GameState::GameOver => update_game_over(),
}
}
}
}
Delta Time for Smooth Movement
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn update() {
unsafe {
let dt = delta_time();
// Movement is frame-rate independent
PLAYER_X += SPEED * dt;
}
}
}
You’re ready to build real games!
Continue to the Build Paddle tutorial to create your first complete game, or explore the API Reference to see all available functions.
Build Paddle
In this tutorial, you’ll build a complete Paddle game from scratch. By the end, you’ll have a fully playable game with:
- Two paddles (player-controlled or AI)
- Ball physics with collision detection
- Score tracking and win conditions
- Sound effects loaded from assets
- Title screen and game over states
- Automatic online multiplayer via Nethercore’s rollback netcode

What You’ll Learn
| Part | Topics |
|---|---|
| Part 1: Setup & Drawing | Project creation, FFI imports, draw_rect() |
| Part 2: Paddle Movement | Input handling, game state |
| Part 3: Ball Physics | Velocity, collision detection |
| Part 4: AI Opponent | Simple AI for single-player |
| Part 5: Multiplayer | The magic of rollback netcode |
| Part 6: Scoring & Win States | Game logic, state machine |
| Part 7: Sound Effects | Assets, nether build, audio playback |
| Part 8: Polish & Publishing | Title screen, publishing to archive |
Prerequisites
Before starting this tutorial, you should have:
- Completed Your First Game
- Rust and WASM target installed (Prerequisites)
- Basic understanding of the game loop
Final Code
The complete source code for this tutorial is available in the examples:
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()andleft_stick_y() - Clamping values to keep paddles on screen
- The difference between
button_pressed()andbutton_held()
Add Input FFI Functions
Update your FFI imports:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
fn set_clear_color(color: u32);
fn 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:
| Function | Behavior |
|---|---|
button_pressed(player, button) | Returns 1 only on the frame the button is first pressed |
button_held(player, button) | Returns 1 every frame the button is held down |
For continuous movement like paddles, use button_held().
Button Constants
#![allow(unused)]
fn main() {
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const BUTTON_X: u32 = 6;
const BUTTON_Y: u32 = 7;
const BUTTON_LB: u32 = 8;
const BUTTON_RB: u32 = 9;
const BUTTON_START: u32 = 12;
const BUTTON_SELECT: u32 = 13;
}
Build and Test
cargo build --target wasm32-unknown-unknown --release
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:
- Follows the ball - Moves toward where the ball is
- Slower speed - Only 70% of max paddle speed
- Dead zone - Doesn’t jitter when ball is near center
- No prediction - Doesn’t anticipate where ball will go
This makes the AI beatable but still challenging.
Making AI Harder or Easier
#![allow(unused)]
fn main() {
// Easier AI (50% speed)
let ai_speed = PADDLE_SPEED * 0.5;
// Harder AI (90% speed)
let ai_speed = PADDLE_SPEED * 0.9;
// Perfect AI (instant tracking) - not fun!
*paddle_y = ball_center - PADDLE_HEIGHT / 2.0;
}
The Magic: Automatic Multiplayer
Here’s the key insight: when a second player connects, the game automatically becomes two-player. You don’t need to do anything special!
This works because:
- We check
player_count()every frame - Player 2 input is always read (even if unused)
- The switch from AI to human is seamless
When a friend joins your game online via 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:
- Player 1’s inputs are sent to Player 2’s game
- Player 2’s inputs are sent to Player 1’s game
- Both games run the same
update()function with the same inputs - Both games show the same result
You didn’t write a single line of networking code.
How It Works
Rollback Netcode
Traditional netcode waits for the other player’s input before advancing. This causes lag.
Nethercore uses rollback netcode:
- Predict: Don’t have remote input? Guess it (usually “same as last frame”)
- Continue: Run the game with the prediction
- Correct: When real input arrives, if it differs from prediction:
- Roll back to the snapshot
- Replay with correct input
- Catch up to present
Automatic Snapshots
Every frame, 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:
- Start the game
- Connect a second controller
- Both players can play!
The player_count() function automatically detects connected players.
Testing Online Multiplayer
Online play is handled by the Nethercore runtime:
- Player 1 hosts a game
- Player 2 joins via game code or direct connect
- The runtime handles all networking
- Your game code doesn’t change at all!
What Rollback Looks Like
During normal play:
You press A → Your game shows jump immediately
(predicting remote player holds same buttons)
50ms later → Remote input arrives, matches prediction
Nothing changes, smooth gameplay!
When prediction is wrong:
You press A → Your game shows jump immediately
(predicting remote player holds same buttons)
50ms later → Remote input arrives: they pressed B!
Game rolls back to frame N-3
Replays frames N-3, N-2, N-1 with correct input
Catches up to present frame N
Visual "correction" happens in ~1-2 frames
With good connections, predictions are usually correct and rollbacks are rare.
Summary
| Traditional Netcode | Nethercore Rollback |
|---|---|
| Wait for input → lag | Predict input → smooth |
| Manual state sync | Automatic snapshots |
| You write network code | You write game code |
| State can be anywhere | State must be in WASM |
The key insight: 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.tomlto bundle assets - Using
nether buildinstead ofcargo build - Loading sounds with
rom_sound() - Playing sounds with
play_sound()and stereo panning
Why Assets Now?
Up until now, we’ve built and tested like this:
cargo build --target wasm32-unknown-unknown --release
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:
| Sound | Description | Duration |
|---|---|---|
hit.wav | Quick beep for paddle/wall hits | ~0.1s |
score.wav | Descending tone when someone scores | ~0.2s |
win.wav | Victory fanfare when game ends | ~0.5s |
Download sample sounds from the tutorial assets, or create your own with:
- JSFXR — Generate retro sound effects in your browser
- Freesound.org — CC-licensed sounds
- Audacity — Record and edit audio
Put them in your assets/ folder:
paddle/
├── Cargo.toml
├── 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:
- Compiles your Rust code to WASM
- Converts WAV files to the optimized format (22050 Hz mono)
- Bundles everything into a
paddle.nczxROM 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);
}
| Parameter | Range | Description |
|---|---|---|
sound | Handle | Sound handle from rom_sound() |
volume | 0.0 - 1.0 | 0 = silent, 1 = full volume |
pan | -1.0 - 1.0 | -1 = left, 0 = center, 1 = right |
Audio Specs
Nethercore uses these audio settings:
| Property | Value |
|---|---|
| Sample rate | 22050 Hz |
| Format | 16-bit mono PCM |
| Channels | Stereo output |
The nether build command automatically converts your WAV files to this format.
Sound Design Tips
- Keep sounds short — 0.1 to 0.5 seconds is plenty for effects
- Use panning — Stereo positioning helps players track action
- Vary volume — Important sounds louder, ambient sounds quieter
- Match your aesthetic — Simple sounds fit retro games
Build and Test
Rebuild with your sound assets:
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 --release | nether build |
nether run target/.../paddle.wasm | nether run |
| No assets needed | Assets 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
- Log in to nethercore.systems
- Go to your Dashboard
- Click “Upload New Game”
- Fill in the details:
- Title: “Paddle”
- Description: Your game description
- Category: Arcade
- Upload your
.nczxROM file - Add your icon and screenshots
- 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:
| Feature | Implementation |
|---|---|
| Graphics | Court, paddles, ball with draw_rect() |
| Input | Analog stick and D-pad with left_stick_y(), button_held() |
| Physics | Ball movement, wall bouncing, paddle collision |
| AI | Simple ball-following AI opponent |
| Multiplayer | Automatic online play via rollback netcode |
| Game Flow | Title, Playing, GameOver states |
| Scoring | Point tracking, win conditions |
| Audio | Sound effects loaded from ROM with stereo panning |
| Assets | Sounds bundled with 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:
- Example Games — Dozens of examples
- API Reference — All available functions
- Asset Pipeline — Advanced asset workflows
- Render Modes Guide — 3D graphics
Join the Community
- Share your game in GitHub Discussions
- Report bugs or request features
- Help other developers
Complete Source Code
The final source code is available at:
nethercore/examples/7-games/paddle/
You can compare your code or use it as a reference.
Summary
In this tutorial, you learned:
- Setup — Creating a Nethercore project
- Drawing — Using
draw_rect()for 2D graphics - Input — Reading sticks and buttons
- Physics — Ball movement and collision
- AI — Simple opponent behavior
- Multiplayer — How rollback netcode “just works”
- Game Flow — State machines for menus
- Assets — Using
nether.tomlandnether buildfor sounds - Audio — Loading and playing sound effects from ROM
- 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
| Mode | Name | Lighting | Best For |
|---|---|---|---|
| 0 | Lambert | Simple diffuse | Flat colors, UI, retro 2D |
| 1 | Matcap | Pre-baked | Stylized, toon, sculpted look |
| 2 | Metallic-Roughness | PBR-style Blinn-Phong | Realistic materials |
| 3 | Specular-Shininess | Traditional Blinn-Phong | Classic 3D, arcade |
Set the mode 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:
| Slot | Purpose | Blend Mode |
|---|---|---|
| 0 | Albedo (UV-mapped) | Base color |
| 1-3 | Matcap (normal-mapped) | Configurable |
Matcap Blend Modes:
- 0 (Multiply): Darkens (shadows, AO)
- 1 (Add): Brightens (highlights, rim)
- 2 (HSV Modulate): Hue/saturation shift
Example:
#![allow(unused)]
fn main() {
// 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:
| Slot | Purpose | Channels |
|---|---|---|
| 0 | Albedo | RGB: Diffuse color |
| 1 | MRE | R: Metallic, G: Roughness, B: Emissive |
Material Functions:
#![allow(unused)]
fn main() {
material_metallic(0.0); // 0 = dielectric, 1 = metal
material_roughness(0.5); // 0 = mirror, 1 = rough
material_emissive(0.0); // Self-illumination
material_rim(0.2, 0.15); // Rim light intensity and power
}
Example:
#![allow(unused)]
fn main() {
// 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:
| Slot | Purpose | Channels |
|---|---|---|
| 0 | Albedo | RGB: Diffuse color |
| 1 | SSE | R: Specular intensity, G: Shininess, B: Emissive |
| 2 | Specular | RGB: Specular highlight color |
Material Functions:
#![allow(unused)]
fn main() {
material_shininess(0.7); // 0-1 → maps to 1-256
material_specular(0xFFD700FF); // Specular highlight color
material_emissive(0.0); // Self-illumination
material_rim(0.2, 0.15); // Rim light
}
Shininess Values:
| Value | Shininess | Appearance |
|---|---|---|
| 0.0-0.2 | 1-52 | Very soft (cloth, skin) |
| 0.2-0.4 | 52-103 | Broad (leather, wood) |
| 0.4-0.6 | 103-154 | Medium (plastic) |
| 0.6-0.8 | 154-205 | Tight (polished metal) |
| 0.8-1.0 | 205-256 | Mirror (chrome, glass) |
Example:
#![allow(unused)]
fn main() {
// 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 lighting | 0 (Lambert) |
| Stylized, consistent lighting | 1 (Matcap) |
| PBR workflow with MRE textures | 2 (Metallic-Roughness) |
| Colored specular, artist control | 3 (Specular-Shininess) |
Performance: All lit modes (1-3) have similar performance. Mode 0 is fastest.
Compatibility: All modes work with procedural meshes and skeletal animation.
Common Setup
All lit modes benefit from proper 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 withepu_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.mdnethercore/include/zx.rs(canonical ABI docs)nethercore/nethercore-zx/shaders/epu/(shader sources)
Quick Start
- Create a packed EPU config: 8 × 128-bit instructions (stored as 16
u64values as 8[hi, lo]pairs). - Call
epu_set(config_ptr)near the start ofrender(), then calldraw_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.rsnethercore/examples/3-inspectors/epu-showcase/src/constants.rs
Architecture Overview
The EPU uses a 128-byte instruction-based configuration:
| Slot | Kind | Recommended Use |
|---|---|---|
| 0–3 | Bounds | RAMP + optional bounds ops (0x02..0x07) |
| 4–7 | Radiance | DECAL/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
| Opcode | Name | Best For | Notes |
|---|---|---|---|
| 0x01 | RAMP | Base bounds | Often used first to explicitly set up/ceil/floor/softness, but any bounds opcode can be layer 0. |
| 0x02 | SECTOR | Opening wedge / interior cues | Bounds modifier |
| 0x03 | SILHOUETTE | Skyline / horizon cutout | Bounds modifier |
| 0x04 | SPLIT | Geometric divisions | Bounds |
| 0x08 | DECAL | Sun disks, signage, portals | Radiance |
| 0x09 | GRID | Panels, architectural lines | Radiance |
| 0x0A | SCATTER | Stars, dust, particles | Radiance |
| 0x0B | FLOW | Clouds, rain, caustics | Radiance |
| 0x12 | LOBE | Sun glow, lamps, neon spill | Radiance |
| 0x13 | BAND | Horizon bands / rings | Radiance |
Authoring Workflow
- Start from a known-good preset (
epu-showcase). - Use the
epu-showcasedebug 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 calldraw_epu().
Slot Conventions
| Slot | Kind | Recommended Use |
|---|---|---|
| 0-3 | Bounds | Any bounds opcode (0x01..0x07). Common convention is RAMP first, not a requirement. |
| 4-7 | Radiance | DECAL / 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
meta5encodes(domain_id << 3) | variant_idfor 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
- EPU API Reference - FFI signatures and instruction encoding
- EPU Architecture Overview - Compute pipeline details
- EPU Feature Catalog
Rollback Safety Guide
Writing deterministic code for Nethercore’s rollback netcode.
How Rollback Works
Nethercore uses GGRS for deterministic rollback netcode:
- Every tick, your
update()receives inputs from all players - GGRS synchronizes inputs across the network
- On misprediction, the game state is restored from a snapshot and replayed
For this to work, your update() must be deterministic: same inputs → same state.
The Golden Rules
1. Use random() for All Randomness
#![allow(unused)]
fn main() {
// GOOD - Deterministic
let spawn_x = (random() % 320) as f32;
let damage = 10 + (random() % 5) as i32;
// BAD - Non-deterministic
let spawn_x = system_time_nanos() % 320; // Different on each client!
let damage = 10 + (thread_rng().next_u32() % 5); // Different seeds!
}
The random() function returns values from a synchronized seed, ensuring all clients get the same sequence.
2. Keep State in Static Variables
All game state must live in WASM linear memory (global statics):
#![allow(unused)]
fn main() {
// GOOD - State in WASM memory (snapshotted)
static mut PLAYER_X: f32 = 0.0;
static mut ENEMIES: [Enemy; 10] = [Enemy::new(); 10];
// BAD - State outside WASM memory
// (external systems, thread-locals, etc. are not snapshotted)
}
3. Same Inputs = Same State
Your update() must produce identical results given identical inputs:
#![allow(unused)]
fn main() {
fn update() {
// All calculations based only on:
// - Current state (in WASM memory)
// - Player inputs (from GGRS)
// - delta_time() / elapsed_time() / tick_count() (synchronized)
// - random() (synchronized)
let dt = delta_time();
for p in 0..player_count() {
if button_pressed(p, BUTTON_A) != 0 {
players[p].jump();
}
players[p].x += left_stick_x(p) * SPEED * dt;
}
}
}
4. Render is Skipped During Rollback
render() is not called during rollback replay. Don’t put game logic in render():
#![allow(unused)]
fn main() {
// GOOD - Logic in update()
fn update() {
ANIMATION_FRAME = (tick_count() as u32 / 6) % 4;
}
fn render() {
// Just draw, no state changes
draw_sprite_region(..., ANIMATION_FRAME as f32 * 32.0, ...);
}
// BAD - Logic in render()
fn render() {
ANIMATION_FRAME += 1; // Skipped during rollback = desynced!
draw_sprite_region(...);
}
}
Common Pitfalls
Floating Point Non-Determinism
Floating point operations can vary across CPUs. 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
- Start a local game with 2 players
- Give identical inputs
- Verify states match
Debug Checklist
If you see desync:
- Check
random()usage - All randomness fromrandom()? - Check iteration order - Using fixed-order arrays?
- Check floating point - Sensitive calculations reproducible?
- Check
render()logic - Any state changes in render? - Check external reads - System time, files, network?
- Check audio timing - Audio triggering consistent?
Example: Deterministic Enemy AI
#![allow(unused)]
fn main() {
static mut ENEMIES: [Enemy; 10] = [Enemy::new(); 10];
static mut ENEMY_COUNT: usize = 0;
#[derive(Clone, Copy)]
struct Enemy {
x: f32,
y: f32,
health: i32,
ai_state: u8,
ai_timer: u32,
}
impl Enemy {
const fn new() -> Self {
Self { x: 0.0, y: 0.0, health: 100, ai_state: 0, ai_timer: 0 }
}
fn update(&mut self, player_x: f32, player_y: f32) {
match self.ai_state {
0 => {
// Idle - random chance to start patrol
if random() % 100 < 5 { // 5% chance per tick
self.ai_state = 1;
self.ai_timer = 60 + (random() % 60); // 1-2 seconds
}
}
1 => {
// Patrol - move toward random target
self.ai_timer -= 1;
if self.ai_timer == 0 {
self.ai_state = 0;
}
// Movement...
}
_ => {}
}
}
}
fn update() {
unsafe {
let px = PLAYER_X;
let py = PLAYER_Y;
// Fixed iteration order
for i in 0..ENEMY_COUNT {
ENEMIES[i].update(px, py);
}
}
}
}
This AI is deterministic because:
random()is synchronized- Array iteration has fixed order
- All state is in WASM memory
- No external dependencies
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
| Format | Extension | Status |
|---|---|---|
| glTF 2.0 | .gltf, .glb | Implemented |
| OBJ | .obj | Implemented |
Recommendation: Use glTF for new projects. It’s the “JPEG of 3D” - efficient, well-documented, and supported everywhere.
Textures
| Format | Status |
|---|---|
| PNG | Implemented |
| JPG | Implemented |
Audio
| Format | Status |
|---|---|
| WAV | Implemented |
Fonts
| Format | Status |
|---|---|
| TTF | Planned |
Manifest-Based Asset Pipeline
Define all your game assets in a single assets.toml file, then build everything with one command.
assets.toml Reference
# Output configuration
[output]
dir = "assets/" # Output directory for converted files
# rust = "src/assets.rs" # Planned: Generated Rust module
# 3D Models
[meshes]
player = "models/player.gltf" # Simple: just the path
enemy = "models/enemy.glb"
level = { path = "models/level.obj", format = "POS_UV_NORMAL" } # With options
# Textures
[textures]
player_diffuse = "textures/player.png"
# Audio
[sounds]
jump = "audio/jump.wav"
# Fonts (planned)
# [fonts]
# ui = { path = "fonts/roboto.ttf", size = 16 }
Build Commands
# Build all assets from manifest
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.nczxmeshplayer_diffuse.nczxtexjump.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
.nczxtexfiles, 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
| Format | Name | Packed Stride |
|---|---|---|
| 0 | POS | 8 bytes |
| 1 | POS_UV | 12 bytes |
| 2 | POS_COLOR | 12 bytes |
| 3 | POS_UV_COLOR | 16 bytes |
| 4 | POS_NORMAL | 12 bytes |
| 5 | POS_UV_NORMAL | 16 bytes |
| 6 | POS_COLOR_NORMAL | 16 bytes |
| 7 | POS_UV_COLOR_NORMAL | 20 bytes |
| 8 | POS_SKINNED | 16 bytes |
| 9 | POS_UV_SKINNED | 20 bytes |
| 10 | POS_COLOR_SKINNED | 20 bytes |
| 11 | POS_UV_COLOR_SKINNED | 24 bytes |
| 12 | POS_NORMAL_SKINNED | 20 bytes |
| 13 | POS_UV_NORMAL_SKINNED | 24 bytes |
| 14 | POS_COLOR_NORMAL_SKINNED | 24 bytes |
| 15 | POS_UV_COLOR_NORMAL_SKINNED | 28 bytes |
Common formats:
- Format 5 (POS_UV_NORMAL): Most common for textured, lit meshes
- Format 13 (POS_UV_NORMAL_SKINNED): Animated characters
Packed Vertex Data
The runtime automatically packs vertex data using GPU-optimized formats for smaller memory footprint and faster uploads.
Attribute Encoding
| Attribute | Packed Format | Size | Notes |
|---|---|---|---|
| Position | Float16x4 | 8 bytes | x, y, z, w=1.0 |
| UV | Unorm16x2 | 4 bytes | 65536 values in [0,1], better precision than f16 |
| Color | Unorm8x4 | 4 bytes | RGBA, alpha=255 if not provided |
| Normal | Octahedral u32 | 4 bytes | ~0.02° angular precision |
| Bone Indices | Uint8x4 | 4 bytes | Up to 256 bones |
| Bone Weights | Unorm8x4 | 4 bytes | Normalized to [0,255] |
Octahedral Normal Encoding
Normals use octahedral encoding for uniform angular precision with 66% size reduction:
Standard normal: 3 floats × 4 bytes = 12 bytes
Octahedral: 1 u32 = 4 bytes
How it works:
- Project 3D unit vector onto octahedron surface
- Unfold octahedron to 2D square [-1, 1]²
- Pack as 2× snorm16 into single u32
Precision: ~0.02° worst-case angular error - uniform across the entire sphere.
The vertex shader decodes the normal automatically.
Memory Savings
| Format | Unpacked | Packed | Savings |
|---|---|---|---|
| POS_UV_NORMAL | 32 bytes | 16 bytes | 50% |
| POS_UV_NORMAL_SKINNED | 52 bytes | 24 bytes | 54% |
| Full format (15) | 64 bytes | 28 bytes | 56% |
Skeletal Animation
Vertex Skinning Data
Each skinned vertex stores:
- Bone Indices: Uint8x4 (4 bytes) - which bones affect this vertex (0-255)
- Bone Weights: Unorm8x4 (4 bytes) - influence weights, normalized
Bone Matrices
Bone transforms use 3×4 affine matrices (not 4×4):
set_bones(matrices_ptr, count)
3×4 Matrix Layout (column-major, 12 floats per bone):
[m00, m10, m20] ← column 0 (X basis)
[m01, m11, m21] ← column 1 (Y basis)
[m02, m12, m22] ← column 2 (Z basis)
[m03, m13, m23] ← column 3 (translation)
The bottom row [0, 0, 0, 1] is implicit (affine transform).
Limits:
- Maximum 256 bones per skeleton
- 48 bytes per bone (vs 64 bytes for 4×4) - 25% memory savings
Tool Reference
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)
NetherZ Format Loading (Recommended)
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:
| Resource | Limit |
|---|---|
| ROM size | 16 MB |
| VRAM | 4 MB |
| Bones per skeleton | 256 |
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
.nczxROM 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?
| Scenario | Recommendation |
|---|---|
| Learning/prototyping | include_bytes!() or procedural |
| Simple arcade games | Either works |
| Complex games with many assets | nether.toml + ROM |
| Games with large textures | nether.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 --watchfor auto-rebuild on changes - Rust code generation - Auto-generated asset loading module
Publishing Your Game
This guide covers everything you need to know about packaging and publishing your Nethercore game.
Overview
The publishing process:
- Build your game (compile to WASM)
- Pack assets into a ROM file (optional)
- Test the final build
- Upload to nethercore.systems
- Share with the world
Building for Release
Using nether-cli (Recommended)
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
| File | Format | Description |
|---|---|---|
| Game | .wasm or .nczx | Your compiled game |
| Icon | 64×64 PNG | Library thumbnail |
Optional Files
| File | Format | Description |
|---|---|---|
| Screenshots | PNG | Game page gallery (up to 5) |
| Banner | 1280×720 PNG | Featured games banner |
Metadata
- Title - Your game’s name
- Description - What your game is about (Markdown supported)
- Category - Arcade, Puzzle, Action, etc.
- Tags - Searchable keywords
Publishing Process
1. Create Developer Account
Visit nethercore.systems/register
2. Access Dashboard
Log in and go to nethercore.systems/dashboard
3. Upload Game
- Click “Upload New Game”
- Fill in title and description
- Select category and tags
- Upload your game file
- Upload icon (required) and screenshots (optional)
- Click “Publish”
4. Game Page
Your game gets a public page:
nethercore.systems/game/your-game-id
Updating Your Game
To release an update:
- Bump version in
nether.toml - Build and test new version
- Go to Dashboard → Your Game → Edit
- Upload new game file
- Update version number
- Save changes
Players with old versions will be prompted to update.
Content Guidelines
Games must:
- Be appropriate for all ages
- Not contain malware or harmful code
- Not violate copyright
- Actually be playable
Games must NOT:
- Contain excessive violence or adult content
- Harvest user data
- Attempt to break out of the sandbox
- Impersonate other developers’ games
Troubleshooting
“WASM validation failed”
Your WASM file may be corrupted or built incorrectly.
Fix:
# Clean build
cargo clean
cargo build --target wasm32-unknown-unknown --release
“Asset not found”
Asset paths in 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 = truetonether.toml(see Texture Compression section) - Use smaller audio sample rates
- Remove unused assets
“Game crashes on load”
Usually a panic in init().
Debug:
- Test locally first
- Check console for error messages
- Simplify
init()to isolate the issue
Best Practices
- Test thoroughly before publishing
- Write a good description - help players find your game
- Create an appealing icon - first impressions matter
- Include screenshots - show off your game
- Respond to feedback - engage with players
- Update regularly - fix bugs, add features
Distribution Alternatives
Besides 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)thenbegin_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:
| Name | Type | Description |
|---|---|---|
| ptr | *const u8 | Pointer to UTF-8 string data |
| len | u32 | Length of the string in bytes |
Example:
#![allow(unused)]
fn main() {
fn init() {
let msg = b"Game initialized!";
log(msg.as_ptr(), msg.len() as u32);
}
fn update() {
if player_died {
let msg = b"Player died";
log(msg.as_ptr(), msg.len() as u32);
}
}
}
Control Flow
quit
Exits the game and returns to the 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:
| Name | Type | Description |
|---|---|---|
| min | i32 | Minimum value (inclusive) |
| max | i32 | Maximum 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:
| Name | Type | Description |
|---|---|---|
| min | f32 | Minimum value (inclusive) |
| max | f32 | Maximum 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:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if held, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Continuous movement while held
if button_held(0, BUTTON_RIGHT) != 0 {
player.x += MOVE_SPEED * delta_time();
}
if button_held(0, BUTTON_LEFT) != 0 {
player.x -= MOVE_SPEED * delta_time();
}
}
}
See Also: button_pressed, button_released
button_pressed
Check if a button was just pressed this tick (edge detection).
Signature:
#![allow(unused)]
fn main() {
fn button_pressed(player: u32, button: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if just pressed this tick, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Jump only triggers once per press
if button_pressed(0, BUTTON_A) != 0 && player.on_ground {
player.velocity_y = JUMP_VELOCITY;
play_sound(jump_sfx, 1.0, 0.0);
}
// Cycle weapons
if button_pressed(0, BUTTON_RB) != 0 {
current_weapon = (current_weapon + 1) % NUM_WEAPONS;
}
}
}
See Also: button_held, button_released
button_released
Check if a button was just released this tick.
Signature:
#![allow(unused)]
fn main() {
fn button_released(player: u32, button: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| button | u32 | Button constant (0-13) |
Returns: 1 if just released this tick, 0 otherwise
Example:
#![allow(unused)]
fn main() {
fn update() {
// Variable jump height (release early = smaller jump)
if button_released(0, BUTTON_A) != 0 && player.velocity_y < 0.0 {
player.velocity_y *= 0.5; // Cut upward velocity
}
// Charged attack
if button_released(0, BUTTON_X) != 0 {
let power = charge_time.min(MAX_CHARGE);
fire_charged_attack(power);
charge_time = 0.0;
}
}
}
See Also: button_held, button_pressed
Bulk Button Queries
For better performance when checking multiple buttons, use bulk queries to reduce FFI overhead.
buttons_held
Get a bitmask of all currently held buttons.
Signature:
#![allow(unused)]
fn main() {
fn buttons_held(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N is held
Example:
#![allow(unused)]
fn main() {
fn update() {
let held = buttons_held(0);
// Check multiple buttons efficiently
if held & (1 << BUTTON_A) != 0 { /* A held */ }
if held & (1 << BUTTON_B) != 0 { /* B held */ }
// Check for combo (A + B held together)
let combo = (1 << BUTTON_A) | (1 << BUTTON_B);
if held & combo == combo {
perform_combo_attack();
}
}
}
See Also: buttons_pressed, buttons_released
buttons_pressed
Get a bitmask of all buttons pressed this tick.
Signature:
#![allow(unused)]
fn main() {
fn buttons_pressed(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N was just pressed
Example:
#![allow(unused)]
fn main() {
fn update() {
let pressed = buttons_pressed(0);
// Check if any face button pressed
let face_buttons = (1 << BUTTON_A) | (1 << BUTTON_B) |
(1 << BUTTON_X) | (1 << BUTTON_Y);
if pressed & face_buttons != 0 {
// Handle menu selection
}
}
}
buttons_released
Get a bitmask of all buttons released this tick.
Signature:
#![allow(unused)]
fn main() {
fn buttons_released(player: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Bitmask where bit N is set if button N was just released
Analog Sticks
left_stick_x
Get the left stick horizontal axis.
Signature:
#![allow(unused)]
fn main() {
fn left_stick_x(player: u32) -> f32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Value from -1.0 (left) to 1.0 (right), 0.0 at center
Example:
#![allow(unused)]
fn main() {
fn update() {
let stick_x = left_stick_x(0);
// Apply deadzone
let deadzone = 0.15;
if stick_x.abs() > deadzone {
player.x += stick_x * MOVE_SPEED * delta_time();
}
}
}
left_stick_y
Get the left stick vertical axis.
Signature:
#![allow(unused)]
fn main() {
fn left_stick_y(player: u32) -> f32
}
Returns: Value from -1.0 (down) to 1.0 (up), 0.0 at center
right_stick_x
Get the right stick horizontal axis.
Signature:
#![allow(unused)]
fn main() {
fn right_stick_x(player: u32) -> f32
}
Returns: Value from -1.0 (left) to 1.0 (right)
Example:
#![allow(unused)]
fn main() {
fn update() {
// Camera control with right stick
camera_yaw += right_stick_x(0) * CAMERA_SPEED * delta_time();
}
}
right_stick_y
Get the right stick vertical axis.
Signature:
#![allow(unused)]
fn main() {
fn right_stick_y(player: u32) -> f32
}
Returns: Value from -1.0 (down) to 1.0 (up)
left_stick
Get both left stick axes in a single FFI call (more efficient).
Signature:
#![allow(unused)]
fn main() {
fn left_stick(player: u32, out_x: *mut f32, out_y: *mut f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
| out_x | *mut f32 | Pointer to write X value |
| out_y | *mut f32 | Pointer to write Y value |
Example:
#![allow(unused)]
fn main() {
fn update() {
let mut x: f32 = 0.0;
let mut y: f32 = 0.0;
left_stick(0, &mut x, &mut y);
// Calculate magnitude for circular deadzone
let mag = (x * x + y * y).sqrt();
if mag > 0.15 {
let nx = x / mag;
let ny = y / mag;
player.x += nx * MOVE_SPEED * delta_time();
player.y += ny * MOVE_SPEED * delta_time();
}
}
}
right_stick
Get both right stick axes in a single FFI call.
Signature:
#![allow(unused)]
fn main() {
fn right_stick(player: u32, out_x: *mut f32, out_y: *mut f32)
}
Analog Triggers
trigger_left
Get the left trigger (LT) value.
Signature:
#![allow(unused)]
fn main() {
fn trigger_left(player: u32) -> f32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| player | u32 | Player index (0-3) |
Returns: Value from 0.0 (released) to 1.0 (fully pressed)
Example:
#![allow(unused)]
fn main() {
fn update() {
let lt = trigger_left(0);
// Brake with analog pressure
if lt > 0.1 {
vehicle.speed *= 1.0 - (lt * BRAKE_FORCE * delta_time());
}
}
}
trigger_right
Get the right trigger (RT) value.
Signature:
#![allow(unused)]
fn main() {
fn trigger_right(player: u32) -> f32
}
Returns: Value from 0.0 (released) to 1.0 (fully pressed)
Example:
#![allow(unused)]
fn main() {
fn update() {
let rt = trigger_right(0);
// Accelerate with analog pressure
if rt > 0.1 {
vehicle.speed += rt * ACCEL_FORCE * delta_time();
}
// Aiming zoom
let zoom = 1.0 + rt * 2.0; // 1x to 3x zoom
camera_fov(60.0 / zoom);
}
}
Complete Input Example
#![allow(unused)]
fn main() {
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const MOVE_SPEED: f32 = 100.0;
const DEADZONE: f32 = 0.15;
static mut PLAYER_X: f32 = 0.0;
static mut PLAYER_Y: f32 = 0.0;
static mut ON_GROUND: bool = true;
static mut VEL_Y: f32 = 0.0;
#[no_mangle]
pub extern "C" fn update() {
unsafe {
let dt = delta_time();
// Movement with left stick
let mut sx: f32 = 0.0;
let mut sy: f32 = 0.0;
left_stick(0, &mut sx, &mut sy);
if sx.abs() > DEADZONE {
PLAYER_X += sx * MOVE_SPEED * dt;
}
// Jump with A button
if button_pressed(0, BUTTON_A) != 0 && ON_GROUND {
VEL_Y = -300.0;
ON_GROUND = false;
}
// Gravity
VEL_Y += 800.0 * dt;
PLAYER_Y += VEL_Y * dt;
// Ground collision
if PLAYER_Y >= 200.0 {
PLAYER_Y = 200.0;
VEL_Y = 0.0;
ON_GROUND = true;
}
}
}
}
Graphics Configuration
Console configuration and render state functions.
Configuration (Init-Only)
These functions must be called in init() and cannot be changed at runtime.
set_tick_rate
Sets the game’s tick rate (updates per second).
Signature:
#![allow(unused)]
fn main() {
fn set_tick_rate(fps: u32)
}
Parameters:
| Value | Tick Rate |
|---|---|
| 0 | 24 fps |
| 1 | 30 fps |
| 2 | 60 fps - default |
| 3 | 120 fps |
Constraints: Init-only. Affects GGRS synchronization.
Example:
#![allow(unused)]
fn main() {
fn init() {
set_tick_rate(2); // 60 fps
}
}
set_clear_color
Sets the background clear color.
Signature:
#![allow(unused)]
fn main() {
fn set_clear_color(color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| color | u32 | RGBA color as 0xRRGGBBAA |
Constraints: Init-only. Default is 0x000000FF (black).
Example:
#![allow(unused)]
fn main() {
fn init() {
set_clear_color(0x1a1a2eFF); // Dark blue
set_clear_color(0x87CEEBFF); // Sky blue
}
}
render_mode
Sets the rendering mode (shader pipeline).
Signature:
#![allow(unused)]
fn main() {
fn render_mode(mode: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | Lambert | Simple diffuse shading |
| 1 | Matcap | Pre-baked lighting via matcap textures |
| 2 | Metallic-Roughness | PBR-style Blinn-Phong with MRE textures |
| 3 | Specular-Shininess | Traditional Blinn-Phong |
Constraints: Init-only. Default is mode 0 (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:
| Name | Type | Description |
|---|---|---|
| color | u32 | RGBA color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// White (no tint)
set_color(0xFFFFFFFF);
draw_mesh(model);
// Red tint
set_color(0xFF0000FF);
draw_mesh(enemy);
// 50% transparent
set_color(0xFFFFFF80);
draw_mesh(ghost);
}
}
cull_mode
Sets face culling mode.
Signature:
#![allow(unused)]
fn main() {
fn cull_mode(mode: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | None | Draw both sides (default) |
| 1 | Back | Cull back faces |
| 2 | Front | Cull front faces |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Normal geometry
cull_mode(1); // Back-face culling
draw_mesh(solid_object);
// Skybox (inside-out)
cull_mode(2); // Front-face culling
draw_mesh(skybox);
// Double-sided foliage
cull_mode(0); // No culling
draw_mesh(leaves);
}
}
texture_filter
Sets texture filtering mode.
Signature:
#![allow(unused)]
fn main() {
fn texture_filter(filter: u32)
}
Parameters:
| Value | Mode | Description |
|---|---|---|
| 0 | Nearest | Pixelated (retro look) |
| 1 | Linear | Smooth (modern look) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Pixel art sprites
texture_filter(0);
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:
| Name | Type | Description |
|---|---|---|
| level | u32 | Alpha level 0-15 (0 = invisible, 15 = opaque) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Fade in effect
let alpha = (fade_progress * 15.0) as u32;
uniform_alpha(alpha);
draw_mesh(fading_object);
// Reset to fully opaque
uniform_alpha(15);
}
}
See Also: dither_offset
dither_offset
Sets the dither pattern offset for animated dithering.
Signature:
#![allow(unused)]
fn main() {
fn dither_offset(x: u32, y: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | u32 | X offset 0-3 |
| y | u32 | Y offset 0-3 |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Animate dither pattern for shimmer effect
let frame = tick_count() as u32;
dither_offset(frame % 4, (frame / 4) % 4);
}
}
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:
| Name | Type | Description |
|---|---|---|
| clear_depth | u32 | 1 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:
| Name | Type | Description |
|---|---|---|
| ref_value | u32 | Stencil reference value to write (0-255) |
| clear_depth | u32 | 1 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:
| Name | Type | Description |
|---|---|---|
| ref_value | u32 | Stencil reference value to test against (0-255) |
| clear_depth | u32 | 1 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:
| Constant | Value | Description |
|---|---|---|
compare::NEVER | 1 | Never pass |
compare::LESS | 2 | Pass if src < dst |
compare::EQUAL | 3 | Pass if src == dst |
compare::LESS_EQUAL | 4 | Pass if src <= dst |
compare::GREATER | 5 | Pass if src > dst |
compare::NOT_EQUAL | 6 | Pass if src != dst |
compare::GREATER_EQUAL | 7 | Pass if src >= dst |
compare::ALWAYS | 8 | Always pass |
Stencil Operation Constants:
| Constant | Value | Description |
|---|---|---|
stencil_op::KEEP | 0 | Keep current value |
stencil_op::ZERO | 1 | Set to zero |
stencil_op::REPLACE | 2 | Replace with ref value |
stencil_op::INCREMENT_CLAMP | 3 | Increment, clamp to max |
stencil_op::DECREMENT_CLAMP | 4 | Decrement, clamp to 0 |
stencil_op::INVERT | 5 | Bitwise invert |
stencil_op::INCREMENT_WRAP | 6 | Increment, wrap to 0 |
stencil_op::DECREMENT_WRAP | 7 | Decrement, 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:
| Name | Type | Description |
|---|---|---|
| n | u32 | Z-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:
| Name | Type | Description |
|---|---|---|
| x | u32 | Left edge of viewport in screen pixels |
| y | u32 | Top edge of viewport in screen pixels |
| width | u32 | Width of viewport in screen pixels |
| height | u32 | Height 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:
| Property | Value |
|---|---|
| FOV | 60° vertical |
| Aspect | 16:9 (960×540 fixed resolution) |
| Near plane | 0.1 units |
| Far plane | 1000 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:
| Name | Type | Description |
|---|---|---|
| x, y, z | f32 | Camera position in world space |
| target_x, target_y, target_z | f32 | Point the camera looks at |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Fixed camera looking at origin
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Third-person follow camera
camera_set(
player.x,
player.y + 3.0,
player.z + 8.0,
player.x,
player.y + 1.0,
player.z
);
}
}
camera_fov
Sets the camera field of view.
Signature:
#![allow(unused)]
fn main() {
fn camera_fov(fov_degrees: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| fov_degrees | f32 | Vertical FOV in degrees (1-179) |
Default: 60 degrees
Example:
#![allow(unused)]
fn main() {
fn render() {
// Normal gameplay
camera_fov(60.0);
// Zoom in for aiming
if aiming {
camera_fov(30.0);
}
// Wide angle for racing
camera_fov(90.0);
}
}
Custom Matrices
For advanced camera control, you can set the view and projection matrices directly.
push_view_matrix
Sets a custom view matrix (camera transform).
Signature:
#![allow(unused)]
fn main() {
fn push_view_matrix(
m0: f32, m1: f32, m2: f32, m3: f32,
m4: f32, m5: f32, m6: f32, m7: f32,
m8: f32, m9: f32, m10: f32, m11: f32,
m12: f32, m13: f32, m14: f32, m15: f32
)
}
Parameters: 16 floats representing a 4x4 column-major matrix.
Matrix Layout (column-major):
| m0 m4 m8 m12 |
| m1 m5 m9 m13 |
| m2 m6 m10 m14 |
| m3 m7 m11 m15 |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam for matrix math
let eye = Vec3::new(0.0, 5.0, 10.0);
let target = Vec3::new(0.0, 0.0, 0.0);
let up = Vec3::Y;
let view = Mat4::look_at_rh(eye, target, up);
let cols = view.to_cols_array();
push_view_matrix(
cols[0], cols[1], cols[2], cols[3],
cols[4], cols[5], cols[6], cols[7],
cols[8], cols[9], cols[10], cols[11],
cols[12], cols[13], cols[14], cols[15]
);
}
}
push_projection_matrix
Sets a custom projection matrix.
Signature:
#![allow(unused)]
fn main() {
fn push_projection_matrix(
m0: f32, m1: f32, m2: f32, m3: f32,
m4: f32, m5: f32, m6: f32, m7: f32,
m8: f32, m9: f32, m10: f32, m11: f32,
m12: f32, m13: f32, m14: f32, m15: f32
)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Custom perspective projection
let aspect = 16.0 / 9.0;
let fov = 60.0_f32.to_radians();
let near = 0.1;
let far = 1000.0;
let proj = Mat4::perspective_rh(fov, aspect, near, far);
let cols = proj.to_cols_array();
push_projection_matrix(
cols[0], cols[1], cols[2], cols[3],
cols[4], cols[5], cols[6], cols[7],
cols[8], cols[9], cols[10], cols[11],
cols[12], cols[13], cols[14], cols[15]
);
// Orthographic projection for 2D
let ortho = Mat4::orthographic_rh(0.0, 960.0, 540.0, 0.0, -1.0, 1.0);
// ... push_projection_matrix with ortho values
}
}
Camera Patterns
Orbiting Camera
#![allow(unused)]
fn main() {
static mut ORBIT_ANGLE: f32 = 0.0;
static mut ORBIT_DISTANCE: f32 = 10.0;
static mut ORBIT_HEIGHT: f32 = 5.0;
fn update() {
unsafe {
// Rotate with right stick
ORBIT_ANGLE += right_stick_x(0) * 2.0 * delta_time();
// Zoom with triggers
ORBIT_DISTANCE -= trigger_right(0) * 5.0 * delta_time();
ORBIT_DISTANCE += trigger_left(0) * 5.0 * delta_time();
ORBIT_DISTANCE = ORBIT_DISTANCE.clamp(5.0, 20.0);
}
}
fn render() {
unsafe {
let cam_x = ORBIT_ANGLE.cos() * ORBIT_DISTANCE;
let cam_z = ORBIT_ANGLE.sin() * ORBIT_DISTANCE;
camera_set(cam_x, ORBIT_HEIGHT, cam_z, 0.0, 0.0, 0.0);
}
}
}
First-Person Camera
#![allow(unused)]
fn main() {
static mut CAM_X: f32 = 0.0;
static mut CAM_Y: f32 = 1.7; // Eye height
static mut CAM_Z: f32 = 0.0;
static mut CAM_YAW: f32 = 0.0;
static mut CAM_PITCH: f32 = 0.0;
fn update() {
unsafe {
// Look with right stick
CAM_YAW += right_stick_x(0) * 3.0 * delta_time();
CAM_PITCH -= right_stick_y(0) * 2.0 * delta_time();
CAM_PITCH = CAM_PITCH.clamp(-1.4, 1.4); // Limit look up/down
// Move with left stick
let forward_x = CAM_YAW.sin();
let forward_z = CAM_YAW.cos();
let right_x = forward_z;
let right_z = -forward_x;
let speed = 5.0 * delta_time();
CAM_X += left_stick_y(0) * forward_x * speed;
CAM_Z += left_stick_y(0) * forward_z * speed;
CAM_X += left_stick_x(0) * right_x * speed;
CAM_Z += left_stick_x(0) * right_z * speed;
}
}
fn render() {
unsafe {
let look_x = CAM_X + CAM_YAW.sin() * CAM_PITCH.cos();
let look_y = CAM_Y + CAM_PITCH.sin();
let look_z = CAM_Z + CAM_YAW.cos() * CAM_PITCH.cos();
camera_set(CAM_X, CAM_Y, CAM_Z, look_x, look_y, look_z);
}
}
}
Split-Screen Cameras
#![allow(unused)]
fn main() {
fn render() {
let count = player_count();
for p in 0..count {
// Set viewport (would need custom projection)
setup_viewport_for_player(p, count);
// Each player's camera follows them
camera_set(
players[p].x,
players[p].y + 5.0,
players[p].z + 10.0,
players[p].x,
players[p].y,
players[p].z
);
draw_scene();
}
}
}
Transform Functions
Matrix stack operations for positioning, rotating, and scaling objects.
Conventions
- Y-up right-handed coordinate system
- Column-major matrix storage (wgpu/WGSL compatible)
- Column vectors:
v' = M * v - Angles in degrees for FFI (converted to radians internally)
Transform Stack
push_identity
Resets the current transform to identity (no transformation).
Signature:
#![allow(unused)]
fn main() {
fn push_identity()
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Reset before drawing each object
push_identity();
draw_mesh(object_a);
push_identity();
push_translate(10.0, 0.0, 0.0);
draw_mesh(object_b);
}
}
transform_set
Sets the current transform from a 4x4 matrix.
Signature:
#![allow(unused)]
fn main() {
fn transform_set(matrix_ptr: *const f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrix_ptr | *const f32 | Pointer to 16 floats (4x4 column-major) |
Matrix Layout (column-major, 16 floats):
[col0.x, col0.y, col0.z, col0.w,
col1.x, col1.y, col1.z, col1.w,
col2.x, col2.y, col2.z, col2.w,
col3.x, col3.y, col3.z, col3.w]
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam
let transform = Mat4::from_scale_rotation_translation(
Vec3::ONE,
Quat::from_rotation_y(angle),
Vec3::new(x, y, z)
);
let cols = transform.to_cols_array();
transform_set(cols.as_ptr());
draw_mesh(model);
}
}
Translation
push_translate
Applies a translation to the current transform.
Signature:
#![allow(unused)]
fn main() {
fn push_translate(x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | f32 | X offset (right is positive) |
| y | f32 | Y offset (up is positive) |
| z | f32 | Z offset (toward camera is positive) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Position object at (10, 5, 0)
push_identity();
push_translate(10.0, 5.0, 0.0);
draw_mesh(object);
// Stack translations (additive)
push_identity();
push_translate(5.0, 0.0, 0.0); // Move right 5
push_translate(0.0, 3.0, 0.0); // Then move up 3
draw_mesh(object); // At (5, 3, 0)
}
}
Rotation
push_rotate_x
Rotates around the X axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_x(angle_deg: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| angle_deg | f32 | Rotation angle in degrees |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_x(45.0); // Tilt forward 45 degrees
draw_mesh(object);
}
}
push_rotate_y
Rotates around the Y axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_y(angle_deg: f32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_y(elapsed_time() * 90.0); // Spin 90 deg/sec
draw_mesh(spinning_object);
}
}
push_rotate_z
Rotates around the Z axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate_z(angle_deg: f32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_rotate_z(45.0); // Roll 45 degrees
draw_mesh(object);
}
}
push_rotate
Rotates around an arbitrary axis.
Signature:
#![allow(unused)]
fn main() {
fn push_rotate(angle_deg: f32, axis_x: f32, axis_y: f32, axis_z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| angle_deg | f32 | Rotation angle in degrees |
| axis_x, axis_y, axis_z | f32 | Rotation axis (will be normalized) |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
// Rotate around diagonal axis
push_rotate(45.0, 1.0, 1.0, 0.0);
draw_mesh(object);
}
}
Scale
push_scale
Applies non-uniform scaling.
Signature:
#![allow(unused)]
fn main() {
fn push_scale(x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| x | f32 | Scale factor on X axis |
| y | f32 | Scale factor on Y axis |
| z | f32 | Scale factor on Z axis |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_scale(2.0, 1.0, 1.0); // Stretch horizontally
draw_mesh(object);
push_identity();
push_scale(1.0, 0.5, 1.0); // Squash vertically
draw_mesh(squashed);
}
}
push_scale_uniform
Applies uniform scaling (same factor on all axes).
Signature:
#![allow(unused)]
fn main() {
fn push_scale_uniform(s: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| s | f32 | Uniform scale factor |
Example:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_scale_uniform(2.0); // Double size
draw_mesh(big_object);
push_identity();
push_scale_uniform(0.5); // Half size
draw_mesh(small_object);
}
}
Transform Order
Transforms are applied in reverse order of function calls (right-to-left matrix multiplication).
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_translate(5.0, 0.0, 0.0); // Applied LAST
push_rotate_y(45.0); // Applied SECOND
push_scale_uniform(2.0); // Applied FIRST
draw_mesh(object);
// Equivalent to: Translate * Rotate * Scale * vertex
// Object is: 1) scaled, 2) rotated, 3) translated
}
}
Common Patterns
Object at position with rotation:
#![allow(unused)]
fn main() {
push_identity();
push_translate(obj.x, obj.y, obj.z); // Position
push_rotate_y(obj.rotation); // Then rotate
draw_mesh(obj.mesh);
}
Hierarchical transforms (parent-child):
#![allow(unused)]
fn main() {
fn render() {
// Tank body
push_identity();
push_translate(tank.x, tank.y, tank.z);
push_rotate_y(tank.body_angle);
draw_mesh(tank_body);
// Turret (inherits body transform, then adds its own)
push_translate(0.0, 1.0, 0.0); // Offset from body
push_rotate_y(tank.turret_angle); // Independent rotation
draw_mesh(tank_turret);
// Barrel (inherits turret transform)
push_translate(0.0, 0.5, 2.0);
push_rotate_x(tank.barrel_pitch);
draw_mesh(tank_barrel);
}
}
Rotating around a pivot point:
#![allow(unused)]
fn main() {
fn render() {
push_identity();
push_translate(pivot_x, pivot_y, pivot_z); // Move to pivot
push_rotate_y(angle); // Rotate
push_translate(-pivot_x, -pivot_y, -pivot_z); // Move back
draw_mesh(object);
}
}
Complete Example
#![allow(unused)]
fn main() {
static mut ANGLE: f32 = 0.0;
fn update() {
unsafe {
ANGLE += 90.0 * delta_time(); // 90 degrees per second
}
}
fn render() {
unsafe {
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Spinning cube at origin
push_identity();
push_rotate_y(ANGLE);
draw_mesh(cube);
// Orbiting cube
push_identity();
push_rotate_y(ANGLE * 0.5); // Orbital rotation
push_translate(5.0, 0.0, 0.0); // Distance from center
push_rotate_y(ANGLE * 2.0); // Spin on own axis
push_scale_uniform(0.5);
draw_mesh(cube);
// Static cube for reference
push_identity();
push_translate(-5.0, 0.0, 0.0);
draw_mesh(cube);
}
}
}
Texture Functions
Loading, binding, and configuring textures.
Loading Textures
load_texture
Loads an RGBA8 texture from WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn load_texture(width: u32, height: u32, pixels: *const u8) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| width | u32 | Texture width in pixels |
| height | u32 | Texture height in pixels |
| pixels | *const u8 | Pointer to RGBA8 pixel data (4 bytes per pixel) |
Returns: Texture handle (non-zero on success)
Constraints: Init-only. Must be called in init().
Example:
#![allow(unused)]
fn main() {
static mut PLAYER_TEX: u32 = 0;
// Embedded pixel data (8x8 checkerboard)
const CHECKER: [u8; 8 * 8 * 4] = {
let mut pixels = [0u8; 256];
let mut i = 0;
while i < 64 {
let x = i % 8;
let y = i / 8;
let white = ((x + y) % 2) == 0;
let idx = i * 4;
pixels[idx] = if white { 255 } else { 0 }; // R
pixels[idx + 1] = if white { 255 } else { 0 }; // G
pixels[idx + 2] = if white { 255 } else { 0 }; // B
pixels[idx + 3] = 255; // A
i += 1;
}
pixels
};
fn init() {
unsafe {
PLAYER_TEX = load_texture(8, 8, CHECKER.as_ptr());
}
}
}
Note: Prefer rom_texture() for assets bundled in the ROM data pack.
See Also: rom_texture
Binding Textures
texture_bind
Binds a texture to slot 0 (albedo/diffuse).
Signature:
#![allow(unused)]
fn main() {
fn texture_bind(handle: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Texture handle from load_texture() or rom_texture() |
Example:
#![allow(unused)]
fn main() {
fn render() {
texture_bind(player_tex);
draw_mesh(player_model);
texture_bind(enemy_tex);
draw_mesh(enemy_model);
}
}
texture_bind_slot
Binds a texture to a specific slot.
Signature:
#![allow(unused)]
fn main() {
fn texture_bind_slot(handle: u32, slot: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Texture handle |
| slot | u32 | Texture slot (0-3) |
Texture Slots:
| Slot | Purpose |
|---|---|
| 0 | Albedo/diffuse texture |
| 1 | MRE texture (Mode 2) or Specular (Mode 3) |
| 2 | Reserved |
| 3 | Reserved |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Bind albedo to slot 0
texture_bind_slot(albedo_tex, 0);
// Bind MRE (Metallic/Roughness/Emissive) to slot 1
texture_bind_slot(mre_tex, 1);
draw_mesh(pbr_model);
}
}
Matcap Textures
matcap_blend_mode
Sets the blend mode for a matcap texture slot.
Signature:
#![allow(unused)]
fn main() {
fn matcap_blend_mode(slot: u32, mode: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Matcap slot (1-3) |
| mode | u32 | Blend mode |
Blend Modes:
| Value | Mode | Description |
|---|---|---|
| 0 | Multiply | Darkens (shadows, ambient occlusion) |
| 1 | Add | Brightens (highlights, rim light) |
| 2 | HSV Modulate | Hue/saturation shift |
Example:
#![allow(unused)]
fn main() {
fn init() {
render_mode(1); // Matcap mode
}
fn render() {
// Dark matcap for shadows (multiply)
matcap_set(1, shadow_matcap);
matcap_blend_mode(1, 0);
// Bright matcap for highlights (add)
matcap_set(2, highlight_matcap);
matcap_blend_mode(2, 1);
texture_bind(albedo_tex);
draw_mesh(character);
}
}
See Also: matcap_set, Render Modes Guide
Texture Formats
RGBA8
Standard 8-bit RGBA format. 4 bytes per pixel.
#![allow(unused)]
fn main() {
// Pixel layout: [R, G, B, A, R, G, B, A, ...]
let pixels: [u8; 4 * 4 * 4] = [
255, 0, 0, 255, // Red pixel
0, 255, 0, 255, // Green pixel
0, 0, 255, 255, // Blue pixel
255, 255, 255, 128, // Semi-transparent white
// ... more pixels
];
}
Texture Tips
- Power-of-two dimensions recommended (8, 16, 32, 64, 128, 256, 512)
- Texture atlases reduce bind calls and improve batching
- Use
rom_texture()for large textures (bypasses WASM memory) - Use
load_texture()only for small procedural/runtime textures
Complete Example
#![allow(unused)]
fn main() {
static mut CHECKER_TEX: u32 = 0;
static mut GRADIENT_TEX: u32 = 0;
// Generate checkerboard at compile time
const CHECKER_PIXELS: [u8; 16 * 16 * 4] = {
let mut pixels = [0u8; 16 * 16 * 4];
let mut i = 0;
while i < 256 {
let x = i % 16;
let y = i / 16;
let white = ((x / 2 + y / 2) % 2) == 0;
let idx = i * 4;
let c = if white { 200 } else { 50 };
pixels[idx] = c;
pixels[idx + 1] = c;
pixels[idx + 2] = c;
pixels[idx + 3] = 255;
i += 1;
}
pixels
};
// Generate gradient at compile time
const GRADIENT_PIXELS: [u8; 8 * 8 * 4] = {
let mut pixels = [0u8; 8 * 8 * 4];
let mut i = 0;
while i < 64 {
let x = i % 8;
let y = i / 8;
let idx = i * 4;
pixels[idx] = (x * 32) as u8; // R increases right
pixels[idx + 1] = (y * 32) as u8; // G increases down
pixels[idx + 2] = 128; // B constant
pixels[idx + 3] = 255;
i += 1;
}
pixels
};
fn init() {
unsafe {
CHECKER_TEX = load_texture(16, 16, CHECKER_PIXELS.as_ptr());
GRADIENT_TEX = load_texture(8, 8, GRADIENT_PIXELS.as_ptr());
}
}
fn render() {
unsafe {
// Draw floor with checker texture
texture_bind(CHECKER_TEX);
texture_filter(0); // Nearest for crisp pixels
push_identity();
push_scale(10.0, 1.0, 10.0);
draw_mesh(plane);
// Draw object with gradient
texture_bind(GRADIENT_TEX);
texture_filter(1); // Linear for smooth
push_identity();
push_translate(0.0, 1.0, 0.0);
draw_mesh(cube);
}
}
}
Mesh Functions
Loading and drawing 3D meshes.
Retained Meshes
Retained meshes are loaded once in init() and drawn multiple times in render().
load_mesh
Loads a non-indexed mesh from vertex data.
Signature:
#![allow(unused)]
fn main() {
fn load_mesh(data_ptr: *const u8, vertex_count: u32, format: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices |
| format | u32 | Vertex format flags |
Returns: Mesh handle (non-zero on success)
Constraints: Init-only.
load_mesh_indexed
Loads an indexed mesh (more efficient for shared vertices).
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_indexed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices |
| index_ptr | *const u16 | Pointer to u16 index data |
| index_count | u32 | Number of indices |
| format | u32 | Vertex format flags |
Returns: Mesh handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut CUBE_MESH: u32 = 0;
// Cube with 8 vertices, 36 indices (12 triangles)
const CUBE_VERTS: [f32; 8 * 6] = [
// Position (xyz) + Normal (xyz)
-1.0, -1.0, -1.0, 0.0, 0.0, -1.0,
1.0, -1.0, -1.0, 0.0, 0.0, -1.0,
// ... more vertices
];
const CUBE_INDICES: [u16; 36] = [
0, 1, 2, 2, 3, 0, // Front face
// ... more indices
];
fn init() {
unsafe {
CUBE_MESH = load_mesh_indexed(
CUBE_VERTS.as_ptr() as *const u8,
8,
CUBE_INDICES.as_ptr(),
36,
4 // FORMAT_POS_NORMAL
);
}
}
}
load_mesh_packed
Loads a packed mesh with half-precision floats (smaller memory footprint).
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_packed(data_ptr: *const u8, vertex_count: u32, format: u32) -> u32
}
Constraints: Init-only. Uses f16 for positions and snorm16 for normals.
load_mesh_indexed_packed
Loads an indexed packed mesh.
Signature:
#![allow(unused)]
fn main() {
fn load_mesh_indexed_packed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
) -> u32
}
Constraints: Init-only.
draw_mesh
Draws a retained mesh with the current transform and render state.
Signature:
#![allow(unused)]
fn main() {
fn draw_mesh(handle: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Mesh handle from load_mesh*() or procedural generators |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Draw at origin
push_identity();
draw_mesh(cube);
// Draw at different position
push_identity();
push_translate(5.0, 0.0, 0.0);
draw_mesh(cube);
// Draw with different color
set_color(0xFF0000FF);
push_identity();
push_translate(-5.0, 0.0, 0.0);
draw_mesh(cube);
}
}
Immediate Mode Drawing
For dynamic geometry that changes every frame.
draw_triangles
Draws non-indexed triangles immediately (not retained).
Signature:
#![allow(unused)]
fn main() {
fn draw_triangles(data_ptr: *const u8, vertex_count: u32, format: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to vertex data |
| vertex_count | u32 | Number of vertices (must be multiple of 3) |
| format | u32 | Vertex format flags |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Dynamic triangle
let verts: [f32; 18] = [
// Position (xyz) + Color (rgb)
0.0, 1.0, 0.0, 1.0, 0.0, 0.0, // Top (red)
-1.0, -1.0, 0.0, 0.0, 1.0, 0.0, // Left (green)
1.0, -1.0, 0.0, 0.0, 0.0, 1.0, // Right (blue)
];
push_identity();
draw_triangles(verts.as_ptr() as *const u8, 3, 2); // FORMAT_POS_COLOR
}
}
draw_triangles_indexed
Draws indexed triangles immediately.
Signature:
#![allow(unused)]
fn main() {
fn draw_triangles_indexed(
data_ptr: *const u8,
vertex_count: u32,
index_ptr: *const u16,
index_count: u32,
format: u32
)
}
Vertex Formats
Vertex format is specified as a bitmask of flags:
| Flag | Value | Components | Bytes |
|---|---|---|---|
| Position | 0 | xyz (3 floats) | 12 |
| UV | 1 | uv (2 floats) | 8 |
| Color | 2 | rgb (3 floats) | 12 |
| Normal | 4 | xyz (3 floats) | 12 |
| Skinned | 8 | bone indices + weights | 16 |
Common Combinations:
| Format | Value | Components | Stride |
|---|---|---|---|
| POS | 0 | Position only | 12 bytes |
| POS_UV | 1 | Position + UV | 20 bytes |
| POS_COLOR | 2 | Position + Color | 24 bytes |
| POS_UV_COLOR | 3 | Position + UV + Color | 32 bytes |
| POS_NORMAL | 4 | Position + Normal | 24 bytes |
| POS_UV_NORMAL | 5 | Position + UV + Normal | 32 bytes |
| POS_COLOR_NORMAL | 6 | Position + Color + Normal | 36 bytes |
| POS_UV_COLOR_NORMAL | 7 | Position + UV + Color + Normal | 44 bytes |
With Skinning (add 8):
| Format | Value | Stride |
|---|---|---|
| POS_NORMAL_SKINNED | 12 | 40 bytes |
| POS_UV_NORMAL_SKINNED | 13 | 48 bytes |
Vertex Data Layout
Data is laid out per-vertex in this order:
- Position (xyz) - 3 floats
- UV (uv) - 2 floats (if enabled)
- Color (rgb) - 3 floats (if enabled)
- Normal (xyz) - 3 floats (if enabled)
- Skinning (indices + weights) - 4 bytes + 4 bytes (if enabled)
Example: POS_UV_NORMAL (format 5)
#![allow(unused)]
fn main() {
// Each vertex: 8 floats (32 bytes)
let vertex: [f32; 8] = [
0.0, 1.0, 0.0, // Position
0.5, 1.0, // UV
0.0, 1.0, 0.0, // Normal
];
}
Complete Example
#![allow(unused)]
fn main() {
static mut TRIANGLE: u32 = 0;
static mut QUAD: u32 = 0;
// Triangle with position + color
const TRI_VERTS: [f32; 3 * 6] = [
// pos xyz, color rgb
0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
-1.0, -1.0, 0.0, 0.0, 1.0, 0.0,
1.0, -1.0, 0.0, 0.0, 0.0, 1.0,
];
// Quad with position + UV + normal (indexed)
const QUAD_VERTS: [f32; 4 * 8] = [
// pos xyz, uv, normal xyz
-1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
1.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0,
1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0,
-1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
];
const QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 3, 0];
fn init() {
unsafe {
// Non-indexed triangle
TRIANGLE = load_mesh(
TRI_VERTS.as_ptr() as *const u8,
3,
2 // POS_COLOR
);
// Indexed quad
QUAD = load_mesh_indexed(
QUAD_VERTS.as_ptr() as *const u8,
4,
QUAD_INDICES.as_ptr(),
6,
5 // POS_UV_NORMAL
);
}
}
fn render() {
unsafe {
camera_set(0.0, 0.0, 5.0, 0.0, 0.0, 0.0);
// Draw triangle
push_identity();
push_translate(-2.0, 0.0, 0.0);
draw_mesh(TRIANGLE);
// Draw textured quad
texture_bind(my_texture);
push_identity();
push_translate(2.0, 0.0, 0.0);
draw_mesh(QUAD);
}
}
}
See Also: Procedural Meshes, rom_mesh
Material Functions
Material properties for PBR (Mode 2) and Blinn-Phong (Mode 3) rendering.
Mode 2: Metallic-Roughness (PBR)
material_metallic
Sets the metallic value for PBR rendering.
Signature:
#![allow(unused)]
fn main() {
fn material_metallic(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Metallic value (0.0 = dielectric, 1.0 = metal) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Non-metallic plastic
material_metallic(0.0);
draw_mesh(plastic_toy);
// Full metal
material_metallic(1.0);
draw_mesh(sword);
// Partially metallic (worn paint on metal)
material_metallic(0.3);
draw_mesh(rusty_barrel);
}
}
material_roughness
Sets the roughness value for PBR rendering.
Signature:
#![allow(unused)]
fn main() {
fn material_roughness(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Roughness value (0.0 = smooth/mirror, 1.0 = rough/matte) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Mirror-like chrome
material_roughness(0.1);
draw_mesh(chrome_bumper);
// Rough stone
material_roughness(0.9);
draw_mesh(stone_wall);
// Smooth plastic
material_roughness(0.4);
draw_mesh(toy);
}
}
material_emissive
Sets the emissive (self-illumination) intensity.
Signature:
#![allow(unused)]
fn main() {
fn material_emissive(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Emissive intensity (0.0 = none, 1.0+ = glowing) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Glowing lava
set_color(0xFF4400FF);
material_emissive(2.0);
draw_mesh(lava);
// Neon sign
set_color(0x00FFFFFF);
material_emissive(1.5);
draw_mesh(neon_tube);
// Normal object (no glow)
material_emissive(0.0);
draw_mesh(normal_object);
}
}
material_rim
Sets rim lighting parameters.
Signature:
#![allow(unused)]
fn main() {
fn material_rim(intensity: f32, power: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| intensity | f32 | Rim light intensity (0.0-1.0) |
| power | f32 | Rim light falloff power (0.0-1.0, maps to 0-32 internally) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Subtle rim for characters
material_rim(0.2, 0.15);
draw_mesh(character);
// Strong backlighting effect
material_rim(0.5, 0.3);
draw_mesh(silhouette_enemy);
// No rim lighting
material_rim(0.0, 0.0);
draw_mesh(ground);
}
}
Mode 3: Specular-Shininess (Blinn-Phong)
material_shininess
Sets the shininess for specular highlights (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn material_shininess(value: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| value | f32 | Shininess (0.0-1.0, maps to 1-256 internally) |
Shininess Guide:
| Value | Internal | Visual | Use For |
|---|---|---|---|
| 0.0-0.2 | 1-52 | Very soft, broad | Cloth, skin, rough stone |
| 0.2-0.4 | 52-103 | Broad | Leather, wood, rubber |
| 0.4-0.6 | 103-154 | Medium | Plastic, painted metal |
| 0.6-0.8 | 154-205 | Tight | Polished metal, wet surfaces |
| 0.8-1.0 | 205-256 | Very tight | Chrome, mirrors, glass |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Matte cloth
material_shininess(0.1);
draw_mesh(cloth);
// Polished armor
material_shininess(0.8);
draw_mesh(armor);
// Chrome
material_shininess(0.95);
draw_mesh(chrome_sphere);
}
}
material_specular
Sets the specular highlight color (Mode 3).
Signature:
#![allow(unused)]
fn main() {
fn material_specular(color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| color | u32 | Specular color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// White specular (default, most materials)
material_specular(0xFFFFFFFF);
draw_mesh(plastic);
// Gold specular
material_specular(0xFFD700FF);
draw_mesh(gold_ring);
// Copper specular
material_specular(0xB87333FF);
draw_mesh(copper_pot);
}
}
Texture Slots
material_albedo
Binds an albedo (diffuse) texture to slot 0.
Signature:
#![allow(unused)]
fn main() {
fn material_albedo(texture: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Texture handle |
Note: Equivalent to texture_bind_slot(texture, 0).
material_mre
Binds an MRE (Metallic/Roughness/Emissive) texture to slot 1 (Mode 2).
Signature:
#![allow(unused)]
fn main() {
fn material_mre(texture: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Texture handle for MRE map |
MRE Texture Channels:
- R: Metallic (0-255 maps to 0.0-1.0)
- G: Roughness (0-255 maps to 0.0-1.0)
- B: Emissive (0-255 maps to emissive intensity)
Example:
#![allow(unused)]
fn main() {
fn render() {
material_albedo(character_albedo);
material_mre(character_mre);
draw_mesh(character);
}
}
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:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Texture 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:
| Name | Type | Description |
|---|---|---|
| skip | u32 | 0 = 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:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| x, y, z | f32 | Light direction (from light, will be normalized) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Sun from upper right
light_set(0, 0.5, -0.7, 0.5);
light_enable(0);
// Fill light from left
light_set(1, -0.8, -0.2, 0.0);
light_enable(1);
}
}
light_color
Sets a light’s color.
Signature:
#![allow(unused)]
fn main() {
fn light_color(index: u32, color: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| color | u32 | Light color as 0xRRGGBBAA |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Warm sunlight
light_color(0, 0xFFF2E6FF);
// Cool fill light
light_color(1, 0xB3D9FFFF);
// Red emergency light
light_color(2, 0xFF3333FF);
}
}
light_intensity
Sets a light’s intensity.
Signature:
#![allow(unused)]
fn main() {
fn light_intensity(index: u32, intensity: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| intensity | f32 | Light intensity (0.0-8.0, default 1.0) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Bright main light
light_intensity(0, 1.2);
// Dim fill light
light_intensity(1, 0.3);
// Flickering torch
let flicker = 0.8 + (elapsed_time() * 10.0).sin() * 0.2;
light_intensity(2, flicker);
}
}
light_enable
Enables a light.
Signature:
#![allow(unused)]
fn main() {
fn light_enable(index: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Enable lights 0 and 1
light_enable(0);
light_enable(1);
}
}
light_disable
Disables a light.
Signature:
#![allow(unused)]
fn main() {
fn light_disable(index: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Disable light 2 when entering dark area
if in_dark_zone {
light_disable(2);
}
}
}
Point Lights
light_set_point
Sets a point light position.
Signature:
#![allow(unused)]
fn main() {
fn light_set_point(index: u32, x: f32, y: f32, z: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| x, y, z | f32 | World position of the light |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Torch at fixed position
light_set_point(0, 5.0, 2.0, 3.0);
light_color(0, 0xFFAA66FF);
light_range(0, 10.0);
light_enable(0);
// Light following player
light_set_point(1, player.x, player.y + 1.0, player.z);
light_enable(1);
}
}
light_range
Sets a point light’s falloff range.
Signature:
#![allow(unused)]
fn main() {
fn light_range(index: u32, range: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| index | u32 | Light index (0-3) |
| range | f32 | Maximum range/falloff distance |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Small candle
light_set_point(0, candle_x, candle_y, candle_z);
light_range(0, 3.0);
light_intensity(0, 0.5);
// Large bonfire
light_set_point(1, fire_x, fire_y, fire_z);
light_range(1, 15.0);
light_intensity(1, 2.0);
}
}
Standard Lighting Setups
Three-Point Lighting
#![allow(unused)]
fn main() {
fn setup_lighting() {
// Key light (main light source)
light_set(0, 0.5, -0.7, 0.5);
light_color(0, 0xFFF2E6FF); // Warm white
light_intensity(0, 1.0);
light_enable(0);
// Fill light (soften shadows)
light_set(1, -0.8, -0.3, 0.2);
light_color(1, 0xB3D9FFFF); // Cool blue
light_intensity(1, 0.3);
light_enable(1);
// Rim/back light (separation from background)
light_set(2, 0.0, -0.2, -1.0);
light_color(2, 0xFFFFFFFF);
light_intensity(2, 0.5);
light_enable(2);
}
}
Outdoor Sunlight
#![allow(unused)]
fn main() {
fn render() {
// 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:
| Name | Type | Description |
|---|---|---|
| inverse_bind_ptr | *const f32 | Pointer to 3x4 matrices (12 floats each, column-major) |
| bone_count | u32 | Number of bones (max 256) |
Returns: Skeleton handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static INVERSE_BIND: &[u8] = include_bytes!("skeleton.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:
| Name | Type | Description |
|---|---|---|
| skeleton | u32 | Skeleton handle, or 0 to disable inverse bind mode |
Skinning Modes:
skeleton_bind() | set_bones() receives | GPU applies |
|---|---|---|
0 or not called | Final skinning matrices | Nothing extra |
| Valid handle | Model-space bone transforms | bone × inverse_bind |
Example:
#![allow(unused)]
fn main() {
fn render() {
unsafe {
// Enable inverse bind mode
skeleton_bind(SKELETON);
// Upload model-space transforms (GPU applies inverse bind)
set_bones(animation_bones.as_ptr(), bone_count);
draw_mesh(character_mesh);
// Disable for other meshes
skeleton_bind(0);
}
}
}
Bone Transforms
set_bones
Uploads bone transforms as 3x4 matrices.
Signature:
#![allow(unused)]
fn main() {
fn set_bones(matrices_ptr: *const f32, count: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrices_ptr | *const f32 | Pointer to array of 3x4 matrices (12 floats each) |
| count | u32 | Number of bones (max 256) |
3x4 Matrix Layout (column-major, 12 floats):
[col0.x, col0.y, col0.z, // X axis
col1.x, col1.y, col1.z, // Y axis
col2.x, col2.y, col2.z, // Z axis
tx, ty, tz] // Translation
// Implicit 4th row: [0, 0, 0, 1]
Example:
#![allow(unused)]
fn main() {
static mut BONE_MATRICES: [f32; 64 * 12] = [0.0; 64 * 12]; // 64 bones max
fn update() {
unsafe {
// Update bone transforms from animation
for i in 0..BONE_COUNT {
let offset = i * 12;
// Set identity with translation
BONE_MATRICES[offset + 0] = 1.0; // col0.x
BONE_MATRICES[offset + 4] = 1.0; // col1.y
BONE_MATRICES[offset + 8] = 1.0; // col2.z
BONE_MATRICES[offset + 9] = bone_positions[i].x;
BONE_MATRICES[offset + 10] = bone_positions[i].y;
BONE_MATRICES[offset + 11] = bone_positions[i].z;
}
}
}
fn render() {
unsafe {
set_bones(BONE_MATRICES.as_ptr(), BONE_COUNT as u32);
draw_mesh(SKINNED_MESH);
}
}
}
set_bones_4x4
Uploads bone transforms as 4x4 matrices (converted to 3x4 internally).
Signature:
#![allow(unused)]
fn main() {
fn set_bones_4x4(matrices_ptr: *const f32, count: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| matrices_ptr | *const f32 | Pointer to array of 4x4 matrices (16 floats each) |
| count | u32 | Number of bones (max 256) |
Example:
#![allow(unused)]
fn main() {
fn render() {
// Using glam Mat4 arrays
let mut bone_mats: [Mat4; 64] = [Mat4::IDENTITY; 64];
// Animate bones
for i in 0..bone_count {
bone_mats[i] = compute_bone_transform(i);
}
// Upload (host converts 4x4 → 3x4)
set_bones_4x4(bone_mats.as_ptr() as *const f32, bone_count);
draw_mesh(skinned_mesh);
}
}
Skinned Vertex Format
Add FORMAT_SKINNED (8) to your vertex format for skinned meshes:
#![allow(unused)]
fn main() {
const FORMAT_SKINNED: u32 = 8;
// Common skinned formats
const FORMAT_SKINNED_UV_NORMAL: u32 = FORMAT_SKINNED | FORMAT_UV | FORMAT_NORMAL; // 13
}
Skinned vertex data layout:
position (3 floats)
uv (2 floats, if FORMAT_UV)
color (3 floats, if FORMAT_COLOR)
normal (3 floats, if FORMAT_NORMAL)
bone_indices (4 u8, packed as 4 bytes)
bone_weights (4 floats)
Example vertex (FORMAT_SKINNED_UV_NORMAL):
#![allow(unused)]
fn main() {
// 52 bytes per vertex: 3 + 2 + 3 + 4bytes + 4 floats
let vertex = [
0.0, 1.0, 0.0, // position
0.5, 0.5, // uv
0.0, 1.0, 0.0, // normal
// bone_indices: [0, 1, 255, 255] as 4 bytes
// bone_weights: [0.7, 0.3, 0.0, 0.0] as 4 floats
];
}
Complete Example
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static mut CHARACTER_MESH: u32 = 0;
static mut BONE_MATRICES: [f32; 32 * 12] = [0.0; 32 * 12];
const BONE_COUNT: usize = 32;
fn init() {
unsafe {
// Load skeleton
SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
// Load skinned mesh
CHARACTER_MESH = rom_mesh(b"player".as_ptr(), 6);
// Initialize bones to identity
for i in 0..BONE_COUNT {
let o = i * 12;
BONE_MATRICES[o + 0] = 1.0;
BONE_MATRICES[o + 4] = 1.0;
BONE_MATRICES[o + 8] = 1.0;
}
}
}
fn update() {
unsafe {
// Animate bones (your animation logic here)
animate_walk_cycle(&mut BONE_MATRICES, elapsed_time());
}
}
fn render() {
unsafe {
// Bind skeleton for inverse bind mode
skeleton_bind(SKELETON);
// Upload bone transforms
set_bones(BONE_MATRICES.as_ptr(), BONE_COUNT as u32);
// Draw character
texture_bind(character_texture);
push_identity();
push_translate(player_x, player_y, player_z);
draw_mesh(CHARACTER_MESH);
// Unbind skeleton
skeleton_bind(0);
}
}
}
See Also: Animation Functions, rom_skeleton
Keyframe Animation Functions
GPU-optimized keyframe animation system for skeletal animation.
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:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to keyframe data |
| byte_size | u32 | Size of data in bytes |
Returns: Keyframe collection handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static WALK_DATA: &[u8] = include_bytes!("walk.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:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID string |
| id_len | u32 | Length of asset ID |
Returns: Keyframe collection handle (non-zero on success)
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut ATTACK_ANIM: u32 = 0;
fn init() {
unsafe {
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
ATTACK_ANIM = rom_keyframes(b"attack".as_ptr(), 6);
}
}
}
Querying Keyframes
keyframes_bone_count
Gets the bone count for a keyframe collection.
Signature:
#![allow(unused)]
fn main() {
fn keyframes_bone_count(handle: u32) -> u32
}
Returns: Number of bones in the animation
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
let bones = keyframes_bone_count(WALK_ANIM);
log_fmt(b"Walk animation has {} bones", bones);
}
}
}
keyframes_frame_count
Gets the frame count for a keyframe collection.
Signature:
#![allow(unused)]
fn main() {
fn keyframes_frame_count(handle: u32) -> u32
}
Returns: Number of frames in the animation
Example:
#![allow(unused)]
fn main() {
fn render() {
unsafe {
let frame_count = keyframes_frame_count(WALK_ANIM);
let current_frame = (ANIM_TIME as u32) % frame_count;
keyframe_bind(WALK_ANIM, current_frame);
}
}
}
Using Keyframes
keyframe_bind
Binds a keyframe directly from GPU buffer (zero CPU overhead).
Signature:
#![allow(unused)]
fn main() {
fn keyframe_bind(handle: u32, index: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Keyframe collection handle |
| index | u32 | Frame index (0 to frame_count-1) |
Example:
#![allow(unused)]
fn main() {
static mut ANIM_FRAME: f32 = 0.0;
fn update() {
unsafe {
ANIM_FRAME += delta_time() * 30.0; // 30 FPS animation
}
}
fn render() {
unsafe {
let frame_count = keyframes_frame_count(WALK_ANIM);
let frame = (ANIM_FRAME as u32) % frame_count;
// Bind frame - GPU reads directly, no CPU decode!
keyframe_bind(WALK_ANIM, frame);
draw_mesh(CHARACTER_MESH);
}
}
}
keyframe_read
Reads a keyframe to WASM memory for CPU-side blending.
Signature:
#![allow(unused)]
fn main() {
fn keyframe_read(handle: u32, index: u32, out_ptr: *mut u8)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Keyframe collection handle |
| index | u32 | Frame index |
| out_ptr | *mut u8 | Destination buffer (must be 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
| Path | Function | Use Case | Performance |
|---|---|---|---|
| Static | keyframe_bind() | Pre-baked ROM animations | Zero CPU work |
| Immediate | set_bones() | Procedural, IK, blended | Minimal overhead |
Static keyframes: Data uploaded to GPU once in init(). keyframe_bind() just sets buffer offset.
Immediate bones: Matrices appended to per-frame buffer, uploaded before rendering.
Complete Example
#![allow(unused)]
fn main() {
static mut SKELETON: u32 = 0;
static mut CHARACTER: u32 = 0;
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut ANIM_TIME: f32 = 0.0;
static mut IS_WALKING: bool = false;
fn init() {
unsafe {
SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
CHARACTER = rom_mesh(b"player".as_ptr(), 6);
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
}
}
fn update() {
unsafe {
// Check movement input
let stick_x = left_stick_x(0);
let stick_y = left_stick_y(0);
IS_WALKING = stick_x.abs() > 0.1 || stick_y.abs() > 0.1;
// Advance animation
let anim_speed = if IS_WALKING { 30.0 } else { 15.0 };
ANIM_TIME += delta_time() * anim_speed;
}
}
fn render() {
unsafe {
skeleton_bind(SKELETON);
// Choose animation
let anim = if IS_WALKING { WALK_ANIM } else { IDLE_ANIM };
let frame_count = keyframes_frame_count(anim);
let frame = (ANIM_TIME as u32) % frame_count;
// Bind keyframe (GPU-side, no CPU decode)
keyframe_bind(anim, frame);
// Draw character
texture_bind(player_texture);
push_identity();
push_translate(player_x, player_y, player_z);
draw_mesh(CHARACTER);
skeleton_bind(0);
}
}
}
See Also: Skinning Functions, rom_keyframes
Procedural Mesh Functions
Generate common 3D primitives at runtime.
All procedural meshes use vertex format 5 (POS_UV_NORMAL): 8 floats per vertex. Works with all render modes (0-3).
Constraints: All functions are init-only. Call in init().
Basic Primitives
cube
Generates a box mesh.
Signature:
#![allow(unused)]
fn main() {
fn cube(size_x: f32, size_y: f32, size_z: f32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| size_x | f32 | Half-width (total width = 2 × size_x) |
| size_y | f32 | Half-height (total height = 2 × size_y) |
| size_z | f32 | Half-depth (total depth = 2 × size_z) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
UNIT_CUBE = cube(0.5, 0.5, 0.5); // 1×1×1 cube
TALL_BOX = cube(1.0, 3.0, 1.0); // 2×6×2 tall box
FLAT_TILE = cube(2.0, 0.1, 2.0); // 4×0.2×4 tile
}
}
}
sphere
Generates a UV sphere mesh.
Signature:
#![allow(unused)]
fn main() {
fn sphere(radius: f32, segments: u32, rings: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius | f32 | Sphere radius |
| segments | u32 | Horizontal divisions (3-256) |
| rings | u32 | Vertical divisions (2-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
LOW_POLY_SPHERE = sphere(1.0, 8, 6); // 48 triangles
SMOOTH_SPHERE = sphere(1.0, 32, 16); // 960 triangles
PLANET = sphere(100.0, 64, 32); // Large, detailed
}
}
}
cylinder
Generates a cylinder or cone mesh.
Signature:
#![allow(unused)]
fn main() {
fn cylinder(radius_bottom: f32, radius_top: f32, height: f32, segments: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius_bottom | f32 | Bottom cap radius |
| radius_top | f32 | Top cap radius (0 for cone) |
| height | f32 | Cylinder height |
| segments | u32 | Radial divisions (3-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PILLAR = cylinder(0.5, 0.5, 3.0, 12); // Uniform cylinder
CONE = cylinder(1.0, 0.0, 2.0, 16); // Cone
TAPERED = cylinder(1.0, 0.5, 2.0, 16); // Tapered cylinder
BARREL = cylinder(0.8, 0.6, 1.5, 24); // Barrel shape
}
}
}
plane
Generates a subdivided plane mesh (XZ plane, Y=0, facing up).
Signature:
#![allow(unused)]
fn main() {
fn plane(size_x: f32, size_z: f32, subdivisions_x: u32, subdivisions_z: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| size_x | f32 | Half-width |
| size_z | f32 | Half-depth |
| subdivisions_x | u32 | X divisions (1-256) |
| subdivisions_z | u32 | Z divisions (1-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
GROUND = plane(50.0, 50.0, 1, 1); // 100×100 simple quad
TERRAIN = plane(100.0, 100.0, 32, 32); // Subdivided for LOD
WATER = plane(20.0, 20.0, 16, 16); // Animated water
}
}
}
torus
Generates a torus (donut) mesh.
Signature:
#![allow(unused)]
fn main() {
fn torus(major_radius: f32, minor_radius: f32, major_segments: u32, minor_segments: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| major_radius | f32 | Distance from center to tube center |
| minor_radius | f32 | Tube thickness |
| major_segments | u32 | Segments around ring (3-256) |
| minor_segments | u32 | Segments around tube (3-256) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
DONUT = torus(2.0, 0.5, 32, 16); // Classic donut
RING = torus(3.0, 0.1, 48, 8); // Thin ring
TIRE = torus(1.5, 0.6, 24, 12); // Car tire
}
}
}
capsule
Generates a capsule (cylinder with hemispherical caps).
Signature:
#![allow(unused)]
fn main() {
fn capsule(radius: f32, height: f32, segments: u32, rings: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| radius | f32 | Capsule radius |
| height | f32 | Cylinder section height (total = height + 2×radius) |
| segments | u32 | Radial divisions (3-256) |
| rings | u32 | Hemisphere divisions (1-128) |
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PILL = capsule(0.5, 1.0, 16, 8); // Pill shape
CHARACTER_COLLIDER = capsule(0.4, 1.2, 8, 4); // Physics capsule
BULLET = capsule(0.1, 0.3, 12, 6); // Projectile
}
}
}
UV-Mapped Variants
These variants are identical but explicitly named for clarity.
cube_uv
#![allow(unused)]
fn main() {
fn cube_uv(size_x: f32, size_y: f32, size_z: f32) -> u32
}
Same as cube(). UV coordinates map 0-1 on each face.
sphere_uv
#![allow(unused)]
fn main() {
fn sphere_uv(radius: f32, segments: u32, rings: u32) -> u32
}
Same as sphere(). Equirectangular UV mapping.
cylinder_uv
#![allow(unused)]
fn main() {
fn cylinder_uv(radius_bottom: f32, radius_top: f32, height: f32, segments: u32) -> u32
}
Same as cylinder(). Radial unwrap for body, polar for caps.
plane_uv
#![allow(unused)]
fn main() {
fn plane_uv(size_x: f32, size_z: f32, subdivisions_x: u32, subdivisions_z: u32) -> u32
}
Same as plane(). Simple 0-1 grid UV mapping.
torus_uv
#![allow(unused)]
fn main() {
fn torus_uv(major_radius: f32, minor_radius: f32, major_segments: u32, minor_segments: u32) -> u32
}
Same as torus(). Wrapped UVs on both axes.
capsule_uv
#![allow(unused)]
fn main() {
fn capsule_uv(radius: f32, height: f32, segments: u32, rings: u32) -> u32
}
Same as capsule(). Radial for body, polar for hemispheres.
Complete Example
#![allow(unused)]
fn main() {
static mut GROUND: u32 = 0;
static mut SPHERE: u32 = 0;
static mut CUBE: u32 = 0;
static mut PILLAR: u32 = 0;
fn init() {
unsafe {
render_mode(2); // PBR lighting
// Generate primitives
GROUND = plane(20.0, 20.0, 1, 1);
SPHERE = sphere(1.0, 24, 12);
CUBE = cube(0.5, 0.5, 0.5);
PILLAR = cylinder(0.3, 0.3, 2.0, 16);
}
}
fn render() {
unsafe {
camera_set(0.0, 5.0, 10.0, 0.0, 0.0, 0.0);
// Ground
material_roughness(0.9);
material_metallic(0.0);
set_color(0x556644FF);
push_identity();
draw_mesh(GROUND);
// Central sphere
material_roughness(0.3);
material_metallic(1.0);
set_color(0xFFD700FF);
push_identity();
push_translate(0.0, 1.0, 0.0);
draw_mesh(SPHERE);
// Pillars
set_color(0x888888FF);
material_metallic(0.0);
for i in 0..4 {
let angle = (i as f32) * 1.57;
push_identity();
push_translate(angle.cos() * 5.0, 1.0, angle.sin() * 5.0);
draw_mesh(PILLAR);
}
// Floating cubes
set_color(0x4488FFFF);
for i in 0..8 {
let t = elapsed_time() + (i as f32) * 0.5;
push_identity();
push_translate(
(t * 0.5).cos() * 3.0,
2.0 + (t * 2.0).sin() * 0.5,
(t * 0.5).sin() * 3.0
);
push_rotate_y(t * 90.0);
draw_mesh(CUBE);
}
}
}
}
2D Drawing Functions
Screen-space sprites, rectangles, and text rendering.
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:
| Property | Value |
|---|---|
| Resolution | 960×540 pixels (fixed) |
| Origin | Top-left corner (0, 0) |
| X-axis | Increases rightward (0 to 960) |
| Y-axis | Increases downward (0 to 540) |
| Anchor point | Top-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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position (top-left corner) |
| w, h | f32 | Size 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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position |
| w, h | f32 | Destination size in pixels |
| src_x, src_y | f32 | Source UV position (0.0-1.0) |
| src_w, src_h | f32 | Source 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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position |
| w, h | f32 | Destination size |
| src_x, src_y, src_w, src_h | f32 | Source UV region (0.0-1.0) |
| origin_x, origin_y | f32 | Rotation origin (0-1 normalized) |
| angle_deg | f32 | Rotation 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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Screen position (top-left) |
| w, h | f32 | Size 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:
| Name | Type | Description |
|---|---|---|
| x1, y1 | f32 | Start point in screen pixels |
| x2, y2 | f32 | End point in screen pixels |
| thickness | f32 | Line 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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Center position in screen pixels |
| radius | f32 | Circle 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:
| Name | Type | Description |
|---|---|---|
| x, y | f32 | Center position in screen pixels |
| radius | f32 | Circle radius in pixels |
| thickness | f32 | Line 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:
| Name | Type | Description |
|---|---|---|
| ptr | *const u8 | Pointer to UTF-8 string |
| len | u32 | String length in bytes |
| x, y | f32 | Screen position |
| size | f32 | Font size in pixels |
Note: Use
set_color(0xRRGGBBAA)before callingdraw_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:
| Name | Type | Description |
|---|---|---|
| ptr | *const u8 | Pointer to UTF-8 string |
| len | u32 | String length in bytes |
| size | f32 | Font 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:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Font texture atlas handle |
| char_width | u32 | Width of each character in pixels |
| char_height | u32 | Height of each character in pixels |
| first_codepoint | u32 | First character code (usually 32 for space) |
| char_count | u32 | Number of characters in atlas |
Returns: Font handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
FONT_TEXTURE = load_texture(128, 64, FONT_PIXELS.as_ptr());
// 8x8 font starting at space (32), 96 characters
MY_FONT = load_font(FONT_TEXTURE, 8, 8, 32, 96);
}
}
}
load_font_ex
Loads a variable-width bitmap font.
Signature:
#![allow(unused)]
fn main() {
fn load_font_ex(
texture: u32,
widths_ptr: *const u8,
char_height: u32,
first_codepoint: u32,
char_count: u32
) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| texture | u32 | Font texture atlas handle |
| widths_ptr | *const u8 | Pointer to array of character widths |
| char_height | u32 | Height of each character |
| first_codepoint | u32 | First character code |
| char_count | u32 | Number of characters |
Returns: Font handle
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
// Width table for characters ' ' through '~'
static CHAR_WIDTHS: [u8; 96] = [
4, 2, 4, 6, 6, 6, 6, 2, 3, 3, 4, 6, 2, 4, 2, 4, // space to /
6, 4, 6, 6, 6, 6, 6, 6, 6, 6, 2, 2, 4, 6, 4, 6, // 0 to ?
// ... etc
];
fn init() {
unsafe {
PROP_FONT = load_font_ex(FONT_TEX, CHAR_WIDTHS.as_ptr(), 12, 32, 96);
}
}
}
font_bind
Binds a font for subsequent draw_text() calls.
Signature:
#![allow(unused)]
fn main() {
fn font_bind(font_handle: u32)
}
Example:
#![allow(unused)]
fn main() {
fn render() {
// Use custom font
font_bind(MY_FONT);
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);
}
}
}
Billboard Functions
Camera-facing quads for sprites in 3D space.
Billboard Modes
| Mode | Name | Description |
|---|---|---|
| 1 | Spherical | Always faces camera (all axes) |
| 2 | Cylindrical Y | Rotates around Y axis only (trees, NPCs) |
| 3 | Cylindrical X | Rotates around X axis only |
| 4 | Cylindrical Z | Rotates around Z axis only |
Functions
draw_billboard
Draws a camera-facing quad using the bound texture.
Signature:
#![allow(unused)]
fn main() {
fn draw_billboard(w: f32, h: f32, mode: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| w | f32 | Width in world units |
| h | f32 | Height in world units |
| mode | u32 | Billboard 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:
| Name | Type | Description |
|---|---|---|
| w, h | f32 | Size in world units |
| src_x, src_y | f32 | UV origin in texture (0.0-1.0) |
| src_w, src_h | f32 | UV size in texture (0.0-1.0) |
| mode | u32 | Billboard 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 calldraw_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 selectedenvironment_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.
| Slot | Kind | Recommended Use |
|---|---|---|
| 0–3 | Bounds | Any 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–7 | Radiance | DECAL / 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.
| Code | Name | Notes |
|---|---|---|
0x00 | NOP | Disable layer |
0x01 | RAMP | Bounds gradient |
0x02 | SECTOR | Bounds modifier |
0x03 | SILHOUETTE | Bounds modifier |
0x04 | SPLIT | Bounds |
0x05 | CELL | Bounds |
0x06 | PATCHES | Bounds |
0x07 | APERTURE | Bounds |
0x08 | DECAL | Radiance |
0x09 | GRID | Radiance |
0x0A | SCATTER | Radiance |
0x0B | FLOW | Radiance |
0x0C | TRACE | Radiance |
0x0D | VEIL | Radiance |
0x0E | ATMOSPHERE | Radiance |
0x0F | PLANE | Radiance |
0x10 | CELESTIAL | Radiance |
0x11 | PORTAL | Radiance |
0x12 | LOBE | Radiance |
0x13 | BAND | Radiance |
For full per-opcode packing/algorithm details, see:
nethercore-design/specs/epu-feature-catalog.mdnethercore/nethercore-zx/shaders/epu/
Region Mask (3-bit bitfield)
Regions are combinable using bitwise OR:
| Value | Binary | Name | Meaning |
|---|---|---|---|
| 7 | 0b111 | ALL | Apply to sky + walls + floor |
| 4 | 0b100 | SKY | Sky/ceiling only |
| 2 | 0b010 | WALLS | Wall/horizon belt only |
| 1 | 0b001 | FLOOR | Floor/ground only |
| 6 | 0b110 | SKY_WALLS | Sky + walls |
| 5 | 0b101 | SKY_FLOOR | Sky + floor |
| 3 | 0b011 | WALLS_FLOOR | Walls + floor |
| 0 | 0b000 | NONE | Layer 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)
| Value | Name | Formula |
|---|---|---|
| 0 | ADD | dst + src * a |
| 1 | MULTIPLY | dst * mix(1, src, a) |
| 2 | MAX | max(dst, src * a) |
| 3 | LERP | mix(dst, src, a) |
| 4 | SCREEN | 1 - (1-dst)*(1-src*a) |
| 5 | HSV_MOD | HSV shift dst by src |
| 6 | MIN | min(dst, src * a) |
| 7 | OVERLAY | Photoshop-style overlay |
meta5
The 5-bit meta5 field (hi bits 116..112) is interpreted as:
meta5 = (domain_id << 3) | variant_iddomain_id = (meta5 >> 3) & 0b11variant_id = meta5 & 0b111
Quick Start
The easiest reference implementation is the EPU showcase presets:
nethercore/examples/3-inspectors/epu-showcase/src/presets.rsnethercore/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
- EPU Environments Guide - Recipes and examples
- EPU Architecture Overview - Compute pipeline details
- EPU Feature Catalog - Opcode catalog + packing details
- ZX FFI Bindings - Canonical ABI docs
Audio Functions
Sound effects and music playback with 16 channels.
Loading Sounds
load_sound
Loads a sound from WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn load_sound(data_ptr: *const u8, byte_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| data_ptr | *const u8 | Pointer to PCM audio data |
| byte_len | u32 | Size of data in bytes |
Returns: Sound handle (non-zero on success)
Audio Format: 22.05 kHz, 16-bit signed, mono PCM
Constraints: Init-only.
Example:
#![allow(unused)]
fn main() {
static JUMP_DATA: &[u8] = include_bytes!("jump.raw");
static mut JUMP_SFX: u32 = 0;
fn init() {
unsafe {
JUMP_SFX = load_sound(JUMP_DATA.as_ptr(), JUMP_DATA.len() as u32);
}
}
}
Note: Prefer rom_sound() for sounds bundled in the ROM data pack.
Sound Effects
play_sound
Plays a sound on the next available channel.
Signature:
#![allow(unused)]
fn main() {
fn play_sound(sound: u32, volume: f32, pan: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| sound | u32 | Sound handle |
| volume | f32 | Volume (0.0-1.0) |
| pan | f32 | Stereo pan (-1.0 = left, 0.0 = center, 1.0 = right) |
Example:
#![allow(unused)]
fn main() {
fn update() {
if button_pressed(0, BUTTON_A) != 0 {
play_sound(JUMP_SFX, 1.0, 0.0);
}
// Positional audio
let dx = enemy.x - player.x;
let pan = (dx / 20.0).clamp(-1.0, 1.0);
let dist = ((enemy.x - player.x).powi(2) + (enemy.z - player.z).powi(2)).sqrt();
let vol = (1.0 - dist / 50.0).max(0.0);
play_sound(ENEMY_GROWL, vol, pan);
}
}
channel_play
Plays a sound on a specific channel with loop control.
Signature:
#![allow(unused)]
fn main() {
fn channel_play(channel: u32, sound: u32, volume: f32, pan: f32, looping: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
| sound | u32 | Sound handle |
| volume | f32 | Volume (0.0-1.0) |
| pan | f32 | Stereo pan (-1.0 to 1.0) |
| looping | u32 | 1 to loop, 0 for one-shot |
Example:
#![allow(unused)]
fn main() {
fn update() {
// Engine sound on dedicated channel (looping)
if vehicle.engine_on && !ENGINE_PLAYING {
channel_play(0, ENGINE_SFX, 0.8, 0.0, 1);
ENGINE_PLAYING = true;
}
// Adjust engine pitch based on speed
if ENGINE_PLAYING {
let vol = 0.5 + vehicle.speed * 0.005;
channel_set(0, vol.min(1.0), 0.0);
}
}
}
channel_set
Updates volume and pan for a playing channel.
Signature:
#![allow(unused)]
fn main() {
fn channel_set(channel: u32, volume: f32, pan: f32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
| volume | f32 | New volume (0.0-1.0) |
| pan | f32 | New stereo pan |
Example:
#![allow(unused)]
fn main() {
fn update() {
// Fade out channel 0
if fading {
fade_vol -= delta_time() * 0.5;
if fade_vol <= 0.0 {
channel_stop(0);
fading = false;
} else {
channel_set(0, fade_vol, 0.0);
}
}
}
}
channel_stop
Stops playback on a channel.
Signature:
#![allow(unused)]
fn main() {
fn channel_stop(channel: u32)
}
Parameters:
| Name | Type | Description |
|---|---|---|
| channel | u32 | Channel index (0-15) |
Example:
#![allow(unused)]
fn main() {
fn update() {
if vehicle.engine_off {
channel_stop(0);
ENGINE_PLAYING = false;
}
}
}
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:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to tracker ID string |
| id_len | u32 | Length 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:
| Name | Type | Description |
|---|---|---|
| data_ptr | u32 | Pointer to XM file data |
| data_len | u32 | Length 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:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Sound handle (from load_sound) or tracker handle (from rom_tracker) |
| volume | f32 | Volume (0.0-1.0) |
| looping | u32 | 1 = 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:
| Name | Type | Description |
|---|---|---|
| paused | u32 | 1 = 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:
| Name | Type | Description |
|---|---|---|
| volume | f32 | Volume (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:
| Name | Type | Description |
|---|---|---|
| order | u32 | Order position (0-based) |
| row | u32 | Row 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:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Music 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:
| Name | Type | Description |
|---|---|---|
| speed | u32 | 1-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:
| Name | Type | Description |
|---|---|---|
| bpm | u32 | 32-255 (XM default is 125) |
music_info
Gets music info.
Signature:
#![allow(unused)]
fn main() {
fn music_info(handle: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Music 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:
| Name | Type | Description |
|---|---|---|
| handle | u32 | Music handle |
| out_ptr | *mut u8 | Output buffer pointer |
| max_len | u32 | Maximum 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:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-3) |
| data_ptr | *const u8 | Pointer to data to save |
| data_len | u32 | Size of data in bytes |
Returns:
| Value | Meaning |
|---|---|
| 0 | Success |
| 1 | Invalid slot |
| 2 | Data too large (>64KB) |
Example:
#![allow(unused)]
fn main() {
fn save_game() {
unsafe {
let save_data = SaveData {
level: CURRENT_LEVEL,
score: SCORE,
health: PLAYER_HEALTH,
position_x: PLAYER_X,
position_y: PLAYER_Y,
checksum: 0,
};
// Calculate checksum
let bytes = &save_data as *const SaveData as *const u8;
let size = core::mem::size_of::<SaveData>();
let result = save(0, bytes, size as u32);
if result == 0 {
show_message(b"Game Saved!");
}
}
}
}
load
Loads data from a slot.
Signature:
#![allow(unused)]
fn main() {
fn load(slot: u32, data_ptr: *mut u8, max_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-3) |
| data_ptr | *mut u8 | Destination buffer |
| max_len | u32 | Maximum bytes to read |
Returns: Number of bytes read (0 if empty or error)
Example:
#![allow(unused)]
fn main() {
fn load_game() -> bool {
unsafe {
let mut save_data = SaveData::default();
let bytes = &mut save_data as *mut SaveData as *mut u8;
let size = core::mem::size_of::<SaveData>();
let read = load(0, bytes, size as u32);
if read == size as u32 {
// Validate checksum
if validate_checksum(&save_data) {
CURRENT_LEVEL = save_data.level;
SCORE = save_data.score;
PLAYER_HEALTH = save_data.health;
PLAYER_X = save_data.position_x;
PLAYER_Y = save_data.position_y;
return true;
}
}
false
}
}
}
delete
Deletes data in a save slot.
Signature:
#![allow(unused)]
fn main() {
fn delete(slot: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| slot | u32 | Save slot (0-3) |
Returns:
| Value | Meaning |
|---|---|
| 0 | Success |
| 1 | Invalid slot |
Example:
#![allow(unused)]
fn main() {
fn delete_save(slot: u32) {
unsafe {
if delete(slot) == 0 {
show_message(b"Save deleted");
}
}
}
}
Save Data Patterns
Simple Struct Save
#![allow(unused)]
fn main() {
#[repr(C)]
struct SaveData {
magic: u32, // Identify valid saves
version: u32, // Save format version
level: u32,
score: u32,
health: f32,
position: [f32; 3],
inventory: [u8; 64],
checksum: u32,
}
impl SaveData {
const MAGIC: u32 = 0x53415645; // "SAVE"
fn new() -> Self {
Self {
magic: Self::MAGIC,
version: 1,
level: 0,
score: 0,
health: 100.0,
position: [0.0; 3],
inventory: [0; 64],
checksum: 0,
}
}
fn calculate_checksum(&self) -> u32 {
// Simple checksum (XOR all bytes except checksum field)
let bytes = self as *const Self as *const u8;
let len = core::mem::size_of::<Self>() - 4; // Exclude checksum
let mut sum: u32 = 0;
for i in 0..len {
unsafe { sum ^= (*bytes.add(i) as u32) << ((i % 4) * 8); }
}
sum
}
fn is_valid(&self) -> bool {
self.magic == Self::MAGIC && self.checksum == self.calculate_checksum()
}
}
}
Multiple Save Slots UI
#![allow(unused)]
fn main() {
fn render_save_menu() {
unsafe {
draw_text(b"SAVE SLOTS".as_ptr(), 10, 200.0, 50.0, 24.0, 0xFFFFFFFF);
for slot in 0..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:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID string |
| id_len | u32 | Length of asset ID |
Returns: Texture handle (non-zero on success, 0 if not found)
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PLAYER_TEX = rom_texture(b"player".as_ptr(), 6);
ENEMY_TEX = rom_texture(b"enemy_sheet".as_ptr(), 11);
TERRAIN_TEX = rom_texture(b"terrain".as_ptr(), 7);
}
}
}
rom_mesh
Loads a mesh from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_mesh(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Mesh handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
LEVEL_MESH = rom_mesh(b"level1".as_ptr(), 6);
PLAYER_MESH = rom_mesh(b"player_model".as_ptr(), 12);
ENEMY_MESH = rom_mesh(b"enemy".as_ptr(), 5);
}
}
}
rom_skeleton
Loads a skeleton (inverse bind matrices) from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_skeleton(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Skeleton handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
PLAYER_SKELETON = rom_skeleton(b"player_rig".as_ptr(), 10);
}
}
}
rom_font
Loads a bitmap font from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_font(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Font handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
UI_FONT = rom_font(b"ui_font".as_ptr(), 7);
TITLE_FONT = rom_font(b"title_font".as_ptr(), 10);
}
}
}
rom_sound
Loads a sound from the data pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_sound(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Sound handle
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
JUMP_SFX = rom_sound(b"jump".as_ptr(), 4);
COIN_SFX = rom_sound(b"coin".as_ptr(), 4);
MUSIC = rom_sound(b"level1_bgm".as_ptr(), 10);
}
}
}
Raw Data Access
For custom data formats (level data, dialog scripts, etc.).
rom_data_len
Gets the size of raw data in the pack.
Signature:
#![allow(unused)]
fn main() {
fn rom_data_len(id_ptr: *const u8, id_len: u32) -> u32
}
Returns: Size in bytes (0 if not found)
Example:
#![allow(unused)]
fn main() {
fn init() {
unsafe {
let len = rom_data_len(b"level1_map".as_ptr(), 10);
if len > 0 {
// Allocate buffer and load
}
}
}
}
rom_data
Copies raw data from the pack into WASM memory.
Signature:
#![allow(unused)]
fn main() {
fn rom_data(id_ptr: *const u8, id_len: u32, out_ptr: *mut u8, max_len: u32) -> u32
}
Parameters:
| Name | Type | Description |
|---|---|---|
| id_ptr | *const u8 | Pointer to asset ID |
| id_len | u32 | Length of asset ID |
| out_ptr | *mut u8 | Destination buffer in WASM memory |
| max_len | u32 | Maximum bytes to copy |
Returns: Bytes copied (0 if not found or buffer too small)
Example:
#![allow(unused)]
fn main() {
static mut LEVEL_DATA: [u8; 4096] = [0; 4096];
fn init() {
unsafe {
let len = rom_data_len(b"level1".as_ptr(), 6);
if len <= 4096 {
rom_data(b"level1".as_ptr(), 6, LEVEL_DATA.as_mut_ptr(), 4096);
parse_level(&LEVEL_DATA[..len as usize]);
}
}
}
}
Game Manifest (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 RAMrom_data→ Data copied to WASM RAM (use sparingly for level data, etc.)- Only WASM RAM (4MB) is snapshotted for rollback
Complete Example
#![allow(unused)]
fn main() {
// Asset handles
static mut PLAYER_TEX: u32 = 0;
static mut PLAYER_MESH: u32 = 0;
static mut PLAYER_SKEL: u32 = 0;
static mut WALK_ANIM: u32 = 0;
static mut IDLE_ANIM: u32 = 0;
static mut JUMP_SFX: u32 = 0;
static mut MUSIC: u32 = 0;
static mut UI_FONT: u32 = 0;
// Level data (copied to WASM memory)
static mut LEVEL_DATA: [u8; 8192] = [0; 8192];
static mut LEVEL_SIZE: u32 = 0;
fn init() {
unsafe {
// Graphics assets → VRAM
PLAYER_TEX = rom_texture(b"player".as_ptr(), 6);
PLAYER_MESH = rom_mesh(b"player".as_ptr(), 6);
PLAYER_SKEL = rom_skeleton(b"player_rig".as_ptr(), 10);
// Animations → GPU storage
WALK_ANIM = rom_keyframes(b"walk".as_ptr(), 4);
IDLE_ANIM = rom_keyframes(b"idle".as_ptr(), 4);
// Audio → Audio memory
JUMP_SFX = rom_sound(b"jump".as_ptr(), 4);
MUSIC = rom_sound(b"music".as_ptr(), 5);
// Font → VRAM
UI_FONT = rom_font(b"ui".as_ptr(), 2);
// Level data → WASM RAM (copied)
LEVEL_SIZE = rom_data_len(b"level1".as_ptr(), 6);
if LEVEL_SIZE <= 8192 {
rom_data(b"level1".as_ptr(), 6, LEVEL_DATA.as_mut_ptr(), 8192);
}
// Start music
music_play(MUSIC, 0.7);
}
}
fn render() {
unsafe {
// Use loaded assets
texture_bind(PLAYER_TEX);
skeleton_bind(PLAYER_SKEL);
keyframe_bind(WALK_ANIM, frame);
draw_mesh(PLAYER_MESH);
font_bind(UI_FONT);
draw_text(b"SCORE: 0".as_ptr(), 8, 10.0, 10.0, 16.0, 0xFFFFFFFF);
}
}
}
See Also: Textures, Meshes, Audio, Animation
Debug Functions
Runtime value inspection via the 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:
| Name | Type | Description |
|---|---|---|
| name_ptr | *const u8 | Pointer to button label string |
| name_len | u32 | Length of button label |
| func_name_ptr | *const u8 | Pointer to exported WASM function name |
| func_name_len | u32 | Length 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:
| Name | Type | Description |
|---|---|---|
| name_ptr | *const u8 | Pointer to button label string |
| name_len | u32 | Length of button label |
| func_name_ptr | *const u8 | Pointer to exported WASM function name |
| func_name_len | u32 | Length 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:
| Name | Type | Description |
|---|---|---|
| name_ptr | *const u8 | Pointer to parameter label string |
| name_len | u32 | Length of parameter label |
| default_value | i32 | Default 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:
| Name | Type | Description |
|---|---|---|
| name_ptr | *const u8 | Pointer to parameter label string |
| name_len | u32 | Length of parameter label |
| default_value | f32 | Default 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
| Key | Action |
|---|---|
| F3 | Toggle Runtime Stats Panel |
| F4 | Toggle Debug Inspector |
| F5 | Pause/unpause |
| F6 | Step one frame (while paused) |
| F7 | Decrease time scale |
| F8 | Increase time scale |
Complete Example
#![allow(unused)]
fn main() {
// Game state
static mut PLAYER_X: f32 = 0.0;
static mut PLAYER_Y: f32 = 0.0;
static mut PLAYER_VEL_X: f32 = 0.0;
static mut PLAYER_VEL_Y: f32 = 0.0;
static mut PLAYER_HEALTH: f32 = 100.0;
// Tuning parameters
static mut MOVE_SPEED: f32 = 5.0;
static mut JUMP_FORCE: f32 = 12.0;
static mut GRAVITY: f32 = 25.0;
static mut FRICTION: f32 = 0.9;
// Debug
static mut GOD_MODE: u8 = 0;
static mut SHOW_HITBOXES: u8 = 0;
static mut ENEMY_COUNT: i32 = 5;
fn init() {
unsafe {
// Player group
debug_group_begin(b"Player".as_ptr(), 6);
debug_watch_f32(b"X".as_ptr(), 1, &PLAYER_X);
debug_watch_f32(b"Y".as_ptr(), 1, &PLAYER_Y);
debug_watch_f32(b"Vel X".as_ptr(), 5, &PLAYER_VEL_X);
debug_watch_f32(b"Vel Y".as_ptr(), 5, &PLAYER_VEL_Y);
debug_register_f32_range(b"Health".as_ptr(), 6, &PLAYER_HEALTH, 0.0, 100.0);
debug_group_end();
// Physics group
debug_group_begin(b"Physics".as_ptr(), 7);
debug_register_f32_range(b"Move Speed".as_ptr(), 10, &MOVE_SPEED, 1.0, 20.0);
debug_register_f32_range(b"Jump Force".as_ptr(), 10, &JUMP_FORCE, 5.0, 30.0);
debug_register_f32_range(b"Gravity".as_ptr(), 7, &GRAVITY, 10.0, 50.0);
debug_register_f32_range(b"Friction".as_ptr(), 8, &FRICTION, 0.5, 1.0);
debug_group_end();
// Debug options
debug_group_begin(b"Debug".as_ptr(), 5);
debug_register_bool(b"God Mode".as_ptr(), 8, &GOD_MODE);
debug_register_bool(b"Show Hitboxes".as_ptr(), 13, &SHOW_HITBOXES);
debug_register_i32_range(b"Enemy Count".as_ptr(), 11, &ENEMY_COUNT, 0, 20);
debug_group_end();
}
}
fn update() {
unsafe {
// Respect debug pause
if debug_is_paused() != 0 {
return;
}
let dt = delta_time() * debug_get_time_scale();
// Use tunable values
PLAYER_VEL_Y += GRAVITY * dt;
PLAYER_VEL_X *= FRICTION;
if button_held(0, BUTTON_RIGHT) != 0 {
PLAYER_VEL_X = MOVE_SPEED;
}
if button_held(0, BUTTON_LEFT) != 0 {
PLAYER_VEL_X = -MOVE_SPEED;
}
if button_pressed(0, BUTTON_A) != 0 {
PLAYER_VEL_Y = -JUMP_FORCE;
}
PLAYER_X += PLAYER_VEL_X * dt;
PLAYER_Y += PLAYER_VEL_Y * dt;
}
}
}
See Also: System Functions
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.mdnethercore/include/zx.rsnethercore/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:
| Constraint | Value |
|---|---|
| Config size | 128 bytes per environment state |
| Layer count | 8 instructions (4 Bounds + 4 Radiance) |
| Instruction size | 128 bits (two u64 values) |
| Cubemaps | None (fully procedural octahedral maps) |
| Mipmaps | Yes (compute-generated downsample pyramid) |
| Color model | Direct RGB24 x 2 per layer |
| Aesthetic | PS1/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:
| Slot | Kind | Recommended Use |
|---|---|---|
| 0-3 | Bounds | Any bounds opcode (0x01..0x07). Any bounds opcode can be first; each bounds layer outputs RegionWeights consumed by later feature/radiance layers. |
| 4-7 | Radiance | DECAL / 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
| Opcode | Name | Kind | Purpose |
|---|---|---|---|
0x00 | NOP | Any | Disable layer |
0x01 | RAMP | Bounds | Bounds gradient (sky/walls/floor) |
0x02 | SECTOR | Bounds | Azimuthal opening wedge modifier |
0x03 | SILHOUETTE | Bounds | Skyline/horizon cutout modifier |
0x04 | SPLIT | Bounds | Geometric divisions |
0x05 | CELL | Bounds | Voronoi/mosaic cells |
0x06 | PATCHES | Bounds | Noise patches |
0x07 | APERTURE | Bounds | Shaped opening/viewport |
0x08 | DECAL | Radiance | Sharp SDF shape (disk/ring/rect/line) |
0x09 | GRID | Radiance | Repeating lines/panels |
0x0A | SCATTER | Radiance | Point field (stars/dust/bubbles) |
0x0B | FLOW | Radiance | Animated noise/streaks/caustics |
0x0C | TRACE | Radiance | Line/crack patterns |
0x0D | VEIL | Radiance | Curtain/ribbon effects |
0x0E | ATMOSPHERE | Radiance | Atmospheric absorption + scattering |
0x0F | PLANE | Radiance | Ground/surface textures |
0x10 | CELESTIAL | Radiance | Moon/sun/planet bodies |
0x11 | PORTAL | Radiance | Portal/vortex effects |
0x12 | LOBE | Radiance | Region-masked directional glow |
0x13 | BAND | Radiance | Region-masked horizon band |
Blend Modes (8 modes)
| Value | Name | Formula |
|---|---|---|
| 0 | ADD | dst + src * a |
| 1 | MULTIPLY | dst * mix(1, src, a) |
| 2 | MAX | max(dst, src * a) |
| 3 | LERP | mix(dst, src, a) |
| 4 | SCREEN | 1 - (1-dst)*(1-src*a) |
| 5 | HSV_MOD | HSV shift dst by src |
| 6 | MIN | min(dst, src * a) |
| 7 | OVERLAY | Photoshop-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:
| Output | Type | Purpose |
|---|---|---|
EnvRadiance[env_id] | mip-mapped octahedral 2D array | Background + roughness-based reflections |
SH9[env_id] | storage buffer | L2 diffuse irradiance (spherical harmonics) |
Frame Execution Order
- Capture EPU draw requests (per viewport/pass) and determine active environment states
- Deduplicate
env_idlist, cap toMAX_ACTIVE_ENVS - Determine which
env_ids are dirty (hash) - Dispatch compute passes:
- Environment evaluation (build
EnvRadiancemip 0) - Mip pyramid generation (2x2 downsample chain)
- Irradiance extraction (SH9)
- Environment evaluation (build
- Barrier: compute to render
- 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^2L_spec = L_lp + (1 - alpha) * (L_hi - L0)L_hiis procedural EPU evaluation at the reflection directionL0isEnvRadiancesampled at mip 0L_lpisEnvRadiancesampled 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_idper draw/instance (internal) - No per-draw rebinding required
Recommended Caps
| Constant | Typical Value |
|---|---|
MAX_ENV_STATES | 256 |
MAX_ACTIVE_ENVS | 32 |
EPU_MAP_SIZE | 128 (default; override via NETHERCORE_EPU_MAP_SIZE) |
EPU_MIN_MIP_SIZE | 4 (default; override via NETHERCORE_EPU_MIN_MIP_SIZE) |
EPU_IRRAD_TARGET_SIZE | 16 |
Dirty-State Caching
For environments, the EPU tracks:
state_hash: Hash of the 128-byte configvalid: Whether the cached entry has been initialized
Update policy:
| Condition | Action |
|---|---|
| Unused this frame | Skip |
| Used + unchanged | Skip |
| Used + changed | Rebuild, then update state_hash |
Format Summary
| Aspect | Value |
|---|---|
| Instruction size | 128-bit |
| Environment size | 128 bytes |
| Opcode bits | 5-bit (32 opcodes) |
| Region | 3-bit mask (combinable) |
| Blend modes | 8 modes |
| Color | RGB24 × 2 per layer |
| Emissive | Reserved (future use) |
| Alpha | 4-bit × 2 (per-color) |
| Parameters | 4 (+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
| Nethercore | Xbox | PlayStation | Nintendo |
|---|---|---|---|
| A | A | X (Cross) | B |
| B | B | O (Circle) | A |
| X | X | Square | Y |
| Y | Y | Triangle | X |
| LB | LB | L1 | L |
| RB | RB | R1 | R |
| START | Menu | Options | + |
| SELECT | View | Share | - |
Input Functions
Checking Button State
#![allow(unused)]
fn main() {
// Returns 1 on the frame button is first pressed, 0 otherwise
fn button_pressed(player: u32, button: u32) -> u32;
// Returns 1 every frame the button is held, 0 otherwise
fn button_held(player: u32, button: u32) -> u32;
// Returns 1 on the frame button is released, 0 otherwise
fn button_released(player: u32, button: u32) -> u32;
}
Usage Examples
#![allow(unused)]
fn main() {
// Jump on button press
if button_pressed(0, BUTTON_A) != 0 {
player_jump();
}
// Continuous movement while held
if button_held(0, BUTTON_LEFT) != 0 {
player.x -= SPEED;
}
// Trigger on release (e.g., charge attack)
if button_released(0, BUTTON_X) != 0 {
release_charged_attack();
}
}
Analog Input
For smooth movement, use the analog sticks:
#![allow(unused)]
fn main() {
fn left_stick_x(player: u32) -> f32; // -1.0 to 1.0
fn left_stick_y(player: u32) -> f32; // -1.0 (up) to 1.0 (down)
fn right_stick_x(player: u32) -> f32;
fn right_stick_y(player: u32) -> f32;
fn left_trigger(player: u32) -> f32; // 0.0 to 1.0
fn right_trigger(player: u32) -> f32;
}
Multiple Players
All input functions take a player parameter (0-3):
#![allow(unused)]
fn main() {
// Player 1 (index 0)
let p1_x = left_stick_x(0);
// Player 2 (index 1)
let p2_x = left_stick_x(1);
// Check how many players are connected
let count = player_count();
}
Copy-Paste Template
#![allow(unused)]
fn main() {
// Button constants
const BUTTON_UP: u32 = 0;
const BUTTON_DOWN: u32 = 1;
const BUTTON_LEFT: u32 = 2;
const BUTTON_RIGHT: u32 = 3;
const BUTTON_A: u32 = 4;
const BUTTON_B: u32 = 5;
const BUTTON_X: u32 = 6;
const BUTTON_Y: u32 = 7;
const BUTTON_LB: u32 = 8;
const BUTTON_RB: u32 = 9;
const BUTTON_START: u32 = 12;
const BUTTON_SELECT: u32 = 13;
}
Example Games
The 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:
- hello-world - 2D text and rectangles, basic input
- triangle - Your first 3D shape
- textured-quad - Loading and applying textures
- procedural-shapes - Procedural meshes with texture toggle
- paddle - Complete game with the tutorial
- platformer - Advanced example with physics, billboards, UI
By Category
1. Getting Started
| Example | Description |
|---|---|
| hello-world | Basic 2D drawing, text, input handling |
| hello-world-c | Same example in C (demonstrates C FFI) |
| hello-world-zig | Same example in Zig (demonstrates Zig FFI) |
| triangle | Minimal 3D rendering |
2. Graphics & Rendering
| Example | Description |
|---|---|
| textured-quad | Texture loading and sprite rendering |
| procedural-shapes | Built-in mesh generators with texture toggle (B button) |
| lighting | PBR rendering with 4 dynamic lights |
| billboard | GPU-instanced billboards |
| dither-demo | PS1-style dithering effects |
| material-override | Per-draw material properties |
3. Inspectors (Mode & Environment)
| Example | Description |
|---|---|
| debug-demo | Debug inspection system (F4 panel) |
| mode0-inspector | Interactive Mode 0 (Lambert) explorer |
| mode1-inspector | Interactive Mode 1 (Matcap) explorer |
| mode2-inspector | Interactive Mode 2 (PBR) explorer |
| mode3-inspector | Interactive Mode 3 (Blinn-Phong) explorer |
| epu-showcase | Curated preset environments + interactive layer controls (F4) |
4. Animation & Skinning
| Example | Description |
|---|---|
| skinned-mesh | GPU skeletal animation basics |
| animation-demo | Keyframe playback from ROM |
| ik-demo | Inverse kinematics |
| multi-skinned-procedural | Multiple animated characters (procedural) |
| multi-skinned-rom | Multiple animated characters (ROM data) |
| skeleton-stress-test | Performance testing with many skeletons |
5. Audio
| Example | Description |
|---|---|
| audio-demo | Sound effects, panning, channels, looping |
| tracker-demo-xm | XM tracker music playback |
| tracker-demo-xm-split | XM tracker music with split sample workflow |
| tracker-demo-it | IT tracker music playback |
| tracker-demo-it-split | IT tracker demo with separate sample assets |
6. Asset Loading
| Example | Description |
|---|---|
| datapack-demo | Full ROM asset workflow (textures, meshes, sounds) |
| font-demo | Custom font loading with rom_font |
| level-loader | Level data loading with rom_data |
| asset-test | Pre-converted asset testing (.nczxmesh, .nczxtex) |
| gltf-test | GLTF import pipeline validation (mesh, skeleton, animation) |
| glb-inline | Direct .glb references with multiple animations |
| glb-rigid | Rigid transform animation imported from GLB |
7. Complete Games
| Example | Description |
|---|---|
| paddle | Classic 2-player paddle game with AI and rollback netcode |
| platformer | Full mini-game with 2D gameplay, physics, collision, UI |
8. Advanced Rendering
| Example | Description |
|---|---|
| stencil-demo | All 4 stencil masking modes |
| viewport-test | Split-screen rendering (2P, 4P) |
| rear-mirror | Rear-view mirror using viewport |
Support Library
| Library | Description |
|---|---|
| examples-common | Reusable utilities (DebugCamera, StickControl, math helpers) |
Building Examples
Using nether CLI (Recommended for Game Developers)
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.