Rust game in the browser: shipping with WebAssembly to itch.io

A game jam has a simple rule nobody writes down: a game people can play in their browser gets played, a download gets skipped. Rust is a good language for the web, because WebAssembly was close to a first-class target from early on, but the first browser build of a Rust game still tends to eat an evening. Not because any single step is hard, but because there are six small steps and each one fails in its own quiet way.
This is the checklist I run through now, with the reasons behind each step. I keep framework specifics general, since macroquad, Bevy and a hand-built wgpu stack each wrap these steps a little differently, and their own docs are the source of truth for the exact commands.
Step 1: the wasm32 target
Browser builds use the wasm32-unknown-unknown target. "Unknown unknown" means no operating system and no standard environment: no files, no threads in the usual sense, no clock from the OS. Your game gets whatever the browser gives it through JavaScript.
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
That produces a .wasm file under target/wasm32-unknown-unknown/release/. On its own it does nothing; it needs a small JavaScript loader and an HTML page. How you get those depends on your stack:
- Macroquad ships its own JavaScript loader, which you copy next to an
index.htmlthat loads your.wasm. It is the shortest path I know. - Projects built on wgpu, winit and similar crates usually go through wasm-bindgen, which generates JavaScript glue for the functions your Rust code calls in the browser. Tools such as trunk wrap the build, the glue generation and a local server into one command.
- Bevy runs through the same wasm-bindgen route and documents the setup in its examples.
Whichever route you take, serve the folder through a local HTTP server, not by double-clicking the HTML file. Browsers refuse to load .wasm from file:// URLs in most configurations, and the error message rarely says so clearly.
Step 2: WebGL2 or WebGPU
On native platforms your renderer talks to Vulkan, Metal or Direct3D. In the browser there are two graphics APIs to choose from.
WebGL2 is available almost everywhere a player is likely to be. It is based on OpenGL ES 3.0, so it is older and has tighter limits: no compute shaders, fewer storage options, smaller guaranteed texture sizes.
WebGPU is the modern API, and wgpu maps onto it closely. Browser support has been growing, but it is not universal, and some players will be on a browser or device where it is missing or switched off.
For a jam game I build for WebGL2. Through wgpu that means enabling its WebGL backend and requesting downlevel limits that WebGL2 can satisfy; macroquad targets WebGL through miniquad already. If you want WebGPU, the safe pattern is to detect support at startup and fall back to WebGL2, but test both paths, because they are genuinely different code under the hood. If the concepts of adapters, limits and surfaces are new, the wgpu getting started guide explains where these choices are made.
Step 3: audio that waits for the player
Browsers do not let a page make sound until the player interacts with it. The Web Audio context starts in a suspended state, and it can only be resumed in response to a user gesture such as a click, a tap or a key press. If your game starts music in its first frame, nothing plays, and some audio libraries quietly drop every sound that was queued before the context woke up.
The fix is design, not code. Give the game a title screen with "click to start", and treat that click as the moment audio begins:
enum Phase {
WaitingForInput,
Playing,
}
fn update(phase: &mut Phase, input: &Input, audio: &mut Audio) {
if let Phase::WaitingForInput = phase {
if input.any_pressed() {
audio.resume(); // called inside the gesture
audio.play_music("theme");
*phase = Phase::Playing;
}
return;
}
// normal game update
}
Input and Audio stand for whatever your framework provides; the point is the order. Nothing audible happens before the first press, and the resume call happens during the frame that handles that press. Some frameworks resume the context automatically on the first input event, but a title screen costs five minutes and makes the behaviour the same everywhere. As a bonus, it fixes a second web problem: until the player clicks the canvas, it may not have keyboard focus.
Step 4: loading assets without a file system
There is no std::fs in the browser. Code that reads assets/player.png from disk compiles for wasm32-unknown-unknown and then fails at runtime. You have two options.
The first is embedding assets in the binary with include_bytes!. It is simple, works identically on every platform, and makes the .wasm file bigger, which delays the first frame because the whole file must download before anything runs. For a jam game with a megabyte of art and sound, this is often the right call.
static PLAYER_PNG: &[u8] = include_bytes!("../assets/player.png");
static JUMP_WAV: &[u8] = include_bytes!("../assets/jump.wav");
The second is loading over HTTP. The framework fetches files relative to the page, asynchronously, and your game shows a loading state until they arrive. Macroquad's loading functions are async for exactly this reason, and Bevy's asset server loads in the background on every platform. This keeps the initial download small and lets you show a progress bar, at the cost of handling the "not loaded yet" case everywhere.
My rule: embed when the total is small, load over HTTP when it is not, and never mix the two for the same kind of asset, because you will forget which is which.
Step 5: file size
Players on a phone connection notice every megabyte. A debug build is enormous and slow; never upload one. These release profile settings shrink the .wasm file noticeably:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" optimises for size instead of speed; try "s" as well and compare, since one is not always smaller than the other for game code. LTO and a single codegen unit let the compiler remove more unused code. panic = "abort" removes the unwinding machinery, which you were not using in the browser anyway. After the build, running wasm-opt from the binaryen tools often trims a little more.
A worked size budget
Here is a budget for a small jam platformer, the sort of thing I aim for:
| Part | Size | Note |
|---|---|---|
| Game code (.wasm) | 1.5 MB | Optimised for size, framework included |
| JavaScript glue and HTML | 0.05 MB | Loader plus one page |
| Sprites and tiles | 0.4 MB | PNG, indexed palette |
| Music, two loops | 2.5 MB | Compressed audio |
| Sound effects | 0.3 MB | Short, mono |
| Total | 4.75 MB |
The numbers are a target, not a measurement of any engine. The lesson is in the proportions: music dominates. Two compressed loops easily outweigh all the code and art together. If you need to cut, shorter loops or a lower bitrate save more than any compiler flag. Pixel art is cheap, especially with a small palette, so keep spritesheets as PNGs; the character sprites guide covers sheet layouts that stay compact.
Remember that the server usually compresses .wasm on the way to the player, so the transfer size is smaller than the file on disk. Check the network tab in your browser's developer tools to see what players actually download.
Step 6: hosting on itch.io
itch.io is the default home for jam games, and it hosts HTML5 games well. The flow is:
- Put
index.html, the loader script, the.wasmfile and any loose assets in one folder.index.htmlmust be at the top level of that folder. - Zip the folder's contents (not the folder itself, or
index.htmlends up one level too deep). - On the project page, set the kind of project to HTML, upload the zip and tick the option that says the file is played in the browser.
- Set the embed size to match your canvas, for example 1280 by 720, and enable the fullscreen button.
- Save as a draft and play it from the draft page before making it public.
itch.io has limits on the number of files and the total size of an HTML upload; check its upload documentation if you have many loose assets, which is another argument for packing them. If you use threads through SharedArrayBuffer, there is a project setting for the extra headers that requires; most small games do not need it.
Testing before the deadline
Test the uploaded build, not your local one, in at least two browsers and on one phone if your game supports touch. The bugs that only show up in the browser are predictable: audio silent until a second click, keyboard input lost when the canvas is not focused, the arrow keys scrolling the page instead of moving the player, and a canvas size that ignores high-DPI screens. Each one is a one-line fix once you know it is there.
If you are still choosing a framework, the macroquad vs Bevy comparison has a section on how each handles web export. The jams and shipping section has more on finishing.
Last piece of advice: do your first browser build on day one of the jam, with a coloured square and one sound effect. If that pipeline works, the last-hour upload is just a zip. If it does not, you have found out with two days to spare instead of twenty minutes.
Filed in Jams & Shipping · Rust Gamedev

Written by Kasimir Lund
Kasimir writes about Rust for games: engine internals, ownership patterns that survive a real game loop, and getting builds onto other people's machines. He has been writing small engines for himself since the SDL days and lives in Gothenburg.


