Documentation menu

The expression syntax tree

Description: Implement an evaluator for conditions, instructions, and stored Expression values.

Narratyr parses conditions, instructions, and stored Expression definitions while you edit them. It stores the result as an abstract syntax tree instead of relying on the source string at runtime. An engine evaluates the expression by recursively visiting the tree and returning the root value (no parser needed).

Use this reference when building a runtime for another engine or a tool that reads expression behavior. Expressions documents the language for people who create graphs.

The language uses 16 node kinds with global variable names and straight-line expressions. A complete evaluator can remain small and the source code of the engine runtimes can be used as a reference.

Nested and flat formats

Narratyr stores the same tree in two formats.

Format Location Structure
Nested Project conditionAst, instructionAst, or an Expression value's ast Objects contain child objects and use a type field
Flat Exported conditionAstFlat, instructionsFlat, or an Expression value's ast An array contains nodes that refer to child indices and use a kind field

Tools that read projects usually use the nested format. Engine exports use the flat format so a runtime can load the tree without creating more objects or parsing the source expression.

Flat format

A flattened tree uses one object:

{
  "rootIndex": 4,
  "nodes": [
    { "kind": "Identifier",        "name": "Gold", "varId": "v_abc" },
    { "kind": "NumberLiteral",     "number": 10 },
    { "kind": "BinaryExpression",  "operator": ">=", "left": 0, "right": 1 },
    { "kind": "Identifier",        "name": "HasMap" },
    { "kind": "LogicalExpression", "operator": "&&", "left": 2, "right": 3 }
  ]
}

This tree represents Gold >= 10 && HasMap. A parent refers to each child by its integer index in nodes. Evaluation starts at nodes[rootIndex] and follows those indices.

The format provides these guarantees:

  • Post-order layout: a child appears before its parent. Use this to validate references. Evaluation must still follow control flow so skipped branches don't run.
  • Explicit root: rootIndex identifies the root even if the layout changes in a later version. Don't assume that nodes.length - 1 is the root.

Each node includes fields used by its kind and omits unused fields instead of assigning null.

Node kinds

The left, right, and children fields contain integer indices into nodes. They don't contain nested objects.

Values

kind Fields Result
NumberLiteral number Number
StringLiteral string Text
BoolLiteral bool True or false
NullLiteral none Empty value
UndefinedLiteral none Empty value
ArrayLiteral children with element indices List of evaluated elements
Identifier name, optional varId Current variable value
EnumReference enumName, entryKey Integer value for the option set entry

Keep NullLiteral and UndefinedLiteral distinct during evaluation. Both convert to false, but null == undefined is false and text concatenation produces "null" or "undefined" respectively. An out-of-range list read returns undefined.

An Identifier uses name for variable lookup. When the editor resolves the name during parsing, varId contains the stable internal ID. Tools can use this ID to track a variable across a rename, while runtime implementations can ignore it.

An EnumReference stays symbolic during export. This lets the evaluator find and return its integer value without parsing expressions again after an entry changes. A list of option set entries becomes a list of integers.

Operations

kind Fields Behavior
UnaryExpression operator (!, -), left for operand Negate one value
BinaryExpression operator, left, right Apply a binary operator
LogicalExpression operator, left, right Apply short-circuiting and or logic
CallExpression left for callee, children for arguments Call a function
IndexExpression left for list, right for index Read a list element

BinaryExpression supports *, /, %, +, -, <, >, <=, >=, ==, !=, in, and not in. LogicalExpression supports && and ||. Logical operators use a separate kind because they short-circuit.

The parser normalizes the word forms and, or, and not to &&, ||, and !.

The meaning of left depends on the node kind. It identifies the left operand of a binary expression, the operand of a UnaryExpression, or the callee of a function call.

Statements

kind Fields Behavior
StatementBlock children with statement indices Run statements in order
Assignment operator, left for target, right for value Apply :=, +=, -=, *=, or /=
UpdateExpression operator (++, --), left for target Add or subtract one

Only instruction trees use these node kinds, and StatementBlock forms the root. An assignment or update target always refers to an Identifier. The parser rejects other targets, so the evaluator can read nodes[node.left].name directly.

A bare expression can also appear as a statement. This usually represents a CallExpression for its side effect. Evaluate it and discard its result.

Expression evaluation

Start at the root and evaluate each node according to its kind.

Variables

An Identifier reads a variable by name. Report an evaluation error when the variable store doesn't contain that name. Returning an empty value could hide a spelling mistake and select the wrong graph branch.

True and false conversion

The caller converts the root value of a condition to true or false. Empty values, zero, and empty text convert to false. Other ordinary values convert to true. Expression handles require explicit evaluation and reject conversion.

Short-circuit logic

For &&, evaluate the left operand first. Return it when it converts to false. Otherwise, return the result of evaluating the right operand. For ||, return the left operand when it converts to true. Otherwise, return the result of evaluating the right operand.

Both operators return an operand value without converting it to a true or false value. Don't evaluate the right operand when the left side determines the result because the right side might call a game function with side effects.

Comparisons

Compare values with matching types without conversion. Numbers compare with numbers, and text uses alphabetical ordering.

Equality supports mixed types. == returns false and != returns true when the types differ. Ordering operators report an evaluation error for different types.

Arithmetic

The + operator joins values as text when either operand contains text. Otherwise, it adds numbers. The -, *, /, and % operators require numbers, as does the prefix - operator. Division or modulo by zero reports an evaluation error.

Keep an integer result when both operands are integers and the operation produces an integer. This matches the Narratyr engine plugins and prevents counts from becoming decimal values.

Membership

The in and not in operators require a list on the right. Compare the left operand with each list element by value. Report an error for another right operand type.

Indexing

IndexExpression requires a list and an integer index. An index outside the list, including a negative index, returns an empty value. This behavior lets conditions read a short list without a separate length check.

Function calls

The callee of CallExpression always refers to an Identifier. Read its name, evaluate arguments from left to right, and dispatch the call. The runtime handles built-ins and sends other calls to the game function host.

Statement block execution

Run the children of the root StatementBlock in order and stop at the first error.

Stage variable writes before committing them. Each assignment stores its result in a temporary map. Reads within the block check that map before the main variable store. Commit the map after the complete block succeeds.

Later statements see earlier writes, so Gold += 5; Gold += 5 adds ten. If an error occurs, the main variable store remains unchanged.

Assignment type conversion

Before writing a value, convert it to the variable's declared type. This prevents an assignment such as MyFlag := "hello" from storing text in a true or false variable.

Apply only conversions defined by the language:

  • True or false: use the condition conversion rules. The text "false" converts to true because it isn't empty.
  • Number: convert true to 1 and false to 0.
  • Integer: convert a true or false value to 1 or 0, and round a decimal value.
  • Text: convert the value with the same rules as text concatenation.

Because the language doesn't parse text as numbers or convert between lists and individual values, report an evaluation error for those conversions.

Writing an undeclared variable also reports an error. Expressions have no var declaration, so assignment can't create variables.

Assignment operators

  • := stores the value.
  • += and -= add or subtract numbers. For lists, they append or remove the last matching element. The variable store removes duplicates when writing a set.
  • *= and /= work with numbers only.
  • ++ and -- add or subtract 1 from the target.

Runtime function dispatch

A CallExpression dispatches to either a built-in or a game function. The exporter resolves macros before runtime.

Built-ins

The runtime implements length, count, and indexOf for lists. It also implements randomInteger, randomNumber, debugLog, traversed, choiceTakenCount, lastChoiceTakenCount, questStatus, and objectiveStatus. See Expressions for return values.

eval is a reserved host built-in for stored Expression definitions. Its result type, purity, and dependencies come from the body, not a fixed catalog return type. See Stored Expression evaluation.

Traversal and status functions read current play state. Pass the traversal counts and most recent choice ID into the evaluation context. While checking a choice's hide or disable condition, also pass that choice ID for choiceTakenCount(). Without a current choice ID, that function returns zero. Authoring checks reject its use in other graph contexts, including through nested eval. Game code may supply the ID when evaluating metadata.

Game functions

Calls matching document function definitions go to the game function host. Send the name and evaluated arguments, then return the host result. Report an error for an unknown name.

Macros

Macros are built-in functions prefixed with '#' that evaluate at export time instead of at runtime.

The exporter resolves names beginning with #, such as #quest("The Heist"), and replaces each call with its resulting string literal. An exported flat tree doesn't contain macro calls, so the runtime doesn't implement them.

A tool that reads a project .ngraph file can encounter a macro as a normal CallExpression. Its callee name starts with #, and its arguments are string literals. #objective takes two literals (quest name and objective display name). The other macros take one.

Nested format

The nested format contains child objects directly and uses type instead of kind. These fields differ from the flat format:

Node Nested (type) Flat (kind)
True or false BooleanLiteral, payload value BoolLiteral, payload bool
Numbers or text value number or string
Array literal elements children
UnaryExpression operand left
Call callee, arguments left, children
Index object, index left, right
Assignment target, value left, right
Update argument left
Statement block statements children

BinaryExpression, LogicalExpression, Identifier, and EnumReference use the same fields in both formats.

This nested object represents Gold >= 10 && HasMap:

{
  "type": "LogicalExpression",
  "operator": "&&",
  "left": {
    "type": "BinaryExpression",
    "operator": ">=",
    "left": { "type": "Identifier", "name": "Gold", "varId": "v_abc" },
    "right": { "type": "NumberLiteral", "value": 10 }
  },
  "right": { "type": "Identifier", "name": "HasMap" }
}

Exported instructions. An exported Instruction Node contains flat instructionsFlat and nested instructions. The nested copy supports debugging and can still contain macro calls. Evaluate instructionsFlat, which contains the resolved literals.

Export fields

Exported graphs store trees for expressions in these locations:

Element Field
Connection between nodes conditionAstFlat
Gate node conditionAstFlat
Event Listener node conditionAstFlat
Choice slot hideConditionAstFlat, disableConditionAstFlat
Instruction node instructionsFlat

Each tree appears beside its source string in condition or instructionText. The source helps with debugging and editor display. Evaluate the tree because it contains the parsed, authoritative form.

Elements without a condition omit the field. A connection or choice slot without a condition is unconditional. A Gate without a condition uses requiredVariables, which requires all listed values to be true. An Event Listener without a condition never fires.

Stored expression evaluation

An Expression variable or property stores a formula. The runtime calculates its result only when game code calls an evaluation method or an authored expression calls eval.

The Unreal, Unity, and Godot integrations already implement the behavior described below. For examples of calling their evaluation methods, see the Unreal, Unity, or Godot guide. If you're building another runtime, this section describes the behavior your implementation must provide.

Stored and exported data

In a Narratyr project, an Expression value has source, a nested ast cache (or null for invalid source), and an optional macroScope: "global" marker for defaults copied into graph instances. Narratyr rebuilds these caches from source on load and import. Tools that rename variables in project files must update both the source and its syntax tree bindings.

Narratyr exports Expression values as an object containing the source and a flat tree:

{
  "source": "Gold * 2",
  "ast": {
    "rootIndex": 2,
    "nodes": [
      { "kind": "Identifier", "name": "Gold", "varId": "gold-variable-id" },
      { "kind": "NumberLiteral", "number": 2 },
      { "kind": "BinaryExpression", "operator": "*", "left": 0, "right": 1 }
    ]
  }
}

Graph, node, choice, and performance metadata mark the field type as "expression" and put this object in value. Entity properties use it as their field value. Unity and Godot variable data use it in default_value. Unreal variable rows use DefaultExpression. Narratyr replaces macros in the exported tree with stable-ID literals. The source field remains available for error messages and may still contain readable macros. The runtime evaluates the tree without parsing source.

Definitions and evaluated values

When a runtime loads an Expression value, it stores a private copy of the compiled tree in an Expression object. Game code can retrieve this object and pass it to an evaluation method, but can't modify the tree through it. This keeps the formula fixed during play while the variables it reads can change.

Retrieving the object doesn't evaluate the formula. Suppose PriceFormula contains Gold * 2. With Gold set to 10, evaluating PriceFormula produces 20. If Gold later changes to 15, the next evaluation produces 30. Both calls use the same definition.

The runtime must reject uses of an unevaluated Expression object as a true/false value, number, text, list, or ordinary function argument. Authored instructions and runtime variable setters also can't assign to or update an Expression variable.

In authored source, eval requires exactly one named Expression variable. In the exported flat tree, the call is a CallExpression: its left index points to an Identifier named eval, and children contains one index pointing to the Expression variable's Identifier. The runtime looks up that variable's definition and evaluates its tree. Passing a source string, primitive value, or an ordinary object containing a syntax tree to eval is an error. Narratyr reserves the name eval, so a custom function can't override it.

Evaluation can return primitive values or lists. Option set entries, null, and undefined are also valid results. The operators && and || return the value of one operand, so a runtime must preserve that value and its type. Expressions can't produce another unevaluated Expression object. Lists of Expression objects and custom function parameters of type Expression aren't supported.

Evaluation calls and results

The runtime's evaluation method returns a result object to the game code that called it. This object contains:

  • A success flag indicating whether evaluation completed without an error.
  • The calculated value and its type, when evaluation succeeds.
  • Error messages identifying the failing definition's source, when evaluation fails.

An expression that evaluates to false has succeeded and returned a false value. The same applies to null and undefined. Game code must check the success flag to detect errors.

Narratyr compiles blank source into a false literal, so evaluating a valid blank definition succeeds with false. If compiled data is missing or malformed, the runtime reports an evaluation error, even if source is blank.

Each evaluation call runs the formula again using the current game state. The runtime doesn't cache the result.

Nested evaluation calls

A definition can use eval to evaluate another Expression variable. The runtime evaluates that nested call using the same variables, game function host, quest status provider, and choice identity as the outer call. The nested call must also see any variable changes that the current instruction block has prepared but hasn't committed yet.

In Gold := 20; Price := eval(PriceFormula), the formula reads the pending value 20. If PriceFormula contains Gold * 2, the block sets Price to 40. If evaluation fails, the runtime discards all pending variable changes from that block. It can't undo side effects from game functions that already ran.

The runtime tracks which definitions it's currently evaluating across these nested calls. This lets it detect a formula that calls itself, directly or through another formula, and stop with an error.

Validation and limits

A custom runtime must validate each stored tree and enforce the following limits:

  • Node fields must have the expected types and supported operator names. Function calls must refer to an Identifier, and child references must be integer indices. Stored Expression trees can't contain statements, unknown node kinds, or macros that Narratyr hasn't replaced with literals. These checks also apply to nodes that evaluation would skip.
  • A stored tree can have at most 256 levels of structural depth. Each child must precede its parent in the flat array. Cyclic or out-of-range references are errors.
  • At most 64 definitions can be active at once, including the outermost definition. Calling a definition that's already active is an error.
  • The runtime must limit the evaluator stack to 512 levels and process ordinary binary and logical left chains iteratively, preserving call order and short-circuiting. The separate 256-level tree limit still applies to stored definitions.

Reactive conditions and saved games

Gates and Event Listeners need to know which variables and quest statuses their conditions depend on so they can reevaluate when those values change. A runtime must inspect the bodies of nested eval calls when collecting these dependencies. The same analysis determines whether a condition needs a choice identity or calls an impure function.

Narratyr warns when a stored definition calls an impure function. Game code and other graph nodes can evaluate these definitions. Gates and Event Listeners reject conditions that reach impure calls, including through nested eval calls. See Function purity.

Expression definitions are part of the exported game content. The runtime omits them when listing mutable variable values or capturing a saved game. A catalog of variable names can still include Expression variables. When restoring a saved game, the runtime must skip values whose target variable is now an Expression. This includes old saved values from before a variable's type changed to Expression.

Next

Expressions defines the source language. Exporting explains how to produce engine data that contains these trees.