The Engine API
This is the deep API reference for the JavaScript reference engine, @patterkit/runtime.
If you just want it running, start with the JavaScript & web guide;
for the cross-engine model, see the play loop. The native
Unity, Unreal, and Godot
ports mirror this same shape.
Engine and Flow
Section titled “Engine and Flow”The Engine owns the world, meaning the bundle, shared global state, and every running
flow. A Flow is one independent position through the story, with its own cursor,
PRNG, and per-flow state. Many flows can run at once over the same data, sharing the
shared state; that’s how you’d run two NPCs from one project. One live instance, one
shared state, and no whole-story copy per branch.
import { Engine } from "@patterkit/runtime";
const engine = new Engine(bundle); // bundle = parsed .patterc JSONconst flow = engine.openFlow("main", { scene: "square" });
for (;;) { const step = flow.advance(); if (step.type === "line") render(step.character, step.characterName, step.text); else if (step.type === "text") narrate(step.text); else if (step.type === "gameEvent") host(step.gameData); // your side-effects else if (step.type === "choice") flow.choose(pick(step.options).id); else if (step.type === "end") break;}Constructing the engine
Section titled “Constructing the engine”new Engine(bundle, options?)options (all optional): seed (the default per-flow PRNG seed), locale (the active
locale; defaults to the bundle’s default), replayPromptOnChoose (speak a chosen option’s
authored prompt back as its first beat, exactly as the choice showed it; a prompt the choice
borrowed from the option’s own first line is not repeated, since that line plays anyway), and
foreignScopes (host-owned property scopes).
There’s also an rng override, but for resumable, save-safe runs use the built-in
seeded PRNG.
Opening a flow
Section titled “Opening a flow”engine.openFlow(id, { scene?, block?, seed? })scene and block accept either a host-facing gameId/address or an internal id;
both default sensibly (first scene, first block). The block is scene-scoped, as for
goto: it must be in the scene you named. Re-opening an existing id replaces it. An
address that does not resolve throws before anything changes, so nothing opens and a flow
already open under that id carries on. Other engine methods: getFlow(id), flows(), closeFlow(id), and reset()
(drop all flows and re-seed shared state).
Dropping a flow finishes it: after closeFlow(id), a reset(), or having its name
re-opened, a reference you are still holding is inert (advancing reports the end, goto
refuses to move it), so a forgotten reference cannot keep running scene entry effects
behind your back.
Playing an address in one call
Section titled “Playing an address in one call”engine.runFlow(name, scene?, block?) // -> the steps that playedOpens the named flow if it does not exist, moves it if it does, plays to the next stop,
and returns what played. Unlike openFlow, it reuses the named flow rather than
replacing it, so variation state (shuffles, once-each lists, visit counts) keeps its place
across calls. An empty array means the address had nothing left to give; an address that
does not resolve throws. See Host navigation.
Walking a flow
Section titled “Walking a flow”flow.advance(): the next step (line, text, game event, choice, or end).flow.advanceToStop():{ played, stop }, collecting beats until the next choice or the end (handy when you render a whole exchange at once).flow.getChoices(): the options of a pending choice.flow.choose(id): pick an eligible option by id, and the nextadvance()runs it. It throws if there’s no pending choice, or the id is unknown or ineligible.flow.isEnded(),flow.currentScene: state for tooling that follows the story across scenes.flow.reset(scene?, block?): begin this flow again at a scene (the first one by default), forgetting everything that is its own: its position, its own properties, its visit counts, and anything waiting to be delivered. Shared state is kept. Every runtime calls itreset(Resetin C# and Blueprint).flow.goto(scene, block?): returns abooleanand moves the cursor to an address, exactly as an authored jump would (on-entry effects run, arriving counts as a visit, the call stack is replaced). It moves rather than resets, so variation and visit counts carry on; it lands immediately, abandoning any part-delivered snippet or pending choice. It returnsfalseand leaves the cursor alone if the address does not resolve; the block is scene-scoped. See Host navigation.flow.isClosed: whether this flow has been finished (see above).
Step shapes
Section titled “Step shapes”step.type |
Fields |
|---|---|
"line" |
id, text, character?, characterName?, direction?, qualifier?, qualifierName?, padAfter, gameData?, tags? |
"text" |
id, text, padAfter, gameData?, tags? |
"gameEvent" |
id, gameData?, tags?: no text; the host-event beat |
"choice" |
groupId, options: ChoiceOption[] |
"end" |
: |
text is interpolated for you (against current property values), except on voiced
lines, which are static. characterName is the localised display name; if a character
has none, it’s absent and you fall back to the character token. qualifier is the line’s
speaker qualifier, by its gameId ("vo", "os",
"radio", or one of the project’s own), and qualifierName is its shown name (V.O.),
localised as characterName is. Both are absent when the line has none, and on a line
closed captions silence. What a qualifier means is your game’s: play a
vo line from a non-positional voice, say, or put a filter on a radio one.
padAfter is the writer’s pause after a line or text beat, in seconds, already resolved from the
beat’s own value and the defaults above it (0.6 when nothing sets one). Negative means the next beat
starts that long before this one ends, so a line or text beat straight after it cuts in on it. A
snippet’s last line or text beat is never negative, since what follows it isn’t certain. A spoken
option prompt’s padAfter times the reply, and is never clamped. When your game times lines itself,
these are the rules the writer expects:
- Start the next line
padAfterafter this one ends, but never before this line itself started. - Ignore the pause on the last line of a conversation, and on the line before a choice, where the player decides.
- Start the first line, and the reply to a choice with no spoken prompt, at once.
- Count a pause after a game event from when the event is done, so a held event doesn’t eat it.
- Apply no pauses when the player clicks through.
A ChoiceOption is { id, prompt?, eligible, gameData? }: ineligible options are still present
(greyed) unless they’re secret; pass id to choose(). Its prompt is { kind, text, padAfter },
where kind is "line" or "text" and padAfter is the pause before the reply, for a game that voices
the prompts itself; a line prompt adds character, characterName, direction, qualifier, and
qualifierName when they’re set. With replayPromptOnChoose, the chosen prompt comes back as the
first step after choose(), carrying the same padAfter. A choice is only offered
when at least one option can be taken: if every remaining option is greyed out, the choice runs dry,
as one with no options does, so its fallback plays if it has one and otherwise the flow moves on.
tags is the beat’s accumulated author tags (its own plus every ancestor’s), absent when empty:
see Tags at runtime.
Properties
Section titled “Properties”Read and write game state from the host:
engine.getProperty("@gold"); // shared @patter globalsengine.setProperty("@gold", 10);flow.getProperty("@scene.locked"); // @scene props live on a flowflow.setProperty("@scene.locked", false);@patter globals are reachable from the engine; @scene properties (and per-flow
property values) are read and written on a Flow. The
@patterkit/play-helpers package adds conveniences like
setProperties(engine, { "@hp": 10 }).
Visit counts, which a story reads with visits() and seen(), are there for a debug panel too:
engine.getVisitCounts() gives the counts shared across every flow and flow.getVisitCounts() a
flow’s own, each by node id. Every runtime has the same call (GetVisitCounts in C# and Blueprint,
get_visit_counts in Godot).
When content fails
Section titled “When content fails”A condition or effect can fail while the story plays, in ways the compiler can’t see: a division by
zero, a value from your game of an unexpected type, or the story setting a @world value your game
marks read-only. The story never stops for one. A failing condition counts as false, so its line,
option, or branch is skipped, and a failing effect is skipped while the rest of its list still runs.
Every runtime does the same.
Each failure is reported, so it’s never silent. Pass onError to hear about it: it receives the flow,
what failed (condition, effect, or best-match), the node, the expression’s source, and the
error. With the decision log on, each is also a diagnostic entry in it.
const engine = new Engine(bundle, { onError: (e) => console.error(`${e.kind} on ${e.node}: ${e.message}`),});| Runtime | How to hear about it | Unset |
|---|---|---|
| JS | onError in the engine options |
console.warn |
| Unity | EngineOptions.OnError (a PlayError) |
Debug.LogWarning |
| Unreal | the engine’s OnError event (an FPatterPlayError); EngineOptions::onError in C++ |
a log warning |
| Godot | "on_error" in the options, a Callable taking a Dictionary |
push_warning |
Patterpad’s Play window marks each failure in its transcript, and the coverage test lists every one it saw.
Why that line?
Section titled “Why that line?”A step tells you what played, not why. The decision log records each decision the engine made and its reasoning:
- each branch or sequence it chose from, with every option it looked at and why each was or wasn’t taken;
- each choice it offered, with the options it greyed out, and the option chosen;
- each jump;
- each property it set, with the value it replaced;
- each content error it played through.
Turn it on with log: true in the engine options. engine.log() is the whole run in order, each entry
naming its flow and scene. A flow’s own log() holds only that flow’s decisions. clearLog() empties a
log, and entries keep counting from where they were, so two reads either side of a clear still agree on
the order.
For a tool that wants the decisions as they happen, onTrace hands each one over with the flow it
happened in, whether the log is on or off. It returns a function that stops it.
const stop = engine.onTrace((flow, entry) => console.log(flow, entry.type));| Runtime | The log | Live |
|---|---|---|
| JS | log option; engine.log(), flow.log(), clearLog() |
engine.onTrace(handler), returning its stop |
| Unity | EngineOptions.Log; Log(), ClearLog() |
engine.OnTrace(handler), returning its stop |
| Unreal | bLog in FPatterEngineOptions; Log(), ClearLog() |
the engine’s OnTrace event; Engine::onTrace in C++ |
| Godot | "log" in the options; log(), clear_log() |
engine.on_trace(handler), returning its stop |
Without the log, and with nothing tracing, the engine does none of this work.
- Integration: save/load, Game Data, localisation at runtime, and helpers.
- Playing in your game: Unity, Unreal, Godot.
- Compatibility: the shared test suite that guarantees parity.
Open source under the MIT licence, made by Ian Thomas.