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>
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).readmeasures,stepsimulatesThe 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 withcirclesfrom@fzlt/core/collide: no layout reads (rule 4).One click, one step
input.pointer.hitis 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;
renderreads the ring and playsfx.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 withe.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 everypopon 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.