Skip to content

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.

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.

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:

FunctionDoes
parseScenario(json)Type-checks the document and returns a Scenario. Throws with a path on the first structural problem.
findConversation, findNode, rootNodeLookups by ID.
findActor(ref), speakerName(node)Resolve the stringified actor IDs in Actor / Conversant.
dialogueText, menuText, dialogueSegmentsField 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, variableNamesWhole-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.

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 down

The HTML player is a complete, readable JavaScript implementation of exactly this — open the file, the runtime is the first <script>.

Three options, in increasing fidelity:

  1. Ignore conditions — treat every condition as true. Fine for a read-only viewer, a localisation tool, a word count. Not a game.
  2. 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.
  3. 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.

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.

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.

  • 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.