Appearance
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:
| Event | Payload | Emitted when |
|---|---|---|
loaded | savedAt timestamp (ms) | engine.load(save) succeeded |
resume | elapsed 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/setXoptions (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 methodsKeep 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.
Related
- Mechanics overview — the gameplay extensions.
- Plugins overview — the support extensions.
- Core concepts.