Rust: compiling to WASM to make a browser-based game using canvas

February 27, 2026 [Programming, Rust, Tech, Videos]

I love writing Rust, and I love writing simple games, so let's combine the two by showing how to make a tiny game framework with these properties:

Set up

Before we start, we need to install:

Note: there is some confusion around documentation for WASM tools for Rust, with the newest tools sometimes linking to outdated documentation. For the purposes of this post, you can just install wasm-pack via the link above.

Want to watch me do this live? There's a video...

Follow along

You can watch me create this setup live here:

and you can find the full source code at: codeberg.org/andybalaam/wasm-game.

Follow me on mastodon: @andybalaam@mastodon.social

The first thing we need to do is create a Rust+WASM project.

Creating the project

wasm-pack provides us with a simple tool to create and build Rust code into WASM. It's a wrapper around standard cargo commands that sets things up the way we need.

To create the project, type:

wasm-pack new wasm-game
cd wasm-game

This creates a project with the right defaults set up:

$ tree
.
├── Cargo.toml
├── src
│   ├── lib.rs
│   └── utils.rs
└── tests
    └── web.rs
...

Have a look at Cargo.toml. It has the normal project info but it also adds a dependency on wasm-bindgen which allows us to call Rust functions from JavaScript, and JavaScript functions from Rust. It gives us console_error_panic_hook which allows us to print panic messages from Rust on the browser console, and it tells the compiler to optimise our WASM for size, which is usually the right choice.

Now let's look at src/lib.rs:

mod utils;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, wasm-game!");
}

The two highlighted lines containing #[wasm_bindgen] do two different things: the first one tells the compiler that an external function exists: a JavaScript function, despite the extern "C" part, which we want to call from within our Rust code. By adding this, we make it possible to call alert from Rust, as we do inside the greet function.

The second #[wasm_bindgen] line does the opposite: it says we want the greet function, which is written in Rust, to be callable from JavaScript code.

Now that we have some code, let's compile it to WASM so we can run it in the browser.

Compiling to WASM

Now we can compile all this Rust into WASM like this:

wasm-pack build --target=web

This creates some files inside the pkg directory:

$ ls -1 pkg
package.json
wasm_game_bg.wasm
wasm_game_bg.wasm.d.ts
wasm_game.d.ts
wasm_game.js
...

package.json is not relevant to us: it will help if we want to make an npm package, but we don't need that as we are writing code for the browser.

The .ts files are type information that people can use if they want to call our code from TypeScript. We're not doing that so we're not too interested.

What we are interested in is wasm_game_bg.wasm, which is the result of compiling our Rust code into WASM, and wasm_game.js, which provides a simple module we can import from JavaScript to run our code. If you open wasm_game.js there is loads of stuff in there, but the important part is that there is an exported function called greet, which is generated because we wrote a Rust function called greet and annotated it #[wasm_bindgen].

So we've got some WASM and some JavaScript, but how do we actually run it?

Running in the browser

We need to write some HTML. Let's make a directory called www for all our hand-written HTML, CSS and JavaScript, and create a file inside called index.html:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
    </head>
    <body>
        <script type="module">
            import init, { greet } from "./wasm_game.js";
            await init();
            greet();
        </script>
    </body>
</html>

This HTML file is mostly actually JavaScript inside a script tag, and what this code does is import the greet function from wasm_game.js, along with init, which we always have to call at the beginning of our program, and then it calls greet.

We want our HTML and our generated WASM and accompanying files in the same directory, so let's copy them into one called public.

mkdir -p public
cp www/* pkg/*.js pkg/*.ts pkg/*.wasm public/

Now we need a web server that serves up all the files inside public. I usually use the one that comes with Python but you can use any one you like:

cd public
python3 -m http.server

This starts a web server, listening on port 8000, that serves up the files in the public directory, which include index.html, wasm_game.js and our compiled WASM code in wasm_game_bg.wasm.

So if we open a web browser and go to http://0.0.0.0:8000/ we see an alert pop up that says "Hello, wasm-game".

Our Rust code has been compiled to WASM and is running in the browser! Have a cup of tea.

Creating a canvas

Now, to make an actual game, we're going to need to be able to draw graphics. A nice way to do that, especially for pixelated retro-games, is to use a canvas. Let's add one to index.html:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <link rel="stylesheet" href="style.css" />
    </head>
    <body>
        <canvas id="canvas" width="22" height="22"></canvas>

        <script type="module">
            import init, { greet } from "./wasm_game.js";
            await init();
            greet();
        </script>
    </body>
</html>

We've added a <canvas> tag, with an id of canvas, and a width and height in pixels - even when we stretch the canvas to fill the screen using CSS, it's only going to display 22x22 large scaled-up pixels, which will suit us very well. Speaking of CSS, we'd better write just enough to centre and scale that canvas to cover most of the screen. We create style.css inside www like this:

* {
    padding: 0px;
    margin: 0px;
}

body {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
}

#canvas {
    width: 90vmin;
    image-rendering: pixelated;
}

This makes the page body display using flex layout, which means we can centre the canvas inside it, and the width: 90vmin instructs the canvas to be 90% of the size of the shortest dimension of the screen. The image-rendering part tells the canvas not to blur any of its huge pixels to make graphics look smoother, but to draw them pixelated.

If your web server is still running, copy all the files into public again and refresh the browser page. The screen will still be blank, but if you use the development tools (press F12) you should be able to see the canvas within the page using the Inspector tab.

I guess we'd better draw something on the canvas!

Drawing onto the canvas

First, let's rename greet in our Rust code to start. We'll also need to update the places in index.html that refer to greet:

In src/lib.rs:

...

#[wasm_bindgen]
pub fn start() {
    set_panic_hook();

    alert("Hello, wasm-game!");
}

In www/index.html:

...
        <script type="module">
            import init, { start } from "./wasm_game.js";
            await init();
            start();
        </script>
...

Notice that we also added a call to set_panic_hook at the top of start - we'll need to import that from our utils module that was created by wasm-pack new. Setting a panic hook here means that if our Rust code panics, the panic message will be printed to the browser console, which is very handy.

Build the code and copy everything into public again:

wasm-pack build --target=web
cp www/* pkg/*.js pkg/*.ts pkg/*.wasm public/

Make sure the web server is still running, and refresh the browser. It should still nag you with the same "Hello" message.

Now we need to write some non-trivial code inside that start function.

First, we need to ask for the ability to call a load of browser-native functions from Rust. To do that, we'll add the web-sys crate as a dependency.

In Cargo.toml, just above [dev-dependencies] let's add this section:

[dependencies.web-sys]
features = [
    "CanvasRenderingContext2d",
    "Document",
    "Element",
    "Event",
    "HtmlCanvasElement",
    "KeyboardEvent",
    "Window",
    "console",
]
version = "0.3.88"

If this looks weird, it's equivalent to adding a single line inside the [dependencies] section that looks like web-sys = { version = "0.3.88", features = [... but it wraps much better onto multiple lines. (Re-arranging it this way is a feature of the TOML configuration language rather than cargo specifically.)

web-sys provides browser functions that we can call from Rust, but it separates everything into features, so if you want to use a particular browser feature, e.g. the Window object, you need to specify it in features. Most of the features, like Window, are very directly named after the object that you want to use. We've added all the features we will want immediately, to avoid messing around later.

I promised you some Rust code, so let's update our start function to draw something on the canvas:

#[wasm_bindgen]
pub fn start() {
    set_panic_hook();

    let window = window().unwrap();
    let document = window.document().unwrap();

    let canvas: HtmlCanvasElement = document
        .get_element_by_id("canvas")
        .unwrap()
        .dyn_into()
        .unwrap();

    let context: CanvasRenderingContext2d = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into()
        .unwrap();

    context.fill_rect(3.0, 2.0, 1.0, 1.0);
    context.fill_rect(4.0, 3.0, 1.0, 1.0);
}

Note that you'll need to add some imports at the top for this to compile - most of them come from the web-sys crate we just added.

This code grabs a reference to the browser window, then uses that to get a reference to the document being displayed. The document has a get_element_by_id method that we can use to find our canvas. If you're familiar with writing JavaScript in a browser, you will be used to spelling this getElementById. All the names are translated in this way into things that feel more familiar to Rust programmers.

Once we have our canvas, to actually draw on it we need a context (of type "2d"). Once we have a context, we can actually do some drawing operations.

If you type in this code and do the usual compile-and-copy routine, then refresh the browser, you should see some black squares being drawn on your canvas!

We've done almost enough to create a game: the most complicated parts are still to come, but at least they are mostly Rust code!

Listening for key presses

Let's listen for the user pressing arrow keys, and store the last direction they pressed in a Game struct.

First we'll create Game, and a supporting enum called Dir. You can put all this inside lib.rs or use separate modules if you prefer:

#[derive(Debug)]
pub enum Dir {
    Left,
    Up,
    Right,
    Down,
}

pub struct Game {
    dir: Dir,
}

impl Game {
    fn new() -> Self {
        Self { dir: Dir::Left }
    }

    fn on_keydown(&mut self, event: KeyboardEvent) {
        match event.key_code() {
            37 => self.dir = Dir::Left,
            38 => self.dir = Dir::Up,
            39 => self.dir = Dir::Right,
            40 => self.dir = Dir::Down,
            _ => {}
        }
        console::log_1(&format!("{:?}", self.dir).into());
    }
}

The interesting part here is the on_keydown method, which expects to receive a KeyboardEvent (another browser type provided by web-sys) and updates our Game to store the correct direction based on the key the user pressed. The key codes we've used are for the arrow keys.

Now that we have a Game that is ready to receive keyboard events, let's delete all that canvas code from start and add an event listener to provide them (we'll re-add the canvas code soon, elsewhere):

#[wasm_bindgen]
pub fn start() {
    set_panic_hook();

    let game = Arc::new(Mutex::new(Game::new()));

    let window = window().unwrap();

    let game_clone = Arc::clone(&game);
    let on_keydown: Closure = Closure::new(
        move |event: Event| {
            game_clone
                .lock()
                .unwrap()
                .on_keydown(event.dyn_into().unwrap())
        }
    );

    window
        .add_event_listener_with_callback(
            "keydown",
            on_keydown.as_ref().unchecked_ref()
        ).unwrap();

    on_keydown.forget();
}

First we great a new Game instance and wrap it in an Arc of a Mutex because it's going to be used by lots of potentially-concurrent code such as event handlers, so we need to be able to call lock to wait our turn to get access to it.

Then we create a Closure, which is a way of wrapping Rust closures so they can be called by JavaScript code. The closure that we wrap calls Game's on_keydown method and passes through the event it receives. Once we've made our Closure, we can convert it into a Function type (by calling .as_ref().unchecked_ref()), which is what we need to pass in to add_event_listener_with_callback. We are asking that every time a keydown event happens, our closure should be called.

Finally, we call forget on the Closure object, because otherwise it will get dropped by Rust when we leave this function, so it won't exist any more when it is called as a callback.

If you compile-and-copy and refresh the browser, then click in the main window and press some arrow keys, you should see some debug messages printed to the Console tab of the developer tools, saying which direction you are facing!

Now, if we're going to make a game, we will need a game loop.

A game loop with requestAnimationFrame

The nicest way to make a game loop in the browser is with a built-in function called requestAnimationFrame, which tries to call your code about 60 times per second. To use it, you have to re-call requestAnimationFrame from inside the callback that was called by it last frame. Doing that inside a Rust closure is slightly tricky, but here's a recipe you can paste into the start function after what is already there:

    let on_frame_refcell: Rc<RefCell<Option<Closure<dyn Fn(BigInt)>>>> =
        Rc::new(RefCell::new(None));

    let window_clone = window.clone();
    let on_frame_refcell_clone = Rc::clone(&on_frame_refcell);
    let callback = move |ts: BigInt| {
        game.lock().unwrap().on_frame(ts.unchecked_into_f64());

        window_clone
            .request_animation_frame(
                on_frame_refcell_clone
                    .borrow()
                    .as_ref()
                    .unwrap()
                    .as_ref()
                    .unchecked_ref(),
            )
            .unwrap();
    };
    let cb: Closure<dyn Fn(BigInt)> = Closure::new(callback);

    on_frame_refcell.replace(Some(cb));

    window
        .request_animation_frame(
            on_frame_refcell
                .borrow()
                .as_ref()
                .unwrap()
                .as_ref()
                .unchecked_ref(),
        )
        .unwrap();

If you want the detail on how all this works, I suggest watching the video at the top of this page. For now, suffice it to say that we have to hold the closure in a Rc<RefCell<_>> so that we can refer to it from inside itself.

For this to work we'll need to add quite a few imports, and also add one more method to Game:

    pub fn on_frame(&mut self, ts: f64) {
        console::log_1(&"on_frame called".into());
    }

If we do the usual compile-and-copy routine and refresh the browser, we should see the "on_frame called" message appearing in the Console section with a number next to it that shoots up as it is called 60 times per second.

We've almost got enough to make a game. Let's quickly add back some drawing to the canvas, and reduce the frame rate a bit.

Drawing every frame, and a lower frame rate

Inside Game's on_frame method, we are going to throttle back the frame rate, only doing anything every 400ms or so, and we're going to draw some stuff on the canvas. Hopefully this will demonstrate how a real game can work: inside the new step method, we expect we would run some game logic (that will probably update some properties that are held within Game), and then draw the outcome onto the canvas.

First, we need to supply the canvas to the Game, so inside start, let's get hold of the canvas and pass it in to new:

#[wasm_bindgen]
pub fn start() {
    // ...

    let canvas: HtmlCanvasElement = document
        .get_element_by_id("canvas")
        .unwrap()
        .dyn_into()
        .unwrap();

    let game = Arc::new(Mutex::new(Game::new(canvas)));

    // ...

and let's adapt Game to hold onto it:

pub struct Game {
    canvas: HtmlCanvasElement,
    dir: Dir,
    next_frame_ts: f64,
}

impl Game {
    pub fn new(canvas: HtmlCanvasElement) -> Self {
        Self {
            canvas,
            dir: Dir::Up,
            next_frame_ts: 0.0,
        }
    }
    // ...

Notice that while we were there, along with canvas, we also added next_frame_ts. We are going to use that to detect when 400ms have passed, so we should process another frame. This effectively slows the frame rate from 60 FPS to about 2.5 FPS:

    pub fn on_frame(&mut self, ts: f64) {
        if ts > self.next_frame_ts {
            self.step();
            self.next_frame_ts = ts + 400.0;
        }
    }

The ts argument increases as time goes on, so we wait until it's time for the next frame, and call step when it is. We'd better add a step method:

    fn step(&mut self) {
        console::log_1(&format!("Inside step dir={:?}", self.dir).into());

        let context: CanvasRenderingContext2d = self
            .canvas
            .get_context("2d")
            .unwrap()
            .unwrap()
            .dyn_into()
            .unwrap();

        context.fill_rect(3.0, 2.0, 1.0, 1.0);
        context.fill_rect(4.0, 3.0, 1.0, 1.0);
    }

If we compile-and-copy and refresh the browser, we can see that this prints a log entry in the Console tab a couple of times a second, and draws on the canvas as well.

This should be enough to get you going writing a little game in Rust that runs inside your browser canvas. Watch my later videos to see what games I come up with!

Can you make it draw something different every time step is called?