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. The name may be written with or without the $ sigil: Story.get("health") and Story.get("$health") read the same variable.

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

Objects and arrays come back frozen; change them with Story.set() or an assignment such as $player.hp = 5. Inside running code ({do}, {set}, a {watch} run action) Story.get() sees the code's own writes so far, so {do}$hp = 5; _h = Story.get("hp"){/do} sets _h to 5. It returns a frozen copy of the value there, so Story.get("obj") === Story.get("obj") is false while the code runs.

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

Set one or more story variables. As with Story.get(), a leading $ is optional.

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

A dot path such as Story.set("player.stats.str", 5) goes through objects, class instances and array indices ("inventory.0"). A path through a missing object throws a TypeError. So does a write into a Map, Set, Date or RegExp, to a non-index key of an array, or through __proto__, since such a property would be lost on the next save. A variable named __proto__ throws a TypeError in Story.get() and Story.set() alike (see Variable Names); names such as constructor or toString are ordinary variables.

Writing a variable that is not declared in StoryVariables (or a % transient not declared in StoryTransients) still works, but logs a console warning once per name: passages cannot reference such a variable, so it is usually a typo. For dot-paths only the root name is checked.

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}

Inside running code, Story.goto(), Story.back(), Story.forward(), Story.restart() and Story.save() act in program order: the code's writes made before the call are applied first (so {do}$hp = 0; Story.goto("Game Over"){/do} records $hp as 0 in the Game Over moment, and a save includes it), and the code then continues from the state the call leaves, such as the new passage's empty temporaries.

The same holds for everything else running code sets off: a {link}, {button}, input or menubar action it performs with Story.performAction() (or a click or input event it dispatches), and the {set}, {unset}, {computed} and {goto} in a link or button body that runs. Their writes take effect at that point, as if the code had made them: the code reads them next, and a later write of the code's own wins over them, while one made before them does not.

Watchers, variableChanged handlers and anything else reading story state while the code runs see the state at that point in the code: before such a write reaches the story, the code's own assignments so far do (and stay, even if the code throws afterwards). In {do}$a = 1; Story.set("b", 2){/do}, variableChanged reports the change of $a and then that of $b, and a handler of the latter reads $a as 1. A Story.watch() called by the code starts from the code's state.

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: same as dismissible)
options.dismissibleboolean?Let the player close the dialog by clicking the backdrop or pressing Escape (default: true)
{do}
  Story.openDialog("Help");
  Story.openDialog("Credits", { panelClass: "wide-panel" });
  Story.openDialog("Custom", { showCloseButton: false });
{/do}

Keyboard and screen readers ​

Every dialog (from Story.openDialog(), {dialog}, watch triggers and the menubar) is a modal dialog for assistive technology: the panel has role="dialog" and aria-modal="true", and the ✕ button is labelled "Close".

  • When a dialog opens, focus moves into it: to an element with the autofocus attribute, else the first focusable element in the dialog's content, else the panel itself.
  • Tab and Shift+Tab cycle through the topmost dialog's controls and do not leave it.
  • Escape closes the topmost dialog if it is dismissible.
  • When a dialog closes, focus returns to the element that had it before the dialog opened.

Non-dismissible dialogs ​

Pass dismissible: false when the player must act inside the dialog before continuing (a required choice, character creation, a confirmation). Clicking the backdrop and pressing Escape do nothing, and the ✕ button is hidden. The dialog stays open until your code closes it with Story.closeDialog() or Story.closeAllDialogs() (restarting the story also closes it).

:: Choose Path
Which way?
{button "Left"}{do}$path = "left"; Story.closeDialog(){/do}{/button}
{button "Right"}{do}$path = "right"; Story.closeDialog(){/do}{/button}

:: Crossroads
{do}Story.openDialog("Choose Path", { dismissible: false }){/do}

To keep the ✕ button but still ignore backdrop clicks, pass showCloseButton: true as well.

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) ​

Turn off <p> wrapping for content nested inside macros, HTML elements and included passages, while keeping inline markdown (bold, italic, etc.). Useful for layout markup — <div>s, {for} loops, widgets, {if} blocks — where stray paragraphs break the layout.

A passage's own top-level text keeps its paragraphs, so prose still reads as prose. To remove those too, tag the passage [nobr] or wrap the text in {nobr}...{/nobr}.

ContentsetNobr(false) (default)setNobr(true)[nobr] passage tag
Top-level text of a passage, dialog, PassageHeader / PassageFooter<p><p>no <p>
Text inside macros ({if}, {for}, widgets, …), HTML elements, includes<p>no <p>no <p>
{do}
  Story.setNobr(true);  // no <p> inside macros, elements and includes
  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

Returns a Promise<void> that resolves once the save is persisted: aftersave handlers have run, hasSave(slot) is true and listSaves() includes it. It rejects if storage fails (the error is also logged). Ignoring the promise is fine — a failure then only shows up in the console.

javascript
await Story.save('slot-2');
renderSlots(await Story.listSaves()); // includes slot-2

Save operations (save, load, deleteSave, getSaveInfo, listSaves, exportSave, importSave and Story.storage's) take effect in the order they are called, whether or not their promises are awaited: Story.save('a'); Story.deleteSave('a'); leaves slot a empty, and a load() called after a save() loads that save.

Story.load(slot?) ​

Load a saved game. When slot is provided, loads from the named slot. A load restores the state at the start of the saved passage, plus the variables a beforesave handler changed. Other variables changed on that passage after entering it are not restored, and the passage runs again (see What a Load Restores).

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

Loading moves the game to the playthrough of the loaded save, so saves made afterwards are grouped with it (no change if the save belongs to the current playthrough). The switch takes effect in call order: Story.load('a'); Story.save('b'); puts b in the playthrough of the save in a. See Playthroughs.

Returns a Promise<void> that resolves once the loaded state is applied (immediately, with nothing changed, if the slot is empty) and rejects if loading fails. A restart called after load() (before the save has been read) wins over it: the promise then resolves without loading, and no beforeload/afterload events fire.

Inside running code, Story.load() takes its place among the save operations at the call, like Story.save() (the code's writes so far are applied first), but the loaded state replaces the game once the save has been read, after the code has finished.

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.

The default save reports slot as ''. Every slot method accepts '' for the default slot as well, so you can pass slot from getSaveInfo() or listSaves() straight to load(), deleteSave(), exportSave() and importSave().

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

Returns a Promise<void> that resolves once the save is removed (hasSave(slot) is false, listSaves() no longer lists it) and rejects if deleting fails.

Story.exportSave(slot?) ​

Returns a Promise<SaveExport | null> with the save in the given slot as a portable plain object, or null if the slot is empty. It is the same format as the Export button in the {saves} dialog. Use JSON.stringify to write it to a file.

javascript
const data = await Story.exportSave('slot-1');
if (data) {
  const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'slot-1.json';
  a.click();
  URL.revokeObjectURL(a.href);
}

The SaveExport object contains version, ifid, exportedAt and save ({ meta, payload }). Use save.meta.title and save.meta.custom to preview a file before importing it.

Story.importSave(data, slot?) ​

Imports a save export into a slot and replaces any save already in that slot. Omit slot to import into the default autosave slot. Returns a Promise<SaveInfo> with the slot's new metadata. Afterwards Story.hasSave(slot) is true and Story.load(slot) loads the imported save.

javascript
const text = await file.text();
try {
  const info = await Story.importSave(JSON.parse(text), 'slot-2');
  console.log('Imported ' + info.title);
} catch (err) {
  console.error(err.message); // invalid file, or a save from another story
}

The promise rejects, and the slot is left unchanged, if data is not a save export or if it comes from a different story (its IFID does not match). The imported save keeps its title and custom metadata. If its playthrough doesn't exist in this browser, it is grouped under an "Imported" playthrough. Importing does not load the save.

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.

Suffixes are counted afresh on every navigation, so a passage's actions get the same IDs each time it is shown. Controls that stay mounted across navigations, such as those in StoryInterface, keep their IDs, and the passage's actions skip them: with [[Forest]] in both StoryInterface and the passage, the interface link is link:Forest and the passage link link:Forest:2.

Authors can override the generated ID using the #id syntax: [[#my-link Go|Forest]]. Author IDs should be unique among the controls on screen: when two mounted controls share one, a warning is logged and Story.getActions() lists only the newer.

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('afternavigate', function (to, from) {
  console.log('Navigated from ' + from + ' to ' + to);
});

// The passage's DOM is in the document (see "Render events" below)
Story.on('passagerender', function (passage, el) {
  initPager(el);
});

// 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);
  }
});

// Loading a game: `slot` is the named slot passed to Story.load(slot),
// or undefined for the default slot, the saves dialog, and session
// restore after a page refresh. A save made in `beforeload` belongs to
// the playthrough being left; when `afterload` fires, the game is in the
// loaded save's playthrough
Story.on('beforeload', function (slot) {
  console.log('Loading ' + (slot || 'game'));
});
Story.on('afterload', function (slot) {
  console.log('Loaded', Story.get('health'));
});

// Later: stop listening
unsub();

Render events ​

afternavigate fires as soon as the story state changes, before the new passage is rendered (with a fade-through transition the new passage mounts only after the outgoing fade). Code that needs the rendered DOM — pagers, drag-and-drop, focus, third-party widgets — listens for the render events instead:

EventArgumentsFires when
passagerender(passage: string, el: HTMLElement)A passage's .passage element has been committed to the DOM: first render, navigation, back/forward, restart, load. el is the live element, never the outgoing transition snapshot.
dialogrender(passage: string, el: HTMLElement)A dialog has opened and rendered (Story.openDialog(), {dialog}, watch dialogs, menubar dialogs). el is its .dialog-panel.

Both fire once per mount, after the DOM commit and before the browser paints. An exception in a handler is logged to the console and does not break rendering.

js
Story.on('dialogrender', function (passage, panel) {
  if (passage === 'Inventory') initDragAndDrop(panel);
});
Story.openDialog('Inventory');

Content that re-renders inside a passage or dialog because a variable changed does not fire these events. For DOM work tied to such content, use a custom macro with ctx.hooks.useEffect, which runs after the macro's own content is committed.

Story.waitForActions() ​

Returns a Promise that resolves with the current actions after the UI has settled: two animation frames, and, if a navigation is still being rendered (for example during a fade-through transition), until the current passage has been mounted. This includes a navigation to the passage already shown, which mounts it anew. 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.

Random numbers drawn in beforesave, aftersave and afterload handlers do not advance the sequence: the story draws the same values again afterwards. Saving therefore never changes the rolls that follow, and the game goes on after a load exactly as it would have after the save. Use Math.random() in these handlers for values that must not repeat the story's next rolls.

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.

Lowering the limit takes effect at once: history keeps the newest moments that include the current one, so Story.back() can't go further back than the new limit allows (if the player had gone back further than that, the moments after the newest kept one are dropped too). The session autosave is updated with the trimmed history, and a save made under a higher limit is trimmed when loaded. Raising the limit doesn't change history.

{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.config.quickSaveKey / Story.config.quickLoadKey ​

Get or set the keyboard shortcuts for quick save and quick load. Values are KeyboardEvent.key names. Defaults: 'F6' and 'F9'. Set a shortcut to null to disable it. The key then reaches the page as usual. The tooltips of the {quicksave} and {quickload} buttons follow the setting.

js
// In the story JavaScript
Story.config.quickSaveKey = null; // disable F6 quick save
Story.config.quickLoadKey = null; // disable F9 quick load

Story.config.quickSaveKey = 'F5'; // or rebind

The shortcuts fire wherever focus is, including in text inputs, so prefer keys that don't type text (function keys). Like maxHistory, these settings survive Story.restart().

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.