Runtime API
The game-side surface — what a runtime entry can reach, how capabilities gate it, and why an undeclared domain simply is not there.
The runtime API is the narraleaf-studio/runtime surface: everything a plugin can reach inside a running game — the Dev Mode window, Preview, and the exported Production build. It is where a plugin's blueprint nodes execute, where its widgets render, and where it stores data, listens to the story, and draws over the game.
Everything hangs off one object, app.game, handed to setup(app) and to every node execute as ctx.game.
import { defineRuntimePlugin } from "narraleaf-studio/runtime";
export default defineRuntimePlugin({
setup(app) {
app.game.blueprintNodes.registerMany(createNodes());
app.game.log("info", "runtime bindings registered");
},
});Six members are always there
| Member | What |
|---|---|
game.blueprintNodes | register / registerMany the game-side execute for your node types. |
game.widgets | register / registerMany the game-side renderer and event declaration (logicApi) for your widget types. |
game.storyActions | register / registerMany what runs when the story reaches a row your story actions inserted. The story waits for it. See game.storyActions. |
game.data | readJson(namespace) — read plugin storage published with the game. |
game.config | get(key) — what the author filled in for your contributes.buildConfig fields. Always present for the same reason game.data is: declaring a field grants the plugin nothing, so there is no capability to gate on. |
game.log | log(level, message) into the host log. |
Every type you register must be declared in contributes.blueprintNodes / contributes.widgets, and game.data only sees namespaces listed in contributes.runtimeData. See the manifest reference.
Everything else is capability-gated
Beyond those six, each namespace on app.game exists only if your manifest declared it in contributes.runtimeCapabilities:
{
"contributes": {
"runtimeCapabilities": ["store", "events", "state.read"]
}
}An undeclared domain is absent from the object, not a method that throws. app.game.state is undefined if you did not declare state.read — there is nothing to call and nothing to catch. Check for presence, do not wrap in try.
That is the whole contract. The install prompt lists exactly the domains present on app.game, so what the author approved at install and what your plugin can do are the same set by construction. You never write these permissions by hand — Studio derives them from contributes, and a hand-written one is a manifest error.
The thirteen capabilities
| Capability | Grants | What it is for |
|---|---|---|
store | app.game.store | Plugin-scoped persistent key/value storage, kept beside the player's saves. |
events | app.game.events | Subscribe to game lifecycle and story events. |
state.read | app.game.state — get, onChange | Read and observe story variables. |
state.write | app.game.state.set | Write story variables. Implies state.read. |
saves.read | app.game.saves — listIds, readMetadata | List save slots and read their metadata. |
saves.write | app.game.saves.write / .load | Overwrite a slot, or replace the running playthrough. |
ui.overlay | app.game.ui.overlay | Draw an element on top of the game. |
assets | app.game.assets | Resolve a packaged asset id to a URL. |
locale | app.game.locale | Read and observe the game's display language, and read the project's translation keys and the plugin's own words in it. |
menu | app.game.menu — set | Declare the whole menu bar above the desktop game. set(spec) replaces the bar, and an empty spec removes it. Each row runs a built-in action, such as opening a page or turning auto forward on and off, or a function of App logic. A label is { key, text, words? }: the words of the translation key key, or text, translated through words, the id the plugin offered text under with registerWords. |
story.compile | app.game.story | Register a compile pass that observes each scene and injects engine actions around its rows. The heaviest of the thirteen: a pass runs over every scene of the project, sees who speaks in each, and can put actions around lines it did not write. It is not state.write in disguise and does not imply it — a pass builds actions, it does not run them. |
diagnostics | app.game.diagnostics — imageCache | Read what the engine's image cache holds and the budgets it is held against. Read-only. imageCache() returns null until a game session is running. |
process.memory | app.game.process — memory | Read how much memory the game's processes hold, as the operating system counts it. Read-only, and memory() returns a promise. |
The list is closed. An unrecognized capability string fails manifest validation rather than being ignored, so a typo can never read as "asked for nothing".
Two of these deliberately split a domain in half:
state.writeimpliesstate.read. Declaring onlywritewould under-report what the plugin can do — anything you can write, you can observe. Studio addsstate.readfor you.saves.writeis heavier thansaves.readand separate from it: it can overwrite a slot or abandon a playthrough. The install prompt says so in as many words.
Sidecars have no capability string
app.game.sidecar exists exactly when contributes.sidecars is non-empty. Declaring the sidecar is the request, so there is nothing extra to forget.
Gating is an intersection
A domain appears when the manifest declared it and this environment can back it. The first half is approval; the second is physical reality — a browser has no child process to spawn, and the editor has no game to read.
When a declared capability has no backing here, the namespace is absent and the host writes a warning to the log saying which one and why. Your plugin still loads.
| Domain | Desktop build | Web export | Android / iOS | Preview | Dev Mode | In-editor preview |
|---|---|---|---|---|---|---|
store | yes | yes (IndexedDB) | yes | yes | yes | no |
events | yes | partial — see below | partial | yes | yes | no |
state | yes | yes | yes | yes | yes | no |
saves | yes | yes | yes | yes | yes | no |
ui.overlay | yes | yes | yes | yes | yes | no |
assets | yes | yes | yes | yes | no | no |
locale | yes | yes | yes | yes | yes | no |
sidecar | yes | never | never | yes | no | no |
menu | yes | no | no | yes | no | no |
diagnostics | yes | yes | yes | yes | yes | no |
process | yes | no | no | yes | the Dev Mode window only | no |
events.closeRequestednever fires on the web — there is no window to close. Useevents.available(name)rather than assuming a desktop shell.- Dev Mode is not Preview. Preview runs the same shell a shipped game runs; the Dev Mode window is a Studio window driving the project directly. Four capabilities differ there:
assetsis absent because asset resolution goes through async IPC while the capability's signature is a synchronousurl(),sidecaris absent because the Dev Mode window hosts no child processes,menuis absent because the Dev Mode window has no game menu bar, andprocess.memoryreports the Dev Mode window's own process only. Test those in Preview. - The in-editor preview (running a node on the editor canvas) has no game at all, so every gated domain is absent.
game.data.readJsonreturnsnullthere too, and callinggame.blueprintNodes.registerthrows — registration belongs toapp.services.*on the studio entry. The sameexecuteruns in both places, which is why a node must degrade rather than assume.
Write nodes that degrade
Because an absent domain is undefined, the guard is ordinary optional chaining — no environment sniffing, no try.
execute: async ctx => {
// Undeclared, or unavailable here: skip the work, keep the flow going.
await ctx.game.store?.set("seen", true);
if (ctx.game.events?.available("closeRequested")) {
// desktop only
}
return { nextPort: "next" };
}ctx.game in a node's execute is the same object setup(app) received as app.game. One API, one capability set, inside and outside a node.
Sub-pages
store
Plugin-scoped persistent key/value storage that survives a new game.
events
Sixteen game and story events, with per-environment availability.
state
Read, write, and observe scene / saved / persistent story variables.
saves
List and read save slots; overwrite or load one with the heavier capability.
ui.overlay
Draw an element above the game — and the one place it will not go.
assets & locale
Resolve packaged asset URLs and follow the player's language.
sidecar
Ship a native child process inside the author's game and talk to it over NDJSON.
What the runtime surface does not have
- No
hostAdapter. A node'sctxcarriesparams,resolveInput,eventName,eventPayload,signal, andgame— nothing else. Earlier builds leaked the host's full internal API throughctx.hostAdapter.blueprintRuntime.hostApi; that path is gone. Everything now goes throughctx.game, which is exactly what the manifest declared. - No editor services.
app.services,app.privileged, and theuikit are the studio entry only. A runtime entry is game code. - No
react-dom/client. The host providesreact,react-dom, and the JSX runtimes as externals, but a plugin must never mount its own React root — a second root would fight the host's over the same tree. Return elements and let the host render them. - No editor i18n.
app.services.i18nis the editor's UI language. A game's player-facing words are translated in the project's translation table; the runtime surface tells you which locale is active, andapp.game.localereads translation keys and the plugin's own words in it. - No cleanup lifecycle. A game process loads plugins once and never unloads them, so
setuphas no cleanup return andregisterreturnsvoid.