The ChatMapper JSON format
This is the canonical interchange format for ChatMapper branching-dialogue projects. It is what the desktop ChatMapper app exports, what ChatMapper Cloud stores, and what every importer and exporter in the ecosystem is measured against.
This document is normative for producers. If you generate ChatMapper JSON, a conforming consumer should be able to read it without knowing which tool wrote it.
Alongside this document:
example-scenario.json— a complete, minimal, entirely fictional project. It is covered by a test in the ChatMapper repository that asserts it validates with zero errors, so it cannot silently rot. It is reproduced in full at the foot of this page.reference-parser.ts— a single dependency-free TypeScript file that parses and walks the format. MIT licensed; compiles standalone undertsc --strict. See Building a custom runtime.
Document shape
Section titled “Document shape”A project is one JSON object:
{ "Language": "en-US", "Title": "The Ferry at Dusk", "Version": "1.5.1.0", "Author": "", "Description": "", "UserScript": "", "Assets": { "Actors": [], "Items": [], "Locations": [], "Conversations": [], "UserVariables": [] }}| Key | Type | Notes |
|---|---|---|
Language | string | BCP-47-ish locale of the primary text, e.g. en-US. |
Title | string | Project title. |
Version | string | The ChatMapper schema version that wrote the file, e.g. 1.5.1.0. Informational. |
Author | string | Free text. |
Description | string | Free text. |
UserScript | string | Lua run once at the start of a playthrough, after the asset tables are built. |
Assets | object | The five asset collections. Required — a document without it is not a ChatMapper project. |
All five collections must be present in a well-formed document, but consumers should treat a missing collection as empty rather than failing.
Assets
Section titled “Assets”Actors, Items, Locations and UserVariables all share one shape:
{ "ID": 1, "Fields": { "Name": "Ferryman" } }| Key | Type | Notes |
|---|---|---|
ID | integer | Unique within its own collection. |
Fields | object | Field title → value. Values are always strings, whatever the field’s declared type: "True", "30", "[]". |
Conventional field titles, by collection:
| Collection | Fields |
|---|---|
| Actors | Name, IsPlayer ("True"/"False"), Description, Pictures, Age, Gender |
| Items | Name, Description, Pictures, Purpose, Scene |
| Locations | Name, Description, Pictures |
| UserVariables | Name, Initial Value, Description |
A project should declare at least one actor with IsPlayer set to "True".
Variables are identified by name, not ID
Section titled “Variables are identified by name, not ID”Lua only ever says Variable["has_token"]. The ID exists for editor
bookkeeping. This matters in practice: ChatMapper XML has no ID attribute
on <UserVariable> at all, so a project that has been through XML comes back
with no variable IDs. Consumers should key variables by Fields.Name and treat
the ID as advisory.
Conversations
Section titled “Conversations”{ "ID": 1, "NodeColor": "White", "Fields": { "Title": "Asking for passage", "Actor": "1", "Conversant": "2" }, "DialogNodes": []}| Key | Type | Notes |
|---|---|---|
ID | integer | Unique across conversations. |
NodeColor | string | integer | Canonical form is a colour name (below). |
Fields | object | Title, Description, Actor, Conversant, Primary Location, plus whatever the project adds. |
DialogNodes | array | The nodes. Exactly one must have IsRoot: true. |
Actor and Conversant hold a stringified actor ID — "2", not
"Ferryman". "" and "-1" both mean unset.
Dialog nodes
Section titled “Dialog nodes”{ "ID": 3, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "Variable[\"has_token\"] == true", "UserScript": "Variable[\"has_token\"] = true", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Accept", "Actor": "1", "Menu Text": "I'll take the crossing." }, "OutgoingLinks": []}| Key | Type | Notes |
|---|---|---|
ID | integer | Unique within its conversation. The root is conventionally 0. |
ConversationID | integer | Must equal the owning conversation’s ID. |
IsRoot | boolean | The conversation’s start marker. Exactly one per conversation. |
IsGroup | boolean | A structural node: not played, presents its children as the choice set. |
ConditionsString | string | Lua expression. Empty means always true. |
UserScript | string | Lua statements run when the node is entered. |
NodeColor | string | integer | Canvas tint. |
DelaySimStatus | boolean | Hold the node’s status change until the next choices are built. |
FalseConditionAction | integer | string | 0/"Block" — drop the node when its condition fails. 1/"Passthrough" — offer its children in its place. |
ConditionPriority | integer | string | 1 High … 5 Low, 3 Normal. Lower wins: within a group, only candidates at the best priority present are offered. |
Fields | object | Text and metadata (below). |
OutgoingLinks | array | Links out of this node. |
Node fields
Section titled “Node fields”| Field | Notes |
|---|---|
Title | Author-facing label. Not shown to players. |
Actor | Stringified actor ID — who speaks this line. |
Conversant | Stringified actor ID — who is spoken to. |
Dialogue Text | The spoken line. |
Menu Text | Present ⇒ this node is a player choice; the value is the button label. Absent/empty ⇒ the node plays automatically. |
Parenthetical | Delivery note, e.g. wearily. |
Audio Files, Video File, Animation Files, Pictures | Media references. |
Inline markup in text
Section titled “Inline markup in text”| Markup | Meaning |
|---|---|
| | Sentence split. "One.|Two." is shown as two successive lines, not one paragraph. |
[f] | On Menu Text: force a menu even when this is the only option. |
[a] | On Menu Text: an action line rather than a spoken one. |
[var=Name] | Substituted with the variable’s current value at display time. |
[?Name], [x=?Name] | Prompt the player for input and store it in Name. Consumed from the displayed line. |
[pic=N], [pica=N], [picc=N] | Swap the displayed image mid-line. |
[em1]…[/em1] … [em4]…[/em4] | Author emphasis spans. The tags are part of the text and round-trip as plain text. |
Markup is part of the canonical text. Consumers that cannot honour a tag should strip it for display rather than rewriting the stored value.
{ "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 3, "DestinationDialogID": 5}OriginConvoID/OriginDialogID must match the node the link lives on.
DestinationConvoID may name a different conversation — that is a
cross-conversation jump, and it is well supported.
Filename is non-null only for a link into a different project file. Most
hosts, including Unity’s Dialogue System, cannot follow those. Producers should
leave it null.
Node colours
Section titled “Node colours”Canonical form is the name. The integers are the legacy desktop enum and are accepted on read.
| Name | Legacy int |
|---|---|
Red | 0 |
Orange | 1 |
Yellow | 2 |
Green | 3 |
LightBlue | 4 |
DarkBlue | 5 |
Pink | 6 |
Purple | 7 |
White | (no int — it is the serialized default) |
Traversal semantics
Section titled “Traversal semantics”What a player is offered from a node, in order:
- Collect every link destination.
- Filter by condition. A node whose
ConditionsStringevaluates false is dropped — unless itsFalseConditionActionis Passthrough, in which case its own children join the candidate pool in its place. - Suppress by priority. Within a group, only candidates at the best
(numerically lowest)
ConditionPrioritypresent survive. - Expand groups. A surviving
IsGroupnode is replaced by its own children, run through the same three steps. ItsUserScriptruns before the chosen child’s.
Then:
- No survivors ⇒ end of conversation.
- Survivors without
Menu Text⇒ play immediately (a line, not a choice). - Exactly one survivor with
Menu Text, not[f]⇒ play it straight through. - Otherwise ⇒ present a menu.
The root node is a start marker: it is never spoken, even if it carries text.
ConditionsString and UserScript are Lua. The host exposes:
Variable["Name"]— user variables.Actor["Name"],Item["Name"],Location["Name"]— asset field tables.Conversation[id],Conversation[id].Dialog[id]with aSimStatusof"Untouched"/"WasOffered"/"WasDisplayed".Dialog[id]— shorthand for the evaluating node’s own conversation.
Names are cleaned before use as Lua keys: " [ ] . \ / are removed,
and -, spaces and newlines become _. So a field titled Menu Text is
Menu_Text, and an actor named Harbour Master is Actor["Harbour_Master"].
Two different assets whose names clean to the same key will collide. Some hosts, Unity’s Dialogue System among them, will silently let the second overwrite the first.
A condition that cannot be evaluated should be treated as true and logged, not as false. Failing open keeps content reachable.
Extending the format
Section titled “Extending the format”The format is extended additively. Do not fork it.
- Custom fields. Add whatever you need to any
Fieldsobject. Consumers that do not know a field must preserve it, not drop it. This is how the LearnBrite runtime fields, the UnitySequencefield and every importer’s*_rawprovenance fields work. _editor. A single top-level object for editor-only metadata — canvas positions, reviewer notes, field type hints, collapsed nodes. It is stripped on export and any consumer may ignore it entirely. Never put anything in_editorthat the project’s meaning depends on.- Graceful degradation. A consumer that does not understand an extension must still produce a working project without it.
What this rules out: renaming canonical keys, changing a field value’s type away
from string, or adding a top-level sibling to Assets that carries meaning.
Relationship to ChatMapper XML
Section titled “Relationship to ChatMapper XML”The desktop app also reads and writes an XML serialization of the same model. Differences worth knowing when moving between them:
- XML is the format Unity’s Pixel Crushers Dialogue System imports natively.
- The XML attribute is spelled
FalseCondtionAction— the typo is part of the format. - Enum-valued properties are names in XML (
"Passthrough","Normal","Green") and often integers in JSON. <UserVariable>has noIDattribute, so variable IDs do not survive a JSON → XML → JSON trip. Names do.- Reviewer notes and per-field
Hint/Typemetadata live inline in XML, but in_editorin JSON. - Empty asset collections are omitted entirely in XML.
Validating a document
Section titled “Validating a document”At minimum, a producer should check:
- Every collection has unique IDs.
- Every conversation has exactly one
IsRootnode and at least one node. - Every node’s
ConversationIDmatches its conversation. - Every link’s origin matches the node it sits on, and its destination exists.
- Every
Actor/Conversantvalue is"","-1", or an existing actor ID. - Every variable named in a
ConditionsStringis declared inUserVariables.
ChatMapper Cloud’s validator implements these plus reachability and content warnings — the full list is on Validation rules.
The complete example
Section titled “The complete example”example-scenario.json — two actors, one item, one location, one variable,
one branching conversation with a group node, a script and a condition.
Download it, or see the same project as
XML, Yarn and
a playable HTML file.
{ "Language": "en-US", "Title": "The Ferry at Dusk", "Version": "1.5.1.0", "Author": "ChatMapper format documentation", "Description": "A minimal but complete example: two actors, one item, one location, one variable, and one branching conversation. Entirely fictional.", "UserScript": "Variable[\"has_token\"] = false", "Assets": { "Actors": [ { "ID": 1, "Fields": { "Name": "Traveller", "IsPlayer": "True", "Description": "The player character.", "Pictures": "[]" } }, { "ID": 2, "Fields": { "Name": "Ferryman", "IsPlayer": "False", "Description": "Works the last crossing of the day.", "Pictures": "[]" } } ], "Items": [ { "ID": 1, "Fields": { "Name": "Brass Token", "Description": "Buys one crossing.", "Pictures": "[]" } } ], "Locations": [ { "ID": 1, "Fields": { "Name": "The Jetty", "Description": "Weathered boards over slack water.", "Pictures": "[]" } } ], "UserVariables": [ { "ID": 1, "Fields": { "Name": "has_token", "Initial Value": "False", "Description": "Set when the traveller accepts the ferryman's token." } } ], "Conversations": [ { "ID": 1, "NodeColor": "White", "Fields": { "Title": "Asking for passage", "Description": "The only conversation in this example.", "Actor": "1", "Conversant": "2", "Primary Location": "1" }, "DialogNodes": [ { "ID": 0, "ConversationID": 1, "IsRoot": true, "IsGroup": false, "ConditionsString": "", "UserScript": "", "NodeColor": "Green", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "START", "Actor": "1", "Conversant": "2" }, "OutgoingLinks": [ { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 0, "DestinationDialogID": 1 } ] }, { "ID": 1, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "", "UserScript": "", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Opening line", "Actor": "2", "Conversant": "1", "Dialogue Text": "Last crossing of the day.|Coming aboard, or staying with the rain?" }, "OutgoingLinks": [ { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 1, "DestinationDialogID": 2 } ] }, { "ID": 2, "ConversationID": 1, "IsRoot": false, "IsGroup": true, "ConditionsString": "", "UserScript": "", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Traveller's options" }, "OutgoingLinks": [ { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 2, "DestinationDialogID": 3 }, { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 2, "DestinationDialogID": 4 } ] }, { "ID": 3, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "", "UserScript": "Variable[\"has_token\"] = true", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Accept the token", "Actor": "1", "Conversant": "2", "Menu Text": "I'll take the crossing.", "Dialogue Text": "I'll take the crossing." }, "OutgoingLinks": [ { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 3, "DestinationDialogID": 5 } ] }, { "ID": 4, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "", "UserScript": "", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Stay ashore", "Actor": "1", "Conversant": "2", "Menu Text": "I'll wait for morning.", "Dialogue Text": "I'll wait for morning." }, "OutgoingLinks": [ { "Filename": null, "OriginConvoID": 1, "DestinationConvoID": 1, "OriginDialogID": 4, "DestinationDialogID": 6 } ] }, { "ID": 5, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "Variable[\"has_token\"] == true", "UserScript": "", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Ending - crossing", "Actor": "2", "Conversant": "1", "Dialogue Text": "Mind the step. She rolls once we clear the piling." }, "OutgoingLinks": [] }, { "ID": 6, "ConversationID": 1, "IsRoot": false, "IsGroup": false, "ConditionsString": "", "UserScript": "", "NodeColor": "White", "DelaySimStatus": false, "FalseConditionAction": 0, "ConditionPriority": 3, "Fields": { "Title": "Ending - ashore", "Actor": "2", "Conversant": "1", "Dialogue Text": "Suit yourself. The rain is not in a hurry either." }, "OutgoingLinks": [] } ] } ] }}