DOM ENGINEV0.21.3FIZLET

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 }). Add settle: 0.5 for anything that should cost nothing while it's still, fps: 30 for 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 in step at a fixed 60 Hz, with no DOM at all. Draw and write the page in render, through engine.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/fx for particles, @fzlt/core/timeline for choreographed DOM moves, @fzlt/core/hud for counts. Settle on.

    Recipe 1

  • An arcade game

    @fzlt/core/input (and @fzlt/core/pad on touch), @fzlt/core/collide, @fzlt/core/fx, @fzlt/core/hud; @fzlt/core/rules for waves, drops and score; @fzlt/core/match for start, pause and results; @fzlt/core/arcade when the page itself is the board.

    Recipe 2

  • Two or more players

    A deterministic step and @fzlt/core/presence: inputs in lockstep, a state hash every 30 frames. syncCheck proves it in a test; a room server you run joins two devices.

    Recipe 3

  • A hero in an Astro slot

    @fzlt/core/slot loads the scene near the viewport and keeps the slot's text alternative; @fzlt/core/camera pans and zooms a DOM stage.

    The slot guide

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.

Click Like. The sparks are pooled (cap 160); a full pool refuses new ones instead of growing.
THE WHOLE THING · paste into a page
<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.

Score 0Lives 3 A/D or ←/→ move · Space fires · or drag and tap

Start, then A/D or ←/→ and Space. On a phone, drag to move and tap to fire.
THE WHOLE THING · paste into a page
<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.

Frame 0 · in sync · P1: A/D or hold the left screen's sides · P2: J/L or the right screen's

Player 1: A/D. Player 2: J/L. On touch, hold the left or right half of a screen. The frame count only moves when both inputs are in.
THE WHOLE THING · paste into a page
<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/debug counts 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.