# Localhost Is Not a Launch.

Canonical: <https://journeytocode.io/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:

```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](https://github.com/journey-to-code-org/rustbound-idle/tree/article_12) 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**

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

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

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

1. Release builds can use localStorage, while debug builds cannot
2. Release builds spend more compile time producing a smaller, optimized download
3. Render refuses to publish debug files

**Answer:** 2. Release builds spend more compile time producing a smaller, optimized download

> **Explanation:** Both builds run the same game. The release build changes how the code is compiled and optimized before visitors download it.


### Measure it

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

```bash
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**

```bash
#!/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
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**

```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**

```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; }
```

**JavaScript**

```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();
```

[Pack the five-file release folder on the canonical article](https://journeytocode.io/localhost-is-not-a-launch/).


The release script makes that allowlist real.

Create `scripts/build.sh`:

**scripts/build.sh**

```bash
#!/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
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.

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

1. Bash cannot copy a whole directory
2. The named allowlist keeps tests, dependencies, and other development files out of the release
3. Render only accepts five files

**Answer:** 2. The named allowlist keeps tests, dependencies, and other development files out of the release

> **Explanation:** The number five belongs to this project today. The important part is that every shipped file is named on purpose.


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

```bash
python3 -m http.server 8080 --directory dist
```


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

```bash
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**

```bash
#!/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**

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

**Question:** Why does this Render service have no start command?

1. WebAssembly starts the Render server automatically
2. Render publishes the files in `dist/` as a static site, so no application process keeps running
3. The start command belongs in Cargo.toml

**Answer:** 2. Render publishes the files in `dist/` as a static site, so no application process keeps running

> **Explanation:** Render runs the build once, then serves the resulting static files. The browser runs the game.


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

```bash
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](https://github.com/journey-to-code-org/rustbound-idle/tree/article_13) holds the finished checkpoint.

Run the local gates one more time:

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


Then, from a second terminal:

```bash
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

- [Render static sites](https://render.com/docs/static-sites)
- [Render Blueprint reference](https://render.com/docs/blueprint-spec)
- [`wasm-pack build`](https://rustwasm.github.io/docs/wasm-pack/commands/build.html)

**Small function. Bigger game.**
[Join the discussion on the canonical article](https://journeytocode.io/localhost-is-not-a-launch/)
