Appearance
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 h8. 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
- Replace the hand-rolled generator with the producers mechanic — cost curves, bulk buying and cascades for free.
- Move manual buy/upgrade actions to
@idlekitjs/economywhen you want explicit resources, requirements, costs, rewards and transaction diagnostics. - Add crafting or boosts.
- Understand the state model and the simulation loop.
- Browse the recipes for copy-paste patterns.