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:
rootIndexidentifies the root even if the layout changes in a later version. Don't assume thatnodes.length - 1is 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
1and false to0. - Integer: convert a true or false value to
1or0, 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 subtract1from 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.