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.
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.
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
.
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.
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.
- wasm4.org. (n.d.). Play | WASM-4. [online] Available at: https://wasm4.org/play/ [Accessed 27 Feb. 2022].
- www.lexaloffle.com. (n.d.). PICO-8 Fantasy Console. [online] Available at: https://www.lexaloffle.com/pico-8.php [Accessed 27 Feb. 2022].
- 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].
- 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].
- 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].