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

Six members are always there

MemberWhat
game.blueprintNodesregister / registerMany the game-side execute for your node types.
game.widgetsregister / registerMany the game-side renderer and event declaration (logicApi) for your widget types.
game.storyActionsregister / registerMany what runs when the story reaches a row your story actions inserted. The story waits for it. See game.storyActions.
game.datareadJson(namespace) — read plugin storage published with the game.
game.configget(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.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 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

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.state — get, onChangeRead and observe story variables.
state.writeapp.game.state.setWrite story variables. Implies state.read.
saves.readapp.game.saves — listIds, 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, and read the project's translation keys and the plugin's own words in it.
menuapp.game.menu — setDeclare 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.compileapp.game.storyRegister 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.
diagnosticsapp.game.diagnostics — imageCacheRead 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.memoryapp.game.process — memoryRead 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.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
menuyesnonoyesnono
diagnosticsyesyesyesyesyesno
processyesnonoyesthe Dev Mode window onlyno
  • 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. Four capabilities differ there: assets is absent because asset resolution goes through async IPC while the capability's signature is a synchronous url(), sidecar is absent because the Dev Mode window hosts no child processes, menu is absent because the Dev Mode window has no game menu bar, and process.memory reports 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.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's player-facing words are translated in the project's translation table; the runtime surface tells you which locale is active, and app.game.locale reads translation keys and the plugin's own words in it.
  • 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