Skip to content
Journey to Code

Part 13 of 13

Rustbound

Localhost Is Not a Launch.

Build a Rust WebAssembly Game for Release and Deploy It on Render

· By · 7 min read

View as Markdown

Cover reading "Localhost Is Not a Launch." connecting a release build script that copies five named files to the finished dist folder, with handwritten notes "Allowlist only" and "Five files ship"

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:

text
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.

text
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:

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:

SettingWhat it changes
opt-level = "s"Optimize for a small file instead of maximum speed.
lto = trueLet the optimizer look across the whole program and remove unused code.
codegen-units = 1Compile 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:

toml
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.

Why do we use a release build for the game visitors download?

Measure it#

On November 1, 2026, with wasm-pack 0.15.0, these were the byte counts on my machine:

Terminal
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
text
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:

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:

Terminal
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.

Pack the five-file release folder
---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:

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:

Terminal
bash ./scripts/build.sh

The result contains five files:

text
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.

Why does build.sh copy named files instead of copying everything under web/?

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:

Terminal
python3 -m http.server 8080 --directory dist

Then point Article 12's Playwright check at that address:

Terminal
cd web
node check.mjs http://localhost:8080/

This is the real output from the release folder:

text
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:

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.

text
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:

render.yaml
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.

Why does this Render service have no start command?

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:

Terminal
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:

text
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.

text
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:

text
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:

Terminal
cargo test
bash ./scripts/build.sh
python3 -m http.server 8080 --directory dist

Then, from a second terminal:

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-opt must 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.