DOM ENGINEV0.21.3FIZLET

01 GUIDE · A MICRO GAME

A micro game
in one file.

This guide builds a whole game in about 80 lines: squares drift around a canvas, a click pops the ones under the pointer, and a score and a 30-second clock count on the page. It uses the kernel and four bricks, keeps every rule of the contract, and gives the page back on Escape.

1. The markup

A root element holds the canvas and the readouts. The engine pauses when this root scrolls off screen, and the HUD looks up its readouts inside it.

<div id="game"><canvas width="640" height="360" style="width:100%"></canvas><p>Score <b data-score>0</b> · <b data-clock>0:30</b></p></div>

THE GAME · game.ts
import { createEngine } from "@fzlt/core";
import { controls } from "@fzlt/core/input";
import { effects } from "@fzlt/core/fx";
import { hud } from "@fzlt/core/hud";
import { circles } from "@fzlt/core/collide";

const W = 640, H = 360, N = 12, R = 14;
const board = document.querySelector<HTMLElement>("#game")!;
const canvas = board.querySelector("canvas")!; // <canvas width="640" height="360">
const ctx = canvas.getContext("2d")!;

const e = createEngine({ root: board, seed: 7 });
const input = controls(e, { target: canvas });
const fx = effects({ cap: 240 });

// State in typed arrays, made once: nothing is allocated while it plays.
const x = new Float64Array(N), y = new Float64Array(N);
const vx = new Float64Array(N), vy = new Float64Array(N);
const stat = new Float64Array(2); // [score, seconds left]
function place(i: number) {
  x[i] = R + e.random() * (W - 2 * R);
  y[i] = R + e.random() * (H - 2 * R);
  const a = e.random() * Math.PI * 2;
  vx[i] = Math.cos(a) * 60;
  vy[i] = Math.sin(a) * 60;
}
for (let i = 0; i < N; i++) place(i);
stat[1] = 30;

let scale = 1; // canvas px per CSS px
e.add({
  read() { scale = W / canvas.clientWidth; }, // the only phase that measures
  step(e, dt) {                                // fixed 60 Hz: no DOM at all
    fx.step(dt);
    if (stat[1] <= 0) return;
    stat[1] = Math.max(0, stat[1] - dt);
    for (let i = 0; i < N; i++) {
      x[i] += vx[i] * dt;
      y[i] += vy[i] * dt;
      if (x[i] < R || x[i] > W - R) vx[i] = -vx[i];
      if (y[i] < R || y[i] > H - R) vy[i] = -vy[i];
    }
    if (!input.pointer.hit) return;            // true in one step per click
    const px = input.pointer.x * scale, py = input.pointer.y * scale;
    for (let i = 0; i < N; i++) {
      if (!circles(px, py, 6, x[i], y[i], R)) continue;
      e.events.emit("pop", i, 0, x[i], y[i]);  // the ring, read in render
      stat[0] += 1;
      place(i);
    }
  },
  render() {
    for (let k = 0; k < e.events.size; k++) {
      const ev = e.events.at(k);
      if (ev && ev.type === "pop") fx.blast(ev.x, ev.y, 0.5);
    }
    ctx.clearRect(0, 0, W, H);
    fx.drawGround(ctx);
    ctx.fillStyle = "#39ffb0";
    for (let i = 0; i < N; i++) ctx.fillRect(x[i] - R / 2, y[i] - R / 2, R, R);
    fx.draw(ctx);
  },
});
hud(e, board).num("[data-score]", [stat, 0]).num("[data-clock]", [stat, 1], "clock");
e.start();

// Escape: Recall. The listener goes with the engine's signal.
addEventListener("keydown", (ev) => { if (ev.key === "Escape") e.destroy(); }, { signal: e.signal });

2. What each part does

  • State in typed arrays

    Positions, velocities, the score and the clock live in Float64Arrays made once. The step reads and writes numbers only, so nothing is allocated while it plays (contract rule 3).

  • read measures, step simulates

    The canvas's CSS width is read in read, once a frame. The step moves the squares at a fixed 60 Hz and checks the click with circles from @fzlt/core/collide: no layout reads (rule 4).

  • One click, one step

    input.pointer.hit is true in exactly one fixed step after a click, even if that step comes frames later, so a click never pops twice or gets lost.

  • Events, not callbacks

    A pop goes on the event ring in the step; render reads the ring and plays fx.blast. The effects are pooled (cap: 240): a full pool refuses particles and counts them, it never grows.

  • A HUD that writes only changes

    hud(e, board).num(…) compares each readout after rounding to what it shows and writes the page only when that changed, through the ledger. The clock is a slot in a typed array, so it never boxes a number.

  • Escape is Recall

    e.destroy() removes every listener (the keydown listener goes with e.signal) and undoes every page change the engine made.

3. What it costs

Each import is its own module with its own budget. Measured on this build, gzip, each with everything it imports: the kernel 3,372 B, @fzlt/core/input 1,335 B, @fzlt/core/fx 2,792 B, @fzlt/core/hud 1,306 B, @fzlt/core/collide 1,265 B. The kernel is shared, so a bundle of all five is smaller than their sum; bundle and gzip your build to see your exact number.

4. Where to take it

  • Sound

    sound(e, { cues: { pop: "boom" } }) plays a synth cue for every pop on the ring: no audio files, at most 8 voices. @fzlt/core/sound.

  • A start screen and a clock

    match(e, root, { count: 3, seconds: 30 }) adds PRESS START, a 3-2-1, pause on Escape or P, and a result with REMATCH. @fzlt/core/match.

  • Touch controls

    pad(e, input, { scheme: "stick" }) adds an on-screen stick and arcade keys that drive the same intents. @fzlt/core/pad.

  • The page as the board

    boardPage(e) turns the page's own letters into terrain and its buttons into targets, and Recall gives every one back. @fzlt/core/board-page.