Skip to content

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.

Download the JavaScript zip from the download page. It carries three things:

  • @storylet-studio/runtime is the interpreter. It’s pure, so it embeds anywhere, whether a browser, a server, or a test harness.

  • @storylet-studio/play-helpers is 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 a StoryletEngine global. 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";

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/scoperegistry as 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.

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());
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.

// 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.

flow.setProperty("world.time_of_day", "night"); // write before you deal
flow.getProperty("story.reputation");
flow.listProperties(); // every declared property: path, type, value, default
flow.advanceTurns("village", 1); // one box's clock
flow.turn("village"); // read it
flow.listBoxes(); // every box: id, gameId, title, turn

Your game’s state has the paths, and when to write them.

const envelope = engine.saveGame(); // a plain object: the whole run, every flow
const report = engine.loadGame(envelope);
const again = engine.getFlow("main"); // loadGame rebuilds every flow: re-take your handles

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.

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 nothing
const 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 resolver
const text = serializeState(engine, world.values()); // write this to a .storyletsave
const savedWorld = deserializeState(engine, text); // read one back...
if (savedWorld) world.load(savedWorld); // ...and apply the world half yourself
flow = engine.getFlow("main"); // and RE-TAKE your handles: a load rebuilds every flow

Two 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.

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.

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 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.

Open source under the MIT licence, made by .