Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Your first cart

Boot the console and create a project at the prompt:

> new mygame

This creates ./mygame, loads it, and drops you into the code editor. Press Esc to hop back to the prompt at any time, and type:

> run

The console compiles your Rust to WebAssembly and boots it: a pink square on a dark blue screen that you can move with the arrow keys. Esc returns to the console. That’s the whole loop — edit, run, play, Esc.

What new made

A Pixel8 project is a real Cargo crate, not a proprietary bundle:

mygame/
  Cargo.toml           # an ordinary manifest, builds a cdylib for wasm32
  src/lib.rs           # your game
  assets.pixel8.json   # sprites, map, sfx, music, metadata
  .cargo/config.toml   # defaults `cargo build` to the wasm target

Cargo.toml is small enough to read in full:

[package]
name = "mygame"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
pixel8 = { version = "0.1", default-features = false }

[workspace]

[profile.release]
opt-level = "s"
lto = true
panic = "abort"

The one dependency is the SDK. default-features = false makes the cart #![no_std] — the normal way Pixel8 carts are written, and what keeps them tiny (the examples weigh 1–5 KiB). The release profile is pre-tuned to shrink the WebAssembly.

The game, line by line

src/lib.rs starts as:

#![no_std]

use pixel8::*;

game!(MyGame { x: 60, y: 70 });

struct MyGame {
    x: i16,
    y: i16,
}

impl Game for MyGame {
    fn update(&mut self, ctx: &mut Context) {
        if ctx.btn(Button::Left) { self.x -= 1; }
        if ctx.btn(Button::Right) { self.x += 1; }
        if ctx.btn(Button::Up) { self.y -= 1; }
        if ctx.btn(Button::Down) { self.y += 1; }
    }

    fn draw(&self, gfx: &mut Graphics) {
        gfx.clear(Color::DARK_BLUE);
        gfx.print("Hello, Pixel8!", 36, 48, Color::WHITE);
        gfx.rect_fill(self.x, self.y, 8, 8, Color::PINK).unwrap();
    }
}

A game is a struct holding your state, plus the Game trait:

  • update(&mut self, ctx: &mut Context) runs 60 times per second (or 30, if you set const FRAME_RATE: FrameRate = FrameRate::Fps30; in the impl). This is where you read input and move the world. Context is the handle to everything a game does: input, audio, the map, random numbers, saved data.
  • draw(&self, gfx: &mut Graphics) runs after each update and paints the frame. Graphics is the handle to the screen. Note it takes &self: drawing observes the world, it doesn’t change it. That split — mutate in update, render in draw — is enforced by the types, and it keeps game logic untangled from presentation.
  • game!(MyGame { x: 60, y: 70 }) declares the entry point and the state the cart starts in. That state is data: the initializer is a constant, so it is written into the cart’s memory image and placed there when the module loads — nothing runs to build it, and nothing copies it onto the stack. A const fn constructor works the same way: game!(MyGame = MyGame::new()).
  • boot(&mut self, ctx: &mut Context) — optional — runs once, before the first update, on that state. It is where anything a constant cannot say goes: reading saved data, asking the clock, seeding the dice, giving a physics world its cast. It is also the first moment a Context exists, so no cart needs a “first frame is secretly the setup” branch in update:
fn boot(&mut self, ctx: &mut Context) {
    // Where the player left the box last time, if they have played before.
    self.x = ctx.storage_get("x").and_then(|v| v.as_i64()).unwrap_or(60) as i16;
}

If your constructor genuinely cannot be a constant — it allocates, or it has to look something up first — say game!(MyGame = defer MyGame::new()) and it is built at start-up instead. game!(MyGame), the Default form, is deferred for the same reason. Deferring costs stack: the state exists twice for a moment, once on the stack and once in the static it is moved into, so a big game built that way needs a big stack reserve.

There is no main loop to write, no window to open, no timing code: the console calls you.

The edit-run loop

Make a change — say, a splash of randomness in update:

if ctx.btnp(Button::X) {
    self.x = ctx.rndi(120) as i16;
    self.y = ctx.rndi(120) as i16;
}

(btnp is “button just pressed”; btn is “button held”. X is the X key.) Then press Ctrl+R — from the editor, from anywhere — to rebuild and run. Ctrl+S saves and kicks off a background build check, flashing saved / building... / build ok in the editor’s bottom bar; compile errors land in the console, trimmed to the useful part.

Using your own editor

Because a project is a plain crate, the integrated editor is optional. Open mygame/ in your usual editor and build from a terminal:

cargo build --release

(The scaffolded .cargo/config.toml already targets wasm32-unknown-unknown.) A console with the project loaded polls the compiled wasm once a second and hot-reloads it when it changes — save in your editor, cargo build, and the running game restarts with your change. rust-analyzer, clippy, unit tests: everything works, it’s just Rust.

When things go wrong

Panics don’t crash the console: the cart stops on a friendly error screen showing the actual panic message. An accidental infinite loop in update is caught by the console’s per-frame work budget and reported as “ran too long” instead of freezing anything.

For printf-debugging, log to the console (visible after Esc):

ctx.log("checkpoint");
logf!(ctx, "frame {} pos ({},{})", self.frame, self.x, self.y);