# A Button Is Not a Rule.

Canonical: <https://journeytocode.io/a-button-is-not-a-rule/>

Give the Engine a Face With One Custom Element

Our engine runs in a browser. Nobody can play it.

There is no button. There is a `<pre>` tag and a number, and the number only changes if you edit the page.

Today the game gets a face: one custom HTML element, `<idle-game>`, that draws the snapshot, turns a click on a button into `game.click()`, keeps a clock, and saves to the browser's storage, so closing the tab is not game over either.

By the end, you open the page, click a few slimes to death, buy an upgrade, close the tab, come back ten minutes later, and the page says:

```text
Welcome back. 10 minutes of progress applied.
```


The browser owns time, storage, and pixels now. It still does not get the rules.

This is the first article in the series where most of the new code is JavaScript. The Rust does not change by a single line. That is the point of everything we did last time, and today we collect on it.

---

## Our starting point

This series builds a small idle game: a player clicks to fight slimes, earns gold and XP, and keeps progressing while you are away.

If you are only reading along, skip past this section to "A tag of our own."

If you are building along, you need the project as it stood at the end of the last article: the `rustbound-idle` library with `src/web.rs`, built for the browser with `wasm-pack`. (If you kept your project, you have it. The `article_11` branch of the companion repository has it too.) The Rust side is not changing today, so this starting point only lists the two files we are about to replace.

```text
rustbound-idle/
├── Cargo.toml
├── src/            (unchanged today)
├── tests/          (unchanged today)
└── web/
    ├── index.html
    └── pkg/        (generated by wasm-pack)
```


`web/index.html`:

**web/index.html**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Rustbound Idle</title>
  </head>
  <body>
    <h1>Rustbound Idle</h1>
    <pre id="output">Loading the engine...</pre>

    <script type="module">
      import init, { Game } from "./pkg/rustbound_idle.js";

      await init();

      const game = new Game();
      const before = JSON.parse(game.stateJson());

      game.tick(3600);

      const after = JSON.parse(game.stateJson());

      document.querySelector("#output").textContent =
        `One hour away.\nStage ${before.stage} -> ${after.stage}  ` +
        `Level ${before.level} -> ${after.level}  ` +
        `Gold ${before.gold} -> ${after.gold}`;
    </script>
  </body>
</html>
```


And for reference, the seven things JavaScript can do, from `web/pkg/rustbound_idle.d.ts` (plus the `[Symbol.dispose]` line we ignored last time):

**web/pkg/rustbound\_idle.d.ts**

```ts
export class Game {
    free(): void;
    [Symbol.dispose](): void;
    buyClickUpgrade(): string;
    click(): void;
    static fromJson(json: string): Game;
    constructor();
    saveJson(): string;
    stateJson(): string;
    tick(seconds: number): void;
}
```


`stateJson()` returns a flat picture of the game with every rule already applied: `stage`, `gold`, `xp`, `xpNeeded`, `level`, `clickDamage`, `clickUpgradeLevel`, `clickUpgradeCost`, `enemyName`, `enemyHealth`, `enemyMaxHealth`. `buyClickUpgrade()` returns the word `"purchased"` or `"notEnoughGold"`. `Game.fromJson()` returns a game or throws a message.

Make sure the package is built and the page is served:

```bash
wasm-pack build --target web --out-dir web/pkg
python3 -m http.server 8080 --directory web
```


Open `http://localhost:8080/` and you should see the hour away. That is where we start.

---

## A tag of our own

The browser already knows a lot of tags. `<button>`, `<h1>`, `<pre>`. Each one knows how to draw itself and what to do when you click it.

We are going to teach it one more:

```html
<idle-game></idle-game>
```


This is a **custom element**, the heart of what people call a Web Component, and it is a feature of the browser itself. No framework. No build step. A tag with a hyphen in its name, a JavaScript class that says what it does, and one line that introduces them to each other.

The page shrinks to almost nothing. Replace `web/index.html` with this:

**web/index.html**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Rustbound Idle</title>
  </head>
  <body>
    <idle-game></idle-game>

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


The script moved out into its own file, and the page does not know what an `<idle-game>` is. It does not need to. `main.rs` works the same way: it creates a game and prints it, and it knows nothing about formulas. Same shape, one layer up.

Now the class. Create `web/idle-game.js`:

**web/idle-game.js**

```js
import init, { Game } from "./pkg/rustbound_idle.js";

class IdleGame extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.game = null;
  }

  async connectedCallback() {
    this.renderShell();

    await init();

    this.game = new Game();
    this.renderState();
  }
}

customElements.define("idle-game", IdleGame);
```


This is how you tell the browser what a new kind of tag is. Four things to read.

`class IdleGame extends HTMLElement` says: this is a kind of element. Every tag the browser knows is an `HTMLElement` underneath, and so is ours.

The `constructor()` runs when the element is created. `super()` lets `HTMLElement` set itself up first. It is required, and the browser will tell you if you forget. Then `attachShadow()` gives the element a private tree of its own to draw in. The page cannot see into it, and its styles do not leak out, so we can use plain names like `.meter` inside without worrying about the rest of the page. That private tree is called the **shadow DOM**, and `this.shadowRoot` is how we reach it.

`connectedCallback()` runs when the element is attached to the page. This is where the work happens. It is `async` so that it can `await init()`, the same engine load as before.

And `customElements.define("idle-game", IdleGame)` is the introduction: when the browser sees that tag, use this class.

The two methods it calls do not exist yet, so do not reload until the next section is done. We write them now.

---

## Draw the shell once

The old page rebuilt its entire output every time. For one line of text that was fine. For a screen with a dozen numbers on it, we want to draw the structure once and then change only the numbers.

So the component has two drawing methods. `renderShell()` builds the HTML once, with placeholders. `renderState()` fills the placeholders in, and it runs every second and after every click.

Add `renderShell()` inside the class, under `connectedCallback()`:

**web/idle-game.js**

```js
  renderShell() {
    this.shadowRoot.innerHTML = `
      <link rel="stylesheet" href="./styles.css" />

      <section class="game">
        <h1>Rustbound Idle</h1>
        <p class="status" data-status role="status" aria-live="polite"></p>

        <section class="stats">
          <p>Stage <strong data-stage>1</strong></p>
          <p>Level <strong data-level>1</strong></p>
          <p>Gold <strong data-gold>0</strong></p>
        </section>

        <section class="enemy">
          <h2 data-enemy-name>Enemy</h2>
          <p><span data-enemy-health>0</span> / <span data-enemy-max-health>0</span> HP</p>
          <div class="meter"><span data-health-bar></span></div>
          <button data-action="attack" type="button">
            Attack for <span data-click-damage>0</span>
          </button>
        </section>

        <section class="progress">
          <p>XP <span data-xp>0</span> / <span data-xp-needed>0</span></p>
          <div class="meter xp"><span data-xp-bar></span></div>
        </section>

        <section class="upgrades">
          <button data-action="buy-click" type="button">
            Sharper clicks (level <span data-click-upgrade-level>0</span>)
            for <span data-click-upgrade-cost>0</span> gold
          </button>
        </section>

        <button class="reset" data-action="reset" type="button">Reset save</button>
      </section>
    `;
  }
```


It is a block of HTML inside a JavaScript string. The backticks let it span lines.

Every number on screen sits in an element with a `data-` attribute: `data-stage`, `data-gold`, `data-enemy-health`. Those are hooks. They do nothing on their own. They are names we can find things by, so that `renderState()` can say "put the stage here" without caring whether it is a `<strong>` or a `<span>`.

The buttons have `data-action` instead, for the same reason.

The `<p data-status role="status" aria-live="polite">` line is where messages go: "Not enough gold yet," "Welcome back." The two extra attributes tell a screen reader that this paragraph is a status line, and that it should be read out when it changes without interrupting. It costs nothing to include, and it is the difference between a game some people can play and a game everyone can.

The `<link>` at the top pulls in a stylesheet. The shadow DOM does not see the page's styles, so the component brings its own.

The stylesheet is deliberately plain. It makes the buttons look like buttons and the meters look like meters, and that is all. Making it beautiful is a different job, one that someone who does not write Rust could take on. Hold that thought for the end of the series. If you are reading along, skip the block. If you are building along, create `web/styles.css`:

**web/styles.css**

```css
:host {
  display: block;
  max-width: 28rem;
  margin: 2rem auto;
  font-family: system-ui, sans-serif;
  line-height: 1.4;
}

.game > section {
  margin-bottom: 1.5rem;
}

.stats {
  display: flex;
  gap: 1.5rem;
}

.stats p {
  margin: 0;
}

.status {
  min-height: 1.4em;
  color: #555;
}

.meter {
  height: 0.75rem;
  background: #ddd;
  border-radius: 0.375rem;
  overflow: hidden;
  margin: 0.5rem 0;
}

.meter span {
  display: block;
  height: 100%;
  width: 0;
  background: #c0392b;
  transition: width 0.15s;
}

.meter.xp span {
  background: #2980b9;
}

button {
  font: inherit;
  padding: 0.6rem 1rem;
  border: 1px solid #888;
  border-radius: 0.375rem;
  background: #f4f4f4;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.reset {
  font-size: 0.85rem;
}
```


**Poke the shadow boundary**

A page rule such as `.meter { background: red; }` does not reach inside a shadow root, because the component's tree is hidden from the page's stylesheet. Two doors are deliberate: CSS custom properties pass through, and `::part()` exposes only the parts a component names. This article takes the simple route and ships its own `styles.css`.

[Try the interactive Web Components explorer on the canonical article](https://journeytocode.io/a-button-is-not-a-rule/).


Two things in it matter to the code. `:host` is the shadow DOM's name for the element itself, our `<idle-game>`. And `.meter` is a grey bar with a `span` inside it for the coloured part, whose width we will set from JavaScript.

**Question:** The page's own stylesheet also has a `.meter` rule. Why does the bar inside `<idle-game>` ignore it?

1. The page's stylesheet loads after the component's, so it is overridden
2. The shadow root hides its tree from the page's stylesheet, so only rules inside that root reach its elements
3. `renderShell()` removes every class name the page has defined
4. Class names are only global when they begin with `data-`

**Answer:** 2. The shadow root hides its tree from the page's stylesheet, so only rules inside that root reach its elements

> **Explanation:** A shadow root is a wall for CSS as well as for JavaScript. That is why the component carries its own `<link>` to `styles.css`, and why its class names cannot clash with the page's.


---

## Fill in the numbers

Now `renderState()`. It asks Rust for the picture and pours it into the hooks:

**web/idle-game.js**

```js
  renderState() {
    const state = JSON.parse(this.game.stateJson());

    this.setText("[data-stage]", state.stage);
    this.setText("[data-level]", state.level);
    this.setText("[data-gold]", state.gold);
    this.setText("[data-enemy-name]", state.enemyName);
    this.setText("[data-enemy-health]", state.enemyHealth);
    this.setText("[data-enemy-max-health]", state.enemyMaxHealth);
    this.setText("[data-click-damage]", state.clickDamage);
    this.setText("[data-xp]", state.xp);
    this.setText("[data-xp-needed]", state.xpNeeded);
    this.setText("[data-click-upgrade-level]", state.clickUpgradeLevel);
    this.setText("[data-click-upgrade-cost]", state.clickUpgradeCost);

    this.setMeter("[data-health-bar]", state.enemyHealth, state.enemyMaxHealth);
    this.setMeter("[data-xp-bar]", state.xp, state.xpNeeded);

    this.shadowRoot.querySelector('[data-action="buy-click"]').disabled =
      state.gold < state.clickUpgradeCost;
  }

  setText(selector, value) {
    this.shadowRoot.querySelector(selector).textContent = String(value);
  }

  setMeter(selector, value, max) {
    const percent = max === 0 ? 0 : Math.min(100, (value / max) * 100);
    this.shadowRoot.querySelector(selector).style.width = `${percent}%`;
  }
```


Put all three under `renderShell()`. The order of methods inside a class does not matter to JavaScript. We list them in this order only so the file reads top to bottom.

`querySelector("[data-stage]")` finds the element with that attribute. Setting `textContent` replaces what is inside it. That is the whole trick, eleven times.

`setMeter()` turns two numbers into a width. Thirty health out of fifty is a bar 60% full.

The last line is the first thing on this screen that looks like a decision: the upgrade button is disabled when you cannot afford it. Look at what it compares. `state.gold` and `state.clickUpgradeCost` both come from Rust. JavaScript did not work out the cost. It read the cost and compared two numbers it was handed. The button going grey is presentation, not a rule. If the pricing formula ever changes, this line does not change with it.

Now look at what `renderState()` does not do. It does not add anything up. It does not know what level 2 means for click damage, or how much XP the next level takes. It is handed every answer and arranges them on the screen.

That is the whole contract for this file: Rust answers, JavaScript arranges.

**Question:** The upgrade button is disabled with `state.gold < state.clickUpgradeCost`. Which change would turn that line into a rule that belongs in JavaScript?

1. Renaming `clickUpgradeCost` to `cost`
2. Computing the cost in JavaScript from the upgrade level, instead of reading it from `stateJson()`
3. Moving the comparison into `setText()`
4. Putting the comparison inside a `data-` attribute

**Answer:** 2. Computing the cost in JavaScript from the upgrade level, instead of reading it from `stateJson()`

> **Explanation:** The comparison stays presentation as long as both numbers come from Rust. Once JavaScript works out what an upgrade costs, the pricing formula lives in two places, and the first one to change will disagree with the other.


Reload the page. The engine loads, and the screen fills in: stage 1, level 1, a Training Slime with 20 of 20 HP, a button that says "Attack for 5," and an upgrade for 10 gold that is greyed out because you have none.

Click Attack. Nothing happens. We have not told the button what a click means yet.

---

## A button is not a rule

Here is what a click means:

**web/idle-game.js**

```js
  bindEvents() {
    this.on("attack", () => {
      this.game.click();
      this.renderState();
    });
  }

  on(action, handler) {
    this.shadowRoot
      .querySelector(`[data-action="${action}"]`)
      .addEventListener("click", handler);
  }
```


Add both under `setMeter()`, and call `bindEvents()` from `connectedCallback()`, right after `this.game = new Game();`:

**web/idle-game.js**

```js
    this.game = new Game();
    this.bindEvents();
    this.renderState();
```


`on()` is a small helper, so that we do not write `this.shadowRoot.querySelector(...).addEventListener(...)` four times. It finds the button by its `data-action` and attaches a function that runs when the button is clicked.

The function for Attack is two lines. Tell the game. Redraw.

That is the title of this article. The button does not decide how much damage a click does. It does not check whether the slime died, pay the player, or spawn the next one. It says *a click happened*, and then it asks what the world looks like now. Everything between those two lines is on the far side of the border, where it has been since the first article:

```text
<button> click
    ↓
IdleGame handler
    ↓
game.click()               JavaScript, glue
    ↓
WebGame::click()           Rust, adapter
    ↓
Game::click()
    ↓
Player::click_damage()
    ↓
rules::click_damage()      Article 1
```


Reload and click. 15 of 20. Click three more times, and the stage ticks to 2, you have 5 gold and 6 XP, and a slime with 30 HP appears. The health bar jumps back to full.

Four clicks to a slime, the same count the seventh article's tests check.

---

## Buying across the border

The upgrade button follows the same shape, with one addition. Add this inside `bindEvents()`, under the attack handler:

**web/idle-game.js**

```js
    this.on("buy-click", () => {
      const outcome = this.game.buyClickUpgrade();
      this.renderState();
      this.showStatus(
        outcome === "purchased" ? "Click damage upgraded." : "Not enough gold yet.",
      );
    });
```


And `showStatus()` under `setMeter()`:

**web/idle-game.js**

```js
  showStatus(message) {
    this.shadowRoot.querySelector("[data-status]").textContent = message;
  }
```


`buyClickUpgrade()` answers in a word, because last time we decided that a word was what could cross. The handler reads the word and chooses a sentence. Again, it does not decide whether the purchase went through. Rust did, and said so.

Reload and click two slimes to death. The first pays 5 gold, the second 7. With 12 gold in hand, the 10-gold upgrade button lights up. Buy it. The status says "Click damage upgraded." The Attack button now reads "Attack for 7," and the upgrade costs 20.

The second button has a surprise in it. Try clicking it while it is greyed out. Nothing happens, because a disabled button does not fire clicks. So the `"notEnoughGold"` branch of that handler can never run.

It stays anyway. The disabled state is a courtesy from `renderState()`. Rust refusing the purchase is the rule. If someone removes the courtesy, or a different screen forgets it, the rule still holds, and the sentence is ready.

---

## The browser owns the clock

Right now the game only moves when you click. An idle game should move on its own.

In the eighth article we decided who owns time: whoever runs the game. In a terminal, that was `main()`, handing over `3600` in one go. In a browser, it is a clock that ticks while the tab is open.

Add this under `showStatus()`:

**web/idle-game.js**

```js
  startClock() {
    this.lastTickAt = Date.now();

    this.timer = setInterval(() => {
      const now = Date.now();
      const elapsedSeconds = Math.floor((now - this.lastTickAt) / 1000);

      if (elapsedSeconds < 1) return;

      this.lastTickAt += elapsedSeconds * 1000;
      this.game.tick(elapsedSeconds);
      this.renderState();
    }, 1000);
  }
```


Give the constructor two more fields, after `this.game = null;`:

**web/idle-game.js**

```js
    this.timer = null;
    this.lastTickAt = 0;
```


And call `startClock()` at the end of `connectedCallback()`, after `renderState()`.

`setInterval()` runs a function over and over, here once every thousand milliseconds. The obvious version of that function is one line: `this.game.tick(1)`. One second passed, so tell the game.

We are not writing that version, because the browser does not promise a thousand milliseconds. A busy page, a background tab, or a laptop going to sleep can make the interval fire late, or stop firing for a while and then catch up. If every firing meant exactly one second, the game would run slow whenever the browser was busy.

So the function measures. `Date.now()` gives the current time in milliseconds. We remember when we last told the game about time, work out how many whole seconds have passed since then, and hand over that many. If the browser fired early and less than a second has passed, we say nothing and wait.

`this.timer` holds what `setInterval()` returns, which is the handle you would pass to `clearInterval()` to stop the clock. Nothing stops it today. The field is there so that a later change can.

`this.lastTickAt += elapsedSeconds * 1000` is the careful line. We move the marker forward by exactly the seconds we handed over, not to `now`. The fraction of a second we have not reported yet waits for the next firing, so nothing gets lost.

Reload, and watch. The slime's health bar drains by one every second, because level 1 idle damage is 1. Click to help, or do not. After twenty seconds the slime is dead either way.

**Question:** The clock hands the game `elapsedSeconds` and then moves `lastTickAt` forward by exactly that many seconds. Why not set `lastTickAt = now`?

1. `Date.now()` returns seconds, so the assignment would be a type error
2. The fraction of a second left over would be thrown away, so the game would run slow over time
3. `setInterval()` would stop firing once `lastTickAt` changes
4. The game would tick twice on the next firing

**Answer:** 2. The fraction of a second left over would be thrown away, so the game would run slow over time

> **Explanation:** Suppose the timer fires at 1.7 seconds. One whole second is handed over. Setting the marker to 1.7 seconds would discard the 0.7, and every firing would lose a little time. Moving it by one second keeps the 0.7 for the next one.


Rust is doing exactly what it did in the test that runs twenty ticks. The only difference is that this time the twenty seconds are real.

---

## Save to the browser

Close the tab now, and everything is gone. Our save system has been ready since the tenth article. It just needs somewhere to put the text.

The browser has a place for that: `localStorage`. It is a small key-value store, kept per website, that survives closing the tab and the browser. You put a string in under a name and get it back later by the same name.

Add this under `startClock()`:

**web/idle-game.js**

```js
  saveGame() {
    localStorage.setItem(
      SAVE_KEY,
      JSON.stringify({ savedAt: Date.now(), game: this.game.saveJson() }),
    );
    this.lastSaveAt = Date.now();
  }
```


And three constants at the top of the file, under the `import` line:

**web/idle-game.js**

```js
const SAVE_KEY = "rustbound-idle.save";
const SAVE_EVERY_MS = 5000;
const MAX_AWAY_SECONDS = 8 * 60 * 60;
```


The constructor gets one more field, `this.lastSaveAt = 0;`.

Look at what gets saved. It is not the game's text on its own. It is a wrapper around that text, with a timestamp. The Rust save has no time in it, since the tenth article's `SaveData` holds only the game's numbers. The game never asks what time it is, as the eighth article established. The browser does, so the browser writes the time down next to the save, not inside it.

Now, when to save. There are three moments.

After every action. Change the attack and upgrade handlers to call a new helper, `afterAction()`, instead of `renderState()`:

**web/idle-game.js**

```js
    this.on("attack", () => {
      this.game.click();
      this.afterAction();
    });

    this.on("buy-click", () => {
      const outcome = this.game.buyClickUpgrade();
      this.afterAction();
      this.showStatus(
        outcome === "purchased" ? "Click damage upgraded." : "Not enough gold yet.",
      );
    });
```


with `afterAction()` just under `bindEvents()`:

**web/idle-game.js**

```js
  afterAction() {
    this.renderState();
    this.saveGame();
  }
```


Every few seconds while the clock runs, so idle progress is not lost either. Add this to the end of the function inside `setInterval()`, after `this.renderState();`:

**web/idle-game.js**

```js
      if (now - this.lastSaveAt >= SAVE_EVERY_MS) {
        this.saveGame();
      }
```


And when the page is about to close, which matters most. In `connectedCallback()`, after `this.startClock();`:

**web/idle-game.js**

```js
    window.addEventListener("beforeunload", () => this.saveGame());
```


`beforeunload` fires when the tab is closed, reloaded, or navigated away from. It is the last thing we get to do.

Reload, click a few times, and look in the browser's developer tools, under Application (Chrome) or Storage (Firefox), at Local Storage. There is one entry, `rustbound-idle.save`. Inside it are a `savedAt` and the same JSON `main()` printed two articles ago. The game's JSON is stored as a string inside the wrapper's JSON, so its quotes show up escaped. That is normal.

**Question:** Why does the save wrapper hold `savedAt` next to the game's JSON, instead of the Rust save holding the time?

1. `localStorage` cannot store numbers
2. The game has no clock, and only the browser knows when the save was written
3. `Game.fromJson()` rejects any save that contains a timestamp
4. The timestamp is needed for `setInterval()` to run

**Answer:** 2. The game has no clock, and only the browser knows when the save was written

> **Explanation:** The rule for offline progress needs the difference between "when we saved" and "now." The engine should not read a clock, because that would make its tests depend on the time they run. The browser is the one that has a clock, so the browser keeps the note.


Saved. Now the other half.

---

## Come back later

When the component starts, it should look for a save before it makes a new game. Replace the `this.game = new Game();` line in `connectedCallback()` with `this.restoreGame();`, so the method reads:

**web/idle-game.js**

```js
  async connectedCallback() {
    this.renderShell();

    await init();

    this.restoreGame();
    this.bindEvents();
    this.renderState();
    this.startClock();

    window.addEventListener("beforeunload", () => this.saveGame());
  }
```


Then `restoreGame()`, under `saveGame()`:

**web/idle-game.js**

```js
  restoreGame() {
    const raw = localStorage.getItem(SAVE_KEY);

    if (raw === null) {
      this.game = new Game();
      return;
    }

    try {
      const save = JSON.parse(raw);

      this.game = Game.fromJson(save.game);

      const awaySeconds = Math.floor((Date.now() - save.savedAt) / 1000);
      const appliedSeconds = Math.max(0, Math.min(awaySeconds, MAX_AWAY_SECONDS));

      this.game.tick(appliedSeconds);

      if (appliedSeconds > 0) {
        this.showStatus(`Welcome back. ${describe(appliedSeconds)} of progress applied.`);
      }
    } catch (error) {
      console.warn("Could not restore the save:", error);
      localStorage.removeItem(SAVE_KEY);
      this.game = new Game();
      this.showStatus("The old save could not be read, so a new game was started.");
    }
  }
```


And two small helpers outside the class, above `customElements.define`:

**web/idle-game.js**

```js
function describe(seconds) {
  if (seconds < 60) return plural(seconds, "second");
  if (seconds < 3600) return plural(Math.floor(seconds / 60), "minute");
  return plural(Math.floor(seconds / 3600), "hour");
}

function plural(count, unit) {
  return count === 1 ? `1 ${unit}` : `${count} ${unit}s`;
}
```


Top to bottom.

If there is no save, we make a new game. That is the first run.

Otherwise, we open the wrapper and hand the game's text to `Game.fromJson()`. That is `from_saved_json()` from the tenth article, reached through the adapter from the last one. Rust checks the shape and the version. JavaScript does not look inside.

Then the time. The wrapper knows when the save was written, and the browser knows the time now. The difference, in seconds, is how long you were gone. Here is the moment the series has been pointing at since the eighth article:

**web/idle-game.js**

```js
      this.game.tick(appliedSeconds);
```


Offline progress is not a feature. It is the same `tick()` that runs every second while the tab is open, handed a bigger number. There is no second system for "while you were away," because the engine never knew the difference between one second and ten thousand.

There is one limit. `MAX_AWAY_SECONDS` is eight hours. Come back after a week and you get eight hours of progress, not a week. That is a design choice, not a rule of combat. The eighth article said this decision belongs with whoever owns the clock. Now the browser owns the clock, so the limit lives here, as one constant anyone can change.

`Math.max(0, ...)` is there in case the clock on the computer went backwards. It happens.

The `try`/`catch` is the border doing its job from the JavaScript side. If `localStorage` holds something that is not JSON, `JSON.parse` throws. If it is JSON but Rust will not accept it, `Game.fromJson` throws the message we wrote in `web.rs`. Either way we land in the `catch`, log it, throw the save away, and start fresh with a sentence on screen that says so. Nobody gets a blank page.

**Question:** `restoreGame()` calls `this.game.tick(appliedSeconds)` on a game loaded from a save. What makes this offline progress rather than a second rule?

1. `Game.fromJson()` runs a separate catch-up function
2. It is the same `tick()` the clock calls every second, handed a larger number of seconds
3. `MAX_AWAY_SECONDS` switches the engine into a slower mode
4. The browser replays every click from the save

**Answer:** 2. It is the same `tick()` the clock calls every second, handed a larger number of seconds

> **Explanation:** The rules have no idea whether a tick covers one second or a week. The clock decides how much time to report, and the engine applies the same rules to it either way.


Now try the whole thing.

Reload. Click a slime to death. Close the tab. Count to thirty. Open it again.

```text
Welcome back. 30 seconds of progress applied.
```


The game has moved on by thirty seconds of idle damage. The slime is thirty health down, or it died and the next one is up.

To see a longer wait without waiting, open the developer tools, find `rustbound-idle.save` in Local Storage, and subtract 600000 (ten minutes, in milliseconds) from `savedAt`. Reload.

```text
Welcome back. 10 minutes of progress applied.
```


To see the catch, replace the whole stored value with the word `banana` and reload. The console says:

```text
Could not restore the save: SyntaxError: Unexpected token 'b', "banana" is not valid JSON
```


and the page says a new game was started. Try it once more with valid JSON that Rust will refuse, `{"savedAt":0,"game":"banana"}`:

```text
Could not restore the save: That save could not be read.
```


That sentence came from Rust. It crossed the border as a throw, exactly as promised.

---

## Reset, and `free()`

One more button, because a game you cannot restart is a game you can only play once per browser.

Add a third handler inside `bindEvents()`:

**web/idle-game.js**

```js
    this.on("reset", () => {
      localStorage.removeItem(SAVE_KEY);
      this.game.free();
      this.game = new Game();
      this.renderState();
      this.showStatus("New game started.");
    });
```


Three of those lines are obvious. The `free()` is the one we skipped last time.

JavaScript tidies up after itself. When nothing refers to an object any more, the browser quietly reclaims its memory. But a `Game` does not live in JavaScript's memory. It lives on the Rust side of the border, in WebAssembly's memory, and the browser's tidying cannot see in there. Replace `this.game` with a new one and JavaScript forgets the old one, but its memory stays taken. `free()` tells the generated glue to give that memory back. It is `drop(game)` from the tenth article, done by hand, because the border cannot do it for us.

In this game, a missed `free()` would leak a small amount per reset, and nobody would notice. The habit is still worth having, because the next project might hold something much bigger.

**Question:** A `Game` is no longer referenced in JavaScript. Why might its memory still be taken?

1. JavaScript's garbage collector never runs in a browser tab
2. The game's memory lives in WebAssembly's memory, which the garbage collector cannot see into
3. `localStorage` keeps a copy of every `Game` alive
4. `new Game()` always allocates its memory twice

**Answer:** 2. The game's memory lives in WebAssembly's memory, which the garbage collector cannot see into

> **Explanation:** The garbage collector frees JavaScript objects. A `Game` is a Rust value on the other side of the border, so `free()` is the only way to say that we are done with it.


---

## A test that opens a browser

This article adds nothing to Rust, so `cargo test` still says thirty-two.

The new code runs in a browser, and testing it means opening one. Playwright is a library that drives a real browser from a script: load a page, click, read text, reload. We already have Node, because we have `npm`, so install it in the `web` folder:

```bash
cd web
npm init -y
npm install --save-dev playwright
npx playwright install chromium
```


`npm init -y` writes a `package.json` that describes the folder. `npm install` adds Playwright to it and downloads it into `node_modules`. The last line downloads a copy of Chromium for Playwright to drive. It is large, and it only happens once.

Then create `web/check.mjs`:

**web/check.mjs**

```js
import { chromium } from "playwright";

const PAGE = process.argv[2] ?? "http://localhost:8080/";
const OTHER_PAGE = new URL("styles.css", PAGE).href;

const browser = await chromium.launch();
const page = await browser.newPage();
const problems = [];

page.on("pageerror", (error) => problems.push(`page error: ${error.message}`));
page.on("console", (message) => {
  if (message.type() === "error") problems.push(`console: ${message.text()}`);
});

const game = page.locator("idle-game");
const text = (selector) => game.locator(selector).textContent();
const number = async (selector) => Number(await text(selector));

await page.goto(PAGE);
await page.waitForFunction(() => {
  const el = document.querySelector("idle-game");
  return el?.shadowRoot?.querySelector("[data-enemy-health]")?.textContent !== "0";
});

const check = (label, condition) => {
  console.log(`${condition ? "ok  " : "FAIL"} ${label}`);
  if (!condition) problems.push(label);
};

check("a new game starts at stage 1", (await number("[data-stage]")) === 1);
check("the slime has 20 of 20 health", (await text("[data-enemy-health]")) === "20");

for (let i = 0; i < 4; i += 1) {
  await game.locator('[data-action="attack"]').click();
}

check("four clicks clear the first slime", (await number("[data-stage]")) === 2);
check("the player was paid", (await number("[data-gold]")) === 5);

// Leave the game page, which saves on the way out. From a page on the same
// server that is not the game, pretend that save is ten minutes old.
await page.goto(OTHER_PAGE);
await page.evaluate(() => {
  const save = JSON.parse(localStorage.getItem("rustbound-idle.save"));
  save.savedAt -= 10 * 60 * 1000;
  localStorage.setItem("rustbound-idle.save", JSON.stringify(save));
});

await page.goto(PAGE);
await page.waitForFunction(() =>
  document
    .querySelector("idle-game")
    ?.shadowRoot?.querySelector("[data-status]")
    ?.textContent.startsWith("Welcome back"),
);

check("the save was restored and ten minutes applied", (await number("[data-stage]")) >= 2);
check("the status says how long we were gone", (await text("[data-status]")).includes("10 minutes"));

await page.goto(OTHER_PAGE);
await page.evaluate(() => localStorage.setItem("rustbound-idle.save", "banana"));
await page.goto(PAGE);
await page.waitForFunction(() =>
  document
    .querySelector("idle-game")
    ?.shadowRoot?.querySelector("[data-status]")
    ?.textContent.includes("could not be read"),
);

check("a broken save starts a new game", (await number("[data-stage]")) === 1);

await browser.close();

if (problems.length > 0) {
  console.error("\nProblems:\n" + problems.join("\n"));
  process.exit(1);
}

console.log("\nAll checks passed.");
```


It does by script what you just did by hand: a new game, four clicks, a backdated save, a broken save. Each `check()` prints a line and remembers failures. Any error the page logs counts as a failure too.

Two bits of JavaScript punctuation may be new. `?.` means *if this exists*, so `el?.shadowRoot` is `undefined` instead of an error when `el` is missing. `??` means *or else*, so the script takes a page address from the command line if you give one, and otherwise uses `localhost:8080`.

The one odd move is the trip to `OTHER_PAGE`. The component saves on `beforeunload`. If the script edited `savedAt` while still on the game page, then reloaded, the component would overwrite the edit on its way out with the real time. So the script steps off the game page first, edits the save from a page that has no component, and comes back.

With the server still running, in the `web` folder:

```bash
node check.mjs
```


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


Seven checks in a real browser, in a few seconds. None of them knows how much damage a click does. They know four clicks clear a slime because the Rust tests say so.

Add `web/node_modules` to the project's `.gitignore`, next to `target` and `web/pkg`. It is as generated as they are.

---

## The complete program

If you are only reading along, skip to the last two sections.

If you have been following along, here are the files in `web/`. The Rust is untouched, and `web/pkg/` is still whatever `wasm-pack` generates.

```text
rustbound-idle/
├── .gitignore          (target, web/pkg, web/node_modules)
├── Cargo.toml
├── src/                (unchanged)
├── tests/              (unchanged)
└── web/
    ├── index.html
    ├── idle-game.js
    ├── styles.css
    ├── check.mjs
    ├── package.json
    ├── node_modules/   (generated)
    └── pkg/            (generated)
```


`web/index.html`, `web/styles.css`, and `web/check.mjs` are exactly as shown above. `web/idle-game.js` is the whole component in one place:

**web/idle-game.js**

```js
import init, { Game } from "./pkg/rustbound_idle.js";

const SAVE_KEY = "rustbound-idle.save";
const SAVE_EVERY_MS = 5000;
const MAX_AWAY_SECONDS = 8 * 60 * 60;

class IdleGame extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.game = null;
    this.timer = null;
    this.lastTickAt = 0;
    this.lastSaveAt = 0;
  }

  async connectedCallback() {
    this.renderShell();

    await init();

    this.restoreGame();
    this.bindEvents();
    this.renderState();
    this.startClock();

    window.addEventListener("beforeunload", () => this.saveGame());
  }

  renderShell() {
    this.shadowRoot.innerHTML = `
      <link rel="stylesheet" href="./styles.css" />

      <section class="game">
        <h1>Rustbound Idle</h1>
        <p class="status" data-status role="status" aria-live="polite"></p>

        <section class="stats">
          <p>Stage <strong data-stage>1</strong></p>
          <p>Level <strong data-level>1</strong></p>
          <p>Gold <strong data-gold>0</strong></p>
        </section>

        <section class="enemy">
          <h2 data-enemy-name>Enemy</h2>
          <p><span data-enemy-health>0</span> / <span data-enemy-max-health>0</span> HP</p>
          <div class="meter"><span data-health-bar></span></div>
          <button data-action="attack" type="button">
            Attack for <span data-click-damage>0</span>
          </button>
        </section>

        <section class="progress">
          <p>XP <span data-xp>0</span> / <span data-xp-needed>0</span></p>
          <div class="meter xp"><span data-xp-bar></span></div>
        </section>

        <section class="upgrades">
          <button data-action="buy-click" type="button">
            Sharper clicks (level <span data-click-upgrade-level>0</span>)
            for <span data-click-upgrade-cost>0</span> gold
          </button>
        </section>

        <button class="reset" data-action="reset" type="button">Reset save</button>
      </section>
    `;
  }

  bindEvents() {
    this.on("attack", () => {
      this.game.click();
      this.afterAction();
    });

    this.on("buy-click", () => {
      const outcome = this.game.buyClickUpgrade();
      this.afterAction();
      this.showStatus(
        outcome === "purchased" ? "Click damage upgraded." : "Not enough gold yet.",
      );
    });

    this.on("reset", () => {
      localStorage.removeItem(SAVE_KEY);
      this.game.free();
      this.game = new Game();
      this.renderState();
      this.showStatus("New game started.");
    });
  }

  afterAction() {
    this.renderState();
    this.saveGame();
  }

  on(action, handler) {
    this.shadowRoot
      .querySelector(`[data-action="${action}"]`)
      .addEventListener("click", handler);
  }

  renderState() {
    const state = JSON.parse(this.game.stateJson());

    this.setText("[data-stage]", state.stage);
    this.setText("[data-level]", state.level);
    this.setText("[data-gold]", state.gold);
    this.setText("[data-enemy-name]", state.enemyName);
    this.setText("[data-enemy-health]", state.enemyHealth);
    this.setText("[data-enemy-max-health]", state.enemyMaxHealth);
    this.setText("[data-click-damage]", state.clickDamage);
    this.setText("[data-xp]", state.xp);
    this.setText("[data-xp-needed]", state.xpNeeded);
    this.setText("[data-click-upgrade-level]", state.clickUpgradeLevel);
    this.setText("[data-click-upgrade-cost]", state.clickUpgradeCost);

    this.setMeter("[data-health-bar]", state.enemyHealth, state.enemyMaxHealth);
    this.setMeter("[data-xp-bar]", state.xp, state.xpNeeded);

    this.shadowRoot.querySelector('[data-action="buy-click"]').disabled =
      state.gold < state.clickUpgradeCost;
  }

  setText(selector, value) {
    this.shadowRoot.querySelector(selector).textContent = String(value);
  }

  setMeter(selector, value, max) {
    const percent = max === 0 ? 0 : Math.min(100, (value / max) * 100);
    this.shadowRoot.querySelector(selector).style.width = `${percent}%`;
  }

  showStatus(message) {
    this.shadowRoot.querySelector("[data-status]").textContent = message;
  }

  startClock() {
    this.lastTickAt = Date.now();

    this.timer = setInterval(() => {
      const now = Date.now();
      const elapsedSeconds = Math.floor((now - this.lastTickAt) / 1000);

      if (elapsedSeconds < 1) return;

      this.lastTickAt += elapsedSeconds * 1000;
      this.game.tick(elapsedSeconds);
      this.renderState();

      if (now - this.lastSaveAt >= SAVE_EVERY_MS) {
        this.saveGame();
      }
    }, 1000);
  }

  saveGame() {
    localStorage.setItem(
      SAVE_KEY,
      JSON.stringify({ savedAt: Date.now(), game: this.game.saveJson() }),
    );
    this.lastSaveAt = Date.now();
  }

  restoreGame() {
    const raw = localStorage.getItem(SAVE_KEY);

    if (raw === null) {
      this.game = new Game();
      return;
    }

    try {
      const save = JSON.parse(raw);

      this.game = Game.fromJson(save.game);

      const awaySeconds = Math.floor((Date.now() - save.savedAt) / 1000);
      const appliedSeconds = Math.max(0, Math.min(awaySeconds, MAX_AWAY_SECONDS));

      this.game.tick(appliedSeconds);

      if (appliedSeconds > 0) {
        this.showStatus(`Welcome back. ${describe(appliedSeconds)} of progress applied.`);
      }
    } catch (error) {
      console.warn("Could not restore the save:", error);
      localStorage.removeItem(SAVE_KEY);
      this.game = new Game();
      this.showStatus("The old save could not be read, so a new game was started.");
    }
  }
}

function describe(seconds) {
  if (seconds < 60) return plural(seconds, "second");
  if (seconds < 3600) return plural(Math.floor(seconds / 60), "minute");
  return plural(Math.floor(seconds / 3600), "hour");
}

function plural(count, unit) {
  return count === 1 ? `1 ${unit}` : `${count} ${unit}s`;
}

customElements.define("idle-game", IdleGame);
```


`web/package.json` is whatever `npm` wrote. You can trim it to this, which also gives you `npm run check` as a shortcut:

**web/package.json**

```json
{
  "name": "rustbound-idle-web",
  "private": true,
  "type": "module",
  "scripts": {
    "check": "node check.mjs"
  },
  "devDependencies": {
    "playwright": "^1.64"
  }
}
```


Run everything:

```bash
cargo test
wasm-pack build --target web --out-dir web/pkg
python3 -m http.server 8080 --directory web
```


and, in a second terminal, in `web/`:

```bash
node check.mjs
```


Thirty-two Rust tests, seven browser checks, and a game you can play at `http://localhost:8080/`.

---

## What we learned

Along the way, we encountered:

- custom elements, `HTMLElement`, and `customElements.define()`
- `constructor()`, `super()`, and `connectedCallback()`
- the shadow DOM, `attachShadow()`, and `:host`
- drawing a shell once and filling in values many times
- `data-` attributes as hooks, and `querySelector()`
- `role="status"` and `aria-live`
- `addEventListener()`, and a handler that tells the game and redraws
- disabling a button from a comparison, not a calculation
- `setInterval()`, and measuring elapsed time instead of trusting the timer
- `localStorage`, and a save wrapper that carries the timestamp
- `beforeunload`
- `try`/`catch` around a call that can throw from Rust
- `tick()` as offline progress, and a cap that lives with the clock
- `free()`
- Playwright, and a test that drives a real browser

You do not need to memorize all of that yet.

Two ideas are worth keeping.

The first is the shape of every handler in this file. Tell the game. Ask what the world looks like now. Draw it. None of them contains an `if` about gold, damage, or health. The only comparison in the whole component is `gold < cost`, and both sides of it came from Rust. When a screen looks like that, you can replace it. Someone could rewrite this file in a framework, or as a canvas, or as a terminal UI, and the game would not notice.

The second is where the clock went. In the eighth article we said time belonged to whoever runs the game, and we left the question of how much away-time to accept for later. Today the browser runs the game, so the browser owns `setInterval`, `Date.now()`, `savedAt`, and `MAX_AWAY_SECONDS`. All four are on the JavaScript side of the border, and `tick(seconds)` is the only one the Rust side ever hears about.

---

## It works on my machine

That phrase has ruined enough afternoons.

The game runs at `localhost:8080`, which means it runs for exactly one person. Next time we make the build repeatable, put the files where a static host can find them, and give Rustbound Idle a public address.

The browser owns the pixels. The server will own the delivery.

Neither of them gets the rules.

**Small function. Bigger game.**
> **Portable fallback:** The authored description of the demo is preserved; the live demonstration remains on the canonical article. Review: Review the authored description of the demo and keep the canonical link for the live version.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

[Join the discussion on the canonical article](https://journeytocode.io/a-button-is-not-a-rule/)
