Skip to content

Extensions

Extension<T> is the single plug-in contract of IdleKit. Gameplay mechanics, lifecycle plugins and your own add-ons all implement it and are installed the same way:

ts
engine.use(pageLifecycle());
engine.use(producersExt);
engine.use(myCustomExtension);

The contract

ts
interface Extension<T extends object> {
  /** Human-readable identifier (diagnostics). */
  id: string;
  /** Called once on registration (engine.use). */
  setup?(engine: EngineContext<T>): void;
  /** Called on each fixed time step, after the systems. */
  update?(state: T, dt: number): void;
  /** Called once per frame, after the bindings are rendered. */
  render?(): void;
  /** Called when resources are released (engine.dispose). */
  teardown?(): void;
}

Every hook is optional — an extension implements only what it needs. A pure data holder (like the modifiers hub) has just an id and a property; a simulation participant (like crafting) adds update; a platform integration (like page-lifecycle) adds setup/teardown for its listeners.

EngineContext: what setup can touch

setup receives a narrow view of the engine — not the concrete Engine class:

ts
interface EngineContext<T extends object> {
  readonly state: T;
  readonly events: EventEmitter<EngineEvents>;
  advance(seconds: number): void;
  pause(): void;
  resume(): void;
}

This is deliberate: extensions stay decoupled from the engine internals and are trivial to test with a stub.

Lifecycle events

The engine emits two events extensions commonly react to:

EventPayloadEmitted when
loadedsavedAt timestamp (ms)engine.load(save) succeeded
resumeelapsed background time (ms)the page-lifecycle bridge resumes

The loaded event is the restore point of the toolkit: mechanics use it to rebuild runtime data from your (just-loaded) state — projects restore completion, collections and boosts republish their modifiers, crafting heals stale jobs. If you write an extension that derives anything from the state, subscribe to loaded.

The factory pattern

IdleKit extensions are factory functions returning an object — no classes to extend, no inheritance:

ts
import type { Extension } from "@idlekitjs/core";

interface KillSwitchOptions<T> {
  isGameOver: (state: T) => boolean;
  onGameOver: () => void;
}

export function killSwitch<T extends object>(options: KillSwitchOptions<T>): Extension<T> {
  let fired = false;
  return {
    id: "kill-switch",
    update(state) {
      if (!fired && options.isGameOver(state)) {
        fired = true;
        options.onGameOver();
      }
    },
  };
}

Conventions the built-in extensions follow (and yours should too):

  • State access through accessors. Never assume a state shape; take getX/setX options (see any mechanic for the pattern).
  • Reassign for reactivity. Structural changes go through the set* accessor with a fresh object/array; see State.
  • Callbacks over custom events. Game hooks are plain options (onPurchase, onComplete, onExpire), not a bespoke event system.
  • Serializable runtime data lives in the game state. The extension itself holds only static definitions and derived indexes.
  • Validate definitions at wiring time. A bad static definition (duplicate id, negative duration) throws immediately in the factory — a clear failure at startup beats a corrupted save at runtime.

Extensions with a public surface

Many extensions expose methods beyond the base contract. The convention is an interface extending Extension<T>:

ts
const kitchen = crafting<State>({ ... });   // CraftingExtension<State>
engine.use(kitchen);

kitchen.start(engine.state, "sandwich", "kitchen-1"); // extra methods

Keep a reference to the returned object — engine.use registers it but does not hand it back to you by id.

Ordering

Extensions update in registration order, after systems. Registration order matters in one common case: an extension that feeds the ModifierRegistry (collections, boosts) should be registered before ones that consume it in the same tick — though in practice the registry pattern is tolerant of one-tick lag.