Skip to content
Journey to Code

Part 14 of 14

Rustbound

A Bookmark Is Not an App.

Make Your Rust WebAssembly Game Installable and Offline With a Manifest and a Service Worker

· By · 9 min read

View as Markdown

Cover reading "A Bookmark Is Not an App." connecting an online browser window to an installed Rustbound window through a service-worker cache, with handwritten notes "Cache the shell" and "Server gone"

Last time, the game got a public address.

Open that address on a plane, in a tunnel, or on a phone with one bar, and you get a browser error page. The game runs entirely on your device. The rules are in WebAssembly. The save is in localStorage. And yet one dropped connection stops you from even opening it.

That is an odd thing for a game with no server to be afraid of.

Today we fix it. The game becomes installable, with its own icon and window, and it opens with no network at all.

The Rust engine does not change. Neither do the rules, saves, or clock. This lesson adds a manifest, a service worker, two icons, and one more reason to save.

Our starting point#

Everything here builds on Article 13. If you kept your project, you have the starting point already. The article_13 branch of the companion repository has it too.

text
rustbound-idle/
├── Cargo.toml
├── render.yaml
├── scripts/
│   ├── dev.sh
│   ├── build.sh
│   └── render-build.sh
├── src/                (unchanged today)
├── tests/              (unchanged today)
└── web/
    ├── index.html
    ├── idle-game.js
    ├── styles.css
    ├── check.mjs
    ├── package.json
    └── pkg/            (generated)

Article 13's release folder contains exactly five files. We are about to give it five more.

Two kinds of offline#

Rustbound already handles one kind of offline.

Close the game for an hour, come back, and the browser measures the time away. The engine runs tick(3600). That is offline progress.

But that only works if the page opens. Opening it still needs the network to download the HTML, JavaScript, and .wasm file.

text
offline progress
→ Rust works out what happened while you were away

offline loading
→ the browser starts the game without the network

We have the first. Now we need the second.

What does offline loading add to the game?

When both work, this happens:

text
no network
    ↓
the app opens from files saved on the device
    ↓
localStorage restores the save
    ↓
JavaScript measures time away
    ↓
Rust receives tick(secondsAway)

Every link in that chain already exists except the second.

A website that knows it is an app#

A Progressive Web App, or PWA, is still a website. We add two pieces:

  1. A manifest tells the browser the app's name, icons, colors, and starting page.
  2. A service worker can answer requests with files saved earlier.

No app store. No wrapper. No second game.

The manifest#

Create web/manifest.webmanifest:

web/manifest.webmanifest
{
  "id": "./",
  "name": "Rustbound Idle",
  "short_name": "Rustbound",
  "description": "A tiny idle clicker powered by a Rust game engine compiled to WebAssembly.",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "background_color": "#171a18",
  "theme_color": "#171a18",
  "icons": [
    { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

A few fields do most of the work:

  • start_url is the page the installed app opens.
  • scope is the part of the site the app owns.
  • display: "standalone" asks for an app window instead of a normal browser tab.
  • icons gives the browser images for a home screen, launcher, or taskbar.
  • id is the app's stable identity. If start_url ever changes, the browser still knows this is the same installed app.

Add the two square PNG files:

text
web/icons/icon-192.png
web/icons/icon-512.png

The companion branch includes the Rustbound icons used by the live game. They are ordinary PNG files. The browser does not need the source artwork.

Now tell the page where to find the manifest. Add this inside <head>, after <title>:

web/index.html
<meta name="theme-color" content="#171a18" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Rustbound" />

<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon-192.png" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />

The manifest link is the important line. The other metadata helps browsers place the right color, name, and icon around the installed page.

The service worker#

A service worker is JavaScript that runs separately from the page. Once it controls the page, it can sit between a request and the network.

Ours has one boring job:

Keep copies of the files the game needs to start, and use them when the network fails.

No combat. No XP. No saves. If game rules appear in this file, they have wandered into the wrong building.

Create web/sw.js. Start with the cache name and the app shell, the files needed to open the game:

web/sw.js
const CACHE_NAME = "rustbound-idle-v1";

const APP_SHELL = [
  "./",
  "./index.html",
  "./styles.css",
  "./idle-game.js",
  "./pwa.js",
  "./manifest.webmanifest",
  "./icons/icon-192.png",
  "./icons/icon-512.png",
  "./pkg/rustbound_idle.js",
  "./pkg/rustbound_idle_bg.wasm",
];

The "./" entry and index.html point to the same file through two addresses. People visit the first address. The manifest names it too. We cache both.

Install: save the shell#

js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
  );

  self.skipWaiting();
});

cache.addAll() is all-or-nothing. If one file cannot be downloaded, the worker does not finish installing.

That is what we want. A half-saved game would open just far enough to disappoint us.

What happens if one file in APP_SHELL is missing from the deployed site?

Activate: remove old copies#

js
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => key !== CACHE_NAME)
            .map((key) => caches.delete(key)),
        ),
      )
      .then(() => self.clients.claim()),
  );
});

When a new worker takes over, it deletes caches with old names. Then clients.claim() lets it control open pages instead of waiting for another visit.

Fetch: try the network, then the cache#

js
self.addEventListener("fetch", (event) => {
  const request = event.request;

  if (request.method !== "GET") return;

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  event.respondWith(networkFirst(request));
});

async function networkFirst(request) {
  const cache = await caches.open(CACHE_NAME);

  try {
    const response = await fetch(request);

    if (response.ok) {
      await cache.put(request, response.clone());
    }

    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;

    if (request.mode === "navigate") {
      const fallback = await cache.match("./index.html");
      if (fallback) return fallback;
    }

    return Response.error();
  }
}

Read networkFirst() as a list:

  1. Try the network.
  2. If it works, save a fresh copy and return it.
  3. If it fails, return the saved copy.
  4. For an uncached page address, fall back to index.html.
  5. If nothing can answer the request, fail honestly.

Network first keeps updates simple. The trade-off is that a connection can be alive and painfully slow. The browser waits for it to fail before using the cache. A larger app may choose another strategy. Our ten-file game can start here.

Register it#

A service worker does nothing until the page registers it. Create web/pwa.js:

web/pwa.js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("./sw.js", {
        scope: "./",
      });

      console.info("Rustbound Idle service worker ready:", registration.scope);
    } catch (error) {
      console.warn("Rustbound Idle could not enable offline mode.", error);
    }
  });
}

Load it under the game's module in index.html:

html
<script type="module" src="./idle-game.js"></script>
<script type="module" src="./pwa.js"></script>

If service workers are unavailable or registration fails, the game still works as a website. The game never waits for the worker. Offline loading is a bonus layered on top, not a gate the engine has to pass through.

Service workers require a secure context. The public game has HTTPS, and browsers treat localhost as secure for development.

An installed game is hidden, not closed#

Article 12 saved during beforeunload. That can help when a browser tab closes, but mobile systems may hide a page and later discard it without firing that event.

The better signal for saving state is visibilitychange. It fires when the document becomes hidden, including when the player switches tabs or moves to another app.

Add this under the existing beforeunload listener in connectedCallback():

web/idle-game.js
window.addEventListener("beforeunload", () => this.saveGame());

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") this.saveGame();
});

We keep the old listener and add the earlier opportunity. The save format, timestamp, and eight-hour cap do not change.

Why save when document.visibilityState becomes hidden?

Two lists must agree#

The service worker promises to cache a list. The release script promises to ship a list.

Those lists now share a contract.

Update the release folder in scripts/build.sh:

scripts/build.sh
rm -rf dist
mkdir -p dist/pkg dist/icons

cp web/index.html web/idle-game.js web/styles.css dist/
cp web/pwa.js web/sw.js web/manifest.webmanifest dist/
cp web/icons/icon-192.png web/icons/icon-512.png dist/icons/
cp web/pkg/rustbound_idle.js web/pkg/rustbound_idle_bg.wasm dist/pkg/

sw.js itself ships, but it is not in APP_SHELL. The browser fetches and updates the worker separately. The other nine physical files must ship and be cached. The "./" cache entry is a second address for index.html, not an eleventh file.

Try the contract below. Turn off either side of a file and check the release. The worker can only keep a file the build actually shipped.

Keep the release and app shell in agreement
---html
<h2>Offline release contract</h2>
<p>Toggle whether each file ships and whether the app shell caches it.</p>
<div class="files"></div>
<button id="check" type="button">Check release</button>
<pre aria-live="polite"></pre>
---css
body { font: 16px/1.45 system-ui; max-width: 52rem; margin: 1.5rem auto; padding: 0 1rem; color: #f5f1e8; background: #15171c; }
.files { display: grid; gap: .5rem; margin: 1rem 0; }
.row { display: grid; grid-template-columns: minmax(12rem, 1fr) auto auto; gap: .5rem; align-items: center; padding: .55rem; background: #20242b; }
button { min-height: 44px; border: 1px solid #777; border-radius: .3rem; padding: .55rem .75rem; color: inherit; background: #292e37; }
button[aria-pressed="true"] { border-color: #ff6b3d; background: #48271d; }
#check { width: 100%; background: #ff5522; border-color: #ff5522; color: #111; font-weight: 700; }
pre { min-height: 5rem; padding: 1rem; white-space: pre-wrap; background: #0d0f13; }
@media (max-width: 34rem) { .row { grid-template-columns: 1fr 1fr; } .name { grid-column: 1 / -1; } }
---js
const files = [
  ["index.html", true, true],
  ["idle-game.js", true, true],
  ["styles.css", true, true],
  ["pwa.js", true, true],
  ["sw.js", true, false],
  ["manifest.webmanifest", true, true],
  ["icons/icon-192.png", true, true],
  ["icons/icon-512.png", true, true],
  ["pkg/rustbound_idle.js", true, true],
  ["pkg/rustbound_idle_bg.wasm", true, true],
].map(([name, shipped, cached]) => ({ name, shipped, cached }));

const list = document.querySelector(".files");
const output = document.querySelector("pre");

for (const file of files) {
  const row = document.createElement("div");
  row.className = "row";
  const name = document.createElement("code");
  name.className = "name";
  name.textContent = file.name;
  row.append(name, toggle(file, "shipped", "Ships"), toggle(file, "cached", "Cached"));
  list.append(row);
}

function toggle(file, key, label) {
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = label;
  button.setAttribute("aria-label", `${label}: ${file.name}`);
  button.setAttribute("aria-pressed", file[key]);
  button.addEventListener("click", () => {
    file[key] = !file[key];
    button.setAttribute("aria-pressed", file[key]);
  });
  return button;
}

document.querySelector("#check").addEventListener("click", () => {
  const missing = files.filter((file) => file.cached && !file.shipped);
  const uncached = files.filter((file) => file.name !== "sw.js" && file.shipped && !file.cached);
  const workerCached = files.find((file) => file.name === "sw.js").cached;
  const problems = [
    ...missing.map((file) => `${file.name}: cached but not shipped`),
    ...uncached.map((file) => `${file.name}: shipped but not cached`),
    ...(workerCached ? ["sw.js: the browser updates the worker separately"] : []),
  ];

  output.textContent = problems.length
    ? `CONTRACT BROKEN\n${problems.join("\n")}`
    : "CONTRACT HOLDS\nTen files ship. Nine physical app files are cached.";
});

document.querySelector("#check").click();

The complete folder is:

text
dist/
├── index.html
├── idle-game.js
├── styles.css
├── pwa.js
├── sw.js
├── manifest.webmanifest
├── icons/
│   ├── icon-192.png
│   └── icon-512.png
└── pkg/
    ├── rustbound_idle.js
    └── rustbound_idle_bg.wasm

Ten files ship. Nine physical files belong to the shell. Two lists. One tiny exception that is now written down.

Test offline by removing the server#

Turning off Wi-Fi once and nodding at the screen is not a test.

We need to serve dist/, load the game, wait for the service worker, shut the server down, and reload.

Create web/check-offline.mjs:

web/check-offline.mjs
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, resolve } from "node:path";
import { chromium } from "playwright";

const DIST = resolve(process.argv[2] ?? "../dist");
const PORT = 8081;
const ORIGIN = `http://localhost:${PORT}/`;

const TYPES = {
  ".html": "text/html",
  ".js": "text/javascript",
  ".css": "text/css",
  ".wasm": "application/wasm",
  ".webmanifest": "application/manifest+json",
  ".png": "image/png",
};

function serve() {
  const server = createServer((req, res) => {
    const path = new URL(req.url, ORIGIN).pathname;
    const file = join(DIST, path.endsWith("/") ? `${path}index.html` : path);

    if (!statSync(file, { throwIfNoEntry: false })?.isFile()) {
      res.writeHead(404).end();
      return;
    }

    res.writeHead(200, {
      "content-type": TYPES[extname(file)] ?? "application/octet-stream",
    });
    createReadStream(file).pipe(res);
  });

  return new Promise((done) => server.listen(PORT, () => done(server)));
}

let failures = 0;

function check(name, ok) {
  console.log(`${ok ? "PASS" : "FAIL"}  ${name}`);
  if (!ok) failures += 1;
}

const server = await serve();
const browser = await chromium.launch();

try {
  const page = await browser.newPage();
  const gameStarted = () => Boolean(document.querySelector("idle-game")?.game);

  await page.goto(ORIGIN);
  await page.waitForFunction(gameStarted);
  check("the game starts while the server is up", true);

  await page.locator("idle-game").locator('[data-action="attack"]').click();

  await page.waitForFunction(() => navigator.serviceWorker.controller, null, {
    timeout: 10000,
  });
  check("a service worker controls the page", true);

  server.close();
  server.closeAllConnections();

  await page.reload();
  await page.waitForFunction(gameStarted);
  check("the game starts again with the server gone", true);

  const stage = await page.locator("idle-game").locator("[data-stage]").textContent();
  check("the game draws its state", stage.trim() !== "");

  const saved = await page.evaluate(() =>
    localStorage.getItem("rustbound-idle.save"),
  );
  check("the save is still there", saved !== null);
} catch (error) {
  console.error(error.message);
  failures += 1;
} finally {
  await browser.close();
  server.close();
}

process.exit(failures === 0 ? 0 : 1);

The click before the server closes is not decoration. It calls the real component's saveGame(), so the final save assertion does not depend on a timer winning a race.

Add the script next to Article 13's check in web/package.json:

web/package.json
"scripts": {
  "check": "node check.mjs",
  "check:offline": "node check-offline.mjs"
}

Build and run it:

Terminal
bash ./scripts/build.sh
cd web
npm run check:offline

This is the real output from the Article 14 release:

text
PASS  the game starts while the server is up
PASS  a service worker controls the page
PASS  the game starts again with the server gone
PASS  the game draws its state
PASS  the save is still there

Step three is the point. The server is gone. The game is not.

Prove the test can fail#

A test that cannot fail tells us nothing.

I removed the pwa.js line from the generated dist/index.html. The game started online, then the test waited ten seconds for a worker that never arrived:

text
PASS  the game starts while the server is up
page.waitForFunction: Timeout 10000ms exceeded.

Then I restored that line and removed pkg/rustbound_idle.js from the generated app shell. The worker took control, but the offline reload could not build the game:

text
PASS  the game starts while the server is up
PASS  a service worker controls the page
page.waitForFunction: Timeout 30000ms exceeded.

Both edits were made only in generated dist/. The next build restores the release.

The second experiment also caught a bug in the first version of this test. It used this condition:

js
element?.shadowRoot?.querySelector("[data-enemy-health]")?.textContent !== "0"

If the element had not loaded, the left side was undefined. Unfortunately, undefined !== "0" is true. The broken game looked ready.

The fixed check waits for the property the component sets only after WebAssembly initializes and the save is restored:

js
Boolean(document.querySelector("idle-game")?.game)

The same condition had been living in Article 13's check.mjs the whole time, passing for the same wrong reason. Both files now wait for .game.

The negative test improved the test. That seems fair.

Install it#

Push the checkpoint and let Render rebuild. Open the live game once while online so the service worker can install its shell.

Browsers place installation in different spots. Chrome and Edge may show an icon near the address bar or an install item in the menu. On iPhone and iPad, installation goes through Share → Add to Home Screen.

The exact prompt is browser UI, not part of our game. The manifest supplies the name, icon, colors, starting page, and standalone display request. The browser decides how to offer them.

Once the site has loaded online, the useful test is less glamorous:

  1. Click the enemy once. That click saves, so the reload has something to restore.
  2. Disconnect the network.
  3. Reload or reopen the game.

The service worker opens the application. localStorage restores the save. JavaScript measures the time away. Rust applies tick(secondsAway).

text
service worker   → the game opens
localStorage     → the save comes back
JavaScript       → measures time away
Rust             → tick(secondsAway)

Shipping an update#

A player with the app installed already has files cached.

Our network-first strategy refreshes successful requests while the player is online. But a release can change several cached files together. We do not want half of one version and half of another pretending they have met.

Whenever a cached file changes, bump the cache name:

js
const CACHE_NAME = "rustbound-idle-v2";

The new worker installs the new shell as one unit. During activation, it removes the old cache. Players already on the old version pick up the new one on their next reload; nothing swaps out from under a running game.

The saved game stays where it was. Cache Storage holds application files. localStorage holds the player's save. They are neighbors, not roommates.

The complete checkpoint#

Article 14 changes no Rust. It adds the browser's offline layer around the same engine:

text
rustbound-idle/
├── scripts/build.sh       (ships ten named files)
├── src/                   (unchanged)
├── tests/                 (unchanged)
└── web/
    ├── check.mjs          (fixes the readiness check)
    ├── check-offline.mjs
    ├── icons/
    │   ├── icon-192.png
    │   └── icon-512.png
    ├── idle-game.js       (saves when hidden)
    ├── index.html         (manifest and PWA metadata)
    ├── manifest.webmanifest
    ├── package.json       (adds check:offline)
    ├── pwa.js
    └── sw.js

The article_14 branch holds the tested checkpoint.

Run the gates one more time:

Terminal
cargo test
bash ./scripts/build.sh
cd web
npm run check:offline

Thirty-two Rust tests. Seven game checks. Five offline checks. Ten files to deploy.

What we learned#

  • A PWA adds install metadata and an offline request layer to a website.
  • A manifest names the app, its icons, its start page, and how it should display.
  • A service worker can answer failed network requests from Cache Storage.
  • The app shell is one unit: cache.addAll() installs it all or not at all, and the build allowlist must ship every file it names.
  • visibilitychange gives the page a dependable moment to save before it may be discarded.
  • An offline test should remove the server, not merely toggle a browser setting.
  • A negative control can expose a test that was passing for the wrong reason.

You do not need to memorize the service worker lifecycle yet.

The important boundary is smaller. Rust still owns what happens in the game. The browser now knows how to keep the game available.

Next: the save trusts us too much#

The game opens offline and restores its save. There is a problem, though.

That save is a string in localStorage. A player can edit it and hand themselves a billion gold. The Rust engine will load whatever it is given.

Next time, we decide what the game should trust.

Further reading#

Small function. Bigger game.

Comments require JavaScript.