Create / Modding reference / Variables & expressions
Variables & expressions
Scenario state lives in ONE flat variable store: string keys, typed values.
VariableSet writes it,
Expression filters read it,
HudReadout displays it, and the whole store
clears at scenario teardown (nothing persists across scenarios or a retry).
Expressions are a small hand-rolled AST with the usual precedence chain -
this page is its complete grammar.
The chain, from the boolean root down to the atoms:
condition LessThan | GreaterThan | Equal (filters only)
expression Add | Subtract | Term (the value root)
term Multiply | Divide | Factor
factor Literal | Name | Parens (the atoms)
Every node is a tuple variant - operands are written positionally, nested inline. The simplest full chains, worth memorizing:
Term(Factor(Literal(Number(0.0)))) // the number 0
Term(Factor(Name("beat"))) // read the variable "beat"
Values: the literal types
Three value types (VariableLiteral):
| variant | payload | example |
|---|---|---|
Number(..) |
64-bit float | Literal(Number(4.0)) |
String(..) |
string | Literal(String("act_two")) |
Boolean(..) |
bool | Literal(Boolean(true)) |
There is no null and no integer type - counters are Numbers.
Factors: the atoms
| variant | payload | meaning |
|---|---|---|
Literal(<literal>) |
a value | a constant |
Name("var") |
a variable key | read that variable; an UNDEFINED name is an evaluation error (the enclosing filter fails closed, a VariableSet skips the write) |
Parens(<expression>) |
a whole expression | parenthesized subexpression - how you put an Add under a Multiply |
Terms: multiply / divide
| variant | operands | semantics |
|---|---|---|
Factor(<factor>) |
- | a bare factor |
Multiply(<factor>, <term>) |
factor x term | Number x Number = product; Boolean x Boolean = logical AND; anything else = type error |
Divide(<factor>, <term>) |
factor / term | Numbers only; dividing by 0.0 is an evaluation error (fails closed, never NaN) |
Expressions: add / subtract (the value root)
The node VariableSet.expression takes.
| variant | operands | semantics |
|---|---|---|
Term(<term>) |
- | a bare term |
Add(<term>, <expression>) |
term + expression | Number + Number = sum; Boolean + Boolean = logical OR; String + String = concatenation; mixed = type error |
Subtract(<term>, <expression>) |
term - expression | Numbers only |
Note the asymmetric arms: the LEFT operand is a term (or factor, one level
down), the RIGHT is a full expression. That is ordinary precedence
plumbing - it is why the shipped increment reads
Add(Factor(Name("n")), Term(Factor(Literal(Number(1.0))))): the left arm
skips straight to a factor, the right arm is a complete expression.
Chains associate RIGHTWARD: a - b - c authored as
Subtract(a, Subtract(b, Term(c))) computes a - (b - c). Chained Add is
safe (associative); for subtraction, use Parens to force the grouping you
mean.
A three-term sum, for the record:
Add(
Factor(Name("a")),
Add(
Factor(Name("b")),
Term(Factor(Name("c"))),
),
)
Conditions: the boolean root
The node an Expression filter wraps. Compares
two value expressions; yields a boolean.
| variant | operands | semantics |
|---|---|---|
LessThan(<expr>, <expr>) |
numeric | l < r; non-numbers = type error |
GreaterThan(<expr>, <expr>) |
numeric | l > r; non-numbers = type error |
Equal(<expr>, <expr>) |
same type | Numbers compare within epsilon 1e-6 (exact float equality burned an author once); Strings/Booleans compare exactly; mixed types = type error |
There is NO NotEqual, LessOrEqual or GreaterOrEqual. Compose instead:
wrap the filter in Conditional(Not(..)), or
flip the comparison (>= n on an integer counter is > n - 1 - and that
form is the count-gate convention anyway).
Queries and watched variables
Queries are typed, read-only world observations. A scenario can expose one as
an auto-updating variable with watches:
watches: [
(
variable: "scenario_elapsed",
query: Scenario((property: Elapsed)),
),
(
variable: "courier_speed",
query: Entity((
filter: (id: "courier"),
property: Speed,
)),
),
],
Use watched values through the normal variable syntax, including HUD readouts:
Name("scenario_elapsed"). A watched name is read-only; VariableSet on it is
a lint error.
Queries can also be inline expression factors. This takes a one-shot speed snapshot when the action runs:
VariableSet((
key: "speed_at_gate",
expression: Term(Factor(Query(Entity((
filter: (id: "courier"),
property: Speed,
))))),
))
Supported queries:
| query | result | meaning |
|---|---|---|
Scenario((property: Elapsed)) |
Number | live, unpaused scenario seconds; resets on teardown |
Entity((filter: (id: "..."), property: Speed)) |
Number | speed in u/s of exactly one matching entity |
Entity is strict-single. Zero matches, multiple matches, or a missing velocity
make the query unavailable. Expressions fail closed. Missing is not zero.
The typed form (in the editor)
The RON above is the authored form, and it is the one the file holds. In the
in-game editor an expression filter is ONE ROW in the tree; selecting it opens
the condition as a PAGE, a row per node: Compare at the top with Left and
Right stepped in under it, each offering the operators its place allows. A
leaf holds one value, typed in the short form below rather than as a nest of
Add(Term(Factor(.... A leaf that names a variable can PICK one instead of
spelling it: the chip beside the box lists what the scenario declares.
A VariableSet action opens the SAME page under its Key, headed Value
instead of Condition: what it writes is an expression, so Writes sits where
Compare does and takes the operators a value may have - + - * / and a leaf,
never a comparison. The tree row of that action reads as the assignment it
makes, beat = beat + 1.
| you type | the file holds |
|---|---|
4 |
Term(Factor(Literal(Number(4.0)))) |
"act_two" |
Term(Factor(Literal(String("act_two")))) |
true |
Term(Factor(Literal(Boolean(true)))) |
beat |
Term(Factor(Name("beat"))) |
beat + 1 |
Add(Factor(Name("beat")), Term(Factor(Literal(Number(1.0))))) |
(a + b) * 2 |
Term(Multiply(Parens(..), Factor(Literal(Number(2.0))))) |
scenario.elapsed |
Term(Factor(Query(Scenario((property: Elapsed))))) |
entity("courier").speed |
Term(Factor(Query(Entity((filter: (id: "courier"), property: Speed))))) |
beat == 3 |
Equal(Term(Factor(Name("beat"))), Term(Factor(Literal(Number(3.0))))) |
Operators are + - * / for values, < > == for conditions, and ( ) groups.
Strings are double-quoted, with \" for a quote inside one. There is nothing
here the grammar above does not already have: no !=, no &&, no operator the
engine cannot evaluate.
There is no unary minus. -3 is the literal -3, read only where a value may
start, so the - in a - 3 stays an operator.
The text round-trips. Reading a file and writing it back gives the same tree,
and the same text - so opening a scenario in the editor and saving it does not
rewrite a condition you did not touch. a - b - c still groups RIGHTWARD, as
Subtract(a, Subtract(b, c)) does; parenthesize when you mean otherwise.
A line that does not parse is REFUSED: the field says why ('&' is not part of an expression, unterminated string) and the config keeps the value it had.
Typing a whole expression into one leaf is allowed - (a + b) * 2 in a single
row reads exactly as it does here - so the tree is how deep you want it to be.
The brackets come back on the way out wherever the shape needs them: a sum
hanging under a product is written (a + b) * 2, never a + b * 2.
Recipes
The compositions every shipped scenario is built from. All of them depend on
one rule: seed every variable in OnStart - expression filters
fail closed on unset names.
Increment a counter (re-evaluated per event, so it accumulates):
VariableSet((
key: "crates_recovered",
expression: Add(Factor(Name("crates_recovered")), Term(Factor(Literal(Number(1.0))))),
))
The count gate - a second handler (often OnUpdate, so it never depends on
handler order) that fires when the counter crosses a threshold.
once: true is what makes
it fire one time. Prefer > n-1 over == n on a count gate: a double-fire
that jumps the counter past n cannot skip a > gate, but sails clean over
an == one.
(
name: OnUpdate,
once: true,
filters: [
Expression((GreaterThan(
Term(Factor(Name("crates_recovered"))),
Term(Factor(Literal(Number(1.0)))), // fires at 2 or more
))),
],
actions: [
// ... the beat ...
],
),
A timed beat is the same shape - a clock threshold and once:
(
name: OnUpdate,
once: true,
filters: [
Expression((GreaterThan(
Term(Factor(Name("scenario_elapsed"))),
Term(Factor(Literal(Number(30.0)))),
))),
],
actions: [ /* ... */ ],
),
Keep a flag only where it is a SIGNAL some OTHER handler reads - "the quota is
met", "wave two is on the board". A flag whose only reader is its own filter
is what once replaces.
A repeating wave - gate on elapsed > next_at, re-arm inside the action:
actions: [
VariableSet((
key: "next_at",
expression: Add(Factor(Name("next_at")), Term(Factor(Literal(Number(30.0))))),
)),
// ... spawn the wave ...
],
Snapshot the clock to measure "since X": store scenario_elapsed into your
own variable when X happens, then gate on
elapsed > snapshot + grace via Add under Parens.
A linear state machine: one numeric beat counter, every handler filtered
on Equal(beat, N) and ending with VariableSet(beat, N+1). This scales
better than a boolean per step; the Shakedown Run and the Gauntlet are both
built this way (see the scenario-engine chapter of the
developer book).
Traps for the unwary
- Everything fails CLOSED: undefined names, type mismatches and division by
zero all log an error and make the filter false / skip the write. No
crash, no default value - the handler just never fires. Seed in
OnStart. - No boolean node exists at the VALUE level beyond the
Add/Multiplyoverloads (OR / AND on booleans). For filter logic, prefer multiplefiltersentries (already ANDed) andConditionalfor OR / NOT. - RON's recursion limit (128) bounds nesting depth; a pathological expression is a parse error, not a hang.