Skip to content

Quickstart

Let's build a tiny idle game — coins that accrue over time, a button that buys a generator, and a save file — in about ten minutes. Every snippet uses the real public API.

1. The state

Your state is a plain, serializable object. Define it first; every other type flows from it.

ts
interface State {
  coins: number;
  generators: number;
}

2. The engine

createEngine wires the reactive state, the fixed-step loop and the event bus. In the browser you inject two things: a Renderer (DOM bindings) and the requestAnimationFrame scheduler.

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: { coins: 0, generators: 0 },
  renderer,
  scheduler: createRafScheduler(),
});

Both are optional — omit them and you have a headless simulation.

3. A system

A system is a function (state, dt) => void called on every fixed time step (dt is in seconds, 1/20 s by default). Mutate the state directly:

ts
engine.addSystem((state, dt) => {
  state.coins += state.generators * dt; // each generator: 1 coin/s
});

Because the step is fixed, the simulation is deterministic — the frame rate only affects how often the screen refreshes, never the outcome.

4. Actions

Actions are just functions that mutate the state. No dispatch, no reducers:

ts
function generatorCost(state: State): number {
  return Math.ceil(10 * Math.pow(1.15, state.generators));
}

function buyGenerator(state: State): void {
  const cost = generatorCost(state);
  if (state.coins >= cost) {
    state.coins -= cost;
    state.generators += 1;
  }
}

5. The UI

With this HTML:

html
<main>
  <h1>Coins: <span id="coins">0</span></h1>
  <button id="buy">Buy generator</button>
</main>

bind the DOM to the state. A binding re-runs only when a state key it read has changed — no virtual DOM, no full redraws:

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

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

renderer.add(bindText(coinsEl, () => Math.floor(engine.state.coins).toString()));
renderer.add(bindText(buyEl, () => `Buy generator (${generatorCost(engine.state)} coins)`));
renderer.add(bindDisabled(buyEl, () => engine.state.coins < generatorCost(engine.state)));

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

6. Saving

Add persistence with a SaveManager (versioned, migratable saves) and the autosave plugin:

ts
import { SaveManager } from "@idlekitjs/core";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";
import { autosave } from "@idlekitjs/plugins/autosave";

const save = new SaveManager<State>({
  key: "my-game",
  version: 1,
  adapter: new LocalStorageAdapter(),
});

engine.use(autosave({ manager: save, getState: () => engine.state, intervalMs: 15_000 }));

7. Offline progress and tab handling

Two more one-liners: pause when the tab is hidden, and credit the elapsed time when the player comes back or reloads:

ts
import { pageLifecycle } from "@idlekitjs/browser/page-lifecycle";
import { offlineProgress } from "@idlekitjs/plugins/offline-progress";

engine.use(pageLifecycle());
engine.use(offlineProgress({ maxMs: 8 * 60 * 60 * 1000 })); // cap: 8 h

8. Load and start

ts
await engine.load(save); // emits "loaded"; offline-progress catches up
engine.start();

engine.load restores the saved state and emits the loaded event; every extension that cares (offline progress, mechanics restoring their data) reacts to it. Loading before start() avoids a flash of the initial state.

Complete file

ts
import { createEngine, SaveManager } from "@idlekitjs/core";
import { Renderer, bindText, bindDisabled } from "@idlekitjs/dom";
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";
import { autosave } from "@idlekitjs/plugins/autosave";
import { offlineProgress } from "@idlekitjs/plugins/offline-progress";
import { pageLifecycle } from "@idlekitjs/browser/page-lifecycle";

interface State {
  coins: number;
  generators: number;
}

const generatorCost = (state: State): number => Math.ceil(10 * Math.pow(1.15, state.generators));

const renderer = new Renderer();
const engine = createEngine<State>({
  initialState: { coins: 0, generators: 0 },
  renderer,
  scheduler: createRafScheduler(),
});

engine.addSystem((state, dt) => {
  state.coins += state.generators * dt;
});

const save = new SaveManager<State>({
  key: "my-game",
  version: 1,
  adapter: new LocalStorageAdapter(),
});

engine.use(pageLifecycle());
engine.use(offlineProgress({ maxMs: 8 * 60 * 60 * 1000 }));
engine.use(autosave({ manager: save, getState: () => engine.state, intervalMs: 15_000 }));

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

renderer.add(bindText(coinsEl, () => Math.floor(engine.state.coins).toString()));
renderer.add(bindText(buyEl, () => `Buy generator (${generatorCost(engine.state)} coins)`));
renderer.add(bindDisabled(buyEl, () => engine.state.coins < generatorCost(engine.state)));

buyEl.addEventListener("click", () => {
  const cost = generatorCost(engine.state);
  if (engine.state.coins >= cost) {
    engine.state.coins -= cost;
    engine.state.generators += 1;
  }
});

await engine.load(save);
engine.start();

Where to go from here