JavaScript
The reference runtime, in pure TypeScript, with no DOM, no filesystem, and 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/runtimeis the interpreter. It’s pure, so it embeds anywhere, whether a browser, a server, or a test harness. -
@storylet-studio/play-helpersis everything that touches the browser or the host, meaning the save-file plumbing, the state logger, the in-page property examiner, and the bundle inspector. -
A browser drop-in,
storyletengine.min.js, is the runtime and the helpers in one classic script that defines aStoryletEngineglobal. Two script tags and no build step:<script src="storyletengine.min.js"></script><script>const engine = new StoryletEngine.Engine(bundle, { seed: 7 });const text = StoryletEngine.serializeState(engine); // the save helpers are on it too</script>
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 zip’s builds carry everything they need inside them, so nothing else has to be
installed. Both are also on npm (@storylet-studio/runtime, @storylet-studio/play-helpers),
and the drop-in is on a CDN, straight from the npm package:
<script src="https://unpkg.com/@storylet-studio/play-helpers/dist/storyletengine.min.js"></script>import { Engine } from "@storylet-studio/runtime";npm or the zip
Section titled “npm or the zip”The two are built differently, for the one thing that differs between them: the registry, the store every engine in your game keeps its properties in (see Running it with Patter).
- From npm, the packages take
@wildwinter/scoperegistryas a peer dependency, so your game and every engine in it, Patter’s included, share the one copy in your install. npm installs it for you; with a package manager that doesn’t install peer dependencies, add it yourself (npm install @wildwinter/scoperegistry). If two packages ever need versions that can’t be one copy, the install stops and says so. - From the zip, and in the drop-in, each build carries its own registry inside it, because there is no install to share one from. A game with only this engine won’t notice. A game that also runs Patter from its zip or its drop-in has one registry per engine, so for two engines sharing one store, install both from npm.
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. A refresh evicts// what is no longer eligible and fills EMPTY slots; a still-eligible card stays dealt.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, because 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"));}A card with no outcomes gets an empty list. Play it with "" once your game has shown it,
flow.play(card.id, "", "the-inn"), and it counts as played, so its cooldown starts, it
leaves the hand, and nothing is written.
An outcome view is { id, gameId, title?, purpose?, fields?, available }. Its fields are
the outcome’s own handoff, there when the box declares outcome fields, such as the line to
show once the press lands.
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, turnYour game’s state has the paths, and when to write them.
Save and load
Section titled “Save and load”const envelope = engine.saveGame(); // a plain object: the whole run, every flowconst report = engine.loadGame(envelope);const again = engine.getFlow("main"); // loadGame rebuilds every flow: re-take your handlesLook before you load
Section titled “Look before you load”A load is forgiving. A card your edit deleted drops off the board, a property you added takes
its default, and a save from an older build goes in without a word. previewLoad says what
that would cost before you spend it, and changes nothing; loadGame returns the same report
once it has.
const report = engine.previewLoad(envelope);if (!report.exact) { console.log(report.evicted); // cards that will not go back, and why console.log(report.defaultedProperties); // declared here, absent from the save console.log(report.version); // { saved, bundle } - drift is reported, not refused}A save for a different project is the one thing both calls refuse.
Parking one flow
Section titled “Parking one flow”saveFlow(id) takes one flow’s state (not the whole engine) and openFlow(id, { restore })
puts it back. Closing the flow in between is what releases the cards it was holding, so
another flow can be dealt them while it’s away. On the way back, a shared card somebody else
now holds is dropped and reported.
const parked = engine.saveFlow("visitor-7");engine.closeFlow("visitor-7"); // the claims are released here
const report = engine.previewFlowRestore("visitor-7", parked); // optional; changes nothingconst flow = engine.openFlow("visitor-7", { restore: parked, onRestoreReport: (r) => console.log(r.evicted),});An engine built on its own carries every property value in its envelope, a self-backed
@world included, so the two lines above are the whole run. A @world you bind to a resolver is
your game’s state, and your game saves it (why). A game that hands the engine
its own registry saves that registry once, beside the envelope
(Running it with Patter). 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 yourselfflow = engine.getFlow("main"); // and RE-TAKE your handles: a load rebuilds every flowTwo things about that last line. The flow you held before the load is now inert, so
you must take a fresh one. And take it with getFlow, not openFlow, because openFlow
on an id that exists replaces it, which here discards the hand the file just restored, and
you find out later when play() refuses the card as not dealt. The engine can tell you
when that happens. Pass onReplacedFlow: (id, dealt) => console.warn(...) in its
options during development, and leave it unset in a shipped game.
A 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”.
Everything an event names, it names by gameId: the hand, the box, the played card, the evicted card, and every card in an ask’s verdicts. So a trace reads back against the shards you wrote, and a tool over it needs no translation table of its own.
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
can’t show a story action in another flow moving shared state. That participant’s value
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. Dev tools describes what each one shows.
The same package ships createLiveLink and applyLiveBundle, which connect the running game
to Storyletter, so saves reach the run without a restart while the Board shows the game’s
deals. Live Link has the wiring and the protocol.
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, with the same content, the same control labels, the same transcript, and one idiom each.
The Hamlet on the web is the second demo, the same project with Patter
performing each card’s dialogue, two engines in one game. It ships as a project zip on the
download page; src/performance.ts is the whole
integration, and Running it with Patter explains the handoff.
- Dev tools covers what every runtime shares.
- Compatibility & conformance explains why it matches the other engines exactly.
Open source under the MIT licence, made by Ian Thomas.