Skip to content

Runtime semantics

This page is the contract a runtime must honour to play a ChatMapper project the way the editor’s simulator, the Desktop app and the standalone HTML player do. Those three are tested against each other; if your runtime follows this page, writers’ expectations transfer.

  • Current node — the node just played (or the root, at the start).
  • Candidates — the nodes reachable from it, before filtering.
  • Offered — what survives filtering: the set the player is about to see, or the single node that plays next.
  • Player choice — a node with non-empty Menu Text.
  • Group — a node with IsGroup: true. Never played; structural.

Given the current node, compute the offered set in four steps:

  1. Collect every OutgoingLinks destination, in link order. A destination may be in another conversation.
  2. Filter by condition. Evaluate each candidate’s ConditionsString against the current state. A candidate whose condition is false is dropped — unless its FalseConditionAction is Passthrough (1), in which case its own children join the candidate pool in its place (and are themselves filtered). An empty condition is true. A condition that cannot be evaluated is treated as true and logged.
  3. Suppress by priority. Among the surviving candidates in the same group, keep only those at the best (numerically lowest) ConditionPriority present. Normal is 3; 1 is highest.
  4. Expand groups. Replace each surviving group node with its own children, run through steps 2–3 again. The group’s UserScript is carried down and runs before the chosen child’s script.

The result is the offered set.

What is a “group” for priority purposes?

Section titled “What is a “group” for priority purposes?”

Priority suppression is scoped so that only siblings compete. Children of the root or of a group node form that node’s group; every other node inherits its parents’ groups. Grouping only follows links inside one conversation; a node reached only from another conversation gets a private group and competes with nobody.

With the offered set in hand:

Offered setRuntime does
EmptyEnd of conversation.
Contains a node with no Menu Text (a line, not a choice)Play it immediately. It is a continuation, not a decision — enter it even if selectable siblings also survived.
Exactly one player choice, and its Menu Text does not contain [f]Play it straight through, no menu.
OtherwisePresent a menu of the offered player choices, in link order, labelled by Menu Text with [f] / [a] stripped and [var=…] substituted.

When the player picks, enter that node and repeat from the top. When a line plays automatically, enter it and repeat. Guard against runaway chains: the reference implementations stop after a large fixed number of automatic advances and report a possible loop.

In this order:

  1. Mark the node WasDisplayed — unless it is the root (never marked), or DelaySimStatus is true and the node has links, in which case defer until the next offered set has been computed.
  2. Run the accumulated group script(s), then the node’s own UserScript.
  3. If the Dialogue Text contains an input prompt ([?Name]), pause: withhold the line, ask the player, store the answer in Name, then emit the line with the token removed and continue.
  4. Emit the line: split Dialogue Text on | into successive segments, strip picture tokens, substitute [var=…], drop empty segments. The root emits nothing even if it has text.

Every node has a status for the current playthrough:

StatusSet when
UntouchedInitial.
WasOfferedThe node appeared in a menu. Never downgrades a WasDisplayed.
WasDisplayedThe node was entered.

Offering marks each menu entry WasOffered — but only when a menu is actually shown; an automatic continuation does not mark its siblings. The root is never marked.

Status is readable and writable from Lua as Conversation[c].Dialog[d].SimStatus (see Lua). It persists across conversations within one playthrough.

A playthrough is the whole project, not one conversation:

  • At start (once): create the asset tables, seed every UserVariables entry from its Initial Value (coerced — see the data model), run the project UserScript.
  • Entering another conversation (via a cross-conversation link, or a trigger from the host): start from that conversation’s root keeping variables and statuses. Do not re-seed, do not re-run the project script.
  • Can a conversation start? Evaluate its root’s ConditionsString against the current state. False means the host should not start it.

From the example scenario:

START(0) ─▶ Opening line(1) ─▶ [group] Traveller's options(2)
├─▶ Accept the token(3) Menu: "I'll take the crossing." script: has_token = true
│ └─▶ Ending - crossing(5) cond: has_token == true
└─▶ Stay ashore(4) Menu: "I'll wait for morning."
└─▶ Ending - ashore(6)
  1. Enter root. Offered from root: {1} — no menu text → play 1. Two segments emitted.
  2. Offered from 1: {2} is a group → expand → {3, 4}, both choices, both Normal priority → menu of two. 3 and 4 become WasOffered.
  3. Player picks 3. Enter 3: WasDisplayed; script sets has_token. Emit its dialogue.
  4. Offered from 3: {5}; condition true; no menu text → play 5.
  5. Offered from 5: empty → end.

Had the player picked 4, node 5’s condition is never evaluated; had 3’s script not run, 5’s condition would be false and — with nothing else offered — the conversation would end after 3.

The ChatMapper repository’s test suite runs a battery of scenarios — groups, priority suppression, passthrough, [f], DelaySimStatus, | splits, [var=], [?Var] prompts, conditions and scripts — through both the editor’s simulator and the HTML player’s runtime and asserts identical transcripts and final variable state. Use the example scenario and the HTML player build of it as your oracle: if your runtime disagrees with the player, the player is right.