# Bug squash

Source: https://signy.dev/examples/bug-squash/

Squash the bugs, spare the duck, beat your best score.

The nine holes are one signal holding an array. `Index` renders them by position, so a bug moving in or out updates one button. The round clock and the bug spawner are effects that run only while a round runs, and `onCleanup()` clears their timers when it stops.

The speed is a computed value that reads the score. It stops changing at its fastest, and from then on the spawner effect that reads it does not restart, because an equal computed does not re-run its readers.

## Preview

[Open the page on its own](https://signy.dev/examples/bug-squash/demo/index.html.md)

## Code

Save it as an `.html` file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

**Show the full page**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Bug squash - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      .holes {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 0.75rem;
        max-width: 22rem;
        margin-bottom: var(--i-spacing);
      }

      .hole {
        aspect-ratio: 1;
        margin: 0;
        font-size: 2.5rem;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.prod.js"></script>
  </head>
  <body>
    <main class="container" id="app"></main>

    <script>
      const { Component, Signal, Computed, application, html, Index, Show, batch, effect, onCleanup } = Signy;

      const HOLES = 9;
      const ROUND = 30;
      const BEST_KEY = "signy-bug-squash-best";
      const LOOKS = { bug: ["🐛", "A bug"], duck: ["🦆", "The duck"] };
      const empty = () => Array(HOLES).fill(null);
      let nextId = 1;

      function loadBest() {
        try {
          return Number(localStorage.getItem(BEST_KEY)) || 0;
        } catch {
          return 0;
        }
      }

      function verdict(score) {
        if (score === 0) return "The bugs won. They usually do.";
        if (score < 10) return "Not bad. The bugs filed a complaint.";
        return "Bug free. Ship it!";
      }

      class BugSquash extends Component {
        // Each hole holds null or a critter, { id, kind }
        holes = new Signal(empty());
        score = new Signal(0);
        left = new Signal(ROUND);
        running = new Signal(false);
        best = new Signal(loadBest());
        message = new Signal("Squash the bugs. Leave the duck alone, it is helping.");
        // Bugs come faster as the score grows, down to one every 350ms. Past that the
        // value stays equal, so the spawner effect below stops restarting.
        speed = new Computed(() => Math.max(350, 900 - this.score.value * 25));

        setup() {
          effect(() => {
            const best = this.best.value;
            try {
              localStorage.setItem(BEST_KEY, String(best));
            } catch {}
          });
          // Timers live in effects, so stopping the round clears them
          effect(() => {
            if (!this.running.value) return;
            const clock = setInterval(() => this.tick(), 1000);
            onCleanup(() => clearInterval(clock));
          });
          effect(() => {
            if (!this.running.value) return;
            const spawner = setInterval(() => this.spawn(), this.speed.value);
            onCleanup(() => clearInterval(spawner));
          });
        }

        put(index, critter) {
          this.holes.set((holes) => holes.map((old, i) => (i === index ? critter : old)));
        }

        spawn() {
          const free = this.holes.peek().flatMap((critter, i) => (critter ? [] : [i]));
          if (!free.length) return;
          const index = free[Math.floor(Math.random() * free.length)];
          const critter = { id: nextId++, kind: Math.random() < 0.15 ? "duck" : "bug" };
          this.put(index, critter);
          // Wanders off if nobody squashes it
          setTimeout(() => {
            if (this.holes.peek()[index] === critter) this.put(index, null);
          }, this.speed.peek() * 2);
        }

        squash(index) {
          const critter = this.holes.peek()[index];
          if (!critter || !this.running.peek()) return;
          batch(() => {
            this.put(index, null);
            if (critter.kind === "duck") {
              this.score.set((score) => Math.max(0, score - 3));
              this.message.value = "Quack! The duck was helping. Minus 3.";
            } else {
              this.score.set((score) => score + 1);
            }
          });
        }

        tick() {
          if (this.left.peek() > 1) {
            this.left.set((left) => left - 1);
            return;
          }
          batch(() => {
            const score = this.score.peek();
            const record = score > this.best.peek();
            if (record) this.best.value = score;
            this.left.value = 0;
            this.running.value = false;
            this.holes.value = empty();
            this.message.value = `${verdict(score)}${record ? " New best score!" : ""}`;
          });
        }

        start() {
          batch(() => {
            this.score.value = 0;
            this.left.value = ROUND;
            this.holes.value = empty();
            this.message.value = "Go!";
            this.running.value = true;
          });
        }

        render() {
          return html`
            <h1>Bug squash</h1>
            <p>Score <strong>${() => this.score.value}</strong>, ${() => this.left.value}s left, best ${() => this.best.value}</p>
            <p aria-live="polite">${() => this.message.value}</p>
            <div class="holes">
              ${Index({
                each: () => this.holes.value,
                children: (critter, index) => html`
                  <button class="hole secondary outlined" onClick=${() => this.squash(index)}
                    aria-label=${() => (critter() ? LOOKS[critter().kind][1] : "Empty hole")}>
                    ${() => (critter() ? LOOKS[critter().kind][0] : "")}
                  </button>
                `,
              })}
            </div>
            ${Show({
              when: () => !this.running.value,
              children: html`<button class="primary" onClick=${() => this.start()}>Start a round</button>`,
            })}
          `;
        }
      }

      application(BugSquash).mount("#app");
    </script>
  </body>
</html>

```

## What it uses

- [Signals](https://signy.dev/documentation/1/reactivity/signals/index.html.md) and [batch()](https://signy.dev/documentation/1/reactivity/batching/index.html.md)
- [Computed](https://signy.dev/documentation/1/reactivity/computed/index.html.md) for the speed
- [Effects](https://signy.dev/documentation/1/reactivity/effects/index.html.md) with `onCleanup()` for the timers, and one that saves the best score
- [Index](https://signy.dev/documentation/1/templates/for-and-index/index.html.md#index) for the holes, and [Show](https://signy.dev/documentation/1/templates/show/index.html.md)
