Building a custom runtime
If your engine is not Unity-with-Dialogue-System and does not use Yarn Spinner, you consume ChatMapper JSON directly. This is not a big job: the format is one document, the traversal contract is a page long, and there is a reference implementation to copy from.
What you get from the editor
Section titled “What you get from the editor”Export → ChatMapper JSON. One file, _editor stripped, every custom
field intact. Deterministic: the same saved project always produces the same
bytes, so it belongs in version control next to your engine code.
The reference parser
Section titled “The reference parser”reference-parser.ts is a single
dependency-free TypeScript file, MIT licensed, that compiles under
tsc --strict with no imports. Drop it into any TypeScript project — a web
game, a Node tool, a build step — and:
import { parseScenario, walkConversation, speakerName, dialogueText, offeredFrom, rootNode } from './reference-parser';
const scenario = parseScenario(JSON.parse(text)); // validates shape, throws on a malformed document
for (const conv of scenario.Assets.Conversations) { for (const step of walkConversation(scenario, conv.ID)) { console.log(step.depth, speakerName(scenario, step.node), dialogueText(step.node)); }}It gives you:
| Function | Does |
|---|---|
parseScenario(json) | Type-checks the document and returns a Scenario. Throws with a path on the first structural problem. |
findConversation, findNode, rootNode | Lookups by ID. |
findActor(ref), speakerName(node) | Resolve the stringified actor IDs in Actor / Conversant. |
dialogueText, menuText, dialogueSegments | Field accessors; dialogueSegments splits on |. |
successors(node) | Link destinations, in link order, across conversations. |
offeredFrom(node) | Successors with group nodes flattened away — the set a player would see, before conditions. |
walkConversation(convId) | Depth-first walk from the root, each node once, loops terminate, with depth. The root is not yielded. |
walkAllNodes, endings, variableNames | Whole-project helpers. |
It deliberately does not evaluate conditions or scripts. Those are Lua, and what a host does with them is the host’s business — see below.
Not on TypeScript? The file is 370 lines with no clever tricks; porting it to C#, GDScript, C++, Rust or Python is an afternoon, and the JSON format page is the specification to port from.
The runtime, step by step
Section titled “The runtime, step by step”A playable runtime is the parser plus the runtime semantics. In pseudocode:
start(project): tables ← build Variable / Actor / Item / Location / Conversation tables (see Lua page) for v in project.Assets.UserVariables: Variable[clean(v.Name)] ← coerce(v["Initial Value"]) run(project.UserScript)
play(convId): node ← root(convId) loop: offered ← offeredFrom(node) # steps 1–4 on the semantics page if offered is empty: return END auto ← first n in offered with no Menu Text if auto: node ← enter(auto); continue if |offered| = 1 and not forced(offered[0]): node ← enter(offered[0]); continue mark each offered node WasOffered choice ← present menu (labels: Menu Text, [f]/[a] stripped, [var=] substituted) node ← enter(choice)
enter(n): unless n.IsRoot or (n.DelaySimStatus and n has links): status[n] ← WasDisplayed run(group scripts carried down); run(n.UserScript) if Dialogue Text has [?Var]: prompt, store, strip token emit each | segment of Dialogue Text with [var=] substituted and picture tags stripped return n
offeredFrom(n): candidates ← n.OutgoingLinks targets keep c where eval(c.ConditionsString) is true or errors # fail open else if c.FalseConditionAction = Passthrough: add c's children to candidates within each sibling group keep only the lowest ConditionPriority present replace each IsGroup c with offeredFrom(c), carrying c.UserScript downThe HTML player is a complete,
readable JavaScript implementation of exactly this — open the file, the
runtime is the first <script>.
Evaluating Lua
Section titled “Evaluating Lua”Three options, in increasing fidelity:
- Ignore conditions — treat every condition as true. Fine for a read-only viewer, a localisation tool, a word count. Not a game.
- Implement the subset. Variable reads/writes, SimStatus, comparisons,
and/or/not, literals,+ - * /in assignments. This is what the HTML player does in ~100 lines by rewriting the expression into the host language, and it covers almost all real projects. The Lua page lists the subset exactly; the editor’s exports warn about scripts outside it. - Embed Lua. Every engine has a binding (MoonSharp or NLua for C#, the Godot Lua modules, the stock C API for C++). Build the tables the Lua page describes and run the strings unchanged. This is what the editor’s simulator does and gives writers exactly what they tested.
Whichever you choose: a condition that errors is true, and log it.
Your engine’s data
Section titled “Your engine’s data”Put it in custom fields and read it from Fields. A cutscene ID, a VO
take name, a camera preset, an emotion tag — register each as a
custom field template
so writers get it on every node, and read it in your importer. No schema
change; the project still simulates in the editor and exports everywhere
else.
Localisation
Section titled “Localisation”Translations live in per-locale fields on the node (de-DE for dialogue,
Menu Text de-DE for menu text). Pick the locale at load time and fall back
to the primary field. _editor.languages is stripped from the export, so
discover locales by scanning field titles that look like xx-YY.
Audio Files and friends are filename lists ([a.mp3;b.mp3]); Video File
is one filename. Ship the files with the build, or take the
CMPKG export and read them from the archive. Pair audio
with | segments by index.
Testing your runtime
Section titled “Testing your runtime”- Play the example scenario and compare with the HTML player build of the same file. Same transcript, same final variables, or your runtime is wrong.
- Have the writers export the standalone HTML player alongside every JSON drop. When a branch “doesn’t fire” in your engine, the player settles whether it is the content or the integration.
- Validate the JSON before loading it — the validation rules are cheap to implement and catch hand-edited files.