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.
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 normal story saveswithSave: false: plugin data is stored in plugin-specific storage and included in export payloads
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.