← Back to Lessons Lesson 24 of 28
Advanced graphicssimulation

Building a Wasm Game

Game Architecture

A Wasm game splits responsibility between Rust and JavaScript:

Rust (game logic)              JavaScript (rendering + input)
┌────────────────────┐         ┌────────────────────────┐
│ Game state         │         │ Canvas 2D rendering    │
│ Physics/collision  │◀───────▶│ Keyboard/mouse input   │
│ Entity management  │  data   │ requestAnimationFrame  │
│ Score/rules        │         │ Audio playback         │
└────────────────────┘         └────────────────────────┘

Rust owns the state, JS handles I/O.

The Game Loop

import init, { Game } from './pkg/game.js';

await init();

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const game = new Game(canvas.width, canvas.height);

// Track pressed keys
const keys = new Set();
document.addEventListener('keydown', (e) => keys.add(e.key));
document.addEventListener('keyup', (e) => keys.delete(e.key));

function gameLoop() {
    // 1. Input
    const speed = 4;
    if (keys.has('ArrowLeft'))  game.move_player(-speed, 0);
    if (keys.has('ArrowRight')) game.move_player(speed, 0);
    if (keys.has('ArrowUp'))    game.move_player(0, -speed);
    if (keys.has('ArrowDown'))  game.move_player(0, speed);

    // 2. Update (Rust)
    game.update();

    // 3. Render (JS)
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // Draw player
    ctx.fillStyle = '#654ff0';
    ctx.beginPath();
    ctx.arc(game.player_x(), game.player_y(), game.player_size(), 0, Math.PI * 2);
    ctx.fill();

    // Draw targets
    const positions = game.target_positions();
    ctx.fillStyle = '#ce422b';
    for (let i = 0; i < positions.length; i += 2) {
        ctx.beginPath();
        ctx.arc(positions[i], positions[i + 1], 15, 0, Math.PI * 2);
        ctx.fill();
    }

    // Draw score
    ctx.fillStyle = '#f1f5f9';
    ctx.font = '20px monospace';
    ctx.fillText(`Score: ${game.score()}`, 10, 30);

    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Collision Detection

Circle-circle collision

fn circles_collide(x1: f64, y1: f64, r1: f64, x2: f64, y2: f64, r2: f64) -> bool {
    let dx = x1 - x2;
    let dy = y1 - y2;
    let dist_sq = dx * dx + dy * dy;
    let radii = r1 + r2;
    dist_sq < radii * radii  // Avoid sqrt for performance
}

AABB (rectangle) collision

fn aabb_collide(
    x1: f64, y1: f64, w1: f64, h1: f64,
    x2: f64, y2: f64, w2: f64, h2: f64,
) -> bool {
    x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2
}

Entity Component System (ECS) Pattern

For complex games, use an ECS pattern:

#[wasm_bindgen]
pub struct World {
    positions: Vec<f64>,    // [x0, y0, x1, y1, ...]
    velocities: Vec<f64>,   // [vx0, vy0, vx1, vy1, ...]
    sizes: Vec<f64>,        // [r0, r1, ...]
    alive: Vec<bool>,
    count: usize,
}

#[wasm_bindgen]
impl World {
    pub fn update(&mut self, dt: f64) {
        for i in 0..self.count {
            if !self.alive[i] { continue; }
            let idx = i * 2;
            self.positions[idx] += self.velocities[idx] * dt;
            self.positions[idx + 1] += self.velocities[idx + 1] * dt;
        }
    }

    pub fn positions(&self) -> Vec<f64> {
        self.positions.clone()
    }
}

Performance Tips for Games

Tip Why
Use flat arrays (Vec<f64>) for bulk data Minimizes Wasm↔JS boundary crossing
Avoid allocating per frame Reuse buffers with .clear()
Use f64 not f32 in JS-facing code JS numbers are f64
Check collisions with spatial partitioning Grid or quadtree for O(n) instead of O(n²)
Keep game logic in Rust, rendering in JS Canvas API isn't accessible from Wasm

Try It

The starter code implements a simple collectible game — a player that moves and collects targets. In a real project, you'd wire this to keyboard input and a Canvas rendering loop.

Try It

Chapter Quiz

Pass all questions to complete this lesson