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

Introduction

Pixel8 (pronounced “pixelate”) is a fantasy console: a complete little game machine — screen, controls, sound chip, editors and all — that never existed as hardware and lives entirely in software, dreamed up with the charm and the limits of a 1980s handheld. Pixel8’s particulars: a 128×128 screen, 16 fixed colors, a 4×6 pixel font, 256 sprites, a 128×64 tile map, four audio channels — and where other fantasy consoles build in an interpreter for a scripting language, Pixel8 has a Rust compiler. You write a little Rust, it compiles to WebAssembly, and it runs sandboxed inside the console at a steady 60 frames per second.

Here is one, running right on this page. Click the cartridge to boot it — arrow keys run, Z jumps, collect the coins and grab the trophy:

A Pixel8 cart. The whole console fits in this box; the game inside it is a few hundred lines of Rust.

A finished game is a cart: a real PNG image with the compiled WebAssembly, all the art and sound, and (by default) the compressed Rust source embedded inside. Anyone can look at the cartridge; Pixel8 can play it; and if the source is included, anyone can turn it back into an editable project.

Not only games

A cart is just code that redraws the screen every frame, so it needn’t be a game at all. Point the same sprites, map and sound chip at something nobody plays and what you have is an animation — one that fits in a PNG, runs in a browser, and can be taken apart by whoever it reaches:

campfire: a night that plays itself — no input, nothing to win (source).

The constraints are the point

thingsize
screen128 × 128 pixels, 16 fixed colors
sprites256 of 8×8 pixels, 8 flags each
map128 × 64 tiles
sfx64 slots, 32 steps, 8 waveforms
music64 patterns, 4 channels
framerate60 fps (or 30, the cart’s choice)
cartone PNG file, at most 128 KiB

Like the consoles it dreams of, Pixel8 is small on purpose. A blank canvas the size of the ocean is paralyzing; 128×128 pixels and 16 colors you can fill by Tuesday. The limits keep projects finishable, carts shareable, and the whole system knowable — you can hold all of Pixel8 in your head.

Who this book is for

You should know a little Rust — enough to read a struct and an impl block. You do not need to know anything about game development, graphics or audio programming; the whole point of a fantasy console is that those are simple here.

Fantasy consoles were popularized by PICO-8, a much-loved commercial console whose games are written in Lua, and Pixel8 is an open-source homage to it: the palette, the editors, the > prompt and the workflow are all lovingly borrowed. If you know PICO-8 you’ll feel at home immediately — the difference is the language, and everything that comes with it: a real type system, real modules, cargo, and your usual editor and tooling if you want them. If you’ve never touched PICO-8, no matter; nothing in this book assumes it.

How the book is organized

  • Getting started installs the console, walks through creating and running your first cart, and tours the built-in editors.
  • Making a game covers the SDK a chapter at a time: drawing, input, sound, saving data, and how to live comfortably inside the console’s limits.
  • Shipping shows how to turn a project into a shareable PNG cartridge or a single-file web page, and how to import assets from PICO-8 carts.
  • Reference tours the example carts (all playable in this book) and points at the deeper documentation.

Everything in this book — every screen, every editor, every playable cart — is produced by the same open-source console, which lives at github.com/zeenix/pixel8.

Installation

Pixel8 is two things: the console (a desktop app with the > prompt and the editors) and the SDK (the pixel8 crate your game code uses). You install the console; it takes care of the SDK when it scaffolds a project.

Prerequisites

You need Rust, installed via rustup, plus the WebAssembly target that carts compile to:

rustup target add wasm32-unknown-unknown

On Linux, the console’s audio backend links ALSA, so you need its headers once:

sudo apt install libasound2-dev        # debian/ubuntu
sudo dnf install alsa-lib-devel        # fedora

(macOS and Windows need nothing extra.)

Installing the console

cargo install pixel8-console

The crate is called pixel8-console, but the command it installs is pixel8. Run it:

pixel8

You should land at the boot console — a black 128×128 screen with a > prompt. Type help and press Enter. If you see the command list, you’re done; skip ahead to Your first cart.

On a machine without a sound card (or without ALSA headers), install a silent console instead:

cargo install pixel8-console --no-default-features

Everything works identically, minus audio output.

The console in a terminal

The same console — editors, carts and all — also runs inside a terminal, as a separate binary:

cargo install pixel8-tui
pixel8-tui                    # boot the console in the terminal
pixel8-tui run mygame.png     # boot, load, and run immediately

Terminals with sixel support (foot, WezTerm, Konsole, iTerm2, xterm…) get real pixels; everywhere else the screen is drawn with unicode half-blocks. Ctrl+Q quits. On Linux, game input needs either a terminal with the kitty keyboard protocol or read access to /dev/input (one-time: sudo usermod -aG input $USER). See docs/TUI.md for the details and tuning knobs.

The rest of this book assumes the desktop console, but everything applies to the terminal one too.

Building from source

If you’d rather run from a checkout (or want to hack on the console itself):

git clone https://github.com/zeenix/pixel8
cd pixel8
cargo console                  # alias for: cargo run --release -p pixel8-console
cargo tui                      # alias for: cargo run --release -p pixel8-tui

A source checkout also gets you the bundled examples:

cargo console -- examples/platformer

then type run at the prompt.

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

The console and its editors

Everything in Pixel8 happens on one 128×128 screen: the boot console, the five editors, and running games all share it. Esc flips between the console prompt and the editors; while a game runs, Esc stops it.

The prompt

The boot console is a tiny shell:

the boot console, with the platformer example loaded

help lists the commands:

commandwhat it does
new <name>create a project (a real cargo crate) and load it
load <dir|cart.png>load a project or a PNG cart
savesave code + assets to disk
reloadre-read from disk, dropping unsaved edits
runbuild + run (Esc stops)
export <f.png|f.html>export a PNG cartridge or a playable web page
import <f.png> <dir>turn an editable cart back into a project
import-pico8 <f> [dir]import a PICO-8 cart’s assets into a new project
infoshow the loaded cart’s metadata
title <text> / author <text>set cart metadata
code / sprite / map / sfx / musicjump to an editor
ls, cls, keys, reboot, exitthe usual suspects

Two kinds of things can be loaded: a project (a directory — full build/run/edit/export powers) or a cart (a PNG — runs as-is; if it embeds source you can import it into a project and edit away).

Keys

keys (or help keys) prints these:

keywhat it does
Escconsole ↔ editor; stop a running game
Ctrl+Rbuild + run, from anywhere
Ctrl+Ssave + background build check
Ctrl+Z / Ctrl+Yundo / redo (in editors)
Alt+← / Alt+→switch between editors
arrows + Z/Xgame buttons (also C/V and N/M)
F1toggle the resource-stats overlay
F6capture the screen as the cartridge label (while a game runs)

The five editors

The tab icons across the top (or Alt+←/→) switch between editors. All of them edit the two halves of your project: the code editor edits the Rust under src/, while the other four edit assets.pixel8.json. Edits live in memory until you save (or Ctrl+S); assets land in that one JSON file next to your code, friendly to git diff. Every editor keeps a status bar along the bottom with its key hints — you can see them in the screenshots below, which show each editor with the platformer example loaded.

Code

the code editor

A small, honest text editor: 31 columns of Rust in a 4×7 pixel font, an immediate cursor, and a status bar with the line count and cursor position. The file name in the top-left corner is the file being edited; Ctrl+O opens a picker to switch between the files under src/ or create a new module. It’s genuinely pleasant for cart-sized programs — but it edits the same files your external editor does, so use whichever you like.

Sprite

the sprite editor

Pixel art on the 128×128 sprite sheet: 256 cells of 8×8 pixels. A zoomed canvas fills the left; the toolbar above it holds the drawing tools. Down the right side: the 16-color palette, the block-size buttons (1 2 4 8 — edit a single sprite or a block up to 8×8 cells at once), and eight dots for the sprite’s flag bits. The strip along the bottom is the sheet itself, for picking which sprite to edit; the status bar shows its number and flags. An 8×8 block is taller than the four rows the strip shows, so at that size it halves its cells and shows eight — the selection is always visible whole. The flags mean nothing to the console — a game assigns its own meanings and reads them back (this is how the platformer marks tiles as solid).

Map

the map editor

Paints sprites onto the 128×64 tile map. A scrollable viewport onto the map fills the screen, with six tools in the toolbar — draw, paste, select, pan, fill and circle — the current brush shown beside them, and the sprite-sheet picker along the bottom; the status bar tracks the tile under the cursor. The block-size buttons (1 2 4 8) are the sprite editor’s, so something drawn there as a 2×2 or 8×8 block goes onto the grid as one stamp instead of a cell at a time. The view zooms out to match — the brush is always 8 px on screen, and at 8× the whole map is in front of you. Rooms, levels, backgrounds: draw once here, then blit whole regions with one map call from your game.

SFX

the sfx editor

A step-sequencer for sound effects: 64 slots, 32 steps each. The top bar picks the slot and sets its speed, loop points and waveform (eight of them — the last is a custom wave you can draw yourself). Below is the pitch view shown above: drag out a graph of pitch bars, with a volume strip underneath. Tab switches to a tracker view (the same 32 steps as a note table, entered with piano keys) and to the wave designer; Space previews the sound.

Music

the music editor

Arranges sfx into songs: 64 patterns, each assigning an SFX slot to up to four channels. The pattern strip across the top selects and orders patterns (with flow flags for looping and stopping); below it, one column per channel, headed by its SFX slot number and showing that SFX’s notes inline — the pencil beside a slot jumps into the SFX editor, where the actual notes are authored. Space plays the pattern.

Running carts and the stats overlay

While a game runs, F1 toggles an overlay of the resource meters: CPU budget used by update and draw, memory high-water, and measured fps. The two CPU rows and the memory row refresh once a second, each showing that second’s peak — the worst frame, not an average that would hide it — so the digits stay readable instead of churning sixty times a second. When your game grows, this is the first place to look — more on the budgets in Living within the limits.

The headless CLI

Every pipeline stage also exists as a subcommand of the pixel8 binary, so scripts and CI can do everything the prompt can:

pixel8                            boot the console
pixel8 <dir|cart.png>             boot with a cart loaded
pixel8 run <dir|cart.png>         boot, load, and run immediately
pixel8 new <dir>                  create a project
pixel8 build <dir>                compile it to wasm
pixel8 export <dir> <out.png>     build + write a png cart (--no-source to omit source)
pixel8 extract <cart.png> <dir>   editable cart -> project
pixel8 import-pico8 <c> [dir]     pico-8 cart (.p8/.p8.png) -> project
pixel8 export-web <in> <o.html>   one self-contained playable web page
                                  (--no-controls for a cart that reads no input)
pixel8 verify <cart.png>          load a cart and run 60 frames headless

verify is how this book’s own example carts are checked on every push — the console is fully testable without a window.

Drawing

All drawing happens in draw, through the Graphics handle. The screen is 128×128 pixels; (0, 0) is the top-left corner, x grows right, y grows down. Positions are i16 and sizes are u16 — comfortably wider than the screen, so things can sit off-screen and slide in.

You never manage a framebuffer, textures or “surfaces”: you call draw functions, the console rasterizes them, and everything drawn off-screen is safely clipped.

The palette

There are exactly 16 colors, and they never change. The Color type has a named constant for each:

indexconstantindexconstant
0Color::BLACK8Color::RED
1Color::DARK_BLUE9Color::ORANGE
2Color::DARK_PURPLE10Color::YELLOW
3Color::DARK_GREEN11Color::GREEN
4Color::BROWN12Color::BLUE
5Color::DARK_GREY13Color::LAVENDER
6Color::LIGHT_GREY14Color::PINK
7Color::WHITE15Color::PEACH

A color from a runtime index is Color::new(i) (returns None past 15). It is const, so const ACCENT: Color = Color::new(8).unwrap(); fails at compile time if the index is out of range.

Shapes and text

fn draw(&self, gfx: &mut Graphics) {
    gfx.clear(Color::BLACK);                              // fill the screen
    gfx.set_pixel(10, 10, Color::WHITE);                  // one pixel
    gfx.line(0, 0, 127, 127, Color::DARK_GREY);           // inclusive endpoints
    gfx.rect(4, 4, 40, 20, Color::BLUE).unwrap();         // outline, w x h
    gfx.rect_fill(50, 4, 40, 20, Color::DARK_BLUE).unwrap();
    gfx.circle(64, 80, 10, Color::YELLOW);                // outline, radius
    gfx.circle_fill(64, 80, 6, Color::ORANGE);
    gfx.ellipse_fill(20, 70, 30, 16, Color::GREEN).unwrap(); // inside a w x h box
    gfx.print("score", 2, 2, Color::WHITE);               // 4x6 pixel font
}

Size-taking calls return Result<(), ZeroSize>: a zero (or negative, computed) width or height draws nothing and tells you so. With literal sizes, .unwrap() is the idiom; with computed sizes, handle or ignore with let _ = as your game prefers.

print returns the x position after the last glyph, so you can continue a line. For formatted text there’s printf!format! arguments with no allocator, into a fixed stack buffer:

printf!(gfx, 2, 2, Color::YELLOW, "coins {}", self.coins);

There is also a persistent pen: set_pen_color / set_cursor / print_pen("...") prints at the cursor in the pen color and advances one line — handy for debug readouts.

Sprites

The sprite sheet is a 128×128 pixel canvas divided into 256 cells of 8×8 — sprite 0 is the top-left cell, numbering runs left-to-right, top-to-bottom. Draw yours in the sprite editor, then:

gfx.sprite(SpriteId(1), self.x, self.y);   // one 8x8 cell

sprite_ext adds flipping and multi-cell sizes — w/h are in pixels, so 16, 16 draws a 2×2-cell block and 8, 4 the top half of one cell:

gfx.sprite_ext(SpriteId(1), self.x, self.y, 8, 8, self.facing_left, false)
    .unwrap();

sprite_stretch (alias sspr) draws any sheet rectangle scaled to any screen rectangle, nearest-neighbor — chunky zooms and simple scaling effects.

Animation is just choosing a different sprite each frame. The sprite_move example does the classic two-frame walk:

let frame = if self.walking && (self.frame / 4).is_multiple_of(2) { 2 } else { 1 };
sprite_move: sprites, flipping and a two-frame walk animation (source).

A whole cart can be nothing but this. The campfire example never reads a button: it is a scene that animates itself, out of sprites swapped on a timer and the particle effects the SDK ships with.

Transparency and palette tricks

By default color 0 (black) is transparent in sprite draws. Change that per color with set_transparent_color (alias palt), reset with reset_transparency.

Two remapping tables unlock the classic tricks:

  • remap_color(from, to) (alias pal) changes what later draws write — recolor one sprite into four enemy variants.
  • remap_display_color(from, to) (alias pal_display) changes how the whole screen shows a color — flash the screen, fade to black.
  • reset_palette() undoes both.

Filled shapes can also paint with a two-color 4×4 stipple via set_fill_pattern / fillp — dithered skies, checkerboards, hatched shadows. clear_fill_pattern() returns to solid fills.

The map

The map is 128×64 tiles; each tile holds a sprite number (0 = empty). Paint it in the map editor, then draw regions of it:

// Draw a 16x16-tile region, starting at map tile (0, 0), at screen (0, 0).
gfx.map(0, 0, 0, 0, 16, 16, BitFlags::empty()).unwrap();

The last argument filters by sprite flag: pass SpriteFlag::Flag0 (or a |-combination) to draw only tiles whose sprite has one of those flags set — that’s how you split one map into background and foreground layers.

The game can also read the map — ctx.map_tile(x, y) (alias mget) — and that plus sprite flags is the whole collision story in tile-based games:

fn is_solid(ctx: &Context, px: i16, py: i16) -> bool {
    ctx.map_tile(px / 8, py / 8)
        .map(|tile| ctx.has_sprite_flag(tile, SpriteFlag::Flag0))
        .unwrap_or(false)
}

Writes (ctx.set_map_tile / mset) go to console RAM only and are discarded on reload, like any self-respecting cartridge — the platformer uses this to remove collected coins and put them back on restart. The same read/write pair exists for sprite-sheet pixels (sprite_pixel/set_sprite_pixel).

Camera and clipping

gfx.camera(x, y) offsets every subsequent draw by (-x, -y). Scrolling a level is: point the camera at the player, draw the map and actors in world coordinates, then reset the camera to draw the HUD in screen coordinates:

fn draw(&self, gfx: &mut Graphics) {
    gfx.clear(Color::DARK_BLUE);
    gfx.camera(self.player_x - 64, 0);              // follow the player
    gfx.map(0, 0, 0, 0, 32, 16, BitFlags::empty()).unwrap();
    gfx.sprite(SpriteId(1), self.player_x, self.player_y);
    gfx.camera(0, 0);                               // back to screen space
    printf!(gfx, 2, 2, Color::YELLOW, "Score {}", self.score);
}

gfx.clip(x, y, w, h) restricts drawing to a rectangle (clip_reset() lifts it) — split screens, minimaps, transition wipes.

PICO-8 fingers welcome

Every drawing call also has its PICO-8-style short alias: cls, pset, pget, circ, circfill, rectfill, oval, spr, sspr, pal, palt, fillp… They are the same functions; use whichever names your fingers know.

Input

A Pixel8 console has six buttons: the four directions plus two action buttons, O and X. On a keyboard:

buttonkeys
Button::Left / Right / Up / Downarrow keys
Button::OZ (also C, N)
Button::XX (also V, M)

On retro handhelds and in web exports on touch screens, the d-pad and two face buttons map to the same six. Design for six buttons and your game runs everywhere Pixel8 does.

Held vs. pressed

Input is read in update, from the Context:

fn update(&mut self, ctx: &mut Context) {
    // Held: true every frame while the key is down. Movement.
    if ctx.is_button_down(Button::Right) {
        self.x += 1;
    }
    // Pressed: true on the frame it goes down (then key-repeat after a
    // short delay: 15 frames, then every 4). Jumping, menus, toggles.
    if ctx.is_button_pressed(Button::O) {
        self.jump();
    }
}

The aliases btn and btnp are the same two functions with PICO-8’s names.

Whole-state reads

buttons_down() and buttons_pressed() return all six as a BitFlags<Button> set — often tidier than six ifs, and the natural shape for diagonals:

let held = ctx.buttons_down();
if held.contains(Button::UP_RIGHT) {   // Up and Right together
    // ...
}
let dx = i16::from(held.contains(Button::Right)) - i16::from(held.contains(Button::Left));
let dy = i16::from(held.contains(Button::Down)) - i16::from(held.contains(Button::Up));

Button::UP_LEFT, UP_RIGHT, DOWN_LEFT and DOWN_RIGHT are provided as ready-made two-button sets.

Smooth sub-pixel movement: Body

Speeds don’t have to be whole pixels — keep positions as f32 and cast when drawing. But there’s a classic gotcha: a sprite moving diagonally at less than a pixel per frame zigzags, because x and y cross their pixel boundaries on different frames. (PICO-8 has this too; it’s integer-grid geometry, not a bug.)

The SDK’s opt-in Body fixes it. It owns the exact position, and emits a phase-coherent pixel position for drawing — both axes step together, so the diagonal is a clean staircase:

struct Mob { body: Body }   // Body::new(x, y) to create

fn update(&mut self, ctx: &mut Context) {
    let speed = 0.6;
    let held = ctx.buttons_down();
    let mut dx = 0.0;
    let mut dy = 0.0;
    if held.contains(Button::Left)  { dx -= speed; }
    if held.contains(Button::Right) { dx += speed; }
    if held.contains(Button::Up)    { dy -= speed; }
    if held.contains(Button::Down)  { dy += speed; }
    self.body.move_by(dx, dy);
}

fn draw(&self, gfx: &mut Graphics) {
    gfx.clear(Color::BLACK);
    // Draw at the coherent pixel; collide against the exact body.x()/y().
    gfx.sprite(SpriteId(1), self.body.draw_x(), self.body.draw_y());
}

The drawn pixel never strays more than one pixel from the true position, so collision against x()/y() stays honest. The platformer’s hero rides a Body, which is why a running jump doesn’t shimmer.

There is no mouse, and that’s fine

No mouse, no text entry, no gamepad rumble — six buttons is the entire input model. Like the 128×128 screen, it’s a constraint that designs half your control scheme for you.

Sound and music

Pixel8’s synthesizer has four channels, shared by everything that makes noise. Sounds come in two flavors:

  • SFX — 64 slots, each a little step-sequence (32 steps, 8 waveforms), authored in the sfx editor.
  • Music — 64 patterns that arrange sfx across the four channels, authored in the music editor.

Nothing is loaded, streamed or mixed by you: the game says play sfx 3, the console does the rest.

Playing sound effects

const JUMP: SfxId = SfxId::new(0).unwrap();   // const-checked: 64+ won't compile

fn update(&mut self, ctx: &mut Context) {
    if ctx.is_button_pressed(Button::O) {
        ctx.sfx(JUMP);            // plays on a free channel
    }
}

ctx.sfx picks a free channel automatically — the right default. When you need manual control (say, an engine hum that must be replaceable), pin a channel with sfx_on(sfx, Channel::Channel2) and silence it with sfx_stop(Channel::Channel2).

sfx_demo: a four-pad soundboard — each arrow key plays a slot (source).

Playing music

Music starts from a builder and hands you back a handle:

struct MyGame {
    music: Option<PlayingMusic>,
}

fn update(&mut self, ctx: &mut Context) {
    if ctx.is_button_pressed(Button::O) && self.music.is_none() {
        self.music = ctx
            .music(MusicId::new(0).unwrap())
            .fade_in(500)                       // milliseconds; optional
            .play()
            .ok();
    }
    if ctx.is_button_pressed(Button::X) {
        if let Some(m) = self.music.take() {
            m.fade_out(500).stop();             // or just drop it
        }
    }
}

The PlayingMusic handle owns the running song: dropping it stops the music (fading out first if you armed fade_out). That plays beautifully with Rust — store the handle in whatever state means “music should be playing”, and the song can never outlive it. The platformer keeps its game-over jingle inside the Ended state variant; leaving that state drops the handle and the jingle with it.

Only one song plays at a time: play() returns Err(MusicBusy) — carrying your request back — if another is running. Stop that one first.

If sound effects keep stealing your melody’s channels, reserve some at start:

ctx.music(song)
    .reserve_channels(Channel::Channel0 | Channel::Channel1)
    .play()

Auto-routed ctx.sfx calls will then avoid channels 0 and 1 while the music plays.

music_demo: Z fades a song in, X fades it out (source).

Authoring the sounds

The editors are where the actual audio comes from, and the best way to learn them is to poke at a finished cart: load the sfx_demo or music_demo cart (or open examples/sfx_demo from a source checkout), press Esc, and walk through its sfx and music editors to see how the sounds are built. Start simple — a jump is a few steps of a square wave sliding down in pitch; a coin is two short high notes. The 8 waveforms and 32 steps go a surprisingly long way.

Saving data

Carts get a persistent key-value store — the cartridge’s save file. High scores, unlocked levels, settings: anything that should survive closing the console.

fn boot(&mut self, ctx: &mut Context) {
    // Once, before the first update: the previous best score, if any.
    self.best = ctx.storage_get("best").and_then(|v| v.as_i64()).unwrap_or(0);
}

fn update(&mut self, ctx: &mut Context) {
    if self.score > self.best {
        self.best = self.score;
        let _ = ctx.storage_set("best", self.score);
    }
}

Reading saved data is exactly the kind of thing boot is for: a cart’s opening state is usually a constant, and no constant can ask the store what happened last time. Whichever game! form made the state, boot runs once on it, before anything is drawn — so there is no “is this the first frame?” branch to write and none to pay for on every frame after it.

Keys are &str; values are primitives — integers, floats, bools — anything that converts into a StorageValue. Reads come back as a StorageValue with as_i64(), as_f64(), as_bool() and is_null() accessors. storage_remove(key) deletes one entry, storage_clear() wipes the save. (dset/dget exist as aliases for PICO-8 fingers.)

The whole API is allocation-free, so it works unchanged in the default #![no_std] carts.

Where saves live

That’s the console’s business, not the cart’s: the desktop console and player keep a JSON file in the user’s cache directory, keyed by the cart’s name. The browser player keeps saves for the session only, and headless verify keeps them in memory. Your code is the same everywhere.

The cap

The whole store, serialized, is capped at 128 KiB — a storage_set that would exceed it returns Err(StorageFull) and stores nothing (the old value under that key is kept). For perspective, PICO-8 gives carts 256 bytes of save data; if you hit this limit, what you’re storing probably isn’t save data.

You can see the full pattern in action in the platformer: the best score is loaded once in boot, and written back only when a run beats it.

Living within the limits

Every runtime limit in Pixel8 is built around one easy number: 128 K. Keep it in mind and you will never be surprised.

whatlimitwhen you exceed it
cart size128 KiBbuild warns; export is rejected
RAM128 KiB“ran out of memory” error screen
per-frame work128 K“ran too long (infinite loop?)” error screen
save data128 KiBstorage_set returns Err(StorageFull)

None of these bite a normally-written game. A simple cart compiles to a few KiB, real game logic uses a sliver of the frame budget, and static no_std state barely dents the RAM. The limits exist to catch runaways — and to keep a Pixel8 cart a Pixel8 cart.

Watching the meters

Press F1 while a game runs to overlay the resource stats. Its CPU and memory rows refresh once a second, each holding that second’s peak; the cart can read the same numbers itself, live, per frame:

ctx.cpu_update()   // 0.0..1.0 of last frame's update budget used
ctx.cpu_draw()     // same, for draw
ctx.mem()          // 0.0..1.0 of the 128 KiB RAM cap (high-water)
ctx.fps()          // measured frames per second

The two CPU meters count the cart’s own work, not the console’s: a screenful of circle_fill reads the same as a single pixel, because only the call is charged and not what it paints. That is usually what you want — it measures the thing you can actually optimize — and the console keeps its own side in check by clipping each primitive to the screen before it draws, so a sprite blown up to 4096x4096 costs only the part you can see. Sheer volume is what the meters miss: a cart issuing tens of thousands of draw calls a frame can still drop below 60 while they look relaxed, and fps() is the honest number when that happens.

If a frame genuinely can’t fit the budget at 60 fps, a cart can opt into 30:

impl Game for MyGame {
    const FRAME_RATE: FrameRate = FrameRate::Fps30;
    // update and draw now run 30 times per second, with double the
    // per-call work budget effectively available per second of gameplay.
}

Staying small: no_std is the normal way

pixel8 new scaffolds a #![no_std] cart, and every game example ships this way: no heap, no allocator, fully static memory. This is less exotic than it sounds — most carts never notice, because the SDK itself is allocation-free (even printf! and the storage API).

Two crates cover most of what std would have given you:

  • heapless — fixed-capacity Vec<T, N>, String<N>, maps and more. The platformer keeps its collected coins in a heapless::Vec<Taken, MAX_TAKEN> and formats HUD text with heapless::format!.
  • libm — the float functions core lacks: libm::sqrtf, sinf, floorf… You often don’t need it: converting a sub-pixel f32 position for a draw call is just x as i16.

Dependency discipline is the real cart-size lever: every crate you add is wasm you ship. The scaffolded release profile (opt-level = "s", lto, panic = "abort") already squeezes hard, and an over-size build warns locally before an export would refuse.

When you really need a heap

Drop the default-features = false from the pixel8 dependency and the cart gets std, an allocator, and ordinary Vec/String. RAM is still capped at 128 KiB total — with the default 32 KiB stack reserve, roughly 95 KiB of heap headroom remains. examples/stress is the one cart that takes this path, deliberately allocating until it hits the cap (worth running once just to see the error screen).

The stack reserve itself is tunable: the scaffolded .cargo/config.toml carries a stack-size=32768 rustflag you can raise or lower. What usually decides how big it has to be is start-up: a game! initializer that is a constant is placed — the state ships as part of the cart and nothing builds it — while a deferred one is assembled on the stack and then moved into the static that holds it, so for a moment a big game exists twice.

The full story — including exact accounting of the memory and fuel budgets — is in docs/LIMITS.md.

Sharing your cart

A finished Pixel8 game ships as one of two artifacts, both produced by export: a PNG cartridge or a single-file web page.

PNG cartridges

> export mygame.png

or headless: pixel8 export mygame/ mygame.png. The output is a real PNG — cartridge art, label, title — that any image viewer shows and any Pixel8 console plays:

pixel8 mygame.png            # boot with the cart loaded, then `run`
pixel8 run mygame.png        # boot and run immediately

Before exporting, give the cartridge its face:

  • Label: press F6 while the game runs to capture the current screen as the cartridge label.
  • Metadata: at the prompt, title Space Miner 8 and author you (shown by info and on the cartridge).

By default the compressed Rust source is embedded in the cart, so anyone can turn your cartridge back into an editable project — the console’s import mygame.png mydir, or pixel8 extract mygame.png mydir headless. This is how fantasy-console culture spreads: play a cart, crack it open, see how it’s made. Export with --no-source if you’d rather not.

It’s good hygiene to check a cart before sharing:

pixel8 verify mygame.png     # load it and run 60 frames headless

Web export

> export mygame.html

or pixel8 export-web mygame/ mygame.html (it also accepts a .png cart as input). This produces one self-contained HTML file: the cart plus the whole console runtime compiled to WebAssembly, embedded. No server, no asset folder — send the file, double-click it, play. Opening it shows the cartridge art; clicking boots the cart (the click also satisfies the browser’s autoplay rule, so audio just works). While the cart runs, pause and stop controls sit under the canvas — Esc pauses too, a hidden tab pauses automatically, and stop returns to the click-to-play screen. On touch screens the page grows an on-screen d-pad and O/X buttons — unless you pass -noctrl (--no-controls headless), which suits a cart that reads no input: no touch pad, no key hint, and the screen keeps the room they would have taken.

The playable carts embedded throughout this book are exactly these files — each frame is one export-web output. Anything that hosts static files can host one: itch.io, GitHub Pages, your blog.

Two things to know: every export weighs ~1.7 MB regardless of cart size (the embedded runtime dominates), and a web export is a player, not a console — no editors, no prompt, and no source embedded (ship the .png alongside if you want people to crack it open). Details in docs/WEB_EXPORT.md.

The standalone player

pixel8-player plays carts with no editors attached — a console-style cart picker over a folder of .png files:

cargo install pixel8-player
pixel8-player ~/carts

Its second life is on retro handhelds (PowKiddy RGB10S, Anbernic RG351/353 and friends on ArkOS/ROCKNIX): a static-musl build runs on the bare display with evdev input and ALSA sound — copy it into the ports folder, drop carts next to it, play. The recipe is in docs/HANDHELD.md.

Which one, when

you wantship
players who have (or will get) Pixel8the .png cart
anyone with a browser, zero frictionthe .html export
a handheld in someone’s pocketthe .png cart + the player
people to learn from your codethe .png cart with source (the default)

Importing from PICO-8

Pixel8 shares its palette, waveforms and sprite layout with PICO-8 — a deliberate act of heritage. The practical payoff: a PICO-8 cart’s assets import almost one-to-one.

pixel8 import-pico8 mygame.p8 mygame      # or mygame.p8.png

(or import-pico8 mygame.p8 mygame at the console prompt.) This creates a new project with the cart’s graphics, sprite flags, map, sound effects and music transferred in, plus a stub src/lib.rs to write the game in Rust.

Only the assets come across — the Lua code is ignored. Pixel8 doesn’t run or translate Lua; porting the game logic to Rust is your (fun) job. In practice this is a lovely way to port a game: all the art and sound are instantly in place, so you can concentrate on the code, comparing behavior side by side.

Appending into an existing project

--into merges selected assets into a project you already have, instead of creating a new one:

pixel8 import-pico8 mygame.p8 --into myproject [--sprites R] [--sfx R] [--music R]

with R selecting ranges of slots — cherry-pick a sprite sheet from one cart and sound effects from another.

What maps, and how faithfully

Graphics and maps transfer essentially exactly (same palette, same 8×8 cells, same 128×64 map). Audio maps waveform-for-waveform onto Pixel8’s synth, which is close but not sample-identical to PICO-8’s. The fine print — per-asset mapping tables and known differences — lives in docs/PICO8_IMPORT.md.

One direction only: Pixel8 imports from PICO-8; it doesn’t export to it.

A tour of the examples

The repository ships example carts under examples/ — each a standalone project you can open, run and take apart. They are all playable right here (and on the cart shelf), and most are the worked answer to one of this book’s chapters.

From a source checkout, any of them opens in the console:

cargo console -- examples/platformer     # then type `run`

Or crack open the published cartridges: save a .png from the shelf and load it — the examples embed their source, so import turns any of them back into an editable project.

sprite_move

Sprites, flipping, and a two-frame walk cycle driven by a frame counter — the Drawing chapter, in 60 lines. Source.

platformer

The capstone: run, jump, collect coins, stomp (or dodge) the badie, grab the trophy before the clock runs out. Almost every chapter of this book is in here, in a few hundred lines split into small modules:

  • map + sprite flags as the collision system (solid tiles carry flag 0, the badie’s sprites flag 1);
  • a camera that follows the hero, with the HUD drawn in screen space;
  • the falling, the walls and the edge of the level from the physics feature: the scene is one World of two seats, which owns where the hero and the badie are, how fast, and what they last ran into. The walls are declared on it once (the same flag the map marks its solid tiles with), it owns the level’s Gravity, and each actor is enlisted in one chain — the Bounds it covers, the rectangle the hero may never leave (confined_to), the wearing cell that says the badie is a badie — keeping the Member handle the chain ends in beside its own game data. A single step an update pulls the hero down, stops it at the solid tiles, holds it inside the level, patrols the badie and tells each of them what it met; the hero draws at the world’s draw_pos, so running jumps climb clean staircases;
  • nothing walks a pair of casts: the badie’s sprites are flagged, so the hero’s contacts report having met one in touches and the cart only decides whether that was a ram or a stomp — same frame, so a stomped badie is retired on the spot;
  • coins collected by rewriting the map in RAM and put back on restart;
  • the best score kept in storage across runs;
  • win/lose music held inside the game-state enum — leaving the state drops the PlayingMusic handle, stopping the song;
  • heapless::Vec and heapless::format! in a #![no_std] cart.

Source.

sfx_demo

A four-pad soundboard: each arrow key fires a slot via ctx.sfx, with the pads lighting up on the screen — the sound half of Sound and music. Source.

music_demo

Starting and stopping a song with fades, the PlayingMusic handle held in an Option, and dancing bars while it plays — the music half of the same chapter. Source.

campfire

Not a game: a scene that plays itself. Nothing reads the buttons and there is nothing to win — it is an animation that happens to be a cartridge. A fire burns under a moon and a scatter of stars, one figure sat beside it rocking in and back, another stood off to the side working through a cigarette, and an owl blinking on a branch of the old tree; the fire, the crickets and an occasional hoot loop underneath it all.

The flames are a SmokingFire, one of the SDK’s plume effects, which sit behind the off-by-default plume-effects feature: turn it on and a fire is three lines of code. Its spent flames carry on as smoke instead of vanishing, so the column reads as one effect rather than two — and the cigarette is the same Smoke, turned right down and pointed up-left. One gusty Wind from the physics feature stands in for the sway both of them do on their own, so the column wanders on the night air with a lean towards the tree and never quite repeats itself. Everything else is the cheapest animation there is: compare ctx.time() against a constant, pick one of two sprites. Scale the fire down for a candle, point it another way for an exhaust trail. Source.

stress

Not on the shelf, and not a game: a cart that deliberately allocates and burns CPU to probe the limits. It’s the one example that opts into std, and the fastest way to see the “ran out of memory” and “ran too long” error screens on purpose. Source.

Going further

You’ve built, drawn, beeped and shipped. What’s left is depth, and it lives in two places: the API docs and the repository’s design documents.

API documentation

The SDK’s reference documentation is on docs.rs: docs.rs/pixel8. Everything this book showed — and every alias, error type and edge case it glossed over — is documented there, on the actual functions.

The deeper documents

In the repository’s docs/:

documentwhat’s inside
ARCHITECTURE.mdhow the console is put together: one framebuffer, one rasterizer, the mode machine
ABI.mdthe raw wasm import surface between cart and console — for the curious, and for anyone building an alternate SDK
LIMITS.mdexact accounting of the 128 K budgets
CART_FORMAT.mdhow a PNG cartridge is laid out, chunk by chunk
WEB_EXPORT.mdhow the single-file web player works
PICO8_IMPORT.mdthe PICO-8 asset mapping, in detail
HANDHELD.mdputting the player on retro handhelds
TUI.mdthe terminal frontend: sixels, input protocols, tuning

The sandbox, in one paragraph

Since you’ll wonder eventually: carts execute inside wasmi with no WASI, no filesystem, no network and no host memory access. The only imports a cart gets are the small C-like functions of the Pixel8 ABI — draw, input, audio, map, storage, log. Fuel metering turns infinite loops into a friendly error screen. That’s why running a stranger’s cartridge is a safe thing to do, and why carts from today will still run bit-identically wherever the runtime goes next.

Contributing

Pixel8 is free software (GPL-3.0-or-later) and welcomes contributions — bug reports, carts, docs, code. Start with CONTRIBUTING.md. And if you make something with it: the whole point of PNG cartridges is that they’re easy to share. Share them.