The expression AST
Description: The parsed shape of every condition and instruction, for writing your own evaluator.
Narratyr parses every condition and instruction at author time and stores the result as a tree, not as a string. Nothing at runtime has to parse anything: an engine evaluates a condition by walking that tree and returning the value at its root.
This page documents the tree so you can walk it yourself. You need it if you're writing a runtime for an engine Narratyr doesn't ship an exporter for, or any tool that has to understand what a condition actually does — a linter, a dependency graph, a report of every variable a quest touches. If you're only authoring narrative, Expressions covers the language itself and this page is safe to skip.
An evaluator for the whole language is a few hundred lines. The tree is small on purpose: sixteen node kinds, no scoping, no control flow, no user-defined functions.
Two forms of the same tree
The same tree is written two ways, depending on which file you're reading.
| Where it appears | Shape | |
|---|---|---|
| Nested | The project's own .ngraph files, under conditionAst / instructionAst |
Objects holding their children directly, discriminated by a type field |
| Flat | Exported engine data, under conditionAstFlat / instructionsFlat |
An array of nodes referring to each other by index, discriminated by a kind field |
They carry identical information. The nested form is what the editor works with and what a tool reading a project on disk sees. The flat form is what exports contain, because it lets a runtime load an AST once and evaluate it with no allocation and no re-parsing on the hot path.
The flat form
A flattened tree is 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 }
]
}
That's Gold >= 10 && HasMap. Every node lives at a fixed position in nodes, and a parent
refers to its children by their integer index into that same array. Evaluation starts at
nodes[rootIndex] and recurses through the indices.
Two properties are worth relying on:
- Layout is post-order — every child appears earlier in the array than its parent, so the root is the last element. This makes a bottom-up evaluation into a single forward pass, if you'd rather not recurse.
- Read
rootIndexanyway. It will always point at the root even if the layout convention changes later; assumingnodes.length - 1will not.
Each node fills only the fields its kind uses; unused fields are omitted rather than set to null, so a small expression stays a small piece of JSON.
Node kinds
Child references — left, right, and the entries of children — are always integer indices
into nodes, never inline objects.
Values
kind |
Fields | Evaluates to |
|---|---|---|
NumberLiteral |
number |
That number |
StringLiteral |
string |
That text |
BoolLiteral |
bool |
True or false |
NullLiteral |
— | An empty value |
UndefinedLiteral |
— | An empty value |
ArrayLiteral |
children (element indices) |
A list of the evaluated elements |
Identifier |
name, optional varId |
The current value of that variable |
EnumReference |
enumName, entryKey |
The enumeration entry's integer value |
NullLiteral and UndefinedLiteral are distinct kinds because the author wrote distinct words.
The shipped engine runtimes collapse both to a single empty value, and nothing an author can
usefully write depends on telling them apart.
An Identifier is looked up by name. The varId is the variable's stable internal id, present
when the editor resolved the name at parse time; it's there for tooling that wants to survive a
rename, and a runtime can ignore it.
An EnumReference is deliberately not resolved during export — it stays symbolic, so changing
an entry's numeric value in the editor takes effect without re-parsing anything. Your evaluator
resolves it against the exported enumeration table, and its value is an integer. That's what makes
ColorEnum.Red in Banner_Colors work: a list of enum values is a list of integers.
Operations
kind |
Fields | Meaning |
|---|---|---|
UnaryExpression |
operator (!, -), left = operand |
Negation |
BinaryExpression |
operator, left, right |
See the operator list below |
LogicalExpression |
operator, left, right |
And / or, short-circuiting |
CallExpression |
left = callee, children = argument indices |
A function call |
IndexExpression |
left = list, right = index |
Element access, MyList[0] |
BinaryExpression carries *, /, %, +, -, <, >, <=, >=, ==, !=, in, and
not in. LogicalExpression carries just the two — && and || — which get their own kind
because they short-circuit and the others don't. Operators are always the source-text spelling,
so the word forms and, or, and not an author may have typed arrive as &&, ||, and !.
Note that left does double duty. On a BinaryExpression it's the left operand, but on a
UnaryExpression it's the only operand, and on a CallExpression it's the callee. The kind tells
you how to read it.
Statements
kind |
Fields | Meaning |
|---|---|---|
StatementBlock |
children = statement indices |
Run each in order |
Assignment |
operator, left = target, right = value |
:=, +=, -=, *=, /= |
UpdateExpression |
operator (++, --), left = target |
Add or subtract one |
These appear only in instruction trees, and StatementBlock is always that tree's root. An
assignment or update target is always a bare Identifier node — the parser rejects anything else,
so you can read nodes[node.left].name without checking.
A statement that is neither an assignment nor an update is a bare expression — usually a
CallExpression run for its side effect. Evaluate it and discard the result.
Evaluating an expression
Recurse from the root, and evaluate a node by looking at its kind.
Variables. An Identifier reads a variable. A name the variable store doesn't know is an
evaluation error, not an empty value — the same reasoning as comparison: a typo should fail loudly
rather than read as falsy and silently route a gate down the wrong edge.
Truthiness. A condition's tree yields a value, which the caller coerces to true or false.
Anything empty, zero, empty text, or false is false; everything else is true. This is why a bare
traversed("...") works as a condition.
Short-circuit. && evaluates its left operand and returns it if that's falsy, otherwise
returns the right. || is the mirror image. Both return the operand, not a coerced boolean — a
detail that matters if you're building a value type rather than working in a dynamic language.
The right operand must not be evaluated when the left decides the outcome, since it may call a
game function with side effects.
Comparison. Compare values of the same type, and never coerce one type into another to force a
comparison through. Numbers compare with numbers, text with text (in the usual alphabetical order).
Equality is total: == between a number and a piece of text is simply false, and != is true —
mixing types is not an error, just never a match. The ordering operators are stricter, because there
is no sensible answer: <, >, <=, and >= across two different types are an evaluation error.
The temptation is to be forgiving here — read text as a number, treat true as one — so that nothing ever fails. Don't. A comparison that quietly coerces will confidently take the wrong branch and give the author nothing to notice, while an error surfaces the mistake the moment it runs. The editor's validator flags these before export anyway, so a runtime that errors is only ever confirming something already known to be wrong.
Arithmetic. + concatenates when either side is text, and adds otherwise; -, *, /, %
require numbers, and anything else is an evaluation error. Unary - likewise requires a number.
Dividing or taking a modulo by zero is an evaluation error, not a silent result.
Where both operands are integers and the operation preserves integrality, keep the result an
integer — the exporters' runtimes do, and it keeps counts from drifting into floats.
Membership. in and not in require a list on the right-hand side and compare the left
operand against each element by value. Anything else is an error.
Indexing. IndexExpression requires a list and an integer. An index outside the list — including a
negative one — yields an empty value rather than an error, so a condition on a possibly-short list
doesn't have to guard the access.
Function calls. A CallExpression's callee is always an Identifier; read its name and
dispatch on that. Evaluate the arguments left to right first. The runtime answers the built-ins
itself and forwards everything else to the host game.
Executing a statement block
The root is a StatementBlock. Run its children in order, stopping at the first error.
The one subtlety is that writes are staged, then committed. Each assignment records its result in a scratch map rather than writing straight through to the variable store, and reads inside the block consult that map before falling back to the store. The whole map is committed once when the block finishes. Two consequences:
- Later statements in a block see earlier writes:
Gold += 5; Gold += 5adds ten. - A block that fails partway through leaves no partial writes behind.
Assignment reconciles with the declared type. A variable has a type the
project declared; an expression produces whatever type it happens to produce. On
the way into the store, the value is made to fit the declaration — otherwise
MyFlag := "hello" leaves text sitting in a slot declared boolean, where it
equals neither true nor false yet still reads as truthy.
Only conversions the language already defines are performed:
- To a boolean, by the same truthiness rule a bare condition uses. There is
no vocabulary of true-like and false-like words:
"false"is a non-empty string and therefore true. - To a number, a boolean becomes 1 or 0. To an integer, a decimal rounds.
- To text, anything stringifies, exactly as
+would.
Anything else is an evaluation error rather than a guess — text into a number would need parsing, which this language does not do anywhere, and a list and a single value are not versions of one another in either direction. The editor's validator reports all of these before export, so a runtime that refuses them is only ever confirming something already known to be wrong.
Assigning to a name nothing declared is an error. There is no var: every
name a statement can write already exists. Creating one on assignment would put a
variable in the store whose type nothing knows, and would swallow a misspelt
target in silence.
The assignment operators:
:=stores the value.+=and-=add and subtract on a number, but on a list they append and remove instead —-=removing the last matching element, so it's symmetric with the append. A variable declared as a set drops duplicates at the point the value is written, not in the evaluator.*=and/=are arithmetic only.++and--are+ 1and- 1on the target.
Functions your runtime must answer
A CallExpression reaches one of three things, all dispatched by name:
Built-ins the runtime implements — no help needed from the game. length, count, and
indexOf over lists; randomInteger and randomNumber; debugLog; the traversal counters
traversed, choiceTakenCount, and lastChoiceTakenCount; and questStatus / objectiveStatus.
See Expressions for what each returns.
The traversal and status built-ins read playthrough state rather than their arguments, so they
need a little context threaded into evaluation: the traversal counts, the id of the most recent
choice, and — while evaluating a choice's own hide or disable condition — that choice's id, which
is what choiceTakenCount() binds to. Everywhere else choiceTakenCount() has nothing to bind
to and is an error.
Functions the game implements — everything in the document's function definitions. Forward the name and evaluated arguments to your host and return what it gives back. An unknown name is an error; the editor's validator means one shouldn't reach you.
Macros — see below. These never reach a runtime.
Macros are already gone
Names beginning with # — #questIdFromName("The Heist") and friends — are resolved during
export and replaced by the literal string they evaluate to. A flat tree from an export never
contains one, and a runtime needs no support for them.
A tool reading a project's own .ngraph files will see them, as ordinary CallExpression nodes
whose callee name starts with #. Their argument is always a string literal.
The nested form
A tool reading .ngraph files directly gets the same tree with children held inline and a type
field instead of kind. The field names differ:
| Node | Nested (type) |
Flat (kind) |
|---|---|---|
| Booleans | BooleanLiteral, payload value |
BoolLiteral, payload bool |
| Numbers / text | value |
number / string |
| Array literal | elements |
children |
| Unary | 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 are the same in both.
So Gold >= 10 && HasMap, nested:
{
"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" }
}
One caveat if you read the nested form. Instruction nodes in an export carry both forms: a
flat instructionsFlat and a nested instructions. The nested copy is kept for debugging and is
not macro-folded, so it can still contain # calls. Evaluate the flat one.
Where to find one
Conditions and instructions live wherever the editor offers them, so an exported graph carries trees on several kinds of element:
| Element | Field |
|---|---|
| Connection between nodes | conditionAstFlat |
| Gate node | conditionAstFlat |
| Event Listener node | conditionAstFlat |
| Choice slot | hideConditionAstFlat, disableConditionAstFlat |
| Instruction node | instructionsFlat |
Each sits beside the original source string (condition, instructionText), which is kept for
debugging and tooltips. Don't evaluate the string — it's the tree that's authoritative, and
re-parsing it means reimplementing the parser as well as the evaluator.
An element that carries no condition simply omits the field, and what that means depends on the
element. A connection or choice slot without one is unconditional. A Gate without one falls back
to its requiredVariables list — the simple "all of these flags are true" form the editor offers
as an alternative to writing an expression. An Event Listener without one never fires, since its
condition is the only thing that can trigger it.
Next
Expressions covers the language these trees come from, and Exporting covers producing the files they live in.