< Home

Rust/Macroquad hot-reloading Wasm dev setup

2025-04-25

Planets! game screenshot

Recently, I made a small game for the Alakajam 21 game jam (link to the game). The game was made with Rust and Macroquad and built to WebAssembly to get more plays at the jam rating time.

I set up a dev environment that allowed me to:

More details about this setup in general can be found in Macroquad's documentation, but I'll give an overview of my custom one here.

Rust, cargo and live server setup

I have a single dependency defined in cargo.toml.

[dependencies]
macroquad = {version = "0.4.13", features = ["audio"]}

Then on the project's local .cargo/config.toml I set the default build target to wasm32-unknown-unknown.

[build]
target = "wasm32-unknown-unknown"

To support hot-reloading of the game while I iterate on it, I use the node-based live-server.

npm install -g live-server

I also have a single serve script to start the server and listen for changes.

dev-server.sh

#!/bin/sh

live-server \
--port=42069 \
--cors \
--verbose \
--no-browser \
--ignore=deps,incremental,examples,build \
--watch=akj-21.wasm \
target/wasm32-unknown-unknown/debug

HTML template

At my project root I have a template folder with the HTML container template and the vendored in Macroquad javascript bundle.

This HTML container takes care of keeping the game's canvas aspect ratio, disabling the right click context menu and managing window resizes.

template/index.html

<html lang="en">

<head>
  <meta charset="utf-8">
  <title>akj-21</title>
  <style>
    /* Make the body fill the viewport and center the canvas */
    html,
    body {
      margin: 0;
      padding: 0;
      width: 100vw;
      height: 100vh;
      background: black;
      display: flex;
      justify-content: center;
      align-items: center;
      overflow: hidden;
    }

    /* The canvas will use the CSS aspect-ratio property to maintain 16:9.
       Its width is set to 100% of its container (up to the maximum possible) and height is automatic. */
    canvas {
      aspect-ratio: 16 / 9;
      width: 100%;
      max-width: 100%;
      height: auto;
    }
  </style>
</head>

<body>
  <canvas id="glcanvas" tabindex="1"></canvas>
  <script src="mq_js_bundle.js"></script>
  <script>
    // Fallback: if needed, dynamically adjust canvas size on window resize.
    function resizeCanvas() {
      const canvas = document.getElementById('glcanvas');
      const windowWidth = window.innerWidth;
      const windowHeight = window.innerHeight;
      const aspect = 16 / 9;
      let newWidth, newHeight;

      if (windowWidth / windowHeight < aspect) {
        newWidth = windowWidth;
        newHeight = windowWidth / aspect;
      } else {
        newHeight = windowHeight;
        newWidth = windowHeight * aspect;
      }
      canvas.style.width = newWidth + 'px';
      canvas.style.height = newHeight + 'px';
    }

    canvas.addEventListener("contextmenu", function (e) {
      e.preventDefault();
    });

    window.addEventListener('resize', resizeCanvas);
    resizeCanvas();
  </script>
  <script>load("akj-21.wasm");</script>
</body>

</html>

Cargo build script

The cargo build script handles copying the assets and the HTML template to the output directory. Then, after building the project, the contents of the target/wasm32-unknown-unknown/release can be copied or zipped to distribute the game (on itch.io in my case).

build.rs

use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

/// Recursively copy all files and subdirectories from `src` to `dst`
fn copy_dir_all(src: &Path, dst: &Path) -> io::Result<()> {
    if !dst.exists() {
        fs::create_dir_all(dst)?;
    }
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let file_type = entry.file_type()?;
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());
        if file_type.is_dir() {
            copy_dir_all(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }
    Ok(())
}

fn main() -> io::Result<()> {
    println!("cargo:rerun-if-changed=template/*");
    println!("cargo:rerun-if-changed=assets/*");

    let out_dir = PathBuf::from(format! {"{}/../../../", env::var("OUT_DIR").unwrap()});
    let out_dir_path = out_dir.as_path();

    copy_dir_all(Path::new("template"), &out_dir_path)?;
    copy_dir_all(
        Path::new("assets"),
        PathBuf::from(out_dir).join("assets").as_path(),
    )?;

    Ok(())
}

Building, serving, iterating

With everything in place, I have a terminal running the dev-server.sh script listening for rebuilds, a browser pointed to http://localhost:42069 to test the game that will be autoreloaded by the live server on change, another terminal for running cargo build, git commands, etc., and another one with my text editor.