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
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
<!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>