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:
- 99% of the code is Rust, compiled to WASM
- the game runs in any non-ancient Web browser
- the screen drawing is on a zoomed-in canvas so we can make pixelated retro-games
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?