Web builds

A bevy-react app also runs in the browser. The Bevy app compiles to wasm32-unknown-unknown, and the same vendor.js and app.js bundles run in the browser's own JS engine instead of the embedded V8. The UI is still bevy_ui drawn into Bevy's canvas, not DOM. No code changes are needed: the bundles detect the browser host at startup. The live demo is the demos app built this way.

Usage

One-time setup:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli

Install the wasm-bindgen CLI at the version of the wasm-bindgen crate in your Cargo.lock (cargo install wasm-bindgen-cli --version <x.y.z>). A CLI a patch or two newer usually works; a larger gap makes wasm-bindgen fail.

Build the three parts into one folder, here ui/dist/ for a package whose binary is my_game:

(cd ui && npx bevy-react build)        # dist/vendor.js + dist/app.js
cargo build --target wasm32-unknown-unknown
wasm-bindgen --target web --out-dir ui/dist --out-name game \
  target/wasm32-unknown-unknown/debug/my_game.wasm
cp -r assets ui/dist/assets

wasm-bindgen writes game.js (the loader) and game_bg.wasm. Pick an --out-name other than app or vendor, so the loader doesn't overwrite a bundle.

Add an index.html to ui/dist/ with a <script type="module"> that boots the parts in this order:

import init from "./game.js";
await init(); // runs your Rust `main`: builds the App, starts Bevy

await loadScript("./vendor.js");
await loadScript("./app.js");

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const s = document.createElement("script");
    s.src = src;
    s.onload = resolve;
    s.onerror = () => reject(new Error(`failed to load ${src}`));
    document.body.appendChild(s);
  });
}

Then serve the folder over HTTP, for example with npx serve ui/dist. Browsers don't load wasm from file:// URLs. All paths are relative, so the site also works from a subdirectory.

For a release build, use npx bevy-react build --prod and cargo build --release, and point wasm-bindgen at target/wasm32-unknown-unknown/release/.

Load order

The order in the page script is required:

  1. init() first. It runs your main. While the App builds, ReactUiPlugin installs the browser host on globalThis.__bevyHost, the object the React runtime sends its ops to. App::run then schedules Bevy's frame loop and returns, so init() resolves.
  2. vendor.js next. It reads __bevyHost when it is evaluated. Loaded before init() resolves, it falls back to the native host and throws (Deno is not defined).
  3. app.js last. It takes React and the runtime from vendor.js.

Both bundles are classic scripts, not ES modules: load them with plain <script> elements as above. React can render as soon as app.js runs. The first commits are held until the Bevy app has finished its asynchronous renderer setup, then applied in order, so there's nothing to wait for.

The Rust side

Your main is the wasm entry point unchanged. A few things differ:

  • ReactUiPlugin::new(path) and hot_reload(..) are ignored: the page loads the bundles, and nothing watches them.
  • Gate native-only code with #[cfg(not(target_arch = "wasm32"))]. This includes the --export-bindings exporter from TypeScript codegen: generate bevy.ts with a native run.
  • Bevy's default features render through WebGL2 (the webgl2 feature).

To make the canvas fill the page and follow the browser window, let it track its parent element:

let window = Window {
    fit_canvas_to_parent: true,
    ..default()
};
app.add_plugins(DefaultPlugins.set(WindowPlugin {
    primary_window: Some(window),
    ..default()
}));

Give html and body a full height with no margin and overflow: hidden. On phones, also set touch-action: none on the canvas: without it the browser turns touch drags into page scrolling and pinch-zoom instead of passing them to the app.

bevy-react enables the browser backend of getrandom for you. If the wasm build still fails inside getrandom, copy the wasm32-unknown-unknown section of the repository's .cargo/config.toml into your project.

Assets

Bevy's default AssetPlugin fetches assets over HTTP from assets/ next to the page. Copy your asset folder to dist/assets/: textures, fonts given to default_font(..) and font(..), cursor images and custom filter shaders all load from there. If the native build points AssetPlugin somewhere else (the demos use file_path: "../assets"), keep the default on the web.

On GitHub Pages, add an empty .nojekyll file to the site so every file is served as is.

Differences from native

  • No hot reload. Rebuild and reload the page: npx bevy-react build --watch still rebuilds app.js on change, but the page doesn't pick it up, and a reload resets all React state. See Hot reload for the native workflow.
  • One thread. React runs on the page's main thread, between Bevy frames, where on native it has a thread of its own. A slow render delays the next frame. Events from Bevy are delivered at the end of each frame, and the ops React commits in response apply on the next one.
  • Logs go to the browser console. That covers console.* from React, Rust panics and Bevy's log output.
  • Devtools run in debug builds with a few gaps: the panel's layout isn't saved between sessions, the Console tab stays empty (use the browser's console), and some timing columns in the Bridge tab are not measured. See Devtools.

Reference setup

The demos app builds for the web with one script, examples/demos/ui/build-web.mjs: it builds both bundles with buildVendor and buildApp from bevy-react/build-lib, compiles the Bevy app to wasm, runs wasm-bindgen, copies index.html and the assets into dist/, and serves it. Its main and window setup are in examples/demos/main.rs. Run it from the repository root:

npm run build:web -w demos                        # debug build, then serve
npm run build:web:prod -w demos -- --build-only   # release build, no server

Limits

  • wasm builds need a lot of disk space: keep tens of GB free. cargo clean --target wasm32-unknown-unknown reclaims it.
  • Integers above 2^53 in event or response payloads can't be sent to React on the web (they are dropped with a console error); on native they arrive as a BigInt.