Book Image

Game Development with Rust and WebAssembly

By : Eric Smith
Book Image

Game Development with Rust and WebAssembly

By: Eric Smith

Overview of 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.
Table of Contents (16 chapters)
1
Part 1: Getting Started with Rust, WebAssembly, and Game Development
4
Part 2: Writing Your Endless Runner
11
Part 3: Testing and Advanced Tricks

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.