02 DOCS · AUTHORING
One shape,
three recipes.
Everything built on Fizlet has the same shape: one root, one engine, state in typed arrays, and systems that measure in read, simulate in step and write in render. Learn the shape once and every brick fits it. The three recipes below are complete: each one runs on this page as written, and each shows the size it ships, measured on this build.
The shape
of every Fizlet piece
A root
One element the piece lives in. The engine pauses when it scrolls away, the HUD finds its readouts inside it, and nothing is drawn outside it.
An engine
createEngine({ root, seed }). Addsettle: 0.5for anything that should cost nothing while it's still,fps: 30for an ambient loop.State in typed arrays
Positions, timers and scores in
Float64Arrays made once. The step reads and writes numbers only, so nothing is allocated while it runs.read, step, render
Measure the page in
read. Simulate instepat a fixed 60 Hz, with no DOM at all. Draw and write the page inrender, throughengine.ledger.Events for effects
The step emits what happened (
e.events.emit("hit", …)); render, sound and the HUD react to the ring. The sim never knows about pixels or audio.A way out
Bind your own listeners to
e.signal.e.destroy()then removes them all and gives back every page change: Recall.
Pick bricks by use
Start from the kernel and add only what the job needs; each brick is its own import and its own budget.
A reaction on the page
A click, a hover, a scroll that should feel alive:
@fzlt/core/fxfor particles,@fzlt/core/timelinefor choreographed DOM moves,@fzlt/core/hudfor counts. Settle on.An arcade game
@fzlt/core/input(and@fzlt/core/padon touch),@fzlt/core/collide,@fzlt/core/fx,@fzlt/core/hud;@fzlt/core/rulesfor waves, drops and score;@fzlt/core/matchfor start, pause and results;@fzlt/core/arcadewhen the page itself is the board.Two or more players
A deterministic step and
@fzlt/core/presence: inputs in lockstep, a state hash every 30 frames.syncCheckproves it in a test; a room server you run joins two devices.A hero in an Astro slot
@fzlt/core/slotloads the scene near the viewport and keeps the slot's text alternative;@fzlt/core/camerapans and zooms a DOM stage.
Recipe 1: an action
on the page
A real button on the page, a burst of sparks out of it on every click, and a count. The canvas is added through the ledger, the class through ledger.cls, the count through @fzlt/core/hud, so destroy() leaves the button as it was. Half a second after the last spark, the engine settles: no frames at all until the next click. With reduced motion it counts without the burst.
<div id="action" style="position: relative; display: grid; place-items: center; min-height: 220px">
<button type="button" class="bui-key bui-arcade bui-attack">Like <b data-likes>0</b></button>
</div>
<script type="module">
// Recipe: an action on the page. A click on a real button bursts sparks out of it and counts; the
// engine sleeps half a second after the last spark. Kernel, fx and hud. Runs as is with action.html.
import { createEngine } from "https://fizlet.com/dist/index.js";
import { effects } from "https://fizlet.com/dist/fx.js";
import { hud } from "https://fizlet.com/dist/hud.js";
const root = document.getElementById("action");
const button = root.querySelector("button");
const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
const e = createEngine({ root, settle: 0.5 });
const fx = effects({ cap: 160, colors: ["#ff5c8f", "#ffd166", "#39ffb0", "#19f0ff"] });
const canvas = e.ledger.add(document.createElement("canvas")); // Recall removes it
canvas.style.cssText = "position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none";
root.append(canvas);
const ctx = canvas.getContext("2d");
// [likes, width, height, button x, button y, bursts waiting]: numbers in a typed array.
const s = new Float64Array(6);
button.addEventListener("click", () => { s[0] += 1; s[5] += 1; e.wake(); }, { signal: e.signal });
e.add({
read() { // measure here, once a frame
s[1] = root.clientWidth;
s[2] = root.clientHeight;
s[3] = button.offsetLeft + button.offsetWidth / 2;
s[4] = button.offsetTop + button.offsetHeight / 2;
},
step(e, dt) { // no DOM here
for (; s[5] > 0; s[5]--) if (!calm) fx.blast(s[3], s[4], 0.45);
fx.step(dt);
if (fx.live) e.busy(); // keep awake while sparks fly
},
render() {
if (canvas.width !== s[1] || canvas.height !== s[2]) { canvas.width = s[1]; canvas.height = s[2]; }
ctx.clearRect(0, 0, s[1], s[2]);
fx.draw(ctx);
e.ledger.cls(button, "liked", s[0] > 0); // change-only, given back on destroy()
},
});
hud(e, root).num("[data-likes]", [s, 0]);
e.start();
</script>Recipe 2: a shooter
Bugs fall; you move and fire. Every bug and shot is a slot in a typed array, spawns come from the seeded e.random (the same seed plays the same wave), hits are circles tests in the step, and the blast and the shake happen in render from the event ring. The engine runs only between Start and the last life, so the page's own keys stay the page's.
@fzlt/core3.3 KBCOMPLETE @fzlt/core/input1.3 KBCOMPLETE @fzlt/core/collide1.2 KBCOMPLETE @fzlt/core/fx2.7 KBCOMPLETE @fzlt/core/hud1.3 KBBETA8.2 KB gzScore 0Lives 3 A/D or ←/→ move · Space fires · or drag and tap
<div id="shooter" style="display: grid; gap: 10px">
<canvas width="480" height="320" aria-label="The shooter: bugs fall, your ship fires up" style="width: 100%; max-width: 560px; touch-action: none; background: #07060f; border-radius: 10px"></canvas>
<p style="display: flex; gap: 14px; align-items: center; margin: 0">
<button type="button" class="bui-key bui-arcade bui-go">Start</button>
<span>Score <b data-score>0</b></span><span>Lives <b data-lives>3</b></span>
<small>A/D or ←/→ move · Space fires · or drag and tap</small>
</p>
</div>
<script type="module">
// Recipe: a shooter. Bugs fall, you fire up at them. Typed-array state, a fixed step, collision,
// pooled blasts and a change-only HUD. Kernel, input, collide, fx and hud. Runs as is with
// shooter.html. Keys count only while a run is live: Start begins one, losing the last life ends it.
import { createEngine } from "https://fizlet.com/dist/index.js";
import { controls } from "https://fizlet.com/dist/input.js";
import { circles } from "https://fizlet.com/dist/collide.js";
import { effects } from "https://fizlet.com/dist/fx.js";
import { hud } from "https://fizlet.com/dist/hud.js";
const W = 480, H = 320, BUGS = 12, SHOTS = 16;
const root = document.getElementById("shooter");
const canvas = root.querySelector("canvas");
const start = root.querySelector("button");
const ctx = canvas.getContext("2d");
const e = createEngine({ root, seed: 7 });
const pad = controls(e, { target: canvas, keys: { left: ["KeyA", "ArrowLeft"], right: ["KeyD", "ArrowRight"], fire: ["Space"] } });
const fx = effects({ cap: 240 });
// Everything that moves lives in typed arrays made once.
const bx = new Float64Array(BUGS), by = new Float64Array(BUGS), bv = new Float64Array(BUGS), alive = new Uint8Array(BUGS);
const sx = new Float64Array(SHOTS), sy = new Float64Array(SHOTS), live = new Uint8Array(SHOTS);
const g = new Float64Array(5); // [ship x, score, lives, next spawn, cooldown]
let scale = 1;
function reset() {
alive.fill(0); live.fill(0); fx.clear();
g.set([W / 2, 0, 3, 0.4, 0]);
}
e.add({
read() { scale = W / canvas.clientWidth; },
step(e, dt) {
// Move: keys, or follow a finger or a held mouse.
g[0] += pad.axis("left", "right") * 260 * dt;
if (pad.pointer.down) g[0] += (pad.pointer.x * scale - g[0]) * Math.min(1, 12 * dt);
g[0] = Math.max(16, Math.min(W - 16, g[0]));
// Fire: Space, or a tap.
g[4] -= dt;
if ((pad.down("fire") || pad.pointer.hit) && g[4] <= 0) {
const i = live.indexOf(0);
if (i >= 0) { live[i] = 1; sx[i] = g[0]; sy[i] = H - 34; g[4] = 0.18; e.events.emit("fire"); }
}
// Spawn from the seeded random: the same seed, the same wave.
if ((g[3] -= dt) <= 0) {
const i = alive.indexOf(0);
if (i >= 0) { alive[i] = 1; bx[i] = 20 + e.random() * (W - 40); by[i] = -12; bv[i] = 40 + e.random() * 30 + g[1] * 2; }
g[3] = Math.max(0.35, 1.1 - g[1] * 0.03);
}
for (let j = 0; j < SHOTS; j++) if (live[j] && (sy[j] -= 420 * dt) < -8) live[j] = 0;
for (let i = 0; i < BUGS; i++) {
if (!alive[i]) continue;
by[i] += bv[i] * dt;
if (by[i] > H + 12) { alive[i] = 0; g[2] -= 1; e.events.emit("lost"); continue; }
for (let j = 0; j < SHOTS; j++) {
if (!live[j] || !circles(sx[j], sy[j], 3, bx[i], by[i], 12)) continue;
alive[i] = 0; live[j] = 0; g[1] += 1;
e.events.emit("hit", i, 0, bx[i], by[i]);
break;
}
}
fx.step(dt);
},
render() {
for (let k = 0; k < e.events.size; k++) {
const ev = e.events.at(k);
if (ev.type === "hit") fx.blast(ev.x, ev.y, 0.5);
if (ev.type === "lost") fx.kick(6);
}
ctx.save();
ctx.clearRect(0, 0, W, H);
ctx.translate(fx.shake * (Math.random() - 0.5), 0); // decoration only, never the sim
fx.drawGround(ctx);
ctx.fillStyle = "#ff5c8f";
for (let i = 0; i < BUGS; i++) if (alive[i]) ctx.fillRect(bx[i] - 10, by[i] - 8, 20, 16);
ctx.fillStyle = "#ffd166";
for (let j = 0; j < SHOTS; j++) if (live[j]) ctx.fillRect(sx[j] - 1.5, sy[j] - 6, 3, 12);
ctx.fillStyle = "#39ffb0";
ctx.beginPath(); ctx.moveTo(g[0], H - 30); ctx.lineTo(g[0] - 14, H - 10); ctx.lineTo(g[0] + 14, H - 10); ctx.fill();
fx.draw(ctx);
ctx.restore();
if (g[2] <= 0) { // game over: stop the engine, so the page's keys are the page's again
e.stop();
e.ledger.attr(start, "disabled", null); // Start again
}
},
});
hud(e, root).num("[data-score]", [g, 1]).num("[data-lives]", () => Math.max(0, g[2]));
start.addEventListener("click", () => {
reset();
e.ledger.attr(start, "disabled", "");
e.start();
}, { signal: e.signal });
reset();
</script>Recipe 3: two players
Two peers on one page, each with its own engine, controls and screen, joined by memory(): an in-memory link with three ticks of latency and jitter. Only inputs cross it, 12 bytes a message. Each sim runs a frame only when both inputs for it are in, so both screens show the same game, and the hash check every 30 frames says so. Swap memory() for join(url) and the same code plays across two devices through a room server: ours is in-house, so build your own.
@fzlt/core3.3 KBCOMPLETE @fzlt/core/input1.3 KBCOMPLETE @fzlt/core/presence2.1 KBBETA @fzlt/core/hud1.3 KBBETA7.2 KB gzFrame 0 · in sync · P1: A/D or hold the left screen's sides · P2: J/L or the right screen's
<div id="duel" style="display: grid; gap: 10px">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px">
<canvas width="320" height="120" style="width: 100%; touch-action: none; background: #07060f; border-radius: 10px" aria-label="Player 1's screen"></canvas>
<canvas width="320" height="120" style="width: 100%; touch-action: none; background: #07060f; border-radius: 10px" aria-label="Player 2's screen"></canvas>
</div>
<p style="margin: 0">Frame <b data-frame>0</b> · <b data-sync>in sync</b> · P1: A/D or hold the left screen's sides · P2: J/L or the right screen's</p>
</div>
<script type="module">
// Recipe: two players. Two lockstep peers on one page, each with its own engine, its own controls
// and its own screen, joined by an in-memory link with latency and jitter. Only inputs cross it,
// 12 bytes a message; both screens show the same game because both sims run the same frames.
// For two devices, swap memory() for join("wss://<your room server>/room/0?players=2").
// Kernel, input and presence. Runs as is with duel.html.
import { createEngine } from "https://fizlet.com/dist/index.js";
import { controls } from "https://fizlet.com/dist/input.js";
import { presence, memory, hash32 } from "https://fizlet.com/dist/presence.js";
import { hud } from "https://fizlet.com/dist/hud.js";
const W = 320, H = 120;
const root = document.getElementById("duel");
const screens = root.querySelectorAll("canvas");
const link = memory({ peers: 2, latency: 3, jitter: 2 }); // ticks of delay, ± jitter
const KEYS = [{ left: ["KeyA"], right: ["KeyD"] }, { left: ["KeyJ"], right: ["KeyL"] }];
const peers = [];
for (let slot = 0; slot < 2; slot++) {
const canvas = screens[slot], ctx = canvas.getContext("2d");
const e = createEngine({ root, seed: 42 }); // same seed on every peer
const pad = controls(e, { target: canvas, keys: KEYS[slot] });
// The whole game state: [x0, x1, score0, score1, coin x]. Same frames in, same bytes out.
const s = new Float64Array([W * 0.25, W * 0.75, 0, 0, W / 2]), bytes = new Uint8Array(s.buffer);
let side = 0; // which half of this screen a finger holds: -1, 0 or 1 (read in read, used in input)
e.add({ read() { side = pad.pointer.down ? (pad.pointer.x < canvas.clientWidth / 2 ? -1 : 1) : 0; } });
const net = presence(e, {
transport: link.ends[slot], slot, players: 2, delay: 2,
input: () => (pad.down("left") || side < 0 ? 1 : 0) | (pad.down("right") || side > 0 ? 2 : 0),
step(e, inputs) { // the sim: inputs and e.random only
for (let p = 0; p < 2; p++) {
const bits = inputs[p];
s[p] = Math.max(8, Math.min(W - 8, s[p] + ((bits & 2 ? 1 : 0) - (bits & 1 ? 1 : 0)) * 3));
if (Math.abs(s[p] - s[4]) < 10) { s[2 + p] += 1; s[4] = 10 + e.random() * (W - 20); }
}
},
hash: () => hash32(bytes), // compared every 30 frames: net.desync
});
e.add({
render() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#ffd166";
ctx.fillRect(s[4] - 5, 55, 10, 10);
for (let p = 0; p < 2; p++) {
ctx.fillStyle = p === 0 ? "#39ffb0" : "#ff5c8f";
ctx.fillRect(s[p] - 8, p === slot ? 80 : 30, 16, 16); // you are the lower one
ctx.font = "bold 14px monospace";
ctx.fillText(`P${p + 1} ${s[2 + p]}`, p === 0 ? 8 : W - 52, 18);
}
},
});
peers.push({ e, net });
}
// The link moves once a frame; the readouts come from peer 1.
peers[0].e.add({ read: () => link.tick() });
const [one] = peers;
hud(one.e, root)
.num("[data-frame]", () => one.net.frame)
.num("[data-sync]", () => one.net.desync, (f) => (f < 0 ? "in sync" : `desync at frame ${f}`));
for (const p of peers) p.e.start();
</script>Before you ship
- Measure it. Bundle, minify and gzip your piece; that is what a visitor downloads. The badges above are measured that way on every build.
- Check the step. No
Math.random, no clock, no layout reads, nothing allocated. Determinism and the contract list the rules;@fzlt/core/debugcounts layout reads in a step. - Check Recall. Snapshot the page, run the piece,
destroy()it, and compare: the DOM should match. - Check stillness. With
settle, a piece that is not moving should ask for no frames. The perf overlay shows it.