The format reference
Everything a tool needs to read or write ChapterScript, in one page. The
normative source of truth is the
ChapterScript Swift package:
its Codable types define the schema, and its test fixtures are
working documents.
The bundle
A project is a .chapterscript directory bundle:
MyStory.chapterscript/
├── chapter.json the entire document
└── assets/ your media, byte for byte as imported
├── intro.mp4
├── ambience.m4a
└── lantern.usdz
chapter.json describes everything a player needs. Files under
assets/ are referenced through the manifest by relative path; the
directory layout under assets/ is up to the author. Copy the folder
and you have copied the project.
chapter.json, top to bottom
The document (format version 3) opens with identity, then the main collections:
| Key | What it is |
|---|---|
| formatVersion | Single integer schema version, currently 3. Drives migration on open. |
| id | Stable experience identifier. |
| displayName | Human-readable title. An optional description may sit alongside it. |
| defaultSequenceId | The sequence to play first. |
| sequences | The timed scenes of the chapter (detailed below). |
| entities | Definitions for everything placeable: primitives, USDZ models, 3D text, lights, video panels, particle emitter bindings, plus a custom-factory escape hatch. |
| particlePresets | Reusable emitter recipes referenced by emitter entities. |
| manifest | One entry per asset file: relative path, byte size, SHA-256 hash, plus probed duration and dimensions for media. |
Sequences
A chapter contains sequences; players run one sequence at a time. Each sequence carries:
- id and name. Authored ids are opaque and never rewritten by tooling.
- presentation:
immersive,mixed(passthrough plus 3D), orwindowed. Unknown values fall back toimmersive, so newer documents degrade safely on older players. - immersiveBackdrop (optional): an ambient backdrop bound at sequence start. See below.
- steps: the ordered timed beats.
- animationTracks: sequence-level keyframe animation. See below.
- onComplete: what happens after the last step, one of
holdOnLastStep,autoAdvance(withnextSequenceId),dismissToHome, ortransitionTo.
Backdrops
The backdrop is a kind-tagged object:
{ "kind": "video", "file": "shore_360.mov", "layout": "mono",
"field": "equirect360", "radius": 1000, "loop": true, "audioEnabled": false }
{ "kind": "image", "file": "dusk.heic", "field": "equirect360", "radius": 1000 }
{ "kind": "usdz", "assetId": "forest_scene" }
- layout is a stereo packing hint:
mono,sideBySide,overUnder, ormultiviewHEVC. - field is the projection:
equirect360,equirect180, or Apple's parametricappleImmersivewith a degrees value. A bare string form is always accepted, and unknown values degrade toequirect360rather than failing the whole document.
Steps and gates
A step is a timed beat:
{
"id": "step_2", "name": "The lantern", "duration": 10.0,
"gate": { "type": "tap", "timeout": 30, "prompt": "Tap the lantern" },
"actions": [ … ],
"scheduledActions": [ { "at": 2.0, "action": { … } } ]
}
- duration is in seconds; when it elapses (and any gate has resolved) the step advances.
- actions fire at the step's start; scheduledActions fire at second offsets within it.
- gate (optional) holds the step until the audience interacts:
tap,gaze,approach, orgrab, with an optionaltimeoutin seconds and an optional on-screenprompt. Unknown gate types decode astap.
The action catalog
Actions are externally tagged: every action object carries a "kind"
plus a case-specific payload, either inline keys or a payload object
("reveal", "move", "video",
"audio", "config", and so on):
{ "kind": "revealEntity", "reveal": { "entity": "lantern", "fadeIn": 1.5 } }
{ "kind": "stopAudio", "channel": "narration" }
| Group | Kinds |
|---|---|
| Entity | showEntity · hideEntity · revealEntity · moveEntity · scaleEntity · fadeEntity · persistEntity · unpersistEntity · animateMotion |
| Attachments | showAttachment · hideAttachment · fadeAttachment · setAttachmentView · positionAttachment |
| Audio | playAudio · stopAudio · fadeAudio · onAudioComplete (nests follow-up actions) |
| Audio mix | setMasterVolume · setCategoryVolume · setBusVolume · setBusEffect · removeBusEffect |
| Audio zones | addAudioZone · removeAudioZone · removeAllAudioZones |
| Video | playVideo · prepareVideo · stopVideo |
| Effects | showPulseRing · hidePulseRing · startSparkBurst · stopSparkBurst |
| Gestures | enableGesture · disableGesture |
| System | setUpperLimbVisibility · setKeyboardPassthrough |
| Escape hatch | custom: an opaque id plus a free-form JSON parameter blob, handled by app-registered factories. |
Field-level payload documentation lives with the types in
the package source, and
the representative.json fixture exercises 30+ kinds as real JSON.
Video payloads
- Presentation: video binds to a SwiftUI
attachment, an in-sceneentitypanel with width and height, or animmersiveskybox sphere with radius and field. - Trims are metadata:
sourceIn/sourceOutwindow the master file without re-encoding it; omitting them plays the whole file. A crop rect is also available.
Animation tracks
Keyframe animation lives on the sequence: one track per animated entity, up to ten
scalar channels (tx ty tz, rx ry rz, sx sy sz,
opacity). Keys sit at absolute seconds from sequence start:
{
"entity": "lantern",
"rotateOrder": "xyz",
"channels": {
"ty": [
{ "t": 0.0, "v": 1.0, "interp": "bezier",
"inTangent": { "dt": -0.4, "dv": 0.0 },
"outTangent": { "dt": 0.4, "dv": 0.0 } },
{ "t": 4.0, "v": 1.6, "interp": "bezier" }
]
}
}
- Tangent handles are
(dt, dv)offsets; keys without explicit tangents get smooth automatic ones, and linear and stepped modes are available. - Rotations are continuous Euler degrees with an explicit rotate order: a two-turn wind-up is stored as 720°, so nothing snaps to a shortest path.
- The package ships the canonical curve evaluator; players and editors sample the same math, so playback matches authoring exactly.
The asset manifest
{ "entries": [
{ "id": "intro.mp4", "relativePath": "intro.mp4",
"byteSize": 48211930, "sha256": "9f31ab…" }
] }
Hashes let players cache aggressively and never show a stale frame; byte sizes let them budget transfers. Probed media metadata (duration, dimensions) rides along where it applies.
Versioning, migration, forward compat
- Deterministic saves: pretty-printed JSON with sorted keys and unescaped slashes, identical bytes for identical documents. Diffs are reviewable and non-colliding edits merge cleanly.
- Migration: JSON-to-JSON migrators run before typed decoding, so older documents stay loadable. The v2 → v3 migration renamed
segments→sequences(anddefaultSegmentId→defaultSequenceId,nextSegmentId→nextSequenceId) without touching ids, timings, or keys. - Tolerant decoding: unknown action kinds parse into an
unknowncase that editors preserve and re-emit unchanged, and players log and skip. Unknown presentation, field, and gate values degrade to safe defaults instead of failing the document.
Pre-1.0: the schema may still change. The first stable release will lock the wire format and start the strict back-compat regime.