NarraLeaf

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");
    },
});

Four members are always there

MemberWhat
game.blueprintNodesregister / registerMany the game-side execute for your node types.
game.widgetsregister / registerMany the game-side renderer for your widget types.
game.datareadJson(namespace) — read plugin storage published with the game.
game.loglog(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 four, 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 nine capabilities

CapabilityGrantsWhat it is for
storeapp.game.storePlugin-scoped persistent key/value storage, kept beside the player's saves.
eventsapp.game.eventsSubscribe to game lifecycle and story events.
state.readapp.game.stateget, onChangeRead and observe story variables.
state.writeapp.game.state.setWrite story variables. Implies state.read.
saves.readapp.game.saveslistIds, readMetadataList save slots and read their metadata.
saves.writeapp.game.saves.write / .loadOverwrite a slot, or replace the running playthrough.
ui.overlayapp.game.ui.overlayDraw an element on top of the game.
assetsapp.game.assetsResolve a packaged asset id to a URL.
localeapp.game.localeRead and observe the game's display language.

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.write implies state.read. Declaring only write would under-report what the plugin can do — anything you can write, you can observe. Studio adds state.read for you.
  • saves.write is heavier than saves.read and 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.

DomainDesktop buildWeb exportAndroid / iOSPreviewDev ModeIn-editor preview
storeyesyes (IndexedDB)yesyesyesno
eventsyespartial — see belowpartialyesyesno
stateyesyesyesyesyesno
savesyesyesyesyesyesno
ui.overlayyesyesyesyesyesno
assetsyesyesyesyesnono
localeyesyesyesyesyesno
sidecaryesneverneveryesnono
  • events.closeRequested never fires on the web — there is no window to close. Use events.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. Two capabilities differ there: assets is absent because asset resolution goes through async IPC while the capability's signature is a synchronous url(), and sidecar is absent because the Dev Mode window hosts no child processes. Test either of 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.readJson returns null there too, and calling game.blueprintNodes.register throws — registration belongs to app.services.* on the studio entry. The same execute runs 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

What the runtime surface does not have

  • No hostAdapter. A node's ctx carries params, resolveInput, eventName, eventPayload, signal, and game — nothing else. Earlier builds leaked the host's full internal API through ctx.hostAdapter.blueprintRuntime.hostApi; that path is gone. Everything now goes through ctx.game, which is exactly what the manifest declared.
  • No editor services. app.services, app.privileged, and the ui kit are the studio entry only. A runtime entry is game code.
  • No react-dom/client. The host provides react, 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.i18n is the editor's UI language. A game localizes its player-facing content through NarraLeaf's own localization system; the runtime surface only tells you which locale is active.
  • No cleanup lifecycle. A game process loads plugins once and never unloads them, so setup has no cleanup return and register returns void.

On this page