Skip to content

Rendering with the DOM

@idlekitjs/dom is IdleKit's optional rendering layer for vanilla DOM: a Renderer plus declarative bindings that only touch the page when the state they read actually changed. No virtual DOM, no framework — and if you prefer React/Vue/Svelte, skip this package entirely and read engine.state from there.

The model

  1. You create bindings: tiny objects wrapping a DOM write (bindText(el, getter)).
  2. The renderer collects them and, connected to the game's reactive store, tracks which state keys each binding reads.
  3. Each frame, only the bindings whose read keys are dirty re-run — and a binding that re-runs still skips the DOM write if the output string didn't change.

Two layers of skipping means a 60 fps game with a static screen does zero DOM work.

Setup

ts
import { createEngine } from "@idlekitjs/core";
import { Renderer } from "@idlekitjs/dom";
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";

const renderer = new Renderer();
const engine = createEngine<State>({
  initialState,
  renderer, // connected to the store automatically
  scheduler: createRafScheduler(), // rAF-driven frames
});

The built-in bindings

All four take a concrete element and a getter:

ts
import { bindText, bindClass, bindVisible, bindDisabled } from "@idlekitjs/dom";

const coinsEl = document.querySelector<HTMLElement>("#coins")!;
const shopEl = document.querySelector<HTMLElement>("#shop")!;
const buyEl = document.querySelector<HTMLButtonElement>("#buy")!;

renderer.add(bindText(coinsEl, () => Math.floor(engine.state.coins).toString()));
renderer.add(bindClass(shopEl, "affordable", () => engine.state.coins >= 10));
renderer.add(bindVisible(shopEl, () => engine.state.unlockedShop));
renderer.add(bindDisabled(buyEl, () => engine.state.coins < 10));
BindingWrites
bindText(el, getter)el.textContent
bindClass(el, className, getter)toggles a CSS class
bindVisible(el, getter)the hidden attribute
bindDisabled(el, getter)el.disabled (buttons/inputs)

Register several at once with renderer.addAll([...]).

Keyed lists: bindEach

For a dynamic set of elements — one node per item, appearing and disappearing over time — bindEach reconciles a container's children by key:

ts
import { bindEach } from "@idlekitjs/dom/bind-each";

renderer.addFrame(
  bindEach(mapEl, {
    items: () => litter.visible(engine.state),
    key: (item) => item.id,
    create: (item) => {
      const el = document.createElement("button");
      el.className = `pickup pickup--${item.type}`;
      el.addEventListener("click", () => collect(item.id));
      return el;
    },
    update: (el, item) => {
      el.style.left = `${(item.position?.x ?? 0) * 100}%`;
      el.style.top = `${(item.position?.y ?? 0) * 100}%`;
      el.style.opacity = String(1 - (item.lifetimeFraction ?? 0));
    },
  }),
);

New keys get create, surviving keys get update (the node is never recreated, so listeners and CSS transitions hold), vanished keys get the optional remove hook — return a promise from it to keep the element in the DOM for an exit animation. Element order follows items() order; only misplaced nodes are moved.

bindEach is pure rendering: it consumes view models (such as a mechanic's visible(state)) and knows nothing about gameplay — click handlers attached in create call back into your game code. Register it with add when the item source marks state keys dirty, or addFrame when the view models derive from values mutated in place (countdowns, positions).

Custom bindings

A binding is any object with an update() method — write your own for anything else (progress bars, canvas widgets):

ts
import type { Binding } from "@idlekitjs/dom";

function bindWidth(el: HTMLElement, getter: () => number): Binding {
  let last: number | undefined;
  return {
    update() {
      const next = getter();
      if (next !== last) {
        last = next;
        el.style.width = `${next * 100}%`;
      }
    },
  };
}

Cache the last value and skip identical writes — that's the whole discipline.

Per-frame bindings

Dependency tracking only sees top-level key writes (see State). Values mutated in place — a crafting job's progress, a producer's cycle — never mark a key dirty on purpose. Bind those with addFrame, which re-runs every frame, bypassing tracking:

ts
renderer.addFrame(bindWidth(barEl, () => kitchen.progressFraction(engine.state, "kitchen-1")));

Keep frame bindings few and cheap: they trade the dirty-key optimization for smooth continuous animation.

Input: just event listeners

There is no synthetic event system. Wire inputs with addEventListener and mutate the state; bindings pick the change up on the next frame:

ts
buyEl.addEventListener("click", () => buyGenerator(engine.state));

Why DOM is a separate package

@idlekitjs/core is headless — it never touches document (a CI guard enforces it). @idlekitjs/dom is the browser half: the Renderer, the bindings, the rAF scheduler and screen helpers (devicePixelRatio, cssToDevicePx, deviceToCssPx). The dependency flows one way (dom → core), so your simulation code never gains a DOM dependency by accident — which is what keeps headless testing trivial.

Common pitfalls

  • Reading state outside the getter. bindText(el, () => cached) can't track anything — read engine.state inside the getter.
  • Binding to in-place-mutated values with add. They'll render once and freeze — use addFrame for continuous values.
  • One binding reading half the state. Tracking is per-binding: small, focused bindings re-run less.