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.