Part 13 of 13
Localhost Is Not a Launch.
Build a Rust WebAssembly Game for Release and Deploy It on Render

For twelve articles, the game has lived at http://localhost:8080.
That address works on exactly one computer. Yours.
Today we fix that. We will make a release build, collect the five files a visitor needs, test that exact folder, and hand it to Render. Render gives the game a public HTTPS address.
The Rust engine does not change. Neither does idle-game.js. This article is about everything around the game.
By the end, the same seven browser checks from the last article run against:
https://rustbound-idle.onrender.com/
Our starting point#
Everything here builds on Article 12. If you kept your project, you have the starting point already. The article_12 branch of the companion repository has it too.
rustbound-idle/
├── Cargo.toml
├── src/ (the Rust engine and WebAssembly adapter)
├── tests/ (two outside tests)
└── web/
├── index.html
├── idle-game.js
├── styles.css
├── check.mjs
├── package.json
└── pkg/ (generated by wasm-pack)
Nothing in src/ changes today.
Debug is for us#
Cargo has two main ways to build a program.
A debug build compiles quickly and keeps information that helps while we are developing. A release build spends more time compiling to make the program smaller and faster.
Visitors must download the whole .wasm file before the game can start. For a browser game, file size is part of how fast the game feels.
Add this to Cargo.toml:
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O", "--enable-bulk-memory"]
The three release settings have three jobs:
| Setting | What it changes |
|---|---|
opt-level = "s" | Optimize for a small file instead of maximum speed. |
lto = true | Let the optimizer look across the whole program and remove unused code. |
codegen-units = 1 | Compile as one unit, which is slower but gives the optimizer more to see. |
The last setting belongs to wasm-pack, not Cargo. Its optimizer needs permission to accept the bulk-memory instructions produced by the current Rust toolchain.
I first tried a fourth Cargo setting:
strip = true
The release build failed. Stripping removed the target-feature information before wasm-bindgen could read it, and then wasm-opt refused the bulk-memory instructions. The warning told us to leave stripping alone and optimize the generated WebAssembly instead.
The failed build was useful. It kept a setting that looked tidy from becoming part of the release recipe.
Measure it#
On November 1, 2026, with wasm-pack 0.15.0, these were the byte counts on my machine:
wasm-pack build --dev --target web --out-dir web/pkg --out-name rustbound_idle
wc -c < web/pkg/rustbound_idle_bg.wasm
wasm-pack build --release --target web --out-dir web/pkg --out-name rustbound_idle
wc -c < web/pkg/rustbound_idle_bg.wasm
debug: 355387 bytes
release: 89578 bytes
The release file was about 75 percent smaller.
Article 11 called the file "about 110 KB." That was an estimate from an earlier build, not a baseline carved into stone. Toolchains change. Exact measurements belong beside the command and the date that produced them.
Make development one command#
We have been typing a build command and a server command every time. Put them in one file.
Create scripts/dev.sh:
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
wasm-pack build \
--dev \
--target web \
--out-dir web/pkg \
--out-name rustbound_idle
printf '\nServing http://localhost:8080\n'
python3 -m http.server 8080 --directory web
set -euo pipefail makes the script stop when a command fails, when it reaches for a variable that does not exist, or when one command in a pipeline fails. A half-built game should not quietly become the next command's problem.
Run it from Git Bash, WSL, or another Bash shell:
bash ./scripts/dev.sh
A folder is not a website#
The web/ folder holds the game. It also holds check.mjs, package.json, and node_modules/. Those files are for us. A visitor does not need our test runner.
Publishing the whole folder would work, but it would also ship whatever happened to be sitting there.
Instead, we build from an allowlist: a list of the files that may ship. Anything not named stays home.
Try the release packer below. Add check.mjs or node_modules and build again. The page still has a folder, but it is no longer the product we meant to publish.
---html
<h2>Release packer</h2>
<p>Choose the files that belong in <code>dist/</code>.</p>
<div class="files" aria-label="Project files"></div>
<button id="build" type="button">Build dist</button>
<pre aria-live="polite"></pre>
---css
body { font: 16px/1.5 system-ui; max-width: 44rem; margin: 2rem auto; padding: 0 1rem; color: #f4f4f5; background: #15171c; }
.files { display: grid; gap: .55rem; margin: 1rem 0; }
.files button, #build { min-height: 44px; border: 1px solid #626775; border-radius: .35rem; color: inherit; background: #242832; text-align: left; padding: .65rem .8rem; }
.files button[aria-pressed="true"] { border-color: #ff6b3d; background: #3a251f; }
#build { background: #ff5522; border-color: #ff5522; color: #111; font-weight: 700; }
pre { min-height: 8rem; padding: 1rem; overflow: auto; background: #0d0f13; }
---js
const files = [
"index.html",
"idle-game.js",
"styles.css",
"pkg/rustbound_idle.js",
"pkg/rustbound_idle_bg.wasm",
"check.mjs",
"package.json",
"node_modules/playwright/...",
];
const required = new Set(files.slice(0, 5));
const selected = new Set(required);
const list = document.querySelector(".files");
const output = document.querySelector("pre");
for (const file of files) {
const button = document.createElement("button");
button.type = "button";
button.textContent = file;
button.setAttribute("aria-pressed", selected.has(file));
button.addEventListener("click", () => {
selected.has(file) ? selected.delete(file) : selected.add(file);
button.setAttribute("aria-pressed", selected.has(file));
});
list.append(button);
}
document.querySelector("#build").addEventListener("click", () => {
const missing = [...required].filter((file) => !selected.has(file));
const extra = [...selected].filter((file) => !required.has(file));
if (missing.length) {
output.textContent = `BUILD FAILED\nMissing: ${missing.join(", ")}`;
return;
}
output.textContent = extra.length
? `BUILT, BUT SHIPPED DEV FILES\nExtra: ${extra.join(", ")}`
: `BUILT FIVE FILES\n${[...required].join("\n")}`;
});
document.querySelector("#build").click();
The release script makes that allowlist real.
Create scripts/build.sh:
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
wasm-pack build \
--release \
--target web \
--out-dir web/pkg \
--out-name rustbound_idle
rm -rf dist
mkdir -p dist/pkg
cp web/index.html web/idle-game.js web/styles.css dist/
cp web/pkg/rustbound_idle.js web/pkg/rustbound_idle_bg.wasm dist/pkg/
printf '\nBuilt static site in %s/dist\n' "$ROOT_DIR"
printf 'wasm size: %s bytes\n' "$(wc -c < dist/pkg/rustbound_idle_bg.wasm)"
Add /dist to .gitignore, then run the build:
bash ./scripts/build.sh
The result contains five files:
dist/
├── idle-game.js
├── index.html
├── styles.css
└── pkg/
├── rustbound_idle.js
└── rustbound_idle_bg.wasm
rm -rf dist removes the previous generated folder first. The target is the fixed dist folder inside this project, not a variable and not a wildcard. Then the two cp commands name every file that may ship. If one is missing, set -e stops the build.
Test the folder we will ship#
A successful compile does not prove the release folder works. A missing file or wrong path appears only when the browser loads dist/ by itself.
Serve it:
python3 -m http.server 8080 --directory dist
Then point Article 12's Playwright check at that address:
cd web
node check.mjs http://localhost:8080/
This is the real output from the release folder:
ok a new game starts at stage 1
ok the slime has 20 of 20 health
ok four clicks clear the first slime
ok the player was paid
ok the save was restored and ten minutes applied
ok the status says how long we were gone
ok a broken save starts a new game
All checks passed.
The game code did not change. The thing under test did: these checks loaded only the five files in dist/.
Prepare Render's build machine#
Render static sites need a build command and a publish directory. Our build command needs Rust, the WebAssembly target, and wasm-pack.
The first version of this setup trusted whichever Rust installation the build machine already had. That failed when the shared installation was read-only. The fixed script keeps Rust and Cargo under the build user's writable home and pins the wasm-pack version we tested.
Create scripts/render-build.sh:
#!/usr/bin/env bash
set -euo pipefail
export RUSTUP_HOME="$HOME/.rustup"
export CARGO_HOME="$HOME/.cargo"
export PATH="$CARGO_HOME/bin:$HOME/.local/bin:$PATH"
WASM_PACK_VERSION="v0.15.0"
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --no-modify-path
# shellcheck disable=SC1091
source "$CARGO_HOME/env"
fi
rustup toolchain install stable --profile minimal --no-self-update
rustup default stable
rustup target add wasm32-unknown-unknown
if ! command -v wasm-pack >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
case "$(uname -m)" in
x86_64) WASM_PACK_TARGET="x86_64-unknown-linux-musl" ;;
aarch64|arm64) WASM_PACK_TARGET="aarch64-unknown-linux-musl" ;;
*) echo "Unsupported build architecture: $(uname -m)" >&2; exit 1 ;;
esac
ARCHIVE="wasm-pack-${WASM_PACK_VERSION}-${WASM_PACK_TARGET}.tar.gz"
URL="https://github.com/wasm-bindgen/wasm-pack/releases/download/${WASM_PACK_VERSION}/${ARCHIVE}"
TMP_DIR="$(mktemp -d)"
curl -L --fail --silent --show-error "$URL" -o "$TMP_DIR/$ARCHIVE"
tar -xzf "$TMP_DIR/$ARCHIVE" -C "$TMP_DIR"
install \
"$TMP_DIR/wasm-pack-${WASM_PACK_VERSION}-${WASM_PACK_TARGET}/wasm-pack" \
"$HOME/.local/bin/wasm-pack"
rm -rf "$TMP_DIR"
fi
bash ./scripts/build.sh
This script prepares the machine. It does not create a second release recipe.
render-build.sh
prepare Rust and wasm-pack
↓
build.sh
↓
dist/
build.sh stays the one place that decides what ships.
Describe the static site#
Render Blueprints keep deployment settings in the repository. Create render.yaml in the project root:
services:
- type: web
name: rustbound-idle
runtime: static
buildCommand: bash ./scripts/render-build.sh
staticPublishPath: ./dist
runtime: static tells Render there is no long-running server. The build command creates dist/, and staticPublishPath tells Render which folder to publish.
There is no start command. Visitors download HTML, CSS, JavaScript, and WebAssembly. The game runs on their device.
Push, connect, deploy#
Commit the release files and push them to GitHub. In Render, create a Blueprint from the repository. Render reads render.yaml, installs the build tools, runs scripts/build.sh, and publishes dist/.
The first build takes longer because the build machine has to install Rust and wasm-pack.
When it finishes, the game has a public address:
https://rustbound-idle.onrender.com/
Open it on another device. Localhost has officially left the building.
Test the real thing#
The browser check accepts a URL, so the deployment does not need a second test suite:
cd web
node check.mjs https://rustbound-idle.onrender.com/
The first time I ran this against the live address, it failed. Render was still serving an older prototype from before the repository moved to the Journey to Code organization. The page opened, but the slime numbers and save format did not match Article 12.
That is the exact reason we point the same test at the public address. "The site returned 200" only proved that something was there.
After the Blueprint rebuilt the current checkpoint, the seven checks passed against the live HTTPS site:
ok a new game starts at stage 1
ok the slime has 20 of 20 health
ok four clicks clear the first slime
ok the player was paid
ok the save was restored and ten minutes applied
ok the status says how long we were gone
ok a broken save starts a new game
All checks passed.
Saves survive a deploy#
The saved game lives in the player's browser under rustbound-idle.save. A deploy replaces the program files. It does not clear localStorage.
Back in Article 10, we gave saves a version number and a named error for versions we do not understand. That boundary matters now. A new build either loads the old save or explains why it cannot. It does not guess.
Render
↓
five static files
↓
browser
├── <idle-game>
├── localStorage
└── WebAssembly
↓
Rust engine
Render does not know this is a game. It serves files. The browser owns the clock, the pixels, and the save. The Rust engine still owns the rules.
The complete release checkpoint#
If you are only reading along, skip to the last two sections.
Article 13 changes no Rust or browser code. It adds the release configuration and scripts around them:
rustbound-idle/
├── .gitignore (adds dist/)
├── Cargo.toml (release and wasm-opt settings)
├── render.yaml
├── scripts/
│ ├── build.sh
│ ├── dev.sh
│ └── render-build.sh
├── src/ (unchanged)
├── tests/ (unchanged)
└── web/ (unchanged)
The scripts and configuration above are the complete changed files. The article_13 branch holds the finished checkpoint.
Run the local gates one more time:
cargo test
bash ./scripts/build.sh
python3 -m http.server 8080 --directory dist
Then, from a second terminal:
cd web
node check.mjs http://localhost:8080/
Thirty-two Rust tests. Seven browser checks. Five files to deploy.
What we learned#
- A release profile changes how Cargo builds the same code for shipping.
- Exact file sizes need a command, a tool version, and a date.
wasm-optmust understand the WebAssembly features the Rust toolchain emits.- A deployable folder should come from an allowlist, not everything in
web/. - Test the folder that ships, then run the same test against the public URL.
- A static site needs a build command and a publish directory, not a running server.
You do not need to memorize every shell command yet.
The important part is the chain. One script makes the release folder. The local server, Render, and the browser test all use that same folder.
Next: the game still needs the internet to open#
The game runs in the browser and saves locally. So why should losing the connection stop it from opening?
Next time, Rustbound becomes an installable, offline-capable Progressive Web App. The game that lives at a URL becomes one that can live on a phone.
Further reading#
Small function. Bigger game.
Comments require JavaScript.