Skip to content

Story API

Spindle exposes a window.Story global object for JavaScript access to story state and functionality. Use it inside {do} blocks or the browser console.

Methods

Story.get(name)

Get a story variable's value.

{do}
  var health = Story.get("health");
{/do}

Story.set(name, value) / Story.set(vars)

Set one or more story variables.

{do}
  Story.set("health", 100);
  Story.set({ health: 100, name: "Hero" });
{/do}

Transient variables

Prefix variable names with % to read/write transient variables:

{do}
  Story.set("%npcList", [...]);
  Story.set({ "%agents": {...}, health: 100 });
  var agents = Story.get("%agents");
{/do}

Transient variables fire variableChanged events with %-prefixed keys:

Story.on("variableChanged", function(changed) {
  // changed = { "%npcList": { from: [...], to: [...] }, health: { from: 90, to: 100 } }
});

Story.goto(passageName)

Navigate to a passage.

{do}
  Story.goto("Game Over");
{/do}

Story.back()

Go to the previous passage in history.

Story.forward()

Go to the next passage in history (after going back).

Story.restart()

Restart the story. Restores variable defaults and re-runs StoryInit.

Story.watch(condition, callbackOrOptions)

Register an edge-triggered watcher. Fires the callback or action when the condition transitions from false to true. Returns an unsubscribe function.

{do}
  // Callback style
  Story.watch('$health <= 0', function() { Story.goto('Game Over'); });

  // Options style
  Story.watch('$health <= 0', { dialog: 'Game Over', once: true });
  Story.watch('$gold >= 100', { goto: 'Victory', once: true, name: 'gold-watch' });
{/do}

Options: goto, dialog, run, once, name, priority. See {watch} macro for details.

Story.unwatch(name)

Remove a named watcher.

{do}
  Story.unwatch('gold-watch');
{/do}

Story.openDialog(passageName, options?)

Open a dialog displaying the given passage. Dialogs stack — if a dialog is already open, the new one appears on top of it. Each stacked dialog gets its own overlay. Closing the top dialog reveals the one beneath.

ParameterTypeDescription
passageNamestringName of the passage to render in the dialog
optionsobject?Optional settings
options.panelClassstring?CSS class added to the dialog panel
options.showCloseButtonboolean?Show the default close button (default: true)
{do}
  Story.openDialog("Help");
  Story.openDialog("Credits", { panelClass: "wide-panel" });
  Story.openDialog("Custom", { showCloseButton: false });
{/do}

Story.closeDialog()

Close the topmost dialog. If other dialogs are stacked beneath it, the next one is revealed.

{button "Done"}
  {do}Story.closeDialog(){/do}
{/button}

Story.closeAllDialogs()

Close all open dialogs at once, clearing the entire stack.

{do}Story.closeAllDialogs(){/do}

Story.isDialogOpen()

Returns true if any dialog is currently displayed.

{do}
  if (Story.isDialogOpen()) {
    Story.closeDialog();
  }
{/do}

Story.setNobr(enabled)

Globally enable or disable <p> tag wrapping from markdown. When true, all passages and macros suppress paragraph wrapping while keeping inline markdown (bold, italic, etc.).

{do}
  Story.setNobr(true);  // disable <p> wrapping everywhere
  Story.setNobr(false); // re-enable (default)
{/do}

Story.setCSS(enabled)

Enable or disable all built-in Spindle styles. Useful when you want full control over styling without needing to override every default rule.

{do}
  Story.setCSS(false); // disable all built-in styles
  Story.setCSS(true);  // re-enable (default)
{/do}

Story.setTransition(config)

Set the default transition used for all passage navigations. Pass null to revert to the built-in default (fade-through, 300ms, 50ms pause).

PropertyTypeDefaultDescription
typestring'none', 'fade', 'fade-through', 'crossfade'
durationnumber?300Animation duration in milliseconds
pausenumber?50Pause between outgoing and incoming (fade-through only)
{do}
  Story.setTransition({ type: 'crossfade', duration: 600 });
  Story.setTransition({ type: 'none' }); // disable transitions
  Story.setTransition(null); // revert to default
{/do}

Story.setNextTransition(config)

Set a one-shot transition for the next navigation only. Consumed automatically when any navigation occurs — even if passage tags override the visual result.

{do}
  Story.setNextTransition({ type: 'none' }); // next navigation is instant
  Story.goto("DroneAttack");
{/do}

Story.save(slot?, custom?)

Perform a save. When slot is provided, saves to a named slot instead of the default autosave slot. Pass custom to attach metadata that can be retrieved later via getSaveInfo().

javascript
Story.save(); // default slot
Story.save('my-slot'); // named slot
Story.save('day-3', { day: 3, phase: 'morning' }); // with custom metadata

Story.load(slot?)

Load a saved game. When slot is provided, loads from the named slot.

javascript
Story.load(); // load from default slot
Story.load('my-slot'); // load from named slot

Story.hasSave(slot?)

Returns true if a save exists for the given slot. Checks actual storage (persists across page reloads).

javascript
Story.hasSave(); // check default slot
Story.hasSave('my-slot'); // check named slot

Story.getSaveInfo(slot?)

Returns a Promise<SaveInfo | null> with metadata for the given save slot.

javascript
const info = await Story.getSaveInfo('my-slot');
if (info) {
  console.log(info.title); // "Room - 3:42 PM"
  console.log(info.passage); // "Room"
  console.log(info.updatedAt); // "2026-03-22T15:42:00.000Z"
  console.log(info.custom); // { day: 3, phase: "morning" }
}

The SaveInfo object contains: slot, title, passage, createdAt, updatedAt, custom.

Story.listSaves()

Returns a Promise<SaveInfo[]> listing all known saves (default + named slots).

javascript
const saves = await Story.listSaves();
for (const save of saves) {
  console.log(`${save.slot || 'autosave'}: ${save.title}`);
}

Story.deleteSave(slot?)

Delete a save by slot name. Omit slot to delete the default autosave.

javascript
Story.deleteSave(); // delete default save
Story.deleteSave('my-slot'); // delete named slot

Story.defineMacro(config)

Register a custom macro. See Custom Macros for full details.

PropertyTypeDescription
namestringMacro name (case-insensitive)
blockboolean?Declare as block macro ({macro}...{/macro}). Auto-set when subMacros given
interpolateboolean?Resolve variable interpolations in className/id
mergedboolean?Provide ctx.merged variable 3-tuple + ctx.evaluate()
storeVarboolean?Bind to a $variable: ctx.varName, ctx.value, ctx.setValue()
subMacrosstring[]?Register sub-macro names for branching
descriptionstring?Optional description for tooling (LSP hover, doc generation)
parametersarray?Optional parameter definitions for tooling (see below)
renderfunction(props, ctx) => VNode | null — the render function
{do}
  Story.defineMacro({
    name: "shout",
    render: function(props, ctx) {
      return ctx.h("span", null, props.rawArgs.toUpperCase());
    }
  });
{/do}

The ctx object provides h, renderNodes, renderInlineNodes, collectText, sourceLocation, hooks, and any values from the enabled feature flags. The render function runs inside a Preact component and can call hooks via ctx.hooks.

Story.getMacroRegistry()

Returns an array of metadata objects describing all registered macros (built-in and user-defined). Useful for tooling, debugging, and introspection.

javascript
var macros = Story.getMacroRegistry();
macros.forEach(function (m) {
  console.log(m.name, m.block ? 'block' : 'inline', m.source);
});

Each metadata object has these properties:

PropertyTypeDescription
namestringMacro name
blockbooleanWhether the macro accepts children ({macro}...{/macro})
subMacrosstring[]Registered sub-macro names (e.g. ['case', 'default'])
storeVarboolean | undefinedWhether the macro binds to a $variable
interpolateboolean | undefinedWhether variable interpolation is enabled
mergedboolean | undefinedWhether ctx.evaluate() is available
source'builtin' | 'user'Whether the macro is built-in or user-defined
descriptionstring | undefinedOptional description (set by macro author for tooling)
parametersParameterDef[] | undefinedOptional parameter definitions for tooling

Story.registerClass(name, constructor)

Register a class so its instances can be cloned, saved, and restored with their prototype intact.

ParameterTypeDescription
namestringUnique name for the class (used in save data)
constructorFunctionThe class constructor
{do
  class Player {
    constructor(data) { Object.assign(this, data); }
    damage(amount) { this.hp = Math.max(0, this.hp - amount); }
    get isDead() { return this.hp <= 0; }
  }
  Story.registerClass('Player', Player);
  $player = new Player($player);
}

See Using Classes for full details.

Passage Lookup

Story.currentPassage()

Returns the full passage object for the current passage, or undefined if not found.

js
var p = Story.currentPassage();
console.log(p.name); // "Forest"
console.log(p.tags); // ["dark", "outdoor"]
console.log(p.metadata); // { position: "600,400" }

Story.previousPassage()

Returns the full passage object for the previous passage in history, or undefined if there is no previous passage (e.g. on the start passage).

js
var prev = Story.previousPassage();
if (prev) {
  console.log('Came from: ' + prev.name);
}

Passage object

Both methods return a passage object with these properties:

PropertyTypeDescription
pidnumberPassage ID from the story data
namestringPassage name
tagsstring[]Tags from the passage header
metadataRecord<string, string>Metadata from the Twee 3 passage header (e.g. position, size, or custom keys)
contentstringRaw passage content

The metadata field contains all attributes from the passage header's JSON metadata block. In Twee 3 format, this is the JSON object at the end of the header line:

:: Forest [dark outdoor] {"position":"600,400","size":"100,200","difficulty":"hard"}

Standard keys like position and size are included alongside any custom keys the author adds.

Passage Tracking

Spindle tracks how many times each passage has been visited (navigated to) and rendered (visited or included). Back/forward navigation does not increment counts — only new visits and {include} calls do.

Story.visited(name?)

Returns the number of times the player has visited the named passage. If name is omitted, uses the current passage.

{do}
  var count = Story.visited("Dark Cave");
  var thisCount = Story.visited(); // current passage
{/do}

Story.hasVisited(name?)

Returns true if the player has visited the named passage at least once. If name is omitted, uses the current passage.

Story.hasVisitedAny(...names)

Returns true if the player has visited any of the named passages.

{do}
  if (Story.hasVisitedAny("Cave", "Forest", "Mountain")) { ... }
{/do}

Story.hasVisitedAll(...names)

Returns true if the player has visited all of the named passages.

Story.rendered(name?)

Returns the number of times the named passage has been rendered — this includes both visits and {include} calls. If name is omitted, uses the current passage.

Story.hasRendered(name?)

Returns true if the named passage has been rendered at least once. If name is omitted, uses the current passage.

Story.hasRenderedAny(...names)

Returns true if any of the named passages have been rendered at least once.

Story.hasRenderedAll(...names)

Returns true if all of the named passages have been rendered at least once.

Actions

Interactive components (links, buttons, inputs, menubar buttons) automatically register themselves as actions — discoverable, programmatically executable units. This enables automated testing, AI agent integration, and story coverage analysis without DOM interaction.

Story.passage

The current passage name (read-only).

js
console.log(Story.passage); // "Start"

Story.getActions()

Returns an array of all currently registered actions.

js
var actions = Story.getActions();
actions.forEach(function (a) {
  console.log(a.id, a.type, a.label);
});

Each action object has these properties:

PropertyTypeDescription
idstringUnique identifier (e.g. link:Forest)
typestringAction type (see below)
labelstringDisplay text
targetstring?Destination passage (links only)
variablestring?Bound variable name (inputs only)
optionsstring[]?Available options (cycle/listbox)
valueunknown?Current value (inputs)
disabledboolean?Whether the action is currently disabled

Action types: link, button, cycle, textbox, numberbox, textarea, checkbox, radiobutton, listbox, back, forward, restart, save, load.

Action IDs

IDs are generated automatically from the action type and a content-based key:

  • Links: link:PassageName
  • Buttons: button:"Take damage"
  • Inputs: textbox:$name, cycle:$weapon
  • Menubar: back:back, forward:forward, restart:restart, save:quicksave, load:quickload

When multiple actions share the same base ID (e.g. two links to the same passage), a suffix is added: link:Forest, link:Forest:2, link:Forest:3.

Authors can override the generated ID using the #id syntax: [[#my-link Go|Forest]].

Story.performAction(id, value?)

Execute an action by its ID. Throws if the action is not found or is disabled.

js
Story.performAction('link:Forest'); // click a link
Story.performAction('textbox:$name', 'Alice'); // fill a textbox
Story.performAction('cycle:$weapon'); // cycle to next option

When called via performAction, {restart} and {quickload} skip their confirmation dialogs.

Story.on(event, callback)

Subscribe to story events. Returns an unsubscribe function.

js
// Navigation events
var unsub = Story.on('navigate', function (to, from) {
  console.log('Navigated from ' + from + ' to ' + to);
});

// Action registry changes (components mount/unmount)
Story.on('actionsChanged', function () {
  console.log('Actions:', Story.getActions().length);
});

// Story initialization (fires on boot and after every restart)
Story.on('storyinit', function () {
  console.log('Story initialized — re-sync external state here');
});

// Variable changes
Story.on('variableChanged', function (changed) {
  // changed = { health: { from: 100, to: 90 }, ... }
  for (var key in changed) {
    console.log(key + ': ' + changed[key].from + ' → ' + changed[key].to);
  }
});

// Later: stop listening
unsub();

Story.waitForActions()

Returns a Promise that resolves with the current actions after the UI has settled (2 animation frames). Useful in scripts that navigate and then need to inspect the new passage's actions.

js
Story.goto('Forest');
Story.waitForActions().then(function (actions) {
  console.log('Forest has ' + actions.length + ' actions');
});

Random Numbers

Spindle includes a seedable pseudo-random number generator (PRNG) for reproducible randomness across save/load cycles. Initialize it in StoryInit, then use random() and randomInt() in expressions or via the Story API.

Story.prng.init(seed?, useEntropy?)

Initialize the PRNG. Call this in StoryInit to enable seeded randomness.

ParameterTypeDefaultDescription
seedstring?Seed string. If omitted, a random seed is generated.
useEntropybooleantrueMix in Date.now() and Math.random() for unique playthroughs. Set to false for fully deterministic sequences.
:: StoryInit
{do}
  Story.prng.init("my-seed");
{/do}

Story.prng.isEnabled()

Returns true if the PRNG has been initialized.

Story.prng.seed

The current seed string (read-only).

Story.prng.pull

The number of times random() has been called since initialization (read-only).

Story.random()

Returns a seeded random number in [0, 1). Falls back to Math.random() if the PRNG is not initialized.

{do}
  var roll = Story.random();
{/do}

Story.randomInt(min, max)

Returns a random integer between min and max (inclusive).

{do}
  var damage = Story.randomInt(1, 6);
{/do}

Using in expressions

random() and randomInt(min, max) are available directly in expressions:

{set $damage = randomInt(1, 6)}
{if random() > 0.5}
  Critical hit!
{/if}
{print randomInt(1, 20)} on your perception check.

Save/load behavior

PRNG state is automatically saved and restored. After loading a save, the random sequence continues from exactly where it was when the save was made. History navigation (back/forward) also restores the PRNG state from that point in the story.

Events

:storystartup

A DOM event dispatched on document after the Story API is installed and author JavaScript has executed, but before passages are parsed or rendered. This is the right place for external scripts to register custom macros (including block macros) so they are available at parse time.

js
document.addEventListener(':storystartup', function () {
  Story.defineMacro({
    name: 'choices',
    block: true,
    subMacros: ['choice'],
    render: function (props, ctx) {
      /* ... */
    },
  });
});

TIP

You don't need :storystartup for macros defined in the story JavaScript (the :: StoryScript passage) — those run synchronously before :storystartup fires and before passages are parsed. The event is for external scripts loaded independently from the story format.

:storyready

A DOM event dispatched on document after Spindle has finished loading and rendering the first passage. Listen for it in your story JavaScript to run code once the story is fully ready.

{do}
  document.addEventListener(':storyready', function() {
    console.log('Story is ready!');
  });
{/do}

TIP

Register your listener in the story JavaScript (the :: StoryScript passage or a <script> tag) rather than in StoryInit, since StoryInit runs before the DOM is rendered.

Properties

Story.title

The story's title (read-only). Returns the name from the story data.

Sub-objects

Story.settings

The settings API. See Settings for full details.

  • Story.settings.addToggle(name, options) — define a toggle setting
  • Story.settings.addList(name, options) — define a list setting
  • Story.settings.addRange(name, options) — define a range setting
  • Story.settings.get(name) — get a setting's current value
  • Story.settings.set(name, value) — change a setting's value
  • Story.settings.getAll() — get all settings as an object
  • Story.settings.hasAny() — returns true if any settings are defined

Story.config

Configuration options for the story engine.

Story.config.maxHistory

Get or set the maximum number of history moments to keep. Oldest entries are discarded when the limit is exceeded. Default: 40.

{do}
  Story.config.maxHistory = 20;  // keep fewer moments (less memory)
  Story.config.maxHistory = 100; // keep more moments (more undo range)
{/do}

Visit and render counts are unaffected by history trimming — they are tracked independently.

Story.saves

The saves API.

  • Story.saves.setTitleGenerator(fn) — set a custom function to generate save titles. The function receives a payload object with passage and variables properties and must return a string.
{do}
  Story.saves.setTitleGenerator(function(payload) {
    return payload.passage + " (" + payload.variables.name + ")";
  });
{/do}

Released under the Unlicense.