Building a Game with Bevy for the Web
This page answers one task: you are building a game with Bevy, the Rust game engine, and want it playable in a browser — not just on desktop — with reasonable download size, correct rendering, working input and audio, and acceptable frame rates.
Prerequisites
- [ ] A Bevy project that runs natively.
- [ ] Rust with the
wasm32-unknown-unknowntarget, pluswasm-bindgen-cli(matching your lockfile) ortrunk. - [ ] A static server for testing, and target browsers for WebGL2 and WebGPU.
How Bevy runs in the browser
Bevy compiles to wasm32-unknown-unknown like any Rust crate. Rendering goes through wgpu, which on the web targets either WebGL2 (widely supported) or
WebGPU (faster and closer to native, supported in current Chromium-based browsers and increasingly elsewhere). Windowing becomes a <canvas> element;
input comes from browser events; audio uses Web Audio; assets load over HTTP instead of from disk. The ECS, game logic and most plugins work unchanged.
The main differences from native are threads and size. Bevy’s multi-threaded executor uses a single thread on wasm32-unknown-unknown by default, so
systems that run in parallel natively run sequentially in the browser. And a Bevy game is a large Wasm binary — tens of megabytes unoptimised — so size
reduction is part of shipping it.
Step 1 — build and run for the web
The quickest loop uses wasm-server-runner, which runs cargo run --target wasm32-unknown-unknown as a local web server:
# .cargo/config.toml
[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"
cargo install wasm-server-runner
cargo run --target wasm32-unknown-unknown
For release builds and deployment, trunk (with an index.html that references the crate) or a manual wasm-bindgen --target web step produces static
files: an HTML page, the .wasm, the JavaScript glue, and your assets/ directory.
Step 2 — choose WebGL2 or WebGPU
Bevy’s feature flags select the web backend. WebGL2 runs nearly everywhere but limits some rendering features (compute shaders, some texture formats,
storage buffers); WebGPU supports Bevy’s full renderer but requires a browser with WebGPU. Many games ship WebGL2 for reach, or build both and choose at
startup by checking navigator.gpu. Test visually on both: lighting, shadows and post-processing can differ, and some features silently fall back.
[dependencies]
bevy = { version = "0.x", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_sprite", "bevy_winit", "webgl2"] }
Disabling default features and enabling only what the game uses also cuts binary size substantially.
Step 3 — fit the canvas and handle input
Configure the primary window to attach to a specific canvas and follow its size:
App::new().add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
canvas: Some("#game".into()),
fit_canvas_to_parent: true,
prevent_default_event_handling: true, // stop the page scrolling on arrow keys / space
..default()
}),
..default()
}));
Keyboard, mouse, touch and gamepad input map to Bevy’s input resources. Pointer lock and fullscreen require a user gesture — request them from a click handler. Browsers deliver some keys to the page or browser chrome (Tab, F-keys, shortcuts); avoid binding gameplay to them.
Step 4 — load assets over HTTP
AssetServer::load("sprites/player.png") fetches assets/sprites/player.png relative to the page. Assets load asynchronously, so show a loading state and
wait for them before starting gameplay (Bevy’s asset states or a loading plugin). Large assets dominate download time: compress textures (KTX2 with Basis
Universal is supported by Bevy and transcodes on the GPU), use compressed audio formats, and serve everything with long-lived cache headers and Brotli or gzip.
Step 5 — shrink the binary
A release Bevy build can exceed 30 MB before optimisation. Apply size settings and measure:
[profile.wasm-release]
inherits = "release"
opt-level = "z" # or "s" — measure frame rate too
lto = "fat"
codegen-units = 1
strip = "debuginfo"
Then run wasm-opt -Oz (or -O3 if speed suffers) on the output and serve it compressed. Disabling unused Bevy features usually saves more than any flag.
Typical small games end up in the 5–15 MB range uncompressed and a fraction of that with Brotli.
Audio unlock
Browsers start audio contexts suspended until the user interacts with the page. Bevy’s audio resumes after a user gesture, but sounds triggered before then are lost or delayed. Start the game behind a “Click to play” screen, which both unlocks audio and gives assets time to load.
Performance compared with native
Expect lower frame rates than native, mainly from single-threaded system execution, the cost of WebGL2 on some GPUs, and the overhead of the browser’s GPU process. Profile with the browser’s Performance panel, which shows Wasm frames; Bevy’s diagnostics plugins work in the browser too. Reducing per-frame allocations, entity counts and draw calls helps more on the web than natively. Threads for Bevy on the web require shared memory and atomics, plus cross-origin isolation; this setup is evolving, so check current Bevy documentation before relying on it.
Platform-specific code in a Bevy game
Most game code is portable, but a few systems differ on the web: saving games (files natively, browser storage on the web), opening URLs, quitting (there is
no “exit” in a browser tab), window management, and anything touching the file system or native libraries. Gate these with
#[cfg(target_arch = "wasm32")] and keep them in a small platform module, so gameplay systems never contain cfg blocks. For saves, a resource that
abstracts “load slot / save slot” with one implementation per platform keeps game code clean. Remove or replace the “Quit” menu item in web builds, and
handle the page being hidden (visibilitychange) by pausing the game, since browsers throttle timers and animation frames in background tabs and a game
that keeps simulating on a stalled clock behaves strangely when the player returns.
Mobile browsers
Bevy web builds run on phones, but mobile adds constraints: touch input instead of keyboard and mouse, smaller memory budgets (large binaries and texture sets can push the tab towards being killed), GPU differences that make WebGL2 the safer choice, and thermal throttling during long sessions. Design touch controls explicitly — Bevy exposes touch events — and test on real devices early. Lower texture resolutions and simpler post-processing for mobile can be chosen at startup based on screen size or a quick benchmark, which keeps the desktop experience intact while making the game playable on phones.
Deployment
The output is static files, so any static host works: itch.io for game distribution, GitHub Pages, or a CDN. Make sure the host serves .wasm with
application/wasm and compresses it, and use hashed filenames so updates are picked up while unchanged assets stay cached.
Expected output
The game runs at 60 fps in Chrome with WebGPU and in Firefox and Safari with WebGL2; the page shows a click-to-play screen that unlocks audio; assets load with a progress indicator; the canvas resizes with the window; arrow keys do not scroll the page; and the Wasm binary is 9 MB uncompressed, 2.6 MB with Brotli.
Gotchas
- Default features on. Huge binaries. Enable only what the game uses.
- WebGPU-only rendering. Many browsers lack it. Ship WebGL2 or detect and choose.
- Gameplay starting before assets load. Missing textures. Wait for asset states.
- Audio before user interaction. Silent. Use a click-to-play screen.
- Mismatched wasm-bindgen versions. Builds fail. Pin the CLI to the lockfile version.
- No pause on hidden tabs. Throttled timers make the simulation jump. Pause on
visibilitychange.
Performance note
For a small 2D game, disabling unused default features cut the release Wasm from 31 MB to 14 MB; size settings plus wasm-opt -Oz brought it to 9 MB, and
Brotli to 2.6 MB on the wire.
Frequently Asked Questions
Does Bevy’s ECS run multi-threaded in the browser?
Not by default; systems run on one thread on wasm32-unknown-unknown.
Can I use Bevy’s UI and text? Yes — they render through the same pipeline.
How do I save game progress?
Use localStorage or IndexedDB through web-sys, or a Bevy plugin that wraps them.
Should I use trunk or wasm-bindgen directly? Trunk is convenient for builds and dev servers; scripts with wasm-bindgen give finer control.
What happens when the player switches tabs?
Browsers throttle background tabs; listen for visibilitychange and pause the game so the simulation does not jump when they return.
Does a Bevy web build work on phones? Yes, with touch controls, smaller textures and WebGL2; test on real devices early because memory and GPUs differ.
Where can I host a Bevy web game?
Any static host — itch.io, GitHub Pages or a CDN — as long as .wasm is served with the right MIME type and compression.
How do I show loading progress? Track pending asset handles in a loading state and draw a progress bar until all required assets report loaded.
Related
- Handling input and audio in a Wasm game — input details.
- Driving WebGPU from Rust Wasm — wgpu directly.
- Shipping a Godot web export — another engine.
- Building a Rust Wasm app with Trunk — the build tool.
← Back to Graphics, Games & Simulation