May 23, 2020

Rust, WebAssembly and web-sys


I am intrigued by Rust and WebAssembly. I believe the combination of the two will open up whole new ways of developing serverless services and web applications. Therefore I have decided to put a serious effort into learning Rust.

My first project was a small browser game. It is a co-op twin-stick shooter where two players fight against a horde of blobby monsters. The primary goal was to learn Rust by writing a non-trivial program, so the game itself is not super fantastic and it is missing a lot of features.

In this article I will describe my experience, and the stumbling blocks I encountered along the way.

The source code is on github at blobs-and-bullets

Getting started with wasm-pack

It is easy to get started with Rust and WebAssembly by using wasm-pack to scaffold a new project.

npm init rust-webpack blobs-and-bullets

It creates a new project using webpack and the wasm-pack plugin. By running npm start you will start webpack-dev-server which automatically refreshes the browser every time you save changes in your editor.

Calling browser APIs with web-sys

The current version of WebAssembly can only receive primitive types. If you want to pass richer objects back and forth between JavaScript and WebAssembly you need to write some glue code for serializing it into WebAssembly memory.

Luckily there is a project called wasm-bindgen that does all the hard work for you. Two additional crates: js-sys and web-sys provide all you need for calling standard JS and browser APIs. You can find a good introduction to them in The wasm-bindgen Guide.

My preferred IDE is IntelliJ. I have used it for almost 20 years for Java, JavaScript and TypeScript, and it also has a great plugin for Rust. Unfortunately the web-sys crate uses some Cargo features that are not yet supported by IntelliJ (and supposedly also Visual Studio Code), so you don't get code completion for web-sys types :-(

Drawing on a canvas

My first goal was to create a canvas and draw a white rectangle on a black background.

In order to keep the size of your program small web-sys needs to know which parts of the browser APIs you want to use. To create a new canvas element and draw on it you need the following features enabled in Cargo.toml.

[dependencies.web-sys]
version = "0.3.22"
features = ["console", "Window", "Document", "Element", "Node",
    "HtmlCanvasElement", "HtmlImageElement", "CanvasRenderingContext2d"]

The big difference from writing this in JavaScript is that web-sys uses the Rust naming convention, so a method like createElement() becomes create_element(). It also uses dyn_into() to dynamically cast from Element to HtmlCanvasElement etc. Rust forces you to handle errors, but for now we just use unwrap().

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{window, CanvasRenderingContext2d, HtmlCanvasElement, HtmlImageElement};

#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
    let window = window().unwrap();
    let document = window.document().unwrap();

    // create a new canvas element
    let canvas = document
        .create_element("canvas")
        .unwrap()
        .dyn_into::<HtmlCanvasElement>()
        .unwrap();
    canvas.set_width(512);
    canvas.set_height(512);

    // attach it to the document body
    document.body().unwrap().append_child(&canvas).unwrap();

    // get the 2D rendering context for painting on the canvas
    let ctx = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<CanvasRenderingContext2d>()
        .unwrap();
    ctx.set_image_smoothing_enabled(false);

    // paint the canvas black
    ctx.set_fill_style(&"#000".into());
    ctx.fill_rect(0., 0., 512., 512.);

    // paint a white rectangle at pos (100, 100)
    // that is 50 pixels wide and 25 pixels tall
    ctx.set_fill_style(&"#fff".into());
    ctx.fill_rect(100., 100., 50., 25.);

    Ok(())
}

Animating with request_animation_frame()

The next step was to get the box to move and this is where I really got to feel how different a beast Rust is. Now the borrow checker reveals its ugly face and you need to use the Interior Mutability Pattern.

I ended up going back and coding along with this great article: Learn Rust With Entirely Too Many Linked Lists. Then it all started to make a bit of sense.

// the box starts at (0, 0)
let mut pos = (100., 100.);

// A reference counted pointer to the closure that will update and render the game
let f = Rc::new(RefCell::new(None));
let g = f.clone();

// create the closure for updating and rendering the game.
*g.borrow_mut() = Some(Closure::wrap(Box::new(|| {
    // fill the canvas with a black color
    ctx.set_fill_style(&"#000".into());
    ctx.fill_rect(0., 0., 512., 512.);

    // move the box
    pos.0 += 2.;
    pos.1 += 1.;

    // if box is outside the screen then wrap around to the other side
    if pos.0 > 512. - 50. { pos.0 = -50.};
    if pos.1 > 512. - 25. { pos.1 = -25.};

    // draw a white rectangle at pos (100, 100)
    // that is 50 pixels wide and 25 pixels tall
    ctx.set_fill_style(&"#fff".into());
    ctx.fill_rect(pos.0, pos.1, 50., 25.);

    // schedule this closure for running again at next frame
    request_animation_frame(f.borrow().as_ref().unwrap());
}) as Box<dyn FnMut() + 'static>));

// start the animation loop
request_animation_frame(g.borrow().as_ref().unwrap());

Drawing Pixel art in Gimp

I created all tiles and sprites in a single image using an 8×8 pixel grid in Gimp.

Sprite sheet with all sprites and tiles

Level editing in Tiled

I loaded the sprite sheet into Tiled Map Editor and created a map which I then exported in JSON format.

Creating a Map in Tiled

Parsing JSON with serde

It turned out to be dead simple to deserialize JSON into Rust structs using serde.

use serde::Deserialize;
use wasm_bindgen::JsValue;

#[derive(Debug, Deserialize)]
pub struct TileMap {
    pub width: u8,
    pub height: u8,
    pub layers: Vec<TileLayer>,
}

#[derive(Debug, Deserialize)]
pub struct TileLayer {
    pub width: u8,
    pub height: u8,
    pub data: Vec<u8>,
}

impl TileMap {
    pub fn new_from_json(json: &JsValue) -> TileMap {
        json.into_serde().unwrap()
    }
}

The JSON can be loaded using the fetch API.

pub async fn load_json(url: &str) -> Result<JsValue, EngineError> {
    let mut opts = RequestInit::new();
    opts.method("GET");
    opts.mode(RequestMode::Cors);

    let request = Request::new_with_str_and_init(&url, &opts)?;
    let window = web_sys::window().unwrap();
    let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
    let resp: Response = resp_value.dyn_into().unwrap();
    let json = JsFuture::from(resp.json()?).await?;
    Ok(json)
}

To render the map I loop over the first layer in the tilemap and calculate the bounds of the tile in the sprite sheet. I render the tiles at double size on the screen. I did this so the sprites could move at sub-pixel resolution, because the movement look too janky otherwise.

Here you can also see how web-sys handles function overloading. It has generated variations of drawImage() that are named after the function parameters.

pub fn draw_map(&self, map: &TileMap, tileset: &HtmlImageElement) {
    let tx_max = min(self.width as usize / 8, map.width as usize);
    let ty_max = min(self.height as usize / 8, map.height as usize);
    let data = &map.layers[0].data;
    for tx in 0..tx_max {
        for ty in 0..ty_max {
            self.ctx
                .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
                    &tileset,
                    8. * f64::from((data[tx + ty * 60] - 1) % 8),
                    8. * f64::from((data[tx + ty * 60] - 1) / 8),
                    8.,
                    8.,
                    16. * (tx as f64),
                    16. * (ty as f64),
                    16.,
                    16.,
                )
                .unwrap();
        }
    }
}

Using the Gamepad API

The gamepad API is annoying.

  • Same gamepad will report different info depending on browser and operating system :-(
  • Firefox returns an array with length matching number of connected gamepads
  • Chrome always returns an array with four elements

I have hardcoded the mapping of a Dualshock 4 controller when running in Firefox on Linux.

Pleasing the borrow-checker

Extracting helper functions from mutable methods is not so easy, because you can only mutably borrow &self once.

Conclusion

Learning Rust is fun but also hard. It forces me to think differently about code. It even forces me to think more about my code before I write it. It is more than just a new syntax and a few nifty features. It will probably end up impacting how I write code in general.

I am not going to take this project much further. The source is available on github if you want to check it out. The biggest missing feature is some smart way of detecting which gamepad you have and doing the proper mapping of controls.