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
formatVersionis1. 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[].tois a real scene id. Authoring-sideentry:<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
typeisanimationand whose id starts withbuiltin-), 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
| Field | Type | Notes |
|---|---|---|
formatVersion | 1 | Literal. Any other value fails the parse. |
version | positive integer | Which published version this is. |
projectId | string | Stable project id. |
title | string, optional | Absent means fall back to the start scene's name. |
graph | object | startSceneId plus edges. |
scenes | record keyed by scene id | See below. |
variables | array | Declared state, each with a required default. |
cast | array | Cast members. |
hud | object | widgets, and optional panels. |
assets | array | The manifest. |
endings | array, defaults to [] | Every ending, in scene declaration order. |
theme | string, optional | One of ember-noir, parchment, terminal, pulp, porcelain. Absent means ember-noir. |
grade | string, optional | The stage's post-processing look: clean, film, noir, glow. Absent means ungraded, and no chain is mounted at all. |
mature | true, optional | Only 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:
| Field | Type |
|---|---|
environmentAssetId | asset id or null |
placements | cast id to mark, one of far-left, left, center, right, far-right |
restingClips | cast id to looping clip name, optional |
initialCamera | wide, two-shot, close-up, over-shoulder |
pushIn | boolean |
ambientLoopAssetId | asset id or null |
enterTransition | cut, fade, dissolve, wipe-left, wipe-right, hold-black |
holdMs | integer 0 to 10000, optional. How long hold-black sits at full black. Absent means 600. Ignored by every other transition. |
handheld | still, gentle, unsteady, optional. Absent means gentle. |
reverb | none, 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.
kind | Payload |
|---|---|
line | text, optional speaker (cast id), optional voiceAsset, optional spans |
stageDirection | action, optional parallel |
set | variable, value, optional op |
wait | trigger |
choice | options, optional presentation, columns, timeout, interrupt |
input | variable (a declared text variable), prompt, optional maxLength |
check | chance, successEdgeId, failureEdgeId |
call | pluginId, 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:
type | Payload |
|---|---|
move | target (cast id), to (a mark or offstage), gait (walk, jog, run, cut), optional face (cast id, camera, away) |
face | target, toward (cast id, camera, away) |
animation | target (cast id), clip (name or animation asset id), optional loop |
camera | preset, 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. |
expression | target, expression |
prop | op (place or remove), asset or binding, at (a mark or { "heldBy": castId }) |
inspect | asset or binding |
light | preset, one of day, dusk, night, candle, neon, storm |
weather | kind, one of none, rain, snow, petals, embers, fog |
music | op (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. |
sfx | same shape as music, without duck and duckMs. Effects never duck. |
effect | effect, one of shake, flash, letterbox |
cutaway | op (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:
kind | Shape |
|---|---|
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
0x6d2b79f5to the state (wrapping to 32 bits) to get the next staten, then computest = imul(n ^ (n >>> 15), 1 | n), thent = (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
checkbeat, taken when the runtime settles onto it. The check passes when the roll is belowchance.
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.