Narratomidocs
Reference

Bundle format

The schema of a published bundle, field by field, for anyone writing an importer or a foreign runtime.

A published version is one JSON file: the bundle. It is the only thing Narratomi exports and it is deliberately engine-neutral, pure declarative data with nothing about our player in it. If you want the story running in Unity, Unreal, or a runtime you wrote yourself, you write an importer against this page.

Getting one

Open Publish and use Download bundle on any retained version. The route is GET /api/projects/<id>/publish?download=<version>, and it reads the stored blob rather than recompiling, so you get the bytes readers were served. One thing changes on the way out: asset urls are rewritten so they resolve outside the app. The file is named <title>-v<version>.json.

There is no import. A bundle is compiled output, with chapters flattened, entry points resolved into plain scene ids, and everything the editor needs but a runtime does not already dropped. It cannot rebuild an editable project.

Guarantees

  • formatVersion is 1. Fields are only ever added within a format version, and every added field is optional with a defined absent-meaning, so a reader written against an older bundle keeps working. A breaking change bumps the number.
  • A published version is immutable. Republishing writes a new version and never edits an old one. Saves pin to a version for that reason.
  • Every graph.edges[].to is a real scene id. Authoring-side entry:<chapterId>:<name> targets are resolved at compile time.
  • The manifest lists only assets the story actually references, plus the built-in animation library (entries whose type is animation and whose id starts with builtin-), which every placed character's idle resolves through.
  • A bundle is only produced when the compiler reports zero errors.
  • Randomness is part of the format, not an implementation detail. See below.

Top-level fields

FieldTypeNotes
formatVersion1Literal. Any other value fails the parse.
versionpositive integerWhich published version this is.
projectIdstringStable project id.
titlestring, optionalAbsent means fall back to the start scene's name.
graphobjectstartSceneId plus edges.
scenesrecord keyed by scene idSee below.
variablesarrayDeclared state, each with a required default.
castarrayCast members.
hudobjectwidgets, and optional panels.
assetsarrayThe manifest.
endingsarray, defaults to []Every ending, in scene declaration order.
themestring, optionalOne of ember-noir, parchment, terminal, pulp, porcelain. Absent means ember-noir.
gradestring, optionalThe stage's post-processing look: clean, film, noir, glow. Absent means ungraded, and no chain is mounted at all.
maturetrue, optionalOnly ever written when flagged. It puts an honor-system age interstitial in front of the share link. It is not access control.

startSceneId is the scene named by the first entry point called start (chapters are scanned in order), and otherwise the first scene of the first chapter.

Edges

An edge is { id, from, to, condition? }. At the end of a scene the first edge in declaration order whose condition passes wins. An edge with no condition always passes. If no edge matches, the story ends there, and if that scene carries an ending, that is which ending was reached.

Scenes

A scene is { id, name, staging, script, ending? }.

staging is the scene's opening state:

FieldType
environmentAssetIdasset id or null
placementscast id to mark, one of far-left, left, center, right, far-right
restingClipscast id to looping clip name, optional
initialCamerawide, two-shot, close-up, over-shoulder
pushInboolean
ambientLoopAssetIdasset id or null
enterTransitioncut, fade, dissolve, wipe-left, wipe-right, hold-black
holdMsinteger 0 to 10000, optional. How long hold-black sits at full black. Absent means 600. Ignored by every other transition.
handheldstill, gentle, unsteady, optional. Absent means gentle.
reverbnone, room, hall, cellar, outdoors, optional. Absent means the environment asset's own reverb, and a dry scene when the set carries none. Present, including none, is the scene overriding its set.

Cast not listed in placements are off stage. Marks and cameras are fixed world-space offsets on a stage line that is identical in every environment, so an importer can hardcode them.

ending is { id, title, flavor? }.

Beats

script is a flat array of typed beats. Every beat has an id and a kind.

kindPayload
linetext, optional speaker (cast id), optional voiceAsset, optional spans
stageDirectionaction, optional parallel
setvariable, value, optional op
waittrigger
choiceoptions, optional presentation, columns, timeout, interrupt
inputvariable (a declared text variable), prompt, optional maxLength
checkchance, successEdgeId, failureEdgeId
callpluginId, optional params. The plugin catalog is empty, so no valid pluginId exists yet.

spans on a line is an optional flat run-list of { text, bold?, italic? }. The spans concatenate to exactly text and no interpolation token straddles a boundary, both compiler-enforced, so a reader may ignore spans and render text.

set carries op as one of assign, add, remove, toggle. Absent means assign. add appends to a list and increments a number when both sides are numeric; toggle flips a flag and ignores the carried value.

wait has trigger of { "type": "ms", "ms": n } or { "type": "click" }.

input rests like a choice: the runtime does not advance past it until an answer is submitted, which assigns the string (truncated to maxLength) to variable and steps on.

check rolls once against chance, which is either a number in 0 to 1 or { "variable": name } naming a declared number variable. A bound chance is clamped to 0 to 1 at roll time, and a missing or non-numeric one reads as 0.

A choice option is { id, text, route, condition?, whenLocked?, castTarget?, propTarget?, imageAsset?, altText? }. propTarget is a prop asset id: while the option is live, a click on that placed prop takes it. route is { "type": "edge", "edgeId": ... } or { "type": "inline", "body": [...] }, where an inline body is a beat list that rejoins the scene afterward and may not contain another choice. whenLocked is hide (the default) or disable. timeout is { ms, optionId } with both required, and a lapse is recorded as an ordinary choice of that option.

Stage actions

action is discriminated on type:

typePayload
movetarget (cast id), to (a mark or offstage), gait (walk, jog, run, cut), optional face (cast id, camera, away)
facetarget, toward (cast id, camera, away)
animationtarget (cast id), clip (name or animation asset id), optional loop
camerapreset, optional target (cast id), optional move (dolly-in, dolly-out, truck-left, truck-right, crane-up, crane-down) and ms. No move means a cut; no ms means 1200.
expressiontarget, expression
propop (place or remove), asset or binding, at (a mark or { "heldBy": castId })
inspectasset or binding
lightpreset, one of day, dusk, night, candle, neon, storm
weatherkind, one of none, rain, snow, petals, embers, fog
musicop (play or stop), asset or binding, optional gain, fadeMs, loop, duck (0 to 1) and duckMs. Absent duck means the default dip under dialogue, 0.4 over 200 ms; 1 switches it off for the cue.
sfxsame shape as music, without duck and duckMs. Effects never duck.
effecteffect, one of shake, flash, letterbox
cutawayop (show or end), asset or binding

The rule that is easy to miss: parallel: false, which is the default, holds the script. The runtime rests on that beat and does not advance until the direction finishes, which is how a cutaway gates the next line. parallel: true runs it alongside the beats that follow. A move holds for distance divided by gait speed (1.4, 2.8 and 4.5 m/s for walk, jog and run) on a wall clock; an inspect holds until the reader dismisses the viewer.

Positions are marks, never coordinates: move.to and prop.at take the same five mark names the staging header uses. move, face, prop, light and weather set stage state that a scene entry resets.

binding names a declared text variable holding an asset id, resolved from the variable snapshot when the op is emitted. Anything unresolvable emits the op with no asset.

Conditions

A condition is a tree. Leaves:

kindShape
cmp{ op, variable, value }
visit{ target: "scene" or "option", id, op, value }
contains{ variable, entry }
count{ variable, op, value }

op is one of eq, ne, lt, lte, gt, gte. Combiners are { kind: "and", clauses }, { kind: "or", clauses }, and { kind: "not", clause }. A missing visit counter reads as 0; a missing or non-list variable reads as the empty list.

Variables

Each entry is { name, scope, type, default } with scope one of project, chapter, scene, persistent. Types are flag (boolean default), number, text, list (array of strings), options (adds choices and multiple), cast and asset (the value is an id, empty string means none).

Only persistent has runtime meaning: it is the one scope a new playthrough does not reset. The other three are authoring metadata. Runtime values are always a boolean, a number, a string, or an array of strings.

Cast and assets

A cast member is { id, displayName, color, blip: { pitch, sound? }, portrait } plus optional avatarAsset, binding, looks, and lookVariable. A look is { name, tints } where tints may carry hair, cloth, skin, eye, all, each a #rrggbb string.

A manifest entry is { id, url, type } plus optional tier, clipNames and reverb. type is one of avatar, environment, animation, prop, audio, image, video. tier (avatars only) is vrm, humanoid-glb, or static. clipNames (animation only) is the clip catalog inside that file. reverb (environments only) is the set's own acoustics, same five presets as a staging header: any scene staged in this set and authoring no reverb of its own is heard in this room.

Manifest urls in a downloaded bundle are absolute and point at content-addressed blobs. The bytes at a url never change, so cache or mirror them freely. They need no credentials.

HUD

hud.widgets is an array of { id, type, label, variable, visibleWhen?, format?, anchor?, range?, icon? }. type is text, meter, icon-counter, or list. format is raw (the default), integer, or percent. anchor is one of top-left (the default), top-right, bottom-left, bottom-right. A meter's range endpoints are each a number or { "variable": name }. visibleWhen may be null, which means always shown, as does its absence.

hud.panels is optional. Each panel is { id, title, widgets } and renders in array order. No panels means no inventory screen, not an invalid bundle.

Randomness

If your runtime rolls differently from ours, replays diverge and saves stop meaning anything, so the generator is specified here.

  • Algorithm: mulberry32. One unsigned 32-bit integer of state. Each step adds 0x6d2b79f5 to the state (wrapping to 32 bits) to get the next state n, then computes t = imul(n ^ (n >>> 15), 1 | n), then t = (t + imul(t ^ (t >>> 7), 61 | t)) ^ t, and the roll is ((t ^ (t >>> 14)) >>> 0) / 4294967296, in [0, 1). All arithmetic is exact 32-bit.
  • Seed: drawn once at story start from a cryptographic source, never redrawn on the roll path.
  • Position: carried in the variable snapshot under the reserved key @rng. The same number is both seed and position, so a save resumes mid-sequence.
  • Consumption: exactly one roll per check beat, taken when the runtime settles onto it. The check passes when the roll is below chance.

Keys beginning with @ are reserved. Scene visit counters live at @visit:<sceneId> and taken-option counters at @taken:<optionId>. Author variable names are identifier-shaped, so nothing an author types can collide.

Saves

A save is { bundleVersion, sceneId, beatIndex, variables } plus optional inline, interrupt, cutaway, music, and sfx. variables is the whole snapshot, reserved keys included. Everything after variables is additive, so a save written before a field existed simply has none.

Worked example

A two-scene story with one variable, one choice, and an ending.

{
  "formatVersion": 1,
  "version": 3,
  "projectId": "prj_9f2",
  "title": "The Lantern",
  "theme": "ember-noir",
  "graph": {
    "startSceneId": "sc_dock",
    "edges": [
      {
        "id": "e_lit",
        "from": "sc_dock",
        "to": "sc_boat",
        "condition": { "kind": "cmp", "op": "eq", "variable": "hasLantern", "value": true }
      },
      { "id": "e_leave", "from": "sc_dock", "to": "sc_boat" }
    ]
  },
  "variables": [
    { "name": "hasLantern", "scope": "project", "type": "flag", "default": false }
  ],
  "cast": [
    {
      "id": "cast_mira",
      "displayName": "Mira",
      "color": "#ff6a3d",
      "blip": { "pitch": 1.1 },
      "portrait": true,
      "avatarAsset": "as_mira"
    }
  ],
  "scenes": {
    "sc_dock": {
      "id": "sc_dock",
      "name": "The dock",
      "staging": {
        "environmentAssetId": "as_dock",
        "placements": { "cast_mira": "center" },
        "initialCamera": "two-shot",
        "pushIn": false,
        "ambientLoopAssetId": null,
        "enterTransition": "fade"
      },
      "script": [
        { "id": "b1", "kind": "line", "speaker": "cast_mira", "text": "You will want the lantern." },
        {
          "id": "b2",
          "kind": "choice",
          "options": [
            {
              "id": "o_take",
              "text": "Take it",
              "route": {
                "type": "inline",
                "body": [
                  { "id": "b2a", "kind": "set", "variable": "hasLantern", "value": true }
                ]
              }
            },
            { "id": "o_leave", "text": "Leave it", "route": { "type": "edge", "edgeId": "e_leave" } }
          ]
        },
        { "id": "b3", "kind": "stageDirection", "action": { "type": "camera", "preset": "close-up" } }
      ]
    },
    "sc_boat": {
      "id": "sc_boat",
      "name": "The boat",
      "staging": {
        "environmentAssetId": null,
        "placements": {},
        "initialCamera": "wide",
        "pushIn": false,
        "ambientLoopAssetId": null,
        "enterTransition": "cut"
      },
      "script": [{ "id": "b4", "kind": "line", "text": "The water takes you." }],
      "ending": { "id": "end_adrift", "title": "Adrift", "flavor": "You went without light." }
    }
  },
  "hud": {
    "widgets": [
      { "id": "w1", "type": "text", "label": "Lantern", "variable": "hasLantern" }
    ]
  },
  "assets": [
    { "id": "as_mira", "url": "https://cdn.example/assets/9c1f...", "type": "avatar", "tier": "vrm" },
    { "id": "as_dock", "url": "https://cdn.example/assets/2ab4...", "type": "environment" }
  ],
  "endings": [{ "id": "end_adrift", "title": "Adrift", "flavor": "You went without light." }]
}

Related reading: publishing and versions, randomness, and persistent state.

On this page