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/Multiply overloads (OR / AND on booleans). For filter logic, prefer multiple filters entries (already ANDed) and Conditional for OR / NOT.
  • RON's recursion limit (128) bounds nesting depth; a pathological expression is a parse error, not a hang.