Plugins
Plugins are how Choicekit intentionally stays minimal at its core while still supporting richer IF systems.
The core engine focuses on passages, state history, deterministic randomness, and save infrastructure. Plugins layer on top to add domain features like achievements, settings, storylets, codex systems, quest logs, and telemetry.
Why plugins exist
Section titled “Why plugins exist”Without plugins, every project would either:
- Fork engine behavior
- Keep ad hoc side systems outside the engine
- Rebuild common IF features repeatedly
With plugins, you get a clean extension boundary:
- Core stays stable and predictable
- Feature systems can evolve independently
- APIs mount in a single discoverable place:
engine.$
Plugin purpose in relation to engine core
Section titled “Plugin purpose in relation to engine core”Think of Choicekit in two layers:
- Core runtime: passage navigation, state snapshots, save/load, migrations, PRNG
- Feature runtime (plugins): player-facing systems specific to your game design
The engine owns save orchestration and lifecycle. Plugins hook into that lifecycle and can persist their own state either with or outside normal save slots.
This is exactly where IF-specific features belong:
- Achievements and unlock tracking
- User settings and accessibility preferences
- Storylet selection logic and narrative scheduling
- Quest journals, codex entries, relationship trackers, meta-progression
Where plugin APIs live
Section titled “Where plugin APIs live”When mounted, each plugin exposes its public API under a namespaced key on engine.$.
engine.$.achievements.get();engine.$.settings.set((draft) => { draft.textSpeed = "fast";});Each plugin id becomes a namespace key (for example "achievements" or "settings").
Plugin lifecycle
Section titled “Plugin lifecycle”At initialization, Choicekit mounts plugins in sequence.
- Resolve namespace conflicts (
onOverride) - Attempt to mount declared dependencies
- Initialize plugin state (
initState, if provided) - Initialize public API (
initApi) and mount it atengine.$[id] - Attempt to restore plugin data stored outside normal saves (for plugins using
serialize.withSave === false)
Plugin shape (what you can implement)
Section titled “Plugin shape (what you can implement)”A plugin can define:
id(required): unique namespace underengine.$onOverride(optional): conflict behavior ("err" | "ignore" | "override")dependencies(optional): other plugins to mount firstinitState(optional): initialize per-engine private plugin stateinitApi(optional): return public API mounted atengine.$[id]serialize(optional): control plugin-state serialization and save placementonDeserialize(optional): restore plugin state from serialized payloadversion(optional): plugin save-data version metadata
Use definePlugin and ValidatePluginGenerics for strong typing and predictable inference.
Dependency config forwarding
Section titled “Dependency config forwarding”Each dependency entry in dependencies supports either:
- a static config object
- a callback
(config) => dependencyConfigwhereconfigis the caller plugin config
This is useful when a plugin needs to derive nested dependency config from its own config.
type CorePluginGenerics = ValidatePluginGenerics<{ id: "core"; config: { prefix: string }; api: { format: (value: string) => string; };}>;
const corePlugin = definePlugin<CorePluginGenerics>({ id: "core", initApi({ config }) { return { format: (value) => `${config.prefix}${value}`, }; },});
type FeaturePluginGenerics = ValidatePluginGenerics<{ id: "feature"; config: { storyPrefix: string }; dependencies: [typeof corePlugin]; api: { summary: () => string; };}>;
const featurePlugin = definePlugin<FeaturePluginGenerics>({ id: "feature", dependencies: [ { plugin: corePlugin, config: (config) => ({ prefix: config.storyPrefix }), }, ], initApi({ engine }) { return { summary: () => engine.$.core.format("ready"), }; },});If the caller plugin has no config, the callback receives {}.
If two branches request the same shared dependency with different resolved configs, Choicekit keeps the first mounted dependency config and logs a warning.
Persistence model for plugin data
Section titled “Persistence model for plugin data”Plugin persistence is opt-in via serialize + onDeserialize.
withSave: true: plugin data is stored with story snapshots, so it rewinds with history movement (backward/forward) and with save loadswithSave: false: plugin data is stored in plugin-specific storage and included in export payloads
triggerSave() persists plugin state according to that mode:
- for
withSave: true, it checkpoints plugin state into the current story snapshot - for
withSave: false, it writes plugin state into the plugin’s own storage partition
Use withSave: false for cross-run systems (for example achievements/settings that should not rewind when loading old story saves).
Registering plugins
Section titled “Registering plugins”Use the builder to mount plugins before build().
import { ChoicekitEngineBuilder, createAchievementsPlugin, createStoryletPlugin, createSettingsPlugin,} from "choicekit";
const achievementsPlugin = createAchievementsPlugin({ storyBeat1: false, storyBeat2: false,});
const settingsPlugin = createSettingsPlugin({ textSpeed: "normal" as "slow" | "normal" | "fast", musicVolume: 0.7,});
const storyletPlugin = createStoryletPlugin();
const engine = await new ChoicekitEngineBuilder() .withName("my-story") .withPassages({ name: "Start", data: "..." }) .withVars({ gold: 0 }) .withPlugin(achievementsPlugin, { default: { storyBeat1: false, storyBeat2: false, }, }) .withPlugin(settingsPlugin, { default: { textSpeed: "normal", musicVolume: 0.7, }, }) .withPlugin(storyletPlugin, { storylets: [ { name: "VillageIntro", passageId: "Start", priority: 1, conditions: [ (engine) => [engine.vars.gold >= 0, 1], ], }, ], }) .build();Building a custom plugin
Section titled “Building a custom plugin”Use definePlugin for custom systems. The example below shows a compact “quest log” plugin with typed config, internal state, public API, and persistence.
import { definePlugin, type ChoicekitPlugin, type ValidatePluginGenerics,} from "choicekit";
type QuestPluginGenerics = ValidatePluginGenerics<{ id: "quests"; config: { default: Record<string, { completed: boolean; title: string }>; }; state: { quests: Record<string, { completed: boolean; title: string }>; }; serializedState: { quests: Record<string, { completed: boolean; title: string }>; }; api: { all(): Readonly<Record<string, { completed: boolean; title: string }>>; complete(id: string): void; }; dependencies: [];}>;
export const createQuestPlugin = (): ChoicekitPlugin<QuestPluginGenerics> => definePlugin({ id: "quests", initState() { return { quests: {} }; }, initApi({ config, state, triggerSave }) { state.quests = { ...config.default };
return { all() { return state.quests; }, complete(id) { if (!state.quests[id]) return; state.quests[id].completed = true; triggerSave(); }, }; }, serialize: { withSave: true, method(state) { return { quests: state.quests }; }, }, onDeserialize({ data, state }) { state.quests = data.quests; }, });Mount it with:
engineBuilder.withPlugin(createQuestPlugin(), { default: { mainQuest: { title: "Find the archive key", completed: false }, },});Current official plugins
Section titled “Current official plugins”These official plugins demonstrate the key plugin patterns:
- Stateful API mounted at
engine.$ - Event emitter API for feature-specific events
- Persistent plugin state (
withSave: falseorwithSave: true) depending on desired rewind behavior
Storylets and other IF systems
Section titled “Storylets and other IF systems”Storylets are a strong candidate for plugins because they are game-design systems, not engine-core mechanics.
A storylet plugin can:
- Evaluate requirements against current engine state
- Return eligible entries sorted by priority
- Keep independent metadata (cooldowns, exhaustion, unlock context)
That approach keeps the base runtime focused and lets you iterate narrative systems without destabilizing passage/state/save internals.
Best practices for plugin authors
Section titled “Best practices for plugin authors”- Keep plugin IDs stable. Changing IDs breaks plugin save partitions.
- Treat plugin config as an explicit contract, not an ad hoc object.
- Use
ValidatePluginGenericsto avoid weak inference onconfig,state, and API. - Decide early whether plugin data should rewind with saves (
withSave: true) or persist independently (withSave: false). - Keep
initStatepure and return fresh data for each engine instance. - Guard dependency usage if dependency mounting can fail in your scenario.