Skip to content

Core concepts

Five ideas explain the entire toolkit. Everything else is detail.

1. The state is a plain object — and it is reactive

Your game state is a plain, serializable data object that you define:

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

const engine = createEngine<State>({ initialState: { coins: 0, clickPower: 1 } });

The engine wraps it in a proxy (ReactiveStore): mutate it directly (engine.state.coins += 1) and the engine records which top-level keys changed. The DOM renderer uses that record to re-run only the bindings that read a changed key.

One rule to remember: only top-level keys are tracked. Deep mutations (state.list.push(x)) are invisible — reassign instead (state.list = [...state.list, x]). Details in State.

2. Systems advance the simulation

A System<T> is a function (state, dt) => void called on every fixed time step:

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

The loop runs at a fixed step (default 1/20 s) regardless of the frame rate, so the simulation is deterministic: 60 fps, 30 fps and a headless test all produce the same result. Details in Simulation loop.

3. Extensions are the one plug-in contract

Anything installable — a gameplay mechanic, an autosave plugin, your own add-on — implements Extension<T> and is registered with engine.use(...):

ts
engine.use(pageLifecycle());
engine.use(offlineProgress({ maxMs: 8 * 3600_000 }));
engine.use(producersExt);

An extension can hook setup (once, on registration), update (every step), render (every frame) and teardown (on dispose). Every hook is optional.

4. Mechanics are generic; your game supplies the data

A mechanic like producers or crafting owns the mechanism — cycle progress, job lifecycles, drop math — and reads/writes its runtime data in your state through accessors you provide:

ts
const kitchen = crafting({
  recipes,
  machines,
  getResources: (state) => state.resources,
  setResources: (state, resources) => {
    state.resources = resources;
  },
  getJobs: (state) => state.crafting.jobs,
  setJobs: (state, jobs) => {
    state.crafting.jobs = jobs;
  },
});

Because the mechanic's data lives in your state as plain JSON, saving, loading and offline catch-up work with zero extra effort. The mechanics overview explains the pattern in depth.

Atomic economic actions are separate: @idlekitjs/economy declares resources, costs, rewards, requirements and transactions. Use it when a click should be previewed, paid and applied as one action; keep continuous dt simulation in mechanics and systems.

5. Everything platform-specific is injected

The core never calls requestAnimationFrame, localStorage or document — a CI guard enforces it. Platform concerns are injected at the edges:

ConcernContractBrowser implementation
Frame drivingFrameSchedulercreateRafScheduler()
RenderingRenderTargetRenderer
PersistenceSaveAdapterLocalStorageAdapter
Tab lifecyclepause() / resume()pageLifecycle() bridge

Omit them all and you have a headless simulation you can drive from a test with manualScheduler or engine.advance(seconds).

Putting it together

txt
your game
  ├─ defines State (plain object)
  ├─ adds systems           (state, dt) => void
  ├─ declares economy       resources / costs / transactions
  ├─ uses mechanics         producers / crafting / boosts / ...
  ├─ uses plugins           autosave / offline-progress
  └─ injects platform       Renderer + createRafScheduler + pageLifecycle + LocalStorageAdapter

@idlekitjs/core runs the loop, tracks the state, emits the events.

Continue with the architecture overview or jump into the quickstart.