Game Development with Rust and WebAssembly

By Eric Smith
    What do you get with a Packt Subscription?

  • Instant access to this title and 7,500+ eBooks & Videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. Chapter 1: Hello WebAssembly

About this book

The Rust programming language has held the most-loved technology ranking on Stack Overflow for 6 years running, while JavaScript has been the most-used programming language for 9 years straight as it runs on every web browser. Now, thanks to WebAssembly (or Wasm), you can use the language you love on the platform that's everywhere.

This book is an easy-to-follow reference to help you develop your own games, teaching you all about game development and how to create an endless runner from scratch. You'll begin by drawing simple graphics in the browser window, and then learn how to move the main character across the screen. You'll also create a game loop, a renderer, and more, all written entirely in Rust. After getting simple shapes onto the screen, you'll scale the challenge by adding sprites, sounds, and user input. As you advance, you'll discover how to implement a procedurally generated world. Finally, you'll learn how to keep your Rust code clean and organized so you can continue to implement new features and deploy your app on the web.

By the end of this Rust programming book, you'll build a 2D game in Rust, deploy it to the web, and be confident enough to start building your own games.

Publication date:
April 2022
Publisher
Packt
Pages
476
ISBN
9781801070973

 

Chapter 1: Hello WebAssembly

Let's cut to the chase – if you're holding this book, you probably already know you love Rust, and you think WebAssembly is a great way to deploy your Rust programs to the web. Good news – you're right! Rust and WebAssembly are a match made in programmer heaven, and while WebAssembly is still in its early days, game development is an ideal candidate for WebAssembly. I am excited to be guiding you through building a game for the web in Stack Overflow's "most-loved" language, Rust.

This chapter is all about equipping yourself with the tools for the game development journey. In this chapter, we'll cover the following topics:

  • What is WebAssembly?
  • Creating a Rust and WebAssembly project skeleton
  • Translating JavaScript code into Rust code
  • Drawing to the screen with HTML5 Canvas
 

Technical requirements

To follow along with the project skeleton, you'll need to install rustup to install the Rust toolchains. This can be found at https://rustup.rs/. While you can install Rust and its various toolchains without using the rustup tool, it's not trivial, and I won't be documenting it here. You'll also need an editor for writing Rust code, and while you can use virtually any editor with rust-analyzer, if you're new to writing Rust, I'd recommend Visual Studio Code and the Rust extension found at https://bit.ly/3tAUyH2. It's easy to set up and works right out of the box.

Finally, you'll need a web browser, and in this chapter, you'll need some familiarity with the terminal and Node.js. If you get stumped, the code for this chapter is available at https://github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly/tree/chapter_1. The final code for the entire book is in the main branch at https://github.com/PacktPublishing/Game-Development-with-Rust-and-WebAssembly.

Check out the following video to see the Code in Action: https://bit.ly/3qMV44E

 

What is WebAssembly?

You picked up this book (thanks!) so in all likelihood, you have some idea of what WebAssembly is, but just in case, let's grab a definition from https://WebAssembly.org:

"WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications."

In other words, Wasm is a binary format that we can compile other languages to so that we can run them in the browser. This is different than transpiling or source-to-source compiling, where languages such as TypeScript are converted into JavaScript for running in JavaScript environments. Those languages are still ultimately running JavaScript, whereas Wasm is bytecode. This makes it a smaller download and parsing and compiling steps are removed when running it, which can lead to significant performance improvements. But let's be honest – you're not using Rust and Wasm for the performance improvements, which aren't guaranteed anyway. You're using it because you like Rust.

And that's okay!

Rust has a great type system, excellent developer tooling, and a fantastic community. While WebAssembly was originally created with C and C++ in mind, Rust is a fantastic language for WebAssembly for all the reasons you love Rust and more. Now, for most of the web's existence, writing applications to run in a browser meant writing JavaScript, and over the years, JavaScript has evolved into a suitably modern language for that purpose. I'm not here to tell you that if you like JavaScript you should stop, but if you love Rust, you should absolutely start compiling to Wasm and running apps in the browser.

Important Note

This book is focused on making web-based games with Rust and Wasm, but you can absolutely run Wasm apps in server-side environments such as Node.js. If you're interested in that, you can check out the book Learn WebAssembly by Mike Rourke, which can be found at https://bit.ly/2N89prp, or the official wasm-bindgen guide at https://bit.ly/39WC63G.

Important Note

This book assumes some familiarity with Rust, although you do not need to be an expert. If at any time you're confused by a Rust concept, I highly encourage you to stop and check "the book", The Rust Programming Language, available for free at https://doc.rust-lang.org/book/.

So, now that I've convinced you to do what you were already going to do anyway, let's go over some of the tools you'll need to write a game for the web in Rust:

  • rustup: Most likely you're already using rustup if you're writing Rust code. If you're not, you should, as it's the standard way to install Rust. It allows for easy installations of toolchains, Rust compilers, and even launches the Rust documentation. You'll need it to install the Wasm toolchain, and you can install it from the previous link. The code in this book has been tested on Rust version 1.57.0.
  • Node.js: I know – I promised you that we'd be writing in Rust! We will, but this is still a web application and you'll be using Node.js to run the application. I recommend installing the current long-term support version (16.13.0 at the time of writing). Older versions of Node.js may not work with the package creation tools as expected. If you're using Ubuntu Linux, be especially cautious when using the Debian distribution, which installs a very old version at this time. When in doubt, use tools for managing multiple versions, such as the Node Version Manager (nvm) tool for Linux/Mac or the corresponding nvm-windows tool for Windows, to ensure that you're using the long-term release version. I use the asdf tool (https://asdf-vm.com/) for managing multiple versions myself, although I don't usually recommend it to people that haven't used a version management tool before.
  • webpack: We'll use webpack to bundle our application for release and run a development server. Most of the time, you won't have to worry about it, but it's there.

    Important Note

    The current template uses webpack 4. Make sure to check that when looking up documentation.

  • wasm-pack: This is a Rust tool for building Rust-generated WebAssembly code. Like webpack, most of the time you won't know it's there, as it's managed by webpack, and your Rust application will largely be managed by Rust build tools.
  • wasm-bindgen: This is one of the crates you'll need to get to know to write Rust-generated WebAssembly code. One limitation of WebAssembly is that you cannot access the Document Object Model (DOM) that represents a web page directly. Instead, WebAssembly programs need to call JavaScript functions to do that, requiring bindings and serializing data back and forth. What wasm-bindgen does is create those bindings and the boilerplate needed to call JavaScript functions from your Rust code, as well as provide tools to create bindings in the other direction so that JavaScript code can call back into the Rust code. We'll cover the details of how wasm-bindgen works as we go through the book, but to avoid getting bogged down in details right now, you can just think of it as a library to call JavaScript from your Rust code.
  • web-sys: This is a crate made up of many pre-generated bindings, using wasm-bindgen, for the web. We'll use web-sys to call browser APIs such as the canvas and requestAnimationFrame. This book assumes at least a passing familiarity with web development but doesn't require expertise in this area, and in fact, one of the advantages of game development in Rust is that we can just treat the browser as a platform library that we call functions on. The web-sys crate means we don't have to create all those bindings ourselves.
  • Canvas: HTML Canvas is a <canvas> browser element, such as headers or paragraphs, only it allows you to draw directly to it. This is how we can make a video game! There are many ways to draw to the canvas, including WebGL and WebGPU, but we're going to use the built-in Canvas API for most of this project. While this isn't the absolute fastest way of making a game, it's fast enough for learning purposes and avoids adding more technologies to our stack.

Finally, while googling web-sys, web-bindgen, or other Rust packages for WebAssembly, you are likely to come across references to cargo-web and stdweb. While both of those projects were important to the development of Rust as a WebAssembly source, neither has been updated since 2019 and can be safely ignored. Now that we know the tools we'll be using, let's start building our first Rust project.

 

A Rust project skeleton

Important Note

These directions are based on the status of rust-webpack-template at the time of writing. It's likely to have changed at the time of reading this, so pay close attention to the changes we are making. If they don't make sense, check the documents for wasm-pack and use your best judgment.

At this point, I'm going to assume you've installed rustup and Node.js. If you haven't, go ahead and follow the instructions for your platform to install them, and then follow these steps:

  1. Initialize the project

Let's start by creating a project skeleton for your application, which will be the Rust webpack Template from the Rust Wasm group. It's found on GitHub at https://github.com/rustwasm/rust-webpack-template, but you don't want to download it. Instead, use npm init to create it, like this:

mkdir walk-the-dog
cd walk-the-dog
npm init rust-webpack

You should see something like this:

npx: installed 17 in 1.941s
 🦀 Rust + 🕸 WebAssembly + Webpack = ❤
Installed dependencies ✅

Congratulations! You have created your project.

  1. Install dependencies

You can install the dependencies with npm:

npm install

Important Note

If you prefer to use yarn, you can, with the exception of the npm init command. I'll be using npm for this book.

  1. Run the server

After the installation completes, you can now run a development server with npm run start. You may see an error, like this:

ℹ  Installing wasm-pack
Error: Rust compilation.
at ChildProcess.<anonymous> (/walk-the-dog/node_modules/@wasm-tool/wasm-pack-plugin/plugin.js:221:16)
at ChildProcess.emit (events.js:315:20)
at maybeClose (internal/child_process.js:1048:16)
at Socket.<anonymous> (internal/child_process.js:439:11)
at Socket.emit (events.js:315:20)
at Pipe.<anonymous> (net.js:673:12)

If that happens, you'll need to install wasm-pack manually.

  1. Install wasm-pack

On Linux and macOS systems wasm-pack is installed with a simple cURL script:

curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

Windows users have a separate installer that can be found at https://rustwasm.github.io.

  1. Run the server – take two

Now that wasm-pack is installed, webpack can use it, and you should be able to run the app:

npm run start

When you see wdm: Compiled successfully. , you can browse your app at http://localhost:8080. Okay, yes, it's a blank page, but if you open the developer tools console, you should see the following:

Figure 1.1 – Hello WebAssembly!

Figure 1.1 – Hello WebAssembly!

You've got the application running in the browser, but the Rust ecosystem updates faster than the template you used can keep up.

  1. Update the Rust edition

The latest Rust edition, with the most recent Rust idioms and conventions, is 2021. This is changed in the generated Cargo.toml file in the package section, as shown here:

# You must change these to your own details.
[package]
name = "rust-webpack-template"
description = "Walk the Dog - the game for the Rust Games with WebAssembly book"
version = "0.1.0"
authors = ["Eric Smith <[email protected]>"]
categories = ["wasm"]
readme = "README.md"
edition = "2021"

It is only the edition field that is changed here.

  1. Update the dependencies

The dependencies in the generated Cargo.toml file are not going to be the latest and greatest unless you happened to pull the template down the moment it was updated. Since neither of us is that lucky, you're going to want to open up that file and modify the dependencies to the following. Please note that the ellipses are just there to mark a gap in the file and are not meant to be typed in:

wasm-bindgen = "0.2.78"
...
[dependencies.web-sys]
version = "0.3.55"
...
[dev-dependencies]
wasm-bindgen-test = "0.3.28"
futures = "0.3.18"
js-sys = "0.3.55"
wasm-bindgen-futures = "0.4.28"

Those are the versions I used while writing this book. If you're feeling adventurous, you can go to http://crates.io and find the most recent version of each dependency, which is what I would do, but I am a glutton for punishment. You're probably smarter than me and will use the versions specified here so that the sample code works.

  1. Update console_error_panic_hook

console_error_panic_hook is a very useful crate during the development of a WebAssembly application. It takes panics in Rust code and forwards them to the console so that you can debug them. The current template attempts to hide it behind a feature flag, but unfortunately, there's a bug and it doesn't work. Remember to double-check your generated code; if it doesn't look like what I've reproduced here, the bug may have been fixed, but in the meantime, delete the following code (still in Cargo.toml).

[target."cfg(debug_assertions)".dependencies]
console_error_panic_hook = "0.1.5"

Then add the to the [dependencies] section, under wasm-bindgen is a good spot:

console_error_panic_hook = "0.1.7"

Later, we'll make this a conditional dependency so that you don't deploy it during release builds, but for now, this is enough progress. Who wants to continue messing with config files anyway? I want to draw stuff to the screen!

Tip

While this application uses an npm init template to create itself, you can use its output to create a cargo generate template so that you don't have to redo these changes every time you create an application, simply by creating a git repository. Of course, if you do that, you'll fall behind changes to the rust-webpack template, so it's a trade-off. If you're curious about using cargo generate to create your own templates, you can find more information here: https://bit.ly/3hCFWTs.

 

Drawing to the canvas

To write our game in Rust, we're going to need to draw to the screen, and for that, we'll use the HTML Canvas element using the 2D context. What the canvas provides is an API for drawing directly to the screen, without knowledge of WebGL or using an external tool. It's not the fastest technology in the world but it's perfectly suitable for our small game. Let's start converting our Rust app from "Hello World" to an application that draws a Sierpiński triangle.

Important Note

The Sierpiński triangle is a fractal image that is created by drawing a triangle, then subdividing that triangle into four triangles, and then subdividing those triangles into four triangles, and so on. It sounds complicated but, as with many fractals, is created from only a few lines of math:

  1. Add the canvas

Canvas is an HTML element that lets us draw to it freely, making it an ideal candidate for games. Indeed, at the time of writing, Adobe Flash is officially dead, and if you see a game on the internet, be it 2D or 3D, it's running in a canvas element. Canvas can use WebGL or WebGPU for games, and WebAssembly will work quite well with those technologies, but they are out of the scope of this book. We'll be using the built-in Canvas 2D API and its 2D context. This means you won't have to learn a shading language, and we'll be able to get images on the screen very quickly. It also means that if you need to, you can find excellent documentation on the Mozilla Developer Network (MDN) Web Docs website: https://mzl.la/3tX5qPC.

To draw to the canvas, we'll need to add it to the web page. Open up static/index.html and add underneath <body> tag <canvas id="canvas" tabindex="0" height="600" width="600">Your browser does not support the canvas.</canvas>. The width and height are pretty arbitrary but seem appropriate for now. The "Your browser does not support the canvas." message will show up on browsers that don't support HTML Canvas, but there aren't many of those anymore.

Important Note

Make sure you don't delete the <script> tag. That's running the JavaScript and WebAssembly you're building in this project!

  1. Clean up errors

Finally, we get to write some Rust code! Well, we get to delete some Rust code anyway. In the src/lib.rs file, you'll see a function named main_js() with the following code:

// This provides better error messages in debug mode.
// It's disabled in release mode so it doesn't bloat 
   up the file size.
    #[cfg(debug_assertions)]
    console_error_panic_hook::set_once();

You can go ahead and remove the comments and the [cfg(debug_annotations)] annotation. For the time being, we'll leave that running in our build and will remove it when preparing for production with a feature flag.

Important Note

If you're seeing an error in your editor that says the console::log_1(&JsValue::from_str("Hello world!")) code is missing an unsafe block, don't worry – that error is wrong. Unfortunately, it's a bug in rust-analyzer that's been addressed in this issue: https://bit.ly/3BbQ39m. You'll see this error with anything that uses procedural macros under the hood. If you're using an editor that supports experimental settings, you may be able to fix the problem; check the rust-analyzer.experimental.procAttrMacros setting. When in doubt, check the output from npm run start, as that is the more accurate source for compiler errors.

Tip

If you diverge from this book and decide to deploy, go to Chapter 10, Continuous Deployment, and learn how to hide that feature behind a feature flag in release mode, so you don't deploy code you don't need into production.

Removing that code will remove the warning: Found 'debug_assertions' in 'target.'cfg(...)'.dependencies'. message on startup of the app. At this point, you may have noticed that I'm not telling you to restart the server after changes, and that's because npm start runs the webpack-dev-server, which automatically detects changes and then rebuilds and refreshes the app. Unless you're changing the webpack config, you shouldn't have to restart.

The current code

Up to now, I've been telling you what to do, and you've been blindly doing it because you're following along like a good reader. That's very diligent of you, if a little trusting, and it's time to take a look at the current source and see just what we have in our WebAssembly library. First, let's start with the use directives.

use wasm_bindgen::prelude::*;
use web_sys::console;

The first import is the prelude for wasm_bindgen. This brings in the macros you'll see shortly, and a couple of types that are pretty necessary for writing Rust for the web. Fortunately, it's not a lot, and shouldn't pollute the namespace too much.

Important Note

"Pollute the namespace" refers to what can happen when you use the '*' syntax and import everything from a given module. If the module has a lot of exported names, you have now those same names in your project, and they aren't obvious when you're coding. If, for instance, wasm_bindgen::prelude had a function named add in it and you also had a function named add in your namespace, they would collide. You can work around this by using explicit namespaces when you call the functions, but then why use * in the first place? By convention, many Rust packages have a module named prelude, which can be imported via * for ease of use; other modules should be imported with their full name.

The other import is web_sys::console, which brings in the console namespace from web_sys, which in turn mimics the console namespace in JavaScript. This is a good time to talk a little more in detail about what these two modules do. I've said it before but it probably bears repeating – wasm_bindgen provides the capability to bind JavaScript functions so you can call them in WebAssembly and to expose your WebAssembly functions to JavaScript. There's that language again, the one we're trying to avoid by writing Rust, but it can't be avoided because we're working in a browser.

In fact, one of the limitations of WebAssembly is that it cannot manipulate the DOM, which is a fancy way of saying that it can't change the web page. What it can do is call functions in JavaScript, which in turn do that work. In addition, JavaScript knows nothing about your WebAssembly types, so any data that is passed to a JavaScript object is marshaled into shared memory and then pulled back out by JavaScript so that it can turn it into something it understands. This is a LOT of code to write over and over again, and that is what the wasm-bindgen crate does for you. Later, we'll use it to bind our own custom bindings to third-party JavaScript code, but what about all the functions already built into the browser, such as console.log? That's where web-sys comes in. It uses wasm-bindgen to bind to all the functions in the browser environment so that you don't have to manually specify them. Think of it as a helper crate that says, "Yeah, I know you'll need all these functions so I created them for you."

So, to sum up, wasm-bindgen gives you the capability to communicate between WebAssembly and JavaScript, and web-sys contains a large number of pre-created bindings. If you're particularly interested in how the calls between WebAssembly and JavaScript work, check out this article by Lin Clark, which explains it in great detail, and with pictures: https://hacks.mozilla.org/2018/10/calls-between-javascript-and-webassembly-are-finally-fast-%F0%9F%8E%89/.

The wee allocator

After the use statements you'll see a comment block referring to the `wee_alloc` feature, which is a WebAssembly allocator that uses much less memory than the default Rust allocator. We're not using it, and it was disabled in the Cargo.toml file, so you can delete it from both the source code and Cargo.toml.

The main

Finally, we get to the main part of our program:

#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {

The wasm_bindgen(start) annotation exports main_js so that it can be called by JavaScript, and the start parameter identifies it as the starting point of the program. If you're curious, you can take a look at pkg/index_bg.wasm.d.ts to see what was generated by it. You'll also want to take note of the return value, Result, where the error type can be JsValue, which represents an object owned by JavaScript and not Rust.

At this point, you may start to wonder how you'll keep track of what's JavaScript and what's Rust, and I'd advise you to not worry too much about it right now. There's a lot of jargon popping up and there's no way you'll keep it all in your head; just let it swim around in there and when it comes up again, I'll explain it again. JsValue is just a representative JavaScript object in your Rust code.

Finally, let's look at the contents:

console_error_panic_hook::set_once();
// Your code goes here!
console::log_1(&JsValue::from_str("Hello world!"));
Ok(())

The first line sets the panic hook, which just means that any panics will be redirected to the web browser's console. You'll need it for debugging, and it's best to keep it at the beginning of the program. Our one line, our Hello World, is console::log_1(&JsValue::from_str("Hello world!"));. That calls the JavaScript console.log function, but it's using the version that's log_1 because the JavaScript version takes varying parameters. This is something that's going to come up again and again when using web-sys, which is that JavaScript supports varargs and Rust doesn't. So instead, many variations are created in the web-sys module to match the alternatives. If a JavaScript function you expect doesn't exist, then take a look at the Rust documents for web-sys (https://bit.ly/2NlRmOI) and see whether there are versions that are similar but built to account for multiple parameters.

Tip

A series of macros for several of the more commonly used functions (such as log) could solve this problem, but that's an exercise for the reader.

Finally, the function returns Ok(()), as is typical of Rust programs. Now that we've seen the generated code, let's break it down with our own.

Drawing a triangle

We've spent a lot of time digging into the code we currently have, and it's a lot to just write "Hello World" to the console. Why don't we have some fun and actually draw to the canvas?

What we're going to do is mimic the following JavaScript code in Rust:

canvas = window.document.getElementById("canvas")
context = canvas.getContext("2d")
context.moveTo(300, 0)
context.beginPath()
context.lineTo(0, 600)
context.lineTo(600, 600)
context.lineTo(300, 0)
context.closePath()
context.stroke()
context.fill()

This code grabs the canvas element we put in index.html, grabs its 2D context, and then draws a black triangle. One way to draw a shape on the context is to draw a line path, then stroke, and, in this case, fill it. You can actually see this in the browser using the web developer tools built into most browsers. This screenshot is from Firefox:

Figure 1.2 – A simple canvas triangle

Figure 1.2 – A simple canvas triangle

Let's do the same thing in our Rust program. You'll see that it's a little…different. Start with the quick addition of a use statement at the top:

use wasm_bindgen::JsCast;

Then, replace the existing main_js function with the following:

console_error_panic_hook::set_once();
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let canvas = document
    .get_element_by_id("canvas")
    .unwrap()
    .dyn_into::<web_sys::HtmlCanvasElement>()
    .unwrap();
let context = canvas
    .get_context("2d")
    .unwrap()
    .unwrap()
    .dyn_into::<web_sys::CanvasRenderingContext2d>()
    .unwrap();
context.move_to(300.0, 0.0); // top of triangle
context.begin_path();
context.line_to(0.0, 600.0); // bottom left of triangle
context.line_to(600.0, 600.0); // bottom right of triangle
context.line_to(300.0, 0.0); // back to top of triangle
context.close_path();
context.stroke();
context.fill();
Ok(())

There are a few differences that stand out, but at a glance, you may just feel like Rust code is a lot noisier than JavaScript code, and that's true. You might be inclined to say that it's less elegant or isn't as clean, but I'd say that's in the eye of the beholder. JavaScript is a dynamically typed language and it shows. It ignores undefined and null, and can just crash if any of the values are not present. It uses duck typing to call all the functions on the context, which means that if the function is present, it simply calls it; otherwise, it throws exceptions.

Rust code takes a very different approach, one that favors explicitness and safety but at the cost of the code having extra noise. In Rust, you have to be more explicit when calling methods on structs, hence the casting, and you have to acknowledge null or failed Result types, hence all the unwraps. I've spent years using dynamic languages, including JavaScript, and I like them a lot. I certainly liked them a lot better than writing in C++, which I find overly verbose without really granting some of the safety advantages, but I think that with some tweaks, we can make Rust code nearly as elegant as JavaScript without glossing over exceptions and results.

My rant aside, if you're still running the program, you'll notice one minor detail – the Rust code doesn't compile! This leads me to the first thing we'll need to cover when translating JavaScript code to Rust code.

web-sys and feature flags

The web-sys crate makes heavy use of feature flags to keep its size down. This means that every time you want to use a function and it doesn't exist, you'll need to check which feature flag it's tied to, which is in its documentation, and add it to Cargo.toml. Fortunately, this is well documented and easy enough to do; we don't even need to restart the server!

Looking at our errors, we should see the following:

error[E0425]: cannot find function 'window' in crate 'web_sys'
--> src/lib.rs:18:27
|
18 | let window = web_sys::window().unwrap();
|                           ^^^^^^ not found in 'web_sys'

There are a few more errors of the same kind, but what you see here is that window is not in the web_sys module. Now, if you check the documentation for the window function in web-sys at https://bit.ly/3ak3sAR, you'll see that, yes, it does exist, but there is the This API requires the following crate features to be activated: Window message.

Open the cargo.toml file and look for dependencies.web-sys. You'll see that it has a features entry with just ["console"] in it; go ahead and add "Window", "Document", "HtmlCanvasElement", "CanvasRenderingContext2d", and "Element" to that list. To be clear, you don't need all those feature flags for just the window function; that's all of the functions we're using.

You'll notice the project will rebuild automatically and should build successfully. If you look in the browser, you'll see your own black triangle! Let's extend it and learn a bit more about how we did it.

Tip

When a function you expect to exist on web-sys doesn't, go and check the feature flags in the documents.

DOM interaction

You'll notice that the method for drawing the triangle after you get the context looks essentially the same as the method in JavaScript – draw a line path, stroke, and fill it. The code at the top that interacted with the DOM looks…different. Let's break down what's going on here:

  • Unwrapping option

Getting the Window is just a function in the web-sys crate, one you enabled when you added the Window feature to Cargo.toml. However, you'll notice it's got unwrap at the end:

let window = web_sys::window().unwrap();

In JavaScript, window can be null or undefined, at least theoretically, and in Rust, this gets translated into Option<Window>. You can see that unwrap is applied to the result of window(), document(), and get_element_by_id() because all of them return Option<T>.

  • dyn_into

What the heck is dyn_into? Well, this oddity accounts for the difference between the way JavaScript does typing and the way Rust does. When we retrieve the canvas with get_element_by_id, it returns Option<Element>, and Element does not have any functions relating to the canvas. In JavaScript, you can use dynamic typing to assume the element has the get_context method, and if you're wrong, the program will throw an exception. This is anathema to Rust; indeed, this is a case where one developer's convenience is another developer's potential bug in hiding, so in order to use Element, we have to call the dyn_into function to cast it into HtmlCanvasElement. This method was brought into scope with the `use wasm_bindgen::JsCast` declaration.

Important Note

Note that HtmlCanvasElement, Document, and Element were all also feature flags you had to add in web-sys.

  • Two unwraps?

After calling get_context("2d"), we actually call unwrap twice; that's not a typo. What's going on is that get_context returns a Result<Option<Object>>, so we unwrap it twice. This is another case where the game can't recover if this fails, so unwrap is okay, but I wouldn't complain if you replaced those with expect so that you can give a clearer error message.

A Sierpiński triangle

Now let's have some real fun, and draw a Sierpiński triangle a few levels deep. If you're up for a challenge, you can try and write the code yourself before following along with the solution presented here. The way the algorithm works is to draw the first triangle (the one you are already drawing) and then draw another three triangles, where the first triangle has the same top point but its other two points are at the halfway point on each side of the original triangle. Then, draw a second triangle on the lower left, with its top at the halfway point of the left side, its lower-right point at the halfway point of the bottom of the original triangle, and its lower-left point at the lower-left point of the original triangle. Finally, you create a third triangle in the lower-right corner of the original triangle. This leaves a "hole" in the middle shaped like an upside-down triangle. This is much easier to visualize than it is to explain, so how about a picture?

Figure 1.3 – A one-level Sierpiński triangle

Figure 1.3 – A one-level Sierpiński triangle

Each of the numbered triangles was one that was drawn. The upside-down blue triangle is what's left of the original triangle because we didn't draw over it.

So that's one triangle subdivided into four. Now, the algorithm works recursively, taking each triangle and subdividing again. So, two levels deep, it looks like this:

Figure 1.4 – A two-level Sierpiński triangle

Figure 1.4 – A two-level Sierpiński triangle

Note that it doesn't subdivide the upside-down triangle in the center, just the three purple ones that you created. Indeed, all the triangles with their points down are just "happy accidents" that make the shape look cool. You now know enough at this point to draw your own Sierpiński triangle, with one catch – you should remove the fill statement on context. Otherwise, all the triangles will be filled black and you won't be able to see them. Go ahead and give it a try.

Drawing the Sierpiński triangle

So, did you give it a try? No, I wouldn't either; I guess we have a lot in common. To get started with creating a Sierpiński triangle, let's replace the hardcoded triangle with a triangle function. Here's the first pass at draw_triangle:

fn draw_triangle(context: &web_sys::CanvasRenderingContext2d,     points: [(f64, f64); 3]) {
        let [top, left, right] = points;
        context.move_to(top.0, top.1);
        context.begin_path();
        context.line_to(left.0, left.1);
        context.line_to(right.0, right.1);
        context.line_to(top.0, top.1);
        context.close_path();
        context.stroke();
}

There are a couple of small changes from the hard-coded version that we started with. The function takes a reference to the context and a list of three points. Points themselves are represented by tuples. We've also gotten rid of the fill function, so we only have an empty triangle. Replace the inline draw_triangle with the function call, which should look like this:

let context = canvas
    .get_context("2d")
    .unwrap()
    .unwrap()
    .dyn_into::<web_sys::CanvasRenderingContext2d>()
    .unwrap();
draw_triangle(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)]);

Now that you're drawing one empty triangle, you're ready to start drawing the recursive triangles. Rather than starting with recursion, let's draw the first subdivision by drawing three more triangles. The first will have the same top point and two side points:

draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0), (450.0, 300.0)]);

Note that the third tuple has an x halfway between 300.0 and 600.0, not between 0 and 600.0, because the top point of the triangle is halfway between the other two points. Also note that y gets larger as you go down, which is upside-down compared to many 3D systems. Now, let's add the lower-left and lower-right triangles:

draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0), (300.0, 600.0)]);
draw_triangle(&context, [(450.0, 300.0), (300.0, 600.0), (600.0, 600.0)]);

Your triangles should look like this:

Figure 1.5 – Your triangles

Figure 1.5 – Your triangles

You will start to see a pattern at this point, and we can begin to turn our hardcoded triangles into an algorithm. We'll create a function called sierpinski that takes the context, the triangle dimensions, and a depth function so that we only draw as many triangles as we want, instead of drawing them to infinity and crashing the browser. Then, we'll move those functions we called into that function:

fn sierpinski(context: &web_sys::CanvasRenderingContext2d, points: [(f64, f64); 3], depth: u8) {
    draw_triangle(&context, [(300.0, 0.0), (0.0, 600.0), 
     (600.0, 600.0)]);
    draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0), 
     (450.0, 300.0)]);
    draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0), 
     (300.0, 600.0)]);
    draw_triangle(&context, [(450.0, 300.0), (300.0, 
     600.0), (600.0, 600.0)]);
}

This function currently ignores everything except the context, but you can replace those four draw_triangle calls from main_js and replace them with a call to sierpinski:

sierpinski(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)], 2);

It's important that you only send a depth of 2 for now so that the image will continue to look the same as we progress. Think of this call as a proto-unit test, guaranteeing our behavior doesn't change while we refactor. Now, in sierpinski, take the first triangle and have it use the passed-in points:

fn sierpinski(context: &web_sys::CanvasRenderingContext2d, points: [(f64, f64); 3], depth: u8) {
    draw_triangle(&context, points);
    ...

Then, after drawing the triangle, reduce the depth by one and see if it is still greater than 0. Then, draw the rest of the triangles:

...
let depth = depth - 1;
if depth > 0 {
    draw_triangle(&context, [(300.0, 0.0), (150.00, 300.0), 
     (450.0, 300.0)]);
    draw_triangle(&context, [(150.0, 300.0), (0.0, 600.0), 
     (300.0, 600.0)]);
    draw_triangle(&context, [(450.0, 300.0), (300.0, 
     600.0), (600.0, 600.0)]);
}

Now, to complete the recursion, you can replace all those draw_triangle calls with calls into sierpinski:

if depth > 0 {
    sierpinski(
        &context,
        [(300.0, 0.0), (150.00, 300.0), (450.0, 300.0)],
        depth,
    );
    sierpinski(
        &context,
        [(150.0, 300.0), (0.0, 600.0), (300.0, 600.0)],
        depth,
    );
    sierpinski(
        &context,
        [(450.0, 300.0), (300.0, 600.0), (600.0, 600.0)],
        depth,
    );
    }

So far so good – you should still see a triangle subdivided into four triangles. Finally, we can actually calculate the midpoints of each line on the original triangle and use those to create the recursive triangles, instead of hardcoding them:

let [top, left, right] = points;
if depth > 0 {
    let left_middle = ((top.0 + left.0) / 2.0, (top.1 + 
     left.1) / 2.0);
    let right_middle = ((top.0 + right.0) / 2.0, (top.1 + 
     right.1) / 2.0);
    let bottom_middle = (top.0, right.1);
    sierpinski(&context, [top, left_middle, right_middle], 
     depth);
    sierpinski(&context, [left_middle, left, 
     bottom_middle], depth);
    sierpinski(&context, [right_middle, bottom_middle, 
     right], depth);
}

Calculating the midpoint of a line segment is done by taking the x and y coordinates of each end, adding those together, and then dividing them by two. While the preceding code works, let's make it clearer by writing a new function, as shown here:

fn midpoint(point_1: (f64, f64), point_2: (f64, f64)) -> (f64, f64) {
    ((point_1.0 + point_2.0) / 2.0, (point_1.1 + point_2.1) 
    / 2.0)
}

Now, we can use that in the preceding function, for clarity:

if depth > 0 {
    let left_middle = midpoint(top, left);
    let right_middle = midpoint(top, right);
    let bottom_middle = midpoint(left, right);
    sierpinski(&context, [top, left_middle, right_middle], 
     depth);
    sierpinski(&context, [left_middle, left, 
     bottom_middle], depth);
    sierpinski(&context, [right_middle, bottom_middle, 
     right], depth);
}

If you've been following along, you should make sure you're still showing a triangle with four inside to ensure you haven't made any mistakes. Now for the big reveal – go ahead and change the depth to 5 in the original Sierpinski call:

sierpinski(&context, [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)], 5);

You should see a recursive drawing of triangles, like this:

Figure 1.6 – A recursive drawing of triangles

Figure 1.6 – A recursive drawing of triangles

Looking good! But what about those colors we saw in the original diagrams? They make it much more interesting.

When libraries aren't compatible

The earlier examples of this image had the triangles filled in with a different random color at each recursive layer. So, the first triangle was one color, three and four were another, the next nine another, and so on. It makes for a more interesting image and it provides a good example of what to do when a library isn't completely WebAssembly-compatible.

To create a random color, we'll need a random number generator, and that is not part of the standard library but instead found in a crate. You can add that crate by changing the Cargo.toml file to include it as a dependency:

console_error_panic_hook = "0.1.7"
rand = "0.8.4"

When you do this, you'll get a compiler error that looks like the following (although your message may differ slightly):

error: target is not supported, for more information see: https://docs.rs/getrandom/#unsupported-targets
   --> /usr/local/cargo/registry/src/github.com-
   1ecc6299db9ec823/getrandom-0.2.2/src/lib.rs:213:9
    |
213 | /         compile_error!("target is not supported, for more information see: \
214 | |                         https://docs.rs/getrandom/#unsupported-targets");

This is a case where a transitive dependency, in this case getrandom, does not compile on the WebAssembly target. In this case, it's an extremely helpful error message, and if you follow the link, you'll get the solution in the documentation. Specifically, you need to enable js in the feature flags for getrandom. Go back to your Cargo.toml file and add the following:

getrandom = { version = "0.2.3", features = ["js"] }

This adds the getrandom dependency with the js feature enabled, and your code will begin compiling again. The lesson to take away from this is that not every Rust crate will compile on the WebAssembly target, and when that happens, you'll need to check the documents.

Tip

When a crate won't compile slowly, read the error message and follow the instructions. It's very easy to skim right over the reason the build is breaking, especially when you're frustrated.

Random colors

Now that we've got the random create building with our project, let's change the color of the triangles as we draw them to a random color. To do that, we'll set fillStyle with a color before we draw the triangle, and we'll add a fill command. This is, generally, how the Context2D API works. You set up the state of the context and then execute commands with that state set. It takes a little getting used to but you'll get the hang of it. Let's add color as a parameter of the three u8 tuples to draw_triangle:

fn draw_triangle(
    context: &web_sys::CanvasRenderingContext2d,
    points: [(f64, f64); 3],
    color: (u8, u8, u8),
) {

Important Note

Colors are represented here as three components, red, green, and blue, where each value can go from 0 to 255. We're using tuples in this chapter because we can make progress quickly, but if it's starting to bother you, you're welcome to make proper structs.

Now that draw_triangle needs a color, our application doesn't compile. Let's move to the sierpinski function and pass a color to it as well. We're going to send the color to the sierpinski function, instead of generating it there, so that we can get one color at every level. The first generation will be one solid color, then the second will all be one color, and then the third a third color, and so on. So let's add that:

fn sierpinski(
    context: &web_sys::CanvasRenderingContext2d,
    points: [(f64, f64); 3],
    color: (u8, u8, u8),
    depth: u8,
) {
    draw_triangle(&context, points, color);
    let depth = depth - 1;
    let [top, left, right] = points;
    if depth > 0 {
        let left_middle = midpoint(top, left);
        let right_middle = midpoint(top, right);
        let bottom_middle = midpoint(left, right);
        sierpinski(&context, [top, left_middle, 
         right_middle], color, depth);
        sierpinski(&context, [left_middle, left, 
         bottom_middle], color, depth);
        sierpinski(&context, [right_middle, bottom_middle, 
         right], color, depth);
    }
}

I put color as the third parameter and not the fourth because I think it looks better that way. Remember to pass color to the other calls. Finally, so that we can compile, we'll send a color to the initial sierpinski call:

sierpinski(
    &context,
    [(300.0, 0.0), (0.0, 600.0), (600.0, 600.0)],
    (0, 255, 0),
    5,
);

Since this is an RGB color, (0, 255, 0) represents green. Now, we've made our code compile, but it doesn't do anything, so let's work back downward from the original call and into the sierpinski function again. Instead of just passing the color through, let's create a new tuple that has a random number for each component. You'll need to add use rand::prelude::*; to the use declarations at the top. Then, add the following code to the sierpinski function, after the if depth > 0 check:

let mut rng = thread_rng();
let next_color = (
    rng.gen_range(0..255),
    rng.gen_range(0..255),
    rng.gen_range(0..255),
);
...
sierpinski(
    &context,
    top, left_middle, right_middle],
    next_color,
    depth,
);
sierpinski(
    &context,
    [left_middle, left, bottom_middle],
    next_color,
    depth,
);
sierpinski(
    &context,
    [right_middle, bottom_middle, right],
    next_color,
    depth,
);

Inside the depth check, we randomly generate next_color and then pass it along to all the recursive sierpinski calls. But of course, our output still doesn't look any different. We never changed draw_triangle to change the color! This is going to be a little weird because the context.fillStyle property takes DOMString in JavaScript, so we'll need to do a conversion. At the top of draw_triangle, add two lines:

let color_str = format!("rgb({}, {}, {})", color.0, color.1, color.2);
context.set_fill_style(&wasm_bindgen::JsValue::from_str(&color_str));

On line one, we convert our tuple of three unsigned integers to a string reading "rgb(255, 0, 255)", which is what the fillStyle property expects. On the second line, we use set_fill_style to set it, doing that funky conversion. There are two things that you need to understand with this function. The first is that, generally, JavaScript properties are just public and you set them, but web-sys generates getter and setter functions. The second is that these generated functions frequently take JsValue objects, which represent an object owned by JavaScript. Fortunately, wasm_bindgen has factory functions for these, so we can create them easily and use the compiler as our guide.

Tip

Whenever you translate from JavaScript code to Rust, make sure that you check the documentation of the corresponding functions to see what types are needed. Passing a string to JavaScript isn't always as simple as you might think.

Finally, we actually need to fill the triangles to see those colors, so after context.stroke(), you need to restore that context.fill() method you deleted earlier, and ta-da!

Figure 1.7 – Filled triangles

Figure 1.7 – Filled triangles

You've done it, and you're ready to start creating a real game.

 

Summary

In this chapter, we've done a lot. We've written our first WebAssembly app using Rust, moving from "Hello World" to drawing in the browser with HTML Canvas. You've added crates, run a development server, and interacted with the DOM. You've learned a lot about interacting with the browser, including the following:

  • Creating the main entry point with #[wasm_bindgen(start)]
  • Translating JavaScript code to Rust code
  • Dealing with crates that compile to JavaScript

You've also been introduced to HTML Canvas. Frankly, it's been a bit of a whirlwind, so don't worry if some information flew over your head, as we'll cover many of these topics again – including in the next chapter, where we'll start drawing sprites.

About the Author

  • Eric Smith

    Eric Smith is a software crafter with over 20 years of software development experience. Since 2005, he's worked at 8th Light, where he consults for companies big and small by delivering software, mentoring developers, and coaching teams. He's a frequent speaker at conferences speaking on topics such as educating developers and test-driven development, and holds a master's degree in video game development from DePaul University. Eric wrote much of the code for this book live on his Twitch stream. When he's not at the computer, you can find Eric running obstacle races and traveling with his family.

    Browse publications by this author
Game Development with Rust and WebAssembly
Unlock this book and the full library FREE for 7 days
Start now