Skip to content

Variables, contexts, enums and bitmasks

Four object types in one panel — every field, how a dotted name resolves, how enum and bitmask values are assigned, and the complete expression language.

All documentation — Variables, logic and commands
On this page

The Variables section, Ctrl+6. Four first-class object types share its tree: variables, contexts, enums and bitmasks. Contexts, enums and bitmasks have no section of their own — they live here, because they exist to serve variables.

This page also carries the expression language, because a variable is only useful in one.

The Variables drawer: loose variables at the top, then the Combat Enums folder, then Contexts with the Interactables context expanded.
The Variables drawer.

Variables

FieldTypeWhat it is
idstring
codestringThe identifier used in expressions
labelstring
descriptionstring
typefield typeOne of the ten below
multiplebooleanAsset, entity and select only — a list
decimalbooleanNumber only. False is a whole number.
multilinebooleanText only. Rendering, never storage.
defaultValuevalue
optionsoption[]Inline options for a select
enumRefstringAn enum id — single-valued select
bitmaskRefstringA bitmask id — multi-valued select
associatedEntityIdstringSet at creation only
tagIdsstring[]
contextIdstring or nullThe context binding
parentIdstring or nullIts folder
ordernumber
createdAt · updatedAt · createdBy

The ten variable types

Not all twenty field types are legal on a variable. These are:

text · number · boolean · date · asset · entity · select · color · vector3 · condition

Excluded on purpose: richText, code, and every reference type — nodeRef, boardRef, documentRef, variableRef, commandCall, tagRef.

Only three types can be lists: asset, entity and select. The Multiple values (list) checkbox appears for those and nowhere else.

The form

One Details section: Code (e.g., skullKeyCount, playerHealth), Label (e.g., Skull Key Count, Player Health), Type, then the type-dependent checkboxes — Multiple values (list), Allow decimals, Multi-line — then Default Value, then either an Options Source (referencing an enum or bitmask) or an inline Options list, then Description (What does this variable track?).

Contexts

A context is a named container that groups related variables. Variables in a context are referenced with a dot: contextCode.variableCode.

FieldType
id, code, label, descriptionstring
parentId, orderIts folder and its place in it
createdAt · updatedAt · createdBy

Form placeholders: Code e.g., playerStats, inventory, questState; Description Optional description of what variables this context groups.

How a dotted name resolves

The variable carries contextId; the context has no member list. Resolving story.gold:

  1. Try story.gold as a bare code first. A variable literally coded story.gold cannot exist — a code has no dots — so this fails, but it is tried first so that an unqualified variable always wins.
  2. Split at the first dot. Find the context whose code is story.
  3. Find the variable coded gold whose contextId is that context.

At play time the qualified code is the key the runtime stores against.

Enums and bitmasks

Structurally identical — one form edits both, switched by which it is. An enum is a pick one; a bitmask is a pick several.

FieldType
id, code, label, descriptionstring
optionsoption[]
parentId, order
createdAt · updatedAt · createdBy

An option

FieldWhat it is
id
valueThe string code a game engine receives. This is what a select field stores.
labelWhat you see
intValueAssigned automatically — see below
color
orderDisplay order only

How intValue is assigned

First optionEach next option
Enum0the highest so far, plus 1
Bitmask1the highest so far, times 2 — so 1, 2, 4, 8, 16…

There is no enforced cap on the number of bitmask bits. The next value is always the previous one doubled, with no ceiling, and the generated C# enum declares no explicit underlying type — so it is C#‘s default int. What happens past 31 options is not asserted anywhere in the code; treat 31 as the practical ceiling until it is.

The expression language

One language, three entry points, and the difference between them is enforced by the grammar rather than by a check.

Entry pointUsed forMay call a command?
Scriptcode script fields, run when the flow enters a node
Single expressionGates on an exit, inline expression editors
Condition statementif / elseif / else / endif markers inside rich text

The purity split is structural: the pure hierarchy has its own grammar productions, and the code path from a gate to a command call does not exist. A condition cannot change anything because there is no syntax for it to do so.

Operators, tightest binding first

LevelOperatorsArityAssociativityAccepts
1! - (prefix)1rightAnything. !x is not truthy; -x coerces to a number.
2* / %2leftBoth sides coerced to numbers. Dividing or taking a modulo by zero gives 0, never infinity or NaN.
3+ -2leftSee below
4== != < > <= >=2leftSee below
5&&2leftShort-circuits, and absorbs errors
6||2leftShort-circuits, and absorbs errors

+ and - are set operations when a list is involved. For +: if either side is a list it is a union; otherwise if either side is a string it is a text join; otherwise it is numeric addition. For -: if the left side is a list it is a difference; otherwise numeric subtraction.

== and != compare lists as multisets — order-insensitive. The four orderings compare lexicographically when both sides are non-numeric text, and numerically otherwise.

There is no ternary, there are no bitwise operators, and there is no while: the language is deliberately not Turing-complete. The word forms and, or, not and is are reserved and unimplemented.

Assignment operators are statement-level, not expression-level: =, +=, -=, *=, /=, %=. The compound forms follow the rules above, so += on a list is a union and -= on a list is a difference. Dividing by zero still gives 0.

Statements

Available in a script only:

StatementForm
ifif (…) · elseif (…) · else · endif
forfor (name in expression) … endfor, with break and continue
letlet name = expression — block-scoped
assignmentto a variable, a dotted name, or an entity field
method callone of the ten below
command callsee commands

A statement may end with an optional ;. A method call’s receiver may be dotted — kim.traits.add("brave") — and the split is at the last dot.

Literals and names

FormNotes
Number12, 3.5
StringDouble or single quotes. A backslash escapes any character; an unrecognised \X decodes to X.
Booleantrue, false
Identifier[a-zA-Z_][a-zA-Z0-9_]*
Member accessa.b, a.b.c
Array literal[a, b, c] — it flattens, so nesting collapses
Parentheses(…)
Comment// … to end of line

A dotted name resolves in this order: a named expression, then a context-qualified variable, then an entity field (the playthrough overlay first, the authored value as the default), then an enum value (GameState.Playing).

An entity field is everything after the first dot. That is how a field an item gets from its parent definition (morrow.health) and a field a trait adds (morrow.stats.armor, the trait’s prefix in front) are written: an item can be read by every field its form shows. A longer path that names no field is refused as having too many dots.

Reserved and refused as identifiers: if elseif else endif for endfor in break continue while do switch case default let const var function return try catch throw true false null undefined and or not is.

The built-in functions — all 47

Numbers

FunctionSignatureNotes
absabs(number)
round · floor · ceilround(number)
sqrtsqrt(number)A negative input gives 0
powpow(number, power)
signsign(number)
min · maxmin(a, b, …)One or more arguments
clampclamp(number, lowest, highest)

Text

FunctionSignatureNotes
upper · lower · trimupper(text)
startsWith · endsWithstartsWith(text, start)Returns a boolean
replacereplace(text, find, put)
substringsubstring(text, from, length)0-based; length optional
splitsplit(text, separator)Returns a list
joinjoin(list, separator)
text · numbertext(value)Conversion

Lists

FunctionSignatureNotes
countcount(list)A string counts its characters
emptyempty(value)
containscontains(list, thing)
first · lastfirst(list)Returns a single value
reverse · sortedsorted(list)sorted is stable, over a total order
sumsum(list)
union · except · intersectunion(a, b)
listlist(value, …)list() is the empty list
vec3vec3(x, y, z)

Over a list, with a binding — the second argument is a bare name bound over the third, which is evaluated once per item:

FunctionSignatureReturns
any · all · noneany(list, name, test)boolean
filterfilter(list, name, test)list
mapmap(list, name, value)list
countWherecountWhere(list, name, test)number
sortBysortBy(list, name, value)list, stable

So filter(items, x, x > 3) — x is a name, not a value.

The playthrough

FunctionSignatureNotes
visitsvisits(nodeId)How many times
seenseen(nodeId)

Random — impure, and refused in a condition

FunctionSignature
randomrandom()
randomIntrandomInt(lowest, highest) — both ends inclusive
rollroll(sides)
pickpick(list)

The ten methods on a variable

MethodSignatureReturnsPure
hashas(value)boolean
hasnthasnt(value)boolean
hasanyhasany(a, b, …)boolean
hasallhasall(a, b, …)boolean
countcount()number
addadd(value, …)list
removeremove(value, …)list
toggletoggle(value)list
setset(value, …)list— set() empties it
clearclear()list

Every argument is flattened, so a list argument counts as its members. has(list) means holds all of them. hasnt means holds none of them — it is not !has. toggle flips each value independently.

Type coercion

CoercionRule
To booleannull false · 0 false · "" false · [] false
To numbernull 0 · non-finite 0 · true/false 1/0 · a string parsed, unparseable is 0 · a list becomes its length
To textnull empty · a number without exponent, at most 6 decimals, trailing zeros trimmed · a list joins with ", "
To listnull empty · a scalar becomes a one-element list

Numbers and their own numeric text compare equal; a non-numeric string and a number do not.

Named expressions

A variable whose type is condition is a named expression, not a value: its text is evaluated wherever the name is used. It is always evaluated in pure mode whatever the outer mode, and a self-reference is refused.

There are no parameters, no call stack and no recursion.

The budget

LimitValue
Expression size5,000 parts
Operations per invocation200,000
Nesting depth64
Items in one value100,000
Characters in one value1,000,000

These are enforced in the browser. Nothing on the server checks them. The look-ahead pass that decides which options are available pays the operation budget too.

What breaks

A runtime error never ends the playthrough. A gate that throws reads false, loudly recorded. && and || absorb an error on one side when the other side already decides the result.

CodeWhenExample message
unsupportedThe language cannot read itThere’s a mistake in this script here — the language can’t read it. · name(…) needs N parts, but got M. Write it as «signature».
bad-methodNo such method'obj' has no action called 'm'. Available: has, hasnt, hasany, hasall, count, add, remove, toggle, set, clear.
impureA command or an impure function reached a gateA condition can’t change anything, and 'm' changes 'obj'. Move it into a script that runs on the node. · A condition can only use the built-in functions, and 'name' isn’t one. If it’s one of your commands, it belongs in a script — a condition must not change anything.
budgetToo much work, or a value too largeThis script did too much work and was stopped after 200,000 steps… · …built a list too large to store (limit 100,000 items)…
too-largeThe expression itself is too bigThis expression is too large to run (N parts, limit 5,000). Break it into smaller pieces.
cycleA named expression refers to itself'name' is defined in terms of itself, so its value can never be worked out.
internalAnything unanticipatedSomething went wrong running this script.

The trace is the “why was this false?” feature. Every evaluation records each part, its value, its depth, whether it errored, and whether it was skipped by a short-circuit — which is a different thing from having evaluated to false. In play mode it is the Why this step? button.

The visual builder

Every condition and script can be edited as rows instead of as text, and the toggle is per control.

The architecture is worth knowing because it decides what you can trust: text is the source of truth, and the visual surface is a projection of it. The builder parses with the same parser and reads the same function and method registries, and serialises back through the same encoder — so there is only ever one tree, not two that could disagree.

The parse into rows is lossy, so every row records the exact source range it came from, and an edit splices only that range. Anything the builder cannot draw is preserved byte for byte.

Switching Code → Visual is refused unless every control parses. Switching Visual → Code always works.

The builder covers the whole language: all 47 built-ins through one operation row derived from the registry, all ten methods on any receiver the grammar admits, real for / let / break / continue rows, and the compound assignment operators. The one thing with no builder affordance is a named expression, which is still authored as a condition variable.

Limits

Nothing caps variables, contexts, enums, bitmask options or expression length on the server — no guard reads such a number, on any plan, and none of these object types is plan-gated. The browser budgets above are the only ceilings that exist.

Written for
Writers · Unity developers · Advanced integrators
Last updated