Skip to content

Project 3: WASM App (Conway's Game of Life)

32 min read

This project is the canonical “hello world” of Rust + WebAssembly: Conway’s Game of Life, rendered to an HTML <canvas> and running entirely in the browser. The simulation logic, the canvas drawing, and even the random board seeding all live in Rust compiled to a .wasm module; a few dozen lines of JavaScript glue wire it to the DOM.

If you have written a Game of Life in JavaScript before — a Uint8Array of cells, a requestAnimationFrame loop, ctx.fillRect calls — you already know the shape of this app. The interesting part is the seam: how a Rust struct becomes a JavaScript class, how the browser reads Rust’s memory with zero copying, and what the wasm-pack toolchain generates for you. We build the whole thing and contrast each piece with how you’d do it in plain Node/browser JavaScript.

This mirrors the kind of frontend you might build with a TypeScript class plus the Canvas API — except the hot loop (the cellular-automaton rules) is compiled, statically typed Rust instead of interpreted JavaScript.

A single-page app that:

  • Renders a 64x48 grid of cells to a <canvas>, repainting at ~60 fps.
  • Evolves the board one generation per frame using Conway’s four rules.
  • Has Pause / Step / Randomize / Clear buttons and an FPS meter.
  • Lets you click a cell to toggle it, or Ctrl/Cmd-click to stamp a glider.
  • Ships as a ~31 KB .wasm file plus generated JavaScript glue.

Here is the app running (live blinkers, blocks, and gliders evolving on the grid, FPS counter underneath):

Conway's Game of Life — Rust + WebAssembly
[Pause] [Step] [Randomize] [Clear]
┌───────────────────────────────────────────┐
│ ▪ ▪▪ ▪ ▪ ▪▪ │
│ ▪▪▪ ▪ ▪▪ │
│ ◇ ▪▪▪ ◇ │
│ ▪▪ ◇ ▪▪▪ ▪▪ │
│ ▪▪▪ ◇ ▪▪ │
└───────────────────────────────────────────┘
60.1 fps

The heart of the app — the rule that decides whether a cell lives or dies — is this match in Rust:

// src/universe.rs (excerpt)
self.next[idx] = match (cell, live_neighbors) {
(Cell::Alive, n) if n < 2 => Cell::Dead, // underpopulation
(Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive, // survival
(Cell::Alive, n) if n > 3 => Cell::Dead, // overpopulation
(Cell::Dead, 3) => Cell::Alive, // reproduction
(state, _) => state,
};

This project builds directly on the WebAssembly section and a handful of core chapters:

You will need the Rust toolchain, the wasm target, and wasm-pack:

Terminal window
rustup target add wasm32-unknown-unknown
cargo install wasm-pack # or: brew install wasm-pack

This guide was built and verified with Rust 1.96.0 (2024 edition), wasm-pack 0.13, wasm-bindgen 0.2.122, and web-sys 0.3.99, against Node v22.

wasm-app-code/
├── Cargo.toml # crate metadata; cdylib + rlib; release size opts
├── src/
│ ├── lib.rs # crate root: modules, #[wasm_bindgen(start)], version()
│ ├── cell.rs # the Cell enum (Dead/Alive) with #[repr(u8)]
│ ├── universe.rs # the Universe board + Conway's rules + native tests
│ ├── render.rs # CanvasRenderer: draws a Universe to a 2D canvas
│ └── utils.rs # panic hook + a console `log!` macro
├── tests/
│ └── web.rs # headless-browser tests (wasm-bindgen-test)
├── www/
│ ├── index.html # the page: canvas + control buttons
│ ├── index.js # JS glue: load wasm, wire controls, run the loop
│ └── life-plain-js.js # a plain-JS Game of Life, for contrast only
└── pkg/ # GENERATED by `wasm-pack build` (gitignored)
├── game_of_life_bg.wasm # the compiled WebAssembly module (~31 KB)
├── game_of_life.js # the JS loader + class bindings
└── game_of_life.d.ts # TypeScript types for the exported API

The Rust source is split the way you would split a TypeScript project into modules: one file per concept. Only items marked #[wasm_bindgen] cross into JavaScript; everything else (the neighbor-counting helper, the tests) stays private to Rust.

A WebAssembly library is a cdylib (a C-style dynamic library) — that is the crate type that produces a standalone .wasm artifact. We also add rlib so the same code can be compiled normally for native cargo test.

Cargo.toml
# Detach this crate from any parent Cargo workspace in the guide repo.
[workspace]
[package]
name = "game-of-life"
version = "0.1.0"
edition = "2024"
description = "Conway's Game of Life compiled to WebAssembly with wasm-bindgen"
license = "MIT OR Apache-2.0"
[lib]
# `cdylib` produces the .wasm artifact that wasm-pack/JS loads.
# `rlib` lets us run native `cargo test` against the same code.
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2.122"
js-sys = "0.3.99"
web-sys = { version = "0.3.99", features = [
"Window",
"Document",
"Element",
"HtmlElement",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"Performance",
"Node",
"Text",
"console",
] }
# Pretty Rust panics in the browser devtools console (opt-in via init()).
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen-test = "0.3.49"
# Optimize the release .wasm for size — important for download time.
[profile.release]
opt-level = "s" # optimize for size (try "z" for even smaller)
lto = true

A few things worth calling out for a JavaScript developer:

  • web-sys features are opt-in. web-sys is an enormous crate that mirrors every browser Web API, but each interface (Window, CanvasRenderingContext2d, …) is gated behind a Cargo feature so you only compile the bindings you actually use. This is the Rust equivalent of tree-shaking — except you declare the surface up front. Forgetting a feature is the most common beginner error; the fix is always “add the type to this list.” See Web APIs.
  • js-sys gives you the JavaScript built-ins that aren’t part of the DOM — here we use js_sys::Math::random() instead of pulling in a Rust RNG crate.
  • [profile.release] with opt-level = "s" and lto = true shrinks the .wasm. Download size is to a Wasm app what bundle size is to a JavaScript app.
  • The empty [workspace] table makes this crate self-contained so it does not get pulled into the surrounding guide repository’s Cargo setup.

Cargo’s cargo add writes these version requirements as caret ranges ("0.2.122" means >=0.2.122, <0.3.0), the same semantics as npm’s ^0.2.122.

A cell is dead or alive. In TypeScript you might write type Cell = 0 | 1 or a string-enum; in Rust we use a real enum with an explicit numeric representation.

src/cell.rs
//! The state of a single cell on the board.
use wasm_bindgen::prelude::*;
/// A cell is either `Dead` or `Alive`.
///
/// We give it an explicit `u8` representation so the values are stable across
/// the JS/Wasm boundary and so we can do arithmetic on them when counting live
/// neighbors (a dead cell counts as 0, a live one as 1).
///
/// In TypeScript you might model this as `type Cell = 0 | 1` or an enum; here
/// the `#[repr(u8)]` makes the Rust enum behave like that union but with real
/// type-checking and pattern-matching.
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
Dead = 0,
Alive = 1,
}
impl Cell {
/// Flip a cell between alive and dead. Used when the user clicks a cell.
pub fn toggle(&mut self) {
*self = match *self {
Cell::Dead => Cell::Alive,
Cell::Alive => Cell::Dead,
};
}
}

The #[repr(u8)] does double duty: it pins Dead = 0 and Alive = 1 so each cell occupies exactly one byte in memory (which lets JavaScript read the buffer as a Uint8Array), and it means we can write count += cell as u8 to tally live neighbors. #[wasm_bindgen] on the enum exports it to JavaScript as a real Cell enum (you’ll see it in the generated .d.ts). See enums for the language details.

This is the core. It is plain Rust — no DOM, no canvas — which is exactly why it is easy to test. The board is a single flat Vec<Cell> indexed by row * width + col, plus a reusable scratch buffer so a tick never allocates.

// src/universe.rs (the struct and its constructor)
/// The toroidal Game of Life board.
///
/// Cells are stored row-major in a single flat `Vec<Cell>` (index = `row *
/// width + column`) rather than a `Vec<Vec<Cell>>`. A flat buffer is one
/// contiguous allocation — cache-friendly and, crucially, it can be handed to
/// JavaScript as a raw pointer + length with zero copying (see [`Self::cells`]).
#[wasm_bindgen]
pub struct Universe {
width: u32,
height: u32,
cells: Vec<Cell>,
// Scratch buffer reused every tick so we don't allocate on each frame.
next: Vec<Cell>,
}
#[wasm_bindgen]
impl Universe {
/// Build a `width` x `height` board seeded with a classic deterministic
/// pattern (every cell where `i % 2 == 0 || i % 7 == 0` starts alive).
/// Deterministic seeding makes the first frame reproducible across runs.
#[wasm_bindgen(constructor)]
pub fn new(width: u32, height: u32) -> Universe {
let len = (width * height) as usize;
let cells = (0..len)
.map(|i| {
if i % 2 == 0 || i % 7 == 0 {
Cell::Alive
} else {
Cell::Dead
}
})
.collect();
Universe {
width,
height,
cells,
next: vec![Cell::Dead; len],
}
}

#[wasm_bindgen(constructor)] is what lets JavaScript write new Universe(64, 48) — the attribute maps a Rust associated function to a JS class constructor. In Node you’d write a class Universe { constructor(width, height) { … } }; here the struct plus its impl block plays that role.

The evolution step applies Conway’s rules to every cell, writing into the scratch buffer, then swaps the two buffers:

// src/universe.rs (the tick)
/// Advance the simulation by one generation, applying Conway's four rules.
pub fn tick(&mut self) {
for row in 0..self.height {
for col in 0..self.width {
let idx = self.get_index(row, col);
let cell = self.cells[idx];
let live_neighbors = self.live_neighbor_count(row, col);
// Conway's rules. `match` on a tuple expresses them as a
// truth table — far clearer than a chain of `if`s.
self.next[idx] = match (cell, live_neighbors) {
// Underpopulation: a live cell with < 2 neighbors dies.
(Cell::Alive, n) if n < 2 => Cell::Dead,
// Survival: a live cell with 2 or 3 neighbors lives on.
(Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
// Overpopulation: a live cell with > 3 neighbors dies.
(Cell::Alive, n) if n > 3 => Cell::Dead,
// Reproduction: a dead cell with exactly 3 neighbors is born.
(Cell::Dead, 3) => Cell::Alive,
// Everything else keeps its state.
(state, _) => state,
};
}
}
// Swap the buffers: `next` becomes current, the old current becomes the
// scratch buffer for the following tick. No allocation, no copy.
std::mem::swap(&mut self.cells, &mut self.next);
}

Two things a JavaScript developer should appreciate here:

  1. The match is exhaustive and checked. The compiler guarantees every (Cell, neighbor_count) combination is handled; you cannot fall through a switch by forgetting a break. Tuple patterns with guards (if n < 2) read like the rule table you’d find on Wikipedia. (See match.)
  2. std::mem::swap is free. In JavaScript [this.cells, this.next] = [this.next, this.cells] swaps two references; in Rust swap exchanges the two Vec headers (pointer + length + capacity) without touching the heap. No garbage, no reallocation per frame — this is a big part of why the Wasm version stays at a steady frame rate.

The methods JavaScript calls from the UI — toggling a cell, clearing, randomizing, stamping a glider — are all #[wasm_bindgen]:

// src/universe.rs (the JS-facing helpers)
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
/// Return a raw pointer to the cell buffer. JavaScript reads the cells
/// directly out of the Wasm linear memory with this pointer + `width *
/// height` — no per-cell function calls, no serialization. This is the
/// key trick that makes the Rust version fast.
pub fn cells(&self) -> *const Cell {
self.cells.as_ptr()
}
/// Toggle a single cell (called when the user clicks the canvas).
pub fn toggle_cell(&mut self, row: u32, col: u32) {
let idx = self.get_index(row, col);
self.cells[idx].toggle();
}
/// Kill every cell. Useful for drawing your own patterns from scratch.
pub fn clear(&mut self) {
for cell in self.cells.iter_mut() {
*cell = Cell::Dead;
}
}
/// Reseed the whole board randomly. Uses `Math.random()` from JavaScript
/// (via `js_sys`) so we don't need a Rust RNG crate in the Wasm build.
pub fn randomize(&mut self) {
for cell in self.cells.iter_mut() {
*cell = if js_sys::Math::random() < 0.5 {
Cell::Alive
} else {
Cell::Dead
};
}
}
/// Stamp a glider (a small pattern that walks across the board) with its
/// top-left corner at (`row`, `col`). Nice for the "Insert glider" button.
pub fn insert_glider(&mut self, row: u32, col: u32) {
// Relative coordinates of the canonical glider, then set them alive.
const GLIDER: [(u32, u32); 5] = [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)];
for (dr, dc) in GLIDER {
let r = (row + dr) % self.height;
let c = (col + dc) % self.width;
let idx = self.get_index(r, c);
self.cells[idx] = Cell::Alive;
}
}

The standout is cells() returning a *const Cell (a raw pointer). This is the famous zero-copy trick of Rust+Wasm. WebAssembly’s memory is a single contiguous ArrayBuffer that JavaScript can see. Instead of marshalling the board across the boundary cell-by-cell (slow) or serializing it to JSON (slower), Rust just hands JavaScript the address of the cell buffer; JS builds a Uint8Array view over the Wasm memory at that offset and reads the bytes directly. There is no copy and no per-cell function call. Reaching into another language’s memory by raw pointer is exactly the sort of thing the borrow checker normally forbids, which is why the consumer side uses a small unsafe block (next step). See JS interop and 21. Performance.

randomize() shows the other direction of interop: calling into JavaScript. js_sys::Math::random() is a binding to the browser’s Math.random(), so the Wasm build needs no random-number crate at all.

There’s also a small render() method that returns the board as an ASCII string (/) — handy for debugging and reused by the tests:

// src/universe.rs (ASCII rendering)
/// Render the board to an ASCII string (`◻` dead, `◼` alive). Handy in the
/// browser and reused by the native tests below. `to_string` via `Display`
/// would also work, but a named method is friendlier to call from JS.
pub fn render(&self) -> String {
let mut buf = String::with_capacity(self.cells.len() + self.height as usize);
for line in self.cells.chunks(self.width as usize) {
for &cell in line {
buf.push(if cell == Cell::Alive { '\u{25FC}' } else { '\u{25FB}' });
}
buf.push('\n');
}
buf
}
}

Finally, the methods that are not exported — the index helper and the wrap-around neighbor count. They have no #[wasm_bindgen], so they stay invisible to JavaScript:

// src/universe.rs (private helpers)
// --- Methods NOT exported to JavaScript (no `#[wasm_bindgen]`) ---
impl Universe {
/// Flatten a (row, col) coordinate into an index into the flat `cells` Vec.
fn get_index(&self, row: u32, column: u32) -> usize {
(row * self.width + column) as usize
}
/// Count the live cells in the 8-neighborhood, wrapping around the edges
/// (the board is a torus). The `+ self.height - 1` / `% self.height` dance
/// avoids underflow on `u32` when we're on row/column 0.
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
let mut count = 0;
for delta_row in [self.height - 1, 0, 1] {
for delta_col in [self.width - 1, 0, 1] {
if delta_row == 0 && delta_col == 0 {
continue; // skip the cell itself
}
let neighbor_row = (row + delta_row) % self.height;
let neighbor_col = (column + delta_col) % self.width;
let idx = self.get_index(neighbor_row, neighbor_col);
count += self.cells[idx] as u8;
}
}
count
}

Edge wrapping without underflow. The board is a torus: the left edge wraps to the right, the top to the bottom. The naive row - 1 would underflow when row == 0 (and on unsigned u32, 0 - 1 panics in debug builds). Adding self.height - 1 and taking % self.height computes “one row up, wrapping” safely. This is a small but real example of how Rust’s strict integer types push you toward correct arithmetic.

Step 4 — Drawing to the canvas from Rust

Section titled “Step 4 — Drawing to the canvas from Rust”

There are two ways to render a Wasm Game of Life. The classic tutorial lets JavaScript read the cell buffer and draw. We do the opposite: a Rust CanvasRenderer drives the canvas API directly through web-sys. This shows off calling DOM/Web APIs from Rust.

src/render.rs
//! Draw a [`Universe`] onto an HTML `<canvas>` 2D context, entirely from Rust
//! via `web-sys`. This shows the *other* WASM rendering strategy: instead of
//! letting JavaScript read the cell buffer and draw, Rust drives the canvas
//! API directly. Both approaches ship in this project; the JS glue picks one.
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
use crate::cell::Cell;
use crate::universe::Universe;
const CELL_SIZE: f64 = 8.0; // pixels per cell
const GRID_COLOR: &str = "#cccccc";
const DEAD_COLOR: &str = "#ffffff";
const ALIVE_COLOR: &str = "#1a1a1a";
/// Owns a canvas + its 2D context and knows how to paint a `Universe`.
#[wasm_bindgen]
pub struct CanvasRenderer {
ctx: CanvasRenderingContext2d,
}
#[wasm_bindgen]
impl CanvasRenderer {
/// Look up a `<canvas>` by `id`, size it to fit `universe`, and grab its
/// 2D context. Returns a `Result` so a missing element surfaces as a JS
/// exception instead of a panic.
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str, universe: &Universe) -> Result<CanvasRenderer, JsValue> {
let document = web_sys::window()
.ok_or_else(|| JsValue::from_str("no global `window` exists"))?
.document()
.ok_or_else(|| JsValue::from_str("should have a document on window"))?;
let canvas = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("no element with id `{canvas_id}`")))?
.dyn_into::<HtmlCanvasElement>()?;
// +1 leaves room for the 1px grid lines around each cell.
let width = universe.width();
let height = universe.height();
canvas.set_width(((CELL_SIZE as u32 + 1) * width) + 1);
canvas.set_height(((CELL_SIZE as u32 + 1) * height) + 1);
let ctx = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("failed to get 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()?;
Ok(CanvasRenderer { ctx })
}

This constructor is a tour of web-sys:

  • web_sys::window() returns Option<Window>. The browser globals are modeled as fallible because Rust has no ambient window — you ask for it and handle the None. The ? operator and ok_or_else turn “missing” into a JS exception rather than a hard crash. (Compare with TypeScript’s window! non-null assertion, which is a promise to the compiler, not a runtime check.)
  • get_element_by_id returns Option<Element>, and an Element is the base type. To call canvas-specific methods we downcast with .dyn_into::<HtmlCanvasElement>() — the Rust equivalent of TypeScript’s document.getElementById('x') as HTMLCanvasElement, except dyn_into is a checked cast that returns Err if the element isn’t actually a canvas. No silently-wrong cast.
  • The constructor returns Result<CanvasRenderer, JsValue>. A Result::Err(JsValue) becomes a thrown JavaScript exception automatically. This is how Rust’s error handling maps onto JS try/catch.

The drawing methods call the same canvas API you know from JavaScript (beginPath, moveTo, lineTo, stroke, fillRect), just spelled in snake_case:

// src/render.rs (drawing)
/// Repaint the whole board: grid lines first, then the cells.
pub fn draw(&self, universe: &Universe) {
self.draw_grid(universe);
self.draw_cells(universe);
}
fn draw_grid(&self, universe: &Universe) {
let width = universe.width();
let height = universe.height();
let step = CELL_SIZE + 1.0;
self.ctx.begin_path();
self.ctx.set_stroke_style_str(GRID_COLOR);
// Vertical lines.
for i in 0..=width {
let x = i as f64 * step + 1.0;
self.ctx.move_to(x, 0.0);
self.ctx.line_to(x, step * height as f64 + 1.0);
}
// Horizontal lines.
for j in 0..=height {
let y = j as f64 * step + 1.0;
self.ctx.move_to(0.0, y);
self.ctx.line_to(step * width as f64 + 1.0, y);
}
self.ctx.stroke();
}
fn draw_cells(&self, universe: &Universe) {
let width = universe.width();
let height = universe.height();
let step = CELL_SIZE + 1.0;
// Read the flat cell buffer straight out of Wasm memory. `cells()`
// returns a `*const Cell`; we rebuild a slice over it. This is sound
// because the buffer lives for as long as `universe` is borrowed here.
let ptr = universe.cells();
let len = (width * height) as usize;
let cells: &[Cell] = unsafe { std::slice::from_raw_parts(ptr, len) };
self.ctx.begin_path();
// Batch by color: set fill once, draw all dead cells, then all alive.
// Switching fillStyle is the expensive canvas call, so we minimize it.
self.ctx.set_fill_style_str(ALIVE_COLOR);
for (idx, &cell) in cells.iter().enumerate() {
if cell != Cell::Alive {
continue;
}
let row = idx as u32 / width;
let col = idx as u32 % width;
self.ctx.fill_rect(
col as f64 * step + 1.0,
row as f64 * step + 1.0,
CELL_SIZE,
CELL_SIZE,
);
}
self.ctx.set_fill_style_str(DEAD_COLOR);
for (idx, &cell) in cells.iter().enumerate() {
if cell == Cell::Alive {
continue;
}
let row = idx as u32 / width;
let col = idx as u32 % width;
self.ctx.fill_rect(
col as f64 * step + 1.0,
row as f64 * step + 1.0,
CELL_SIZE,
CELL_SIZE,
);
}
self.ctx.stroke();
}
}

Note the single unsafe block: std::slice::from_raw_parts(ptr, len) turns the raw pointer from universe.cells() back into a safe &[Cell] slice we can iterate. This is the one place we step outside the borrow checker, and it’s sound because the slice only lives within draw_cells, while universe is borrowed and can’t be moved or freed. (For the rules of unsafe, see 20. Unsafe & FFI.) The set_fill_style_str batching — fill color set once per color, not once per cell — is the same micro-optimization you’d apply in plain canvas JavaScript.

Step 5 — The crate root, panic hook, and log!

Section titled “Step 5 — The crate root, panic hook, and log!”

lib.rs declares the modules, re-exports the public types, installs a panic hook, and exposes a version() smoke test.

src/lib.rs
//! # Conway's Game of Life — Rust + WebAssembly
//!
//! A `cdylib` crate compiled to WebAssembly with `wasm-bindgen`. The browser
//! loads the generated glue (`wasm-pack build --target web`) and drives the
//! simulation from a little JavaScript file (`www/index.js`).
//!
//! Module map:
//! - [`cell`] — the `Cell` enum (`Dead`/`Alive`).
//! - [`universe`] — the `Universe` board + Conway's rules (pure, testable).
//! - [`render`] — a `CanvasRenderer` that paints a `Universe` to a `<canvas>`.
//! - [`utils`] — the panic hook and a `log!` macro.
#[macro_use]
mod utils;
mod cell;
mod render;
mod universe;
// Re-export the public types at the crate root so `wasm-bindgen` exposes them
// as top-level JS classes (`Universe`, `Cell`, `CanvasRenderer`).
pub use cell::Cell;
pub use render::CanvasRenderer;
pub use universe::Universe;
use wasm_bindgen::prelude::*;
/// Runs automatically when the Wasm module is instantiated (the
/// `#[wasm_bindgen(start)]` attribute is WebAssembly's equivalent of an ES
/// module's top-level side effect). We use it to install the panic hook so any
/// Rust panic prints a readable trace to the devtools console.
#[wasm_bindgen(start)]
pub fn start() {
utils::set_panic_hook();
log!("game-of-life wasm module initialized");
}
/// Expose the package version to JavaScript (a handy smoke test that the module
/// loaded and the JS<->Wasm bridge works).
#[wasm_bindgen]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}

The #[wasm_bindgen(start)] function runs automatically the moment the module is instantiated — think of it as the top-level body of an ES module. We use it to install console_error_panic_hook, which converts the default cryptic Wasm trap (“unreachable executed”) into a readable Rust panic message with a stack trace in the devtools console.

src/utils.rs
//! Small helpers shared across the crate.
/// Install a panic hook so a Rust `panic!` shows a readable message and stack
/// trace in the browser's devtools console instead of the cryptic
/// "unreachable executed" you get by default. Safe to call more than once.
pub fn set_panic_hook() {
console_error_panic_hook::set_once();
}
/// `console.log!(...)` — a `println!`-style macro that writes to the browser
/// console via `web_sys`. Handy for debugging from Rust without reaching for
/// JavaScript glue. Mirrors `console.log(...)` in Node/browser code.
#[macro_export]
macro_rules! log {
( $( $t:tt )* ) => {
web_sys::console::log_1(&format!( $( $t )* ).into());
};
}

The log! macro is a println!-style wrapper over console.log, so we can debug from Rust with log!("ticked {} cells", n).

The page is a plain <canvas>, four buttons, and an FPS readout. The <script type="module"> is what lets us import the generated Wasm loader.

www/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Conway's Game of Life — Rust + WebAssembly</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 2rem auto;
max-width: 760px;
color: #1a1a1a;
}
h1 { font-size: 1.4rem; }
#controls { margin: 1rem 0; display: flex; gap: 0.5rem; flex-wrap: wrap; }
button {
font: inherit;
padding: 0.4rem 0.9rem;
border: 1px solid #888;
border-radius: 6px;
background: #f4f4f4;
cursor: pointer;
}
button:hover { background: #e8e8e8; }
canvas { border: 1px solid #ccc; }
#fps { font-variant-numeric: tabular-nums; color: #555; margin-top: 0.5rem; }
code { background: #f0f0f0; padding: 0.1rem 0.3rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>Conway's Game of Life — Rust + WebAssembly</h1>
<p>
The simulation runs entirely in Rust compiled to WebAssembly. Click a cell
to toggle it; <kbd>Ctrl</kbd>+click stamps a glider.
</p>
<div id="controls">
<button id="play-pause">Pause</button>
<button id="step">Step</button>
<button id="random">Randomize</button>
<button id="clear">Clear</button>
</div>
<canvas id="game-of-life-canvas"></canvas>
<div id="fps">measuring…</div>
<!-- ES module entry point. `type="module"` lets us `import` the wasm glue. -->
<script type="module" src="./index.js"></script>
</body>
</html>

The glue loads the module, then drives it. The --target web build emits an ES module whose default export is an async init() that fetches and instantiates the .wasm; the named exports are our Rust types as JS classes.

www/index.js
// JS glue: load the Wasm module, wire up the controls, and run the render loop.
//
// `wasm-pack build --target web` emits `../pkg/game_of_life.js` (the loader)
// plus `game_of_life_bg.wasm`. We import the loader's default `init` to fetch
// and instantiate the wasm, then the exported classes (`Universe`,
// `CanvasRenderer`) just like normal JS classes.
import init, { Universe, CanvasRenderer, version } from "../pkg/game_of_life.js";
const CANVAS_ID = "game-of-life-canvas";
const CELL_SIZE = 8; // must match render.rs
async function main() {
// `init()` returns the raw Wasm exports, including `memory` — the linear
// memory buffer we read cell state out of without copying.
const wasm = await init();
console.log("game-of-life version:", version());
const universe = new Universe(64, 48);
const renderer = new CanvasRenderer(CANVAS_ID, universe);
const canvas = document.getElementById(CANVAS_ID);
// --- FPS meter --------------------------------------------------------
const fpsEl = document.getElementById("fps");
let lastFrame = performance.now();
let frames = [];
function recordFps() {
const now = performance.now();
const fps = 1000 / (now - lastFrame);
lastFrame = now;
frames.push(fps);
if (frames.length > 60) frames.shift();
const mean = frames.reduce((a, b) => a + b, 0) / frames.length;
fpsEl.textContent = `${mean.toFixed(1)} fps`;
}
// --- Render loop ------------------------------------------------------
let animationId = null;
const isPaused = () => animationId === null;
function renderLoop() {
recordFps();
universe.tick(); // Rust evolves the board one generation
renderer.draw(universe); // Rust paints it to the canvas
animationId = requestAnimationFrame(renderLoop);
}
// --- Controls ---------------------------------------------------------
const playPauseBtn = document.getElementById("play-pause");
function play() {
playPauseBtn.textContent = "Pause";
renderLoop();
}
function pause() {
playPauseBtn.textContent = "▶ Play";
cancelAnimationFrame(animationId);
animationId = null;
}
playPauseBtn.addEventListener("click", () => (isPaused() ? play() : pause()));
document.getElementById("step").addEventListener("click", () => {
if (!isPaused()) pause();
universe.tick();
renderer.draw(universe);
});
document.getElementById("random").addEventListener("click", () => {
universe.randomize();
renderer.draw(universe);
});
document.getElementById("clear").addEventListener("click", () => {
universe.clear();
renderer.draw(universe);
});
// Translate a canvas click into a (row, col) and toggle that cell, or stamp
// a glider when Ctrl/Cmd is held.
canvas.addEventListener("click", (event) => {
const boundingRect = canvas.getBoundingClientRect();
const scaleX = canvas.width / boundingRect.width;
const scaleY = canvas.height / boundingRect.height;
const canvasX = (event.clientX - boundingRect.left) * scaleX;
const canvasY = (event.clientY - boundingRect.top) * scaleY;
const col = Math.min(Math.floor(canvasX / (CELL_SIZE + 1)), universe.width() - 1);
const row = Math.min(Math.floor(canvasY / (CELL_SIZE + 1)), universe.height() - 1);
if (event.ctrlKey || event.metaKey) {
universe.insert_glider(row, col);
} else {
universe.toggle_cell(row, col);
}
renderer.draw(universe);
});
// First paint + start running.
renderer.draw(universe);
play();
}
main();

From JavaScript’s point of view, Universe and CanvasRenderer are ordinary classes — new Universe(64, 48), universe.tick(), renderer.draw(universe). The only Wasm-specific ceremony is await init() at the top, which is the asynchronous fetch+compile of the .wasm binary. Everything else is the render loop you’d write for any canvas app.

How would you do this in plain JavaScript? Exactly the same UI code, but the Universe class would be JavaScript too. For comparison, www/life-plain-js.js in the project is a complete plain-JS Game of Life with the identical flat-Uint8Array layout and the same rules — no Wasm, no build step. For a 64x48 board both are smooth; the Wasm version pulls ahead as the grid grows into the hundreds because the rule loop is compiled and there’s no per-frame garbage. See 19. Performance.

Here is the rule kernel side by side — Rust (left, from src/universe.rs) vs. the plain-JS reference (www/life-plain-js.js):

// www/life-plain-js.js — the same four rules in JavaScript
this.next[idx] =
cell === 1
? n < 2 || n > 3
? 0
: 1
: n === 3
? 1
: 0;

The JavaScript ternary nest does the same job as Rust’s match, but nothing stops you from forgetting a branch or mixing up n < 2 and n <= 2; the Rust compiler checks the match is exhaustive.

Because the Universe logic is pure Rust with no DOM, we can test it two ways. First, fast native unit tests (cargo test) that compile to your machine, not Wasm:

// src/universe.rs (tests module, excerpt)
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::Cell;
/// A blinker (3 cells in a row) oscillates with period 2: horizontal ->
/// vertical -> horizontal. This is the canonical correctness test.
#[test]
fn blinker_oscillates() {
let mut u = Universe::new(5, 5);
u.clear();
// Horizontal blinker centered in a 5x5 board.
u.set_cells(&[(2, 1), (2, 2), (2, 3)]);
u.tick();
// After one tick it should be vertical.
let expected_vertical = {
let mut v = Universe::new(5, 5);
v.clear();
v.set_cells(&[(1, 2), (2, 2), (3, 2)]);
v
};
assert_eq!(u.get_cells(), expected_vertical.get_cells());
u.tick();
// After two ticks it's back to horizontal.
let expected_horizontal = {
let mut v = Universe::new(5, 5);
v.clear();
v.set_cells(&[(2, 1), (2, 2), (2, 3)]);
v
};
assert_eq!(u.get_cells(), expected_horizontal.get_cells());
}

Second, headless-browser tests with wasm-bindgen-test, which run the actual Wasm in a real browser — the only place js_sys::Math::random() works:

tests/web.rs
//! Headless-browser tests for the Wasm build.
//!
//! Run with: `wasm-pack test --headless --firefox` (or `--chrome`).
//! These exercise the same public API the browser uses, but inside a real
//! Wasm runtime — so things like `js_sys::Math::random()` actually work here,
//! unlike the native `cargo test` in `src/universe.rs`.
use game_of_life::Universe;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn blinker_oscillates_in_wasm() {
let mut u = Universe::new(5, 5);
u.clear();
u.toggle_cell(2, 1);
u.toggle_cell(2, 2);
u.toggle_cell(2, 3);
let horizontal = u.render();
u.tick();
let vertical = u.render();
u.tick();
let back_to_horizontal = u.render();
assert_ne!(horizontal, vertical, "blinker should change shape after one tick");
assert_eq!(horizontal, back_to_horizontal, "period-2 oscillator");
}
#[wasm_bindgen_test]
fn randomize_uses_js_math_random() {
// This would panic under native `cargo test` (no JS engine), proving these
// tests really run inside the browser's Wasm runtime.
let mut u = Universe::new(16, 16);
u.randomize();
let alive = u.render().chars().filter(|&c| c == '\u{25FC}').count();
assert!(alive > 0, "a random 16x16 board should have some live cells");
}

See 13. Testing for the broader testing story.

First make sure you have the Wasm target installed, then check the crate compiles for it:

Terminal window
rustup target add wasm32-unknown-unknown
cargo check --target wasm32-unknown-unknown

Real output:

Checking game-of-life v0.1.0 (/Users/ahmet/Code/ts_to_rust/30-projects/wasm-app-code)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Terminal window
cargo test

Real output:

running 4 tests
test universe::tests::blinker_oscillates ... ok
test universe::tests::block_is_stable ... ok
test universe::tests::toggle_flips_state ... ok
test universe::tests::lonely_cell_dies ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Terminal window
wasm-pack build --target web

Real output (trimmed to the interesting lines):

[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
Compiling game-of-life v0.1.0 (/Users/ahmet/Code/ts_to_rust/30-projects/wasm-app-code)
Finished `release` profile [optimized] target(s) in 35.39s
[INFO]: found wasm-opt at "/opt/homebrew/bin/wasm-opt"
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Done in 35.99s
[INFO]: Your wasm pkg is ready to publish at .../30-projects/wasm-app-code/pkg.

That produces the pkg/ directory. The compiled module is small:

Terminal window
ls -lh pkg/game_of_life_bg.wasm
-rw-r--r-- 1 ahmet staff 31K pkg/game_of_life_bg.wasm

wasm-pack also generates a TypeScript declaration file (pkg/game_of_life.d.ts) for free, so the API is fully typed if you consume it from a TypeScript project:

// pkg/game_of_life.d.ts (excerpt — generated)
export class Universe {
constructor(width: number, height: number);
cells(): number;
clear(): void;
height(): number;
insert_glider(row: number, col: number): void;
randomize(): void;
render(): string;
tick(): void;
toggle_cell(row: number, col: number): void;
width(): number;
}
export function version(): string;

fetch-ing a .wasm file requires a real HTTP server (the file:// protocol won’t do, and the server must send Content-Type: application/wasm). Any static server works; Python’s built-in one is handy:

8731/www/index.html
python3 -m http.server 8731

On load, the devtools console shows the panic hook’s startup log and the version() smoke test — both came from a real browser run:

game-of-life wasm module initialized pkg/game_of_life.js:277
game-of-life version: 0.1.0 www/index.js:15

The board immediately starts evolving at ~60 fps (the FPS meter under the canvas reads 60.0 fps), with blinkers oscillating, blocks sitting still, and gliders walking diagonally across the grid. Clicking toggles a cell; Ctrl/Cmd-click stamps a glider.

To confirm the simulation is correct, here is the classic blinker captured from the actual Wasm module in the browser — a horizontal row of three cells flipping to vertical and back (◼ alive, ◻ dead), one generation per tick():

generation 0 generation 1 generation 2
◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◼◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◼◼◼◻◻◻ ◻◻◻◼◻◻◻◻ ◻◻◼◼◼◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◼◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻
◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻ ◻◻◻◻◻◻◻◻

This matches the blinker_oscillates unit test exactly — period 2, as Conway’s rules require.

5. (Optional) Run the headless-browser tests

Section titled “5. (Optional) Run the headless-browser tests”
Terminal window
wasm-pack test --headless --firefox
# or: wasm-pack test --headless --chrome

This compiles tests/web.rs to Wasm and runs it in a headless browser, which is the only environment where js_sys::Math::random() resolves.

This project cements the core ideas of compiling Rust to the web:

  • #[wasm_bindgen] is the bridge. It turns Rust structs into JS classes, associated functions into constructors (#[wasm_bindgen(constructor)]), methods into instance methods, and Result::Err into thrown exceptions. The generated .js glue and .d.ts types are produced for you. (See wasm-bindgen.)
  • Zero-copy data sharing. Returning a *const Cell and reading it from JS through a Uint8Array view over Wasm linear memory avoids copying the board every frame — the single biggest performance lever in a Wasm app. (See JS interop, 21. Performance.)
  • match as a rule table. Conway’s four rules become one exhaustive, compiler-checked match on a (Cell, count) tuple. (See match.)
  • Enums with #[repr(u8)]. A Cell is a real type, but laid out as a single byte so it crosses the boundary cleanly and supports cell as u8 arithmetic. (See enums.)
  • Ownership across the FFI boundary. The cell buffer is owned by Rust; JavaScript only borrows a view into it. The one unsafe block (slice::from_raw_parts) is contained and justified. (See 05. Ownership, 20. Unsafe & FFI.)
  • Calling both directions. Rust calls into JS (js_sys::Math::random(), web_sys canvas API) and JS calls into Rust (tick, toggle_cell). (See Web APIs, DOM manipulation.)
  • Two-tier testing. Pure logic gets fast native cargo test; anything that touches the JS runtime gets wasm-bindgen-test. (See 13. Testing.)
  1. Pure-JS rendering for contrast. Drop the Rust CanvasRenderer and instead read universe.cells() from index.js into a Uint8Array over wasm.memory.buffer, drawing from JavaScript. Benchmark it against the Rust renderer on a 256x256 board to feel the boundary-crossing cost. (See 19. Performance.)
  2. A double-buffered <canvas> or WebGL. For very large grids, batch all cells into a single Path2D or move to WebGL via web-sys’s WebGl2RenderingContext feature.
  3. Pattern presets and load/save. Add buttons to stamp gliders guns, pulsars, and spaceships; serialize the board to a run-length-encoded string with serde so users can share patterns by URL. (See 15. Serialization.)
  4. Bundle with Vite and deploy. Swap the hand-written python3 -m http.server for a Vite dev server with vite-plugin-wasm, then deploy the static pkg/ + www/ to GitHub Pages or Netlify. (See deployment.)
  5. Try a framework. Rebuild the UI in Leptos or Yew so the controls and the canvas live in Rust components too, eliminating the JS glue entirely.