JavaScript
The reference runtime, in pure TypeScript: no DOM, no filesystem, no engine. It runs in any browser or Node app, and every native port reproduces its results exactly.
Install
Section titled “Install”Download the JavaScript zip from the download page. It carries three things:
@storylet-studio/runtime: the interpreter. Pure, so it embeds anywhere: a browser, a server, a test harness.@storylet-studio/play-helpers: everything that touches the browser or the host: the save-file plumbing, the state logger, the in-page property examiner and the bundle inspector.- A browser drop-in for a plain HTML page with no build step.
Each package folder has a dist/ with index.js (ESM), index.cjs and index.d.ts. Copy
the folders into your project and import from them with your bundler, or with a plain path
import. The packages aren’t on npm; the zip is the distribution.
import { Engine } from "@storylet-studio/runtime";Load a bundle
Section titled “Load a bundle”The runtime takes a parsed bundle object. It does no I/O, so load the .storyletsc however
suits you: fetch() it, import it, or read it from disk in Node.
const bundle = await fetch("the-hamlet.storyletsc").then((r) => r.json());Build an engine, open a flow
Section titled “Build an engine, open a flow”const engine = new Engine(bundle, { seed: 7, log: true });const flow = engine.openFlow("main");The engine is the world: it holds the bundle, the shared state and your game’s @world
binding. Every play call lives on a flow - one playthrough - opened by name. A
single-player game opens "main" and never thinks about it again; an experience with many
participants opens one flow each, all over the same shared world
(the sharing rules). Re-opening a name replaces that flow with a fresh
one, and a closed flow’s handle refuses every call.
seed defaults to 0 and seeds each flow’s own generator (override per flow:
openFlow("bob", { seed: 3 })), so the same seed always deals the same cards. log: true
keeps each flow’s trace events so you can read them back later (capped at 1000, oldest
dropped first; { cap: n } sets your own). It’s off by default, and with no subscribers and
no retained log the flow does no trace work at all.
Deal, peek, outcomes, play
Section titled “Deal, peek, outcomes, play”// Refresh every hand. Returns what was dealt, keyed by hand gameId.const dealt = flow.dealMany();
// Or one hand by name.const cards = flow.deal("the-inn");
// What's out right now, across the whole board or one box's hands.const board = flow.board();const barks = flow.board("barks");
// Look at what a box would deal, without dealing anything.const looks = flow.peek("village", { area: "forest" }, 3);A dealt card is { id, gameId, title?, purpose?, fields? }. fields is your handoff: the
scene id, the animation reference, whatever the box’s card template declared.
Ask for outcomes when you’re about to show them; a dealt card doesn’t carry them:
for (const o of flow.outcomes(card.id, "the-inn")) { if (o.available) offer(o.title ?? o.gameId, () => flow.play(card.id, o.gameId, "the-inn"));}play throws before changing anything if the outcome is gated shut or the card isn’t in that
hand. You can only play a card that’s on the board.
Your game’s state
Section titled “Your game’s state”flow.setProperty("world.time_of_day", "night"); // write before you dealflow.getProperty("story.reputation");flow.listProperties(); // every declared property: path, type, value, default
flow.advanceTurns("village", 1); // one box's clockflow.turn("village"); // read itflow.listBoxes(); // every box: id, gameId, title, turnThe paths, and when to write them: Your game’s state.
Save and load
Section titled “Save and load”const envelope = engine.saveGame(); // a plain object: the whole run, every flowengine.loadGame(envelope);const again = engine.getFlow("main"); // loadGame rebuilds every flow: re-take your handles@world is deliberately not in the envelope - it’s your game’s state, and your game saves
it (why). For files, play-helpers gives you the string boundary,
which wraps the envelope together with your world values:
import { serializeState, deserializeState, createWorldContainer } from "@storylet-studio/play-helpers";
const world = createWorldContainer(bundle); // or bind your own resolverconst text = serializeState(engine, world.values()); // write this to a .storyletsaveconst savedWorld = deserializeState(engine, text); // read one back...if (savedWorld) world.load(savedWorld); // ...and apply the world half yourselfA foreign, malformed or wrong-project blob is refused, so a bad file can’t corrupt a run.
The trace
Section titled “The trace”const unsubscribe = flow.subscribeTrace((event) => console.log(event));Events are deal, peek, evict, play, write, turns and diagnostic. A deal or
peek event lists every card that was considered and why it was or wasn’t dealt (dealt,
capped, cooldown, deck-gate, tags, condition, priority, claimed,
claimed-elsewhere, taken). A write
carries the path and the previous value, so a log line reads “0 -> 1”.
If you created the engine with log: true, flow.log() gives you the same events, each
stamped with a sequence number and the turn of the box it happened in. The log lives for the
flow and never rides a save; the durable play history is in the save’s playLog.
When you run several flows, engine.log() is the run’s log: every flow’s events in one
order, each entry carrying the flow it happened in. You want it because a flow’s own log
cannot show a story action in another flow moving shared state - that participant’s value
simply changes, with nothing in their log to explain it. engine.subscribeTrace((flowId, event) => ...) is the same stream live, and engine.clearLog() drops the retained one.
Dev tools
Section titled “Dev tools”import { createPropertyInspector, createBundleInspector, createStateLogger,} from "@storylet-studio/play-helpers";
createPropertyInspector(engine, flow, { container: document.getElementById("state") });createBundleInspector(bundle, { container: document.getElementById("bundle") });The property examiner shows and edits a running flow’s state, turns and board, with Save State… / Load State… buttons. The bundle inspector shows what a bundle offers your code, with no flow running. Leave both out of a shipping build. What each one shows: Dev tools.
The same package ships createLiveLink and applyLiveBundle: connect the running game to
Storyletter, and saves reach the run without a restart while the Board shows the game’s
deals. Wiring and the protocol: Live Link.
The Board demo
Section titled “The Board demo”The helpers package carries a demo folder: the whole play loop as one clickable page, with
every hand a labelled group of card buttons, outcomes revealed beneath the open card, a
transcript, and both examiners mounted beside the board. Its README has the two commands that
build and serve it.
The same Board demo ships with the Unity, Unreal and Godot runtimes: same content, same control labels, same transcript, one idiom each.
- What every runtime shares: Dev tools.
- Why it matches the other engines exactly: Compatibility & conformance.
MIT-licensed open source · Made by Ian Thomas · storylet.studio