Making games on WASM4 with Rust

WASM4 is a virtual game console that's very reminiscent of the Pico8 system. It's an idealized system with a display format, sound system, and controller inputs similar to retro gaming consoles without the inconvenience of physical hardware. Games are distributed as self-contained WebAssembly (WASM) modules and are fondly referred to as Cartridges (carts) within its ecosystem. You can play games using the web runtime on their community library or you can install the runtime yourself locally using npm.

$ npm install -g wasm4
$ w4 --help

Usage: w4 [options] [command]

WASM-4: Build retro games using WebAssembly for a fantasy console.

Learn more: https://wasm4.org

Options:
  -V, --version                     output the version number
  -h, --help                        display help for command

Commands:
  new|create [options] <directory>  Create a new blank project
  init [options]                    Create a new blank project in current
                                    folder
  watch [options]                   Rebuild and refresh when source code
                                    changes
  run [options] <cart>              Open a cartridge in the web runtime
  run-native <cart>                 Open a cartridge in the native desktop
                                    runtime
  png2src [options] <images...>     Convert images to source code
  bundle [options] <cart>           Bundle a cartridge for final distribution
  help [command]                    display help for command

Installing WASM4 gives us access to the w4 command. We can use it to run downloaded cartridges using w4 run.

Let's take a look at the hardware specifications

  • Display: 160x160 pixels, 4 customizable colors, updated at 60 Hz.
  • Memory: 64 KB linear RAM, memory-mapped I/O, save states.
  • Cartridge Size Limit: 64 KB.
  • Input: Keyboard, mouse, touchscreen, up to 4 gamepads.
  • Audio: 2 pulse wave channels, 1 triangle wave channel, 1 noise channel.
  • Disk Storage: 1024 bytes.

This is not a lot of power to work with. To put things in perspective, photos taken with modern smartphones are Megabytes in size with millions of pixels. However, these may be intentional design decisions similar to those made on the Pico8 made by the author to encourage creativity through constraints.

With a 4 color palette, WASM4 is even more limiting than the Pico8. However, it does offer more RAM and a larger cartridge size.

Creating our game

w4 comes with a handy tool to create new projects. Running it generates bindings that can be used to interact with the host runtime. However, I think there's a lot we can learn about interacting with WASM systems by implementing these ourselves.

To get started we need to install the wasm32 target which will allow us to compile our Rust code to WASM as well as create a new project.

$ rustup target add wasm32-unknown-unknown
$ cargo init --lib wasm4sample
$ cd wasm4test

Next we need to update our Cargo.toml to specify this as a cdylib

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

At this point, we should be able to build and run our module in WASM4.

$ cargo build --target wasm32-unknown-unknown
$ w4 run target/wasm32-unknown-unknown/debug/wasm4test.wasm

   ▄▄▄▄▄▄▄ ▄ ▄ ▄   ▄ ▄▄▄▄▄▄▄ 
   █ ▄▄▄ █ █▄▄▄ █▀█▄ █ ▄▄▄ █ 
   █ ███ █ ▄▄█▀█ ▀▄▄ █ ███ █ 
   █▄▄▄▄▄█ ▄▀▄▀▄▀█▀▄ █▄▄▄▄▄█ 
   ▄  ▄▄▄▄▄▄█ ▀██  ▄▄  ▄ ▄▄▄ 
    ██▀██▄█▀ ▄▀█ █ ▀▀▀▀▀█▀▀▄ 
   █ ▀▄ ▀▄▀▄  ▄ ▄▄▀▀▄ █▄▀▀▀█ 
   █▄▄▀▄▄▄█  ▀ ▀ █ ▀▀▄██▄▄█▄ 
   █ ▀█▄ ▄  ▀█▄█▀▀█▄██▄█▄█▄▀ 
   ▄▄▄▄▄▄▄ █ █▀▄▄▄▄█ ▄ █▄█▀▄ 
   █ ▄▄▄ █ ████ █ ▀█▄▄▄█▄▀▄█ 
   █ ███ █ ▀▀▄▄▀█  ▄ ▄█ ▀█▄█ 
   █▄▄▄▄▄█ ▄█▀███  ▄▀█▀██ ▀█ 
  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
  
Open http://localhost:4444, or scan this QR code on your mobile device. Press ctrl-C to exit.
Warning: Cart is larger than 65535 bytes. Ensure the release build of your cart is small enough to be bundled.
^C

This should open up a browser window with our running cart.

To open the built-in devtools window push f8

This may not be a lot to look at, but it is a working cartridge. Looking through our logs we can see that we got a warning. Let's fix that.

Warning: Cart is larger than 65535 bytes. Ensure the release build of your cart is small enough to be bundled.

The warning points out that our module is bigger than 64KB. Using ls we can verify that this is true.

ls -la target/wasm32-unknown-unknown/debug/wasm4test.wasm

-rwxr-xr-x  2 user  user  1593119 Feb 23 21:18 target/wasm32-unknown-unknown/debug/wasm4test.wasm

A debug build without any code outputs a 1.6MB module at the time I ran my build. This is because of debugging data that gets bundled in debug builds. While this may be useful in other contexts, it's not very relevant for our use case. We can use the wasm-gc tool to strip it out.

$ wasm-gc target/wasm32-unknown-unknown/debug/wasm4pong.wasm
$ ls -la target/wasm32-unknown-unknown/debug/wasm4pong.wasm

-rwxr-xr-x  2 user  user  184 Feb 24 13:32 target/wasm32-unknown-unknown/debug/wasm4pong.wasm

Alternatively, we can run add enable Link-Time Optimization (lto) in our Cargo.toml and build in release mode which gives us a similar result.

// Cargo.toml
[package]
name = "wasm4sample"
version = "0.1.0"
edition = "2021"

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

[profile.release]
opt-level = "z"
lto = true


$ cargo build --release --target wasm32-unknown-unknown
$ ls -la target/wasm32-unknown-unknown/release/wasm4pong.wasm

-rwxr-xr-x  2 nishtahir  staff  185 Feb 24 13:37 target/wasm32-unknown-unknown/release/wasm4pong.wasm

Let's take a look at the generated text representation of our module using wasm2wat.

(module
  (memory (;0;) 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

While it might not be obvious at first glance, there are a couple of issues here. First, the generated code seems to be attempting to instantiate 16 pages of WASM memory, however, from the spec we're limited to just 64KB which is equal to 1 page. Another issue is that we are not importing memory from the host runtime. This is needed because the host may need to initialize memory with state that's necessary to run the system like the virtual I/O.

We can address these issues by providing compiler flags. --initial-memory and --max-memory to specify the size of the memory we need, and --import-memory to indicate that we want to import memory from the host.

$ RUSTFLAGS='-C link-arg=--import-memory -C link-arg=--initial-memory=65536 -C link-arg=--max-memory=65536' cargo build --release --target wasm32-unknown-unknown

error: linking with `rust-lld` failed: exit status: 1
  |
  = note: "rust-lld" "-flavor" "wasm" "--rsp-quoting=posix" "--export=__heap_base" "--export=__data_end" "-z" "stack-size=1048576" ...
  = note: rust-lld: error: initial memory too small, 1048576 bytes needed
Truncated for conciseness

Uh oh, we get an error stating that the amount of memory is too small. This is because the default stack size is set to 1048576 bytes. This was visible in our module through the $__stack_pointer global. We can adjust it with the link-arg=-zstack-size flag.  Looking at the WASM4 memory map, it mentions that program memory begins at $19a0 which means that the first 6560 bytes of memory is reserved for the system. As a result, we should place our stack pointer after the reserved area. The amount of stack space we choose to reserve for our rust code may be somewhat arbitrary, however, we have to balance the amount of memory we allocate for this purpose against any additional data such as assets and strings we may need to bundle with our module. For now, let's reserve 8192 bytes for our rust stack, which brings our total stack size up to 14752.

$ RUSTFLAGS='-C link-arg=--import-memory -C link-arg=--initial-memory=65536 -C link-arg=--max-memory=65536 -C link-arg=-zstack-size=14752' cargo build --release --target wasm32-unknown-unknown
$ wasm2wat target/wasm32-unknown-unknown/release/wasm4test.wasm

(module
  (import "env" "memory" (memory (;0;) 1 1))
  (global $__stack_pointer (mut i32) (i32.const 14752))
  (global (;1;) i32 (i32.const 14752))
  (global (;2;) i32 (i32.const 14752))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

Rendering output

Now we may begin writing the code for our game. WASM4 expects our module to export a update function. This is called before the system draws the next frame and as a result, affords us a chance to update the frame buffer.

To export a function in our module we have to annotate it with the #[no_mangle] attribute to prevent the compiler from optimizing out and/or mangling the function name at compile time. We also need to mark it as unsafe since we'll be calling extern functions later.

#[no_mangle]
unsafe fn update() {

}

WASM4 exposes functions that we can use to interact with the host runtime. Rust allows access to these functions through the extern keyword. WASM4 exposes a rect drawing function that allows us to draw a rectangle on the screen. Let's create a binding for that.

extern "C" {
    pub fn rect(x: i32, y: i32, width: u32, height: u32);
}

Before we can use it, we have to set the drawing color. The documentation describes DRAW_COLORS as memory index 0x14. It holds a value that references the pallette index that drawing functions use. We can set this value through rust by directly accessing the memory location.

pub const DRAW_COLORS: *mut u16 = 0x14 as *mut u16;

We can set the value by updating the pointer value in our update function.

*DRAW_COLORS = 2;

This sets the value to index 2 of the color palette, which by default is #86c06c.

Default color palette

Now we can call the rect function to draw our rectangle. WASM4 uses a coordinate/grid system with the top left corner being 0,0.

To draw a 10x10 square in the top left corner of the screen our x and y values are going to be 0,0 and our width and height values will be 10.

#[no_mangle]
unsafe fn update() {
    *DRAW_COLORS = 2;
    rect(0, 0, 10, 10);
}

Compiling and running this gives us a humble 10x10 square in the top left corner.

Rendering Text

WASM4, fortunately, comes with builtin text rendering capabilities. Just like with, we're going to have to declare an extern binding function to access this capability. There is a text function available, however, that accepts a null-terminated string. Rust strings are UTF-8 formatted so that's not going to work for us. Fortunately, there's an undocumented function similar to text that works with UTF-8 aptly named textUtf8. Let's update our bindings to include this.

extern "C" {
    pub fn rect(x: i32, y: i32, width: u32, height: u32);
    pub fn textUtf8(text: *const u8, len: usize, x: i32, y: i32);
}

We can call this within our update function quite easily passing in the length of the string as well as the coordinates where we'd like our text placed.

#[no_mangle]
unsafe fn update() {
    *DRAW_COLORS = 2;
    rect(0, 0, 10, 10);
    
    let str = "Hello World!";
    textUtf8(str.as_ptr(), str.len(), 0, 20);
}

And we have our text rendered as we would expect.

Handling input

Let's add some interactivity to our game. We can achieve this by responding to input from the user. WASM4's primary way of accepting input is through gamepads. A gamepad has 4 directional buttons and 2 action buttons similar to an old NES controller. These are memory-mapped and stored as 1 byte in memory starting at 0x16.

To get a reference to the first gamepad let's create a direct memory pointer to it.

pub const GAMEPAD_1: *const u8 = 0x16 as *const u8;

A button being pressed is represented as a bit set to 1 in the gamepad memory

0000 0000 		// Test for it with
|||| |||└ X 		(& 1)
|||| ||└ Z 		(& 2)
|||| |└ Unused 		(& 4)
|||| └ Unused 		(& 8)
|||└ Left 		(& 16)
||└ Right 		(& 32)
|└ Up 			(& 64)
└ Down 			(& 128)

As a result, to test if a button is pressed we need to mask the gamepad state to see if the relevant bit corresponding to the button is set. For our inputs let's test for and handle left and right input. We can conditionally render text based on this input.

pub const BUTTON_LEFT: u8 = 16; // 0001 0000
pub const BUTTON_RIGHT: u8 = 32; // 0010 0000

if *GAMEPAD_1 & BUTTON_LEFT != 0 {
    let str = "Left Pressed";
    textUtf8(str.as_ptr(), str.len(), 0, 20);
}

if *GAMEPAD_1 & BUTTON_RIGHT != 0 {
    let str = "Right Pressed";
    textUtf8(str.as_ptr(), str.len(), 0, 20);
}
    

Let's try adding some motion to our square. We can begin by introducing some global state for its x&y coordinates.

struct Position {
    x: i32,
    y: i32,
}

static mut SQUARE: Position = Position { x: 0, y: 0 };

We can also update this state based on the user's input before rendering our square.

extern "C" {
    pub fn textUtf8(text: *const u8, len: usize, x: i32, y: i32);
    pub fn rect(x: i32, y: i32, width: u32, height: u32);
}

pub const DRAW_COLORS: *mut u16 = 0x14 as *mut u16;
pub const GAMEPAD_1: *const u8 = 0x16 as *const u8;
pub const BUTTON_LEFT: u8 = 16;
pub const BUTTON_RIGHT: u8 = 32;

struct Position {
    x: i32,
    y: i32,
}
static mut SQUARE: Position = Position { x: 0, y: 0 };

#[no_mangle]
unsafe fn update() {
    *DRAW_COLORS = 2;
    
    if *GAMEPAD_1 & BUTTON_LEFT != 0 {
        let str = "Left Pressed";
        textUtf8(str.as_ptr(), str.len(), 0, 20);
        SQUARE.x -= 1;
    }
    
    if *GAMEPAD_1 & BUTTON_RIGHT != 0 {
        let str = "Right Pressed";
        textUtf8(str.as_ptr(), str.len(), 0, 20);
        SQUARE.x += 1;
    }
    
    rect(SQUARE.x, SQUARE.y, 10, 10);
}

Here's a working sample of what we've been able to put together so far.

Push Left/Right to interact. (May not render properly on mobile. I'm still figuring out the embedding)

Conclusion

I think WASM4 is an awesome platform. It takes advantage of the portable and cross-platform nature of WASM to build a platform that will only get better as the standard evolves. I hope this serves as a great starting point for you and highlights the amazing potential this has in fields like education. I'm super excited to see it grow and will be looking out for amazing games that the community puts out.


  1. wasm4.org. (n.d.). Play | WASM-4. [online] Available at: https://wasm4.org/play/ [Accessed 27 Feb. 2022].
  2. ‌www.lexaloffle.com. (n.d.). PICO-8 Fantasy Console. [online] Available at: https://www.lexaloffle.com/pico-8.php [Accessed 27 Feb. 2022].
  3. wasm4.org. (n.d.). Setting Color Palette | WASM-4. [online] Available at: https://wasm4.org/docs/tutorials/snake/setting-color-palette [Accessed 27 Feb. 2022].
  4. ‌www.hanselman.com. (n.d.). The PICO-8 Virtual Fantasy Console is an idealized constrained modern day game maker. [online] Available at: https://www.hanselman.com/blog/the-pico8-virtual-fantasy-console-is-an-idealized-constrained-modern-day-game-maker [Accessed 27 Feb. 2022].
  5. doc.rust-lang.org. (n.d.). Linker-plugin based LTO - The rustc book. [online] Available at: https://doc.rust-lang.org/rustc/linker-plugin-lto.html [Accessed 27 Feb. 2022].

Subscribe to Another Dev's Two Cents

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe