Skip to content

Game Event Script language specification

Since: 0.1.0

Game Event Script (GES) is an event-oriented scripting language for deterministic game logic. A source declares records, predicates, functions, and message handlers. Handler execution can enqueue local messages or publish messages to the host’s outbound boundary.

This document is the normative language definition. It specifies accepted source text, declarations, static semantics, evaluation, and script-visible values. Exact Unicode rules belong to Text semantics, numeric rules to Number semantics, and seeded randomness, iteration, equality, and stable ordering to Determinism. Host loading, scheduling, queues, sinks, and observation belong to Host runtime. Bytecode lowering is not part of source-language meaning and is specified separately in Bytecode.

Contents

  • Source compatibility across releases
  • State ownership
  • Program structure
  • Lexical rules
  • Messages and dispatch
  • Statements
  • Expressions and operators
  • Types and values
  • Collections and selectors
  • Randomness, dice, and series
  • Extensions
  • Static errors and execution limits
  • Normative grammar

Source compatibility across releases

The following guarantees apply from release 1.0 onward, within one major release line. They do not freeze the current 0.x language. A later compatible compiler must continue to accept previously valid source under equivalent compiler options and native catalogs, preserving its defined meaning. Additions must not silently reinterpret existing valid source. Incompatible language changes require a major release under the version policy.

Compatibility preserves defined values, control flow and observable native effects, including calls whose results are unused. Message ordering is owned by Host runtime, conversions by Numbers and Text.

Recompilation need not produce identical instructions, register allocation or binary bytes. Optimizations may change random consumption and executed opcode counts as qualified in Determinism. Those exceptions do not authorize independently removing or reordering observable native effects or message operations. Implementations may correct behavior that violates the owning specification; such fixes are not permission to change the contract itself. Previously invalid source has no acceptance guarantee.

State ownership

GES has immutable script bindings and data values, with no script-owned mutable store or persistence facility.

The embedding application owns mutable game state and its persistence. Native message handlers and extensions may change that application state under the embedding’s own contract. Script immutability does not imply that native calls are pure. External field access still obeys the per-handler logical-value stability required by the External-type API; mutating application state does not relax that requirement. Such effects do not introduce assignment or mutable collections into GES.

The runtime does not provide a transaction or rollback guarantee for application state changed by native callbacks. The embedding defines synchronization, failure recovery and persistence for that state while respecting the host’s serial execution contract. Reproducibility remains subject to the inputs and native behavior required by Determinism.

Program Structure

A compilation unit consists of one or more source documents. Each source contains an optional module declaration followed by top-level declarations:

module battle
constant $startingHp be 100
record :Unit as {
hp: :Number clamped between 0 and maxHp,
maxHp: :Number
}
predicate alive(_ unit) be unit.hp > 0
function missingHp(_ unit) be unit.maxHp - unit.hp
on Damage(unit, amount) {
if unit is alive {
emit Damaged(unit: unit, amount: amount)
}
}

Top-level declarations are:

  • constant $name be scalarLiteral
  • record :Name as { ... }
  • predicate name(...) be expression
  • function name(...) be expression
  • on Message(...) { ... }
  • on Message as localName { ... }
  • on initialization { ... }
  • on undeliverable as localName { ... }

The module declaration is metadata and does not create a namespace. Definitions from every source in one compilation unit share compilation-unit scope and may refer to one another independent of source order or alphabetical name order. This includes callable bodies constructing records and record field expressions calling other constructors or callables. Functions and predicates share one callable namespace. Records use a separate type namespace because their source references are prefixed with :. Multiple handlers for the same endpoint are allowed and retain their source registration order. When no non-empty module name is supplied, the compiler derives a deterministic anonymous Program name from the complete compiler output. Module identity does not change expression semantics.

Constants are immutable compile-time names rather than runtime storage. There are no global variables or mutable script storage. Each handler or callable invocation receives fresh parameters and locals. State that outlives a handler exists only through messages or host-provided facilities outside this language.

Functions and predicates

Functions and predicates are immutable, globally named expression callables:

function damage(base, bonus as :Number) be base + bonus
predicate alive(_ unit) be unit.hp > 0

Callable identity is the callable name plus its ordered external argument labels. Functions may therefore overload a name when at least one label or the arity differs:

function takeDamage(unit, enemy) be unit.hp - enemy.damage
function takeDamage(unit, collision) be unit.hp - collision.force

The calls takeDamage(unit: hero, enemy: attacker) and takeDamage(unit: hero, collision: impact) select those two signatures respectively. Parameter local names and declared types are not part of identity, and there is no overload resolution by type. In particular, two _ parameters with different local names or types still have the same external label _.

Functions and predicates share one name namespace: if any function named takeDamage exists, no predicate named takeDamage may exist, regardless of either declaration’s signature. Within one kind, duplicate signatures are static errors.

Record identity is likewise the custom type name alone. A record cannot be overloaded by changing its fields or constructor labels, and a script record cannot use the name of a configured external type. The type namespace is separate from the callable namespace, so record :Damage ... and function damage(...) ... may coexist.

A parameter written label has both the external argument label and local name label. _ localName declares an unlabeled argument with the indicated local name. Parameters are evaluated and bound left to right. A declared parameter type casts the supplied value before evaluation of the body; a failed cast binds nothing. Calls must match the declaration’s ordered external labels exactly. Thus function f(x) ... is called as f(x: 10), while function f(_ x) ... is called positionally as f(10). Duplicate local parameter names and duplicate non-_ labels are static errors. Duplicate local parameter names are rejected with validate.duplicateDefinitionParameter, regardless of whether a call precedes the definition or appears in another source document of the program.

A function may return any value. A predicate must statically produce a boolean or nothing; predicate results are normalized to that three-state result. value is predicateName is shorthand for invoking the unique unary overload of that predicate name. The shorthand has no argument label with which to select between multiple unary overloads, so such a family is ambiguous and rejected; an explicit ordinary call selects a predicate by its full signature. Every call receives a fresh frame. The synchronous call graph must be acyclic: direct and indirect recursion are not part of the language. Call-graph validation and resource analysis must support acyclic chains within the portable Program limits without depending on the embedding’s native stack size. Runtime call-depth limits apply when loading and executing a Program, not as an additional source-compilation limit.

Lexical Rules

ASCII space and tab separate tokens. LF, CRLF, CR, and semicolons separate statements where the grammar permits a separator. Line comments start with // and continue up to, but do not consume, the next logical newline.

let x be 10
let y be 20; let z be x + y // same statement separator model

Names are intentionally narrow:

  • Callable names, argument labels, fields, map keys, tags, constant names, and extension namespace/function components start with a lowercase ASCII letter and continue with ASCII letters or digits. They never contain _.
  • Local binding names use that same lower-name form and may additionally end in one canonical derivation suffix _0, _1, and so on. Leading zeroes are forbidden except for _0; _01 is invalid. This suffix is available only to variable bindings and their references, including unlabeled parameter locals, loop variables, and selector variables.
  • Local names must resolve to a local, parameter, or capture visible at compile time. Callable names resolve in the program-wide callable namespace. Unknown names are compile errors; they do not evaluate to nothing.
  • Message names start with an uppercase ASCII letter and continue with ASCII letters or digits.
  • Tags start with # and use a lower name without _. #true, #false, #pi, and #infinity are ordinary tag values.
  • Constants start with $ and use a lower name without _.
  • Built-in and custom type names start with an uppercase ASCII letter and continue with ASCII letters or digits. Type references, generated collection forms, and type constructors use : followed by that PascalCase name.
  • Extension references use lowercase :namespace.function components.
  • A module name is one or more dot-separated components. Every component starts with a lowercase ASCII letter and continues with lowercase ASCII letters or digits. Uppercase letters, _, and - are forbidden.

All of these name grammars use ASCII letters and digits; they do not depend on platform Unicode classification. Source is strict UTF-8, an optional initial BOM is removed, and only ASCII space/tab plus LF, CRLF, or CR are portable whitespace/newlines. The complete normative contract is in Text semantics.

Text literals can use single or double quotes. The quote character is escaped by doubling it:

let a be 'didn''t'
let b be "say ""hello"""

Text literals may contain logical newlines. Backslash has no escape meaning. Decoded text is always a value token: quoted keywords, operators, delimiters, and separators never satisfy grammar-token expectations. For example, let x "be" 5 is a syntax error, while let x be "be" is valid. Tokens and keywords are case-sensitive. of is a reserved keyword token; it is valid only in the grammar phrases that use it, including min of, max of, extension argument lists, in values of, and dice patterns. It does not start a list literal.

Numeric literals contain an integer part and an optional fractional part, use . as decimal separator, and have no sign or exponent syntax; unary minus supplies a negative sign. Single underscores may separate adjacent digits but may not lead, trail, or repeat. A following %, m, s, or ° forms a percentage or unit literal without intervening whitespace:

let integer be 42
let groupedInteger be 1_000_000
let fraction be 12.5
let negative be -7 // unary minus followed by an integer literal
let probability be 25% // percentage literal
let distance be 3.5m // meter quantity
let duration be 2s // second quantity
let angle be 90° // degree quantity

Literal value decoding shares the numeric conversion rules with text casts as defined in Number semantics. The source lexer still owns token boundaries; in particular, commas remain argument and list separators rather than numeric digit separators.

Messages and Dispatch

Messages have a name, ordered argument names, argument values, and tags. The ordered argument names form the signature. An unlabeled argument uses _. Argument order is observable; named arguments are not a dictionary. Duplicate named labels are invalid, while multiple _ positions are valid.

emit Hit(target: unit, amount: 10)
emit Log('ready') // signature Log(_)

Exact handlers receive messages whose name and ordered argument names match:

on Hit(target, amount) {
emit Applied(target: target, amount: amount)
}
on Log(_ text) {
emit Logged(text: text)
}

on Ready and on Ready() both declare the empty exact signature. The empty parentheses describe only the absence of message arguments; they place no restriction on message tags. Without a matching or without clause, an exact handler accepts every tag set, including an empty set. A parameter written name has signature label name and local name name. A parameter written _ localName has signature label _ and local name localName. Parameter local names must be unique within the handler.

Parameters can declare a type. The runtime casts the incoming value before the handler body runs. Failed casts produce nothing; they do not prevent dispatch:

on Move(unit, speed as :Quantity(m)) {
emit Moving(unit: unit, speed: speed)
}

Message-name handlers match every signature having the declared message name, subject to their tag filters. They receive one :Message value instead of the message’s individual arguments. The identifier following as is a freely chosen local binding name; the word message is not required:

on Hit as incoming matching #enemy without #silent {
emit Heard(name: incoming.name, tags: incoming.tags)
}

The message value contains its name, signature string, argument map, and tags. It exposes the read-only members name, signature, arguments, and tags. The message itself retains ordered arguments. Accessing arguments materializes a map keyed by argument label and therefore follows map key ordering and last-entry-wins behavior; repeated _ positions are not individually addressable through that map.

initialization is a parameterless special handler invoked once per loaded instance. The host’s initial Start phase and later FIFO initialization, including failure and output commitment, are defined in HostRuntime. It is not an externally addressable message:

on initialization {
emit Ready
}

undeliverable is the fallback endpoint for a message that has no matching ordinary handler after signature, name, and tag filters have been evaluated. It must use the message-name binding form. The chosen local receives the original message as a :Message value, and filters on the fallback handler inspect the original message’s tags:

on undeliverable as rejectedMessage without #ignored {
emit Unknown(message: rejectedMessage)
}

Both exact-signature handlers and message-name handlers can filter tags:

on Radio(channel, payload) matching #open, #enemy without #encrypted {
emit ExactIntercept(channel: channel, payload: payload)
}
on Radio as incoming matching #open, #enemy without #encrypted {
emit AnyRadioMessage(message: incoming)
}

matching requires the message to contain every listed tag. It is a subset test, not an exact-set comparison: additional message tags remain allowed. without rejects the message when it contains any listed tag. With neither clause, all tag sets are accepted.

For example, this handler accepts both messages below:

on Something() matching #a {
emit Matched
}
emit Something() with #a
emit Something() with #a, #b

The corresponding rules are:

Handler filter Message tags Eligible
none none, #a, or any other set yes
matching #a #a yes
matching #a #a, #b yes
matching #a #b or none no
without #silent #a or none yes
without #silent #a, #silent no
matching #a without #silent #a, #b yes
matching #a without #silent #a, #silent no

The clauses may appear in either order and may be repeated; all matching tags are combined into one required set and all without tags into one excluded set. Repeating a tag has the same meaning as listing it once. Filtering never removes tags: the delivered message retains its complete original tag list. initialization is the exception and cannot use tag filters. Exact-signature and message-name handlers are both eligible for the same message; dispatch ordering and undeliverable selection are defined by Host runtime.

Statements

emit and publish

emit queues a message only in the local host. publish first queues the same message locally and then offers it to the host’s outbound publish sink.

emit Done
emit Done(value: 10)
emit Done(value: 10) with #combat, #visible
publish Fire(target: enemy)
publish Scan with [#radar, #active]

Tags after with can be tags or lists of tags.

The message operand may instead be any expression. It is emitted or published only when its result is a :Message; any other result produces no message. Tag expressions are evaluated left to right. Lists are recursively flattened, each remaining value is converted to its text form and normalized by the portable tag grammar, empty tags are omitted, and later duplicates are omitted while retaining first occurrence order. Applying with to an existing message replaces that message’s tags with the normalized clause result.

Result-Bearing and Delayed Sends

Since: 0.2.0

emit Message(...) and publish Message(...) are also expressions. Their Boolean result means the host accepted the send, independent of recipient existence, handler success, or publish-sink acceptance. A statement discards the result. Unused results never make sends removable side effects. Ordinary expression short-circuiting can prevent a send from being evaluated. In result-bearing sends, an indirect message operand and each with operand use unary precedence; binary operators following them apply to the send result. Parenthesize a compound message or tag expression, for example emit (primary default fallback) or emit Ping() with (tags | extraTags). This applies to delayed sends as well. Legacy immediate statement sends retain their full-expression operands.

emit after duration Message(...) with tags and the corresponding publish form add a delay. Duration is evaluated first, then additional tags and message arguments using the existing send argument order; each is evaluated once. The message and tags are captured immediately. Duration must be a numeric Quantity(s), not a unitless number or another unit. Unit-preserving computations such as abs (-0.2s) are valid durations; a numeric result must not be assumed unitless when its unit is unknown. Statically established wrong types produce validate.invalidTypeConstructor; dynamically invalid values, nothing, negative values, nonfinite values or unrepresentable delays produce false without sending. Zero seconds uses the instant send path. Timing, rounding, delivery snapshots and initialization staging are defined in HostRuntime. Emit/publish do not become synchronous handler calls. A result-bearing send also returns false when its message operand is not a Message or its tag clause cannot be normalized.

emit after 0.2s Fire(power: 100%) with #cannon
publish after 5s Ready(unit: unit) with #team
let accepted be emit after 1s Retry(attempt: 2)
if publish Report(value: 42) {
emit Log("Report accepted for sending")
}

The first two statements schedule local delivery and publication, respectively. The publication reaches the outbound sink only when due. The Boolean check in the last example confirms acceptance, not successful delivery. Use a time quantity such as 0.2s; emit after 10 Fire() is invalid because 10 has no time unit.

let

let creates an immutable local binding:

let damage be base + bonus
let percent be rawPercent as :Percentage

let has no declared-type form. A desired conversion is part of the value expression: write let value be input as :Number, not let value as :Number be input. Parameter and record-field type declarations remain part of their respective signatures.

A name may be declared only once in one lexical scope, and a binding may not reuse a name that is visible from an enclosing lexical scope. This no-shadowing rule applies uniformly to locals, loop variables, generated-collection identifiers, and selector identifiers. Parameters occupy the routine’s outer scope. The if and else blocks are sibling child scopes, so both may declare the same name when that name does not exist in their common parent. There is no assignment statement and a binding never changes after initialization. The same visibility rules apply inside function and predicate bodies, record field expressions, message tags, and random-seed expressions. A selector or generated-collection binding is visible to its nested predicate/projection expressions, but not to its source collection. Separate selector expressions are sibling scopes, including a choose predicate and its weight expression. Each expression can resolve its own binding and visible ancestor bindings, but not the other expression’s binding. An otherwise unbound reference to that sibling binding is compile.unresolvedSymbol. An enclosing collection binding retains its current value throughout every nested iteration. This also applies when the binding holds the result of an earlier projection in a selector pipeline.

if

Since: 0.2.0 — conditional bindings and branch scopes

if runs the then-branch only when the condition is true. nothing is not true. else runs for every non-true condition.

if unit.hp > 0 {
emit Alive(unit: unit)
} else {
emit Defeated(unit: unit)
}

Single-statement bodies are allowed:

if ready emit Ready

Every if owns a local scope shared by its header and then body. Its else body owns a separate sibling scope. Braces do not change these scope boundaries: no binding declared in either branch is visible after the if. Only the value true enters the then-branch. false and nothing select else or continue after the statement. To declare a binding for subsequent statements, use a conditional expression such as let y be 20 when condition otherwise nothing; the binding always exists, even when its value is nothing.

An if header may contain multiple semicolon-separated checks, evaluated from left to right. Each check is either an ordinary condition or let name be expression. A final semicolon before a braced body is optional. Newlines within a continued header are formatting; they do not replace the semicolon. Commas inside guarded-choice expressions retain their normal meaning and require no additional parentheses.

if let scan be lookup();
let target be scan.target;
target.distance < 100m {
emit Found(target)
}

A binding initializer is evaluated once and implicitly tested with has value. This is not truth conversion: zero and false have value; nothing and empty collections/text fail according to the existing has value rule. The first failed check selects the else branch without evaluating later checks. Each successful binding is visible in subsequent checks and the then body, including an unbraced then body, but never in its own initializer, the else body, or after the if. Bindings cannot shadow visible names or repeat in the chain. Ordinary conditions still require true; let is not a general expression.

for

Loops can iterate a collection expression or a range.

for unit in units {
emit Seen(unit: unit)
}
for index from 1 to 10 step 2 {
emit Tick(index: index)
}

A direct range after in is rejected. Use for x from ... to ..., or bind the range to a value first.

The iteration expression is evaluated once. Items follow the deterministic order defined for their value kind. A non-iterable or nothing source performs zero iterations. Each iteration assigns the next value to the immutable loop binding; bindings created inside a braced loop body do not escape the iteration body.

A compact chain is equivalent to nested loops, with the leftmost clause outermost:

for suit in suits and rank in ranks {
emit Card(suit: suit, rank: rank)
}
for row from 1 to 3 and column from 1 to row emit Cell(row: row, column: column)

Each clause supports the existing collection/range syntax and component bindings. The inner source is evaluated once per outer iteration, may refer to preceding bindings, and is never evaluated if an outer source has no items. This does not construct a Cartesian-product list. Every clause introduces the same lexical scope and counts toward the same statement nesting limit as an explicit nested loop; bindings cannot shadow one another and do not escape the loop.

At the outer level of a loop source, and followed by binding names and in or from starts another loop clause. Parenthesize a source expression containing that token pattern when it is intended as Boolean conjunction/membership instead. Inside parentheses (including argument lists), list/map literals and pipeline selectors, and retains its ordinary expression meaning. Clauses may span lines, and the final body may be braced or a single statement.

Expression statements

Any expression may be used as a statement. It is evaluated for its observable effects, such as an extension invocation, and its resulting value is discarded. Unused computations may be removed when they have no program effects. Emit, Publish, and calls into host code are program effects; these must be preserved, including when reached indirectly through functions, record field casts, clamps, or computed fields. Host and extension calls without an explicit purity contract must be treated conservatively as potentially effectful.

Random consumption, allocations, instruction counts, and runtime-budget checks are not program effects for this rule and do not prevent removal of an unused computation. Optimized and unoptimized programs need not consume the same random draws or reach the same runtime limits. Seeded repeatability applies to execution of the same validated Program as defined in determinism semantics, not to different compiler optimizations. An implementation is not required to remove every unused computation that is eligible for removal. An expression statement does not implicitly emit or publish a message.

Seeded Random Scope

random with evaluates a body with a seed-dependent random context. The seed may be used directly when the compiler can establish that it is a unitless exact integer. This includes integer literals and immutable bindings inferred from such values. A statically unknown value, especially a message parameter, must state the intended conversion explicitly with as :Number.

random with 123 {
emit Roll(value: random from 1 to 6)
}
let fixedSeed be 123
let fixedValue be random with fixedSeed (random from 1 to 100)
on Start(seed) {
let suppliedValue be random with (seed as :Number) (random from 1 to 100)
emit Value(value: suppliedValue)
}

At runtime, a converted value is a seed only when it becomes a unitless exact integer in the signed 64-bit range. If conversion instead yields nothing, a fractional value, an out-of-range value, or a quantity-bearing value, no diagnostic is reported. The body still runs once, but it consumes an isolated copy of the current random state. On exit the original state is restored exactly, regardless of the number of draws made by the body.

Random scopes may be nested. Leaving a nested scope resumes its parent at the exact point where it entered the scope. Integer draws include both ordered bounds. Binary64 draws use a [0, 1) unit source, although binary64 rounding can make the scaled result equal the upper bound.

The portable seeded PRNG algorithm, bounded sampling rules, and known-answer vectors are specified in Determinism.

Expressions and Operators

Operands are evaluated from left to right except where short-circuiting omits an operand. Binary operators at one precedence level associate left-to-right, except implication and power. and, or, and implication short-circuit. xor always evaluates both operands.

From high to low precedence:

  1. Postfix: member access x.y, lookup/selector x[...]
  2. Power: ^, ², ³
  3. Unary: -, not, !, ~, ¬, empty, parse, prefix intrinsics
  4. Multiplicative: *, ×, ·, ⋅, /, ÷, div, mod, rem
  5. Additive: +, -, −
  6. Relational: <, >, <=, >=, ≤, ≥
  7. Type and predicate operations: is, is not, as
  8. Membership and text boundaries: in, ∈, ∉, in values of, starts with, ends with
  9. Equality: =, <>, ≠
  10. Boolean conjunction: and, ∧
  11. Boolean exclusive-or: xor, ⊕
  12. Boolean disjunction: or, ∨
  13. Collection zip: zip
  14. Collection intersection: &
  15. Collection union: |
  16. Presence fallback: default
  17. Implication: ->, →, ⇒
  18. Guarded choice: value when condition, otherwise fallback

Power accepts at most one ^ or superscript suffix at each unparenthesized power node; its right operand is unary, which makes a ^ b ^ c right-associative. Parentheses override precedence. The fixed Unicode aliases are token aliases and have exactly the semantics and precedence of their ASCII counterparts.

The complete Unicode token aliases are:

ASCII or word form Unicode alias Meaning
* ×, ·, ⋅ multiplication
/ ÷ floating division
- − subtraction or unary negation
<=, >=, <> ≤, ≥, ≠ comparison
and, or, xor, not ∧, ∨, ⊕, ¬ boolean operators
x in y, not (x in y) x ∈ y, x ∉ y membership and negated membership
-> →, ⇒ implication
=> ↦ selector projection
sqrt, cbrt √, ∛ unary root intrinsics
x ^ 2, x ^ 3 x², x³ square and cube postfix forms
infinity, pi, e, tau ∞, π/∏, ℇ, τ numeric constants
degree ° degree quantity name and literal suffix

Boolean Logic

Predicate declarations must statically evaluate to :Boolean or nothing. Use as :Boolean when coercion is intentional. Predicate-call results, including extension predicates used through is, are normalized to boolean or nothing.

Conditions and logical operators use a three-state truth view:

Value Truth view
:Boolean its boolean value
number, quantity, or percentage false for numeric zero, true otherwise
text true for ASCII-case-insensitive true or exact 1; false otherwise
tag false, including #true and #false
vector or point false when every component is zero; true otherwise
nothing, list, map, record, range, dice, series, message, or handler indeterminate

Logical operators return nothing for an indeterminate result. Their complete truth tables are:

a b a and b a or b a xor b a -> b
false false false false false true
false true false true true true
true false false true true false
true true true true false true
false unknown false unknown unknown true
true unknown unknown true unknown unknown
unknown false false unknown unknown unknown
unknown true unknown true unknown true
unknown unknown unknown unknown unknown unknown

not swaps true and false and returns nothing for unknown.

if, selector predicates, and guarded-choice conditions pass only for a true truth view. False and indeterminate values do not pass.

Implication A -> B is right-associative. It is true when A is false or B is true; it is false when A is true and B is false; unresolved cases produce nothing.

Guarded Choice

Guarded choices select the first value whose condition is true. when and otherwise are keywords; then is a contextual keyword in condition-first choices.

let status be
#dead when hp <= 0,
#wounded when hp < maxHp,
otherwise #healthy

The condition-first spelling is an equivalent expression:

let status be
when hp <= 0 then #dead,
when hp < maxHp then #wounded
otherwise #healthy

Both spellings have the same precedence, evaluation order and result. Choose one spelling for an entire choice; mixing value-first and condition-first branches in the same chain is invalid. Separate branches with commas, optionally followed by or, as in the value-first form. A comma before otherwise is optional. Newlines may separate the keywords and expressions. then separates each condition from its result; otherwise is required. Branch results are expressions, not statement blocks. Use parentheses to nest either spelling inside a condition, branch result, fallback or higher-precedence expression.

Branch conditions are evaluated in source order. The first condition whose truth view is true selects its associated value; false and indeterminate conditions are skipped. If none is true, only the otherwise expression is evaluated.

Predicate Sugar

Parameterless script predicates can be called as is name or is not name, equivalent to name() and not name(). This is a primary expression, available wherever expressions are accepted, not only in if conditions. It consumes only the predicate name; following logical operators belong to the surrounding expression. Newlines may separate is, the optional negation operator, and the name. The target must resolve to a predicate with zero parameters; functions, local values, unary-only predicates and extension references are not eligible. Other overloads of the same predicate name do not affect this zero-parameter lookup. The optional negation accepts the same spellings as ordinary not.

predicate drawable() be true
let available be is drawable
if is not drawable { emit Blocked() }

Unary predicates can be used with is:

predicate high(_ value) be value > 50
let ok be value is high
let notOk be value is not high

Extension predicates can use the same shape:

let visible be unit is :sensor.visible

Domain Phrases

The parser accepts several readable comparison forms:

value is at least 10
value is at most 10
value is between 10 and 20
value is not between 10 and 20
value is less than 10
value is more than 10
value is 10 or less
value is 10 or more
value is empty
value has value

Since: Unreleased — inclusive range comparisons

value is between lower and upper is an inclusive range check with the same comparison and three-valued Boolean semantics as value >= lower and value <= upper, except that value is evaluated exactly once. Evaluation is left to right: value, lower bound, then (unless the lower comparison is false) upper bound. A nothing lower comparison does not short-circuit. is not between negates the complete result using ordinary not semantics. Bounds are not reordered or clamped. Each bound accepts an additive expression; parenthesize more complex expressions. The separating and belongs to the range check; a following and combines the completed check with the next condition. between remains a contextual word.

Prefix Intrinsics and Selectors

items[:count]
chance 25%
map[:keys]
map[:values]
map[:entries]
abs value
ln value
exp value
sin value
cos value
tan value
asin value
acos value
atan value
sqrt value
√ value
cbrt value
∛ value
floor value
ceil(value)
truncate value
round half even value
round half up value
round half down value
rad degrees
deg radians
wrap degree value
clamp value between 0 and 100
min of a and b and c
max of a and b and c

x[:count] counts Unicode scalar values in text and raw tag text, so #active[:count] is 6.

x[:keys], x[:values], and x[:entries] are defined only for maps and map-backed custom type values. x[:keys] returns a list of :Text keys, x[:values] returns the corresponding values, and x[:entries] returns maps with key and value fields, where key is always :Text. These projections preserve the complete key text, including empty strings and text that is not a valid tag, regardless of whether a key was originally supplied as text or a tag. All three projections use stable Unicode-scalar key order. If the operand is nothing, the result is nothing; if the operand is any other non-map value, the result is also nothing.

Square and cube roots lower to powers with exponents 0.5 and 1/3. exp is the natural exponential counterpart of ln.

Unary intrinsics accept either operation expression or operation(expression). floor, ceil, truncate, and the three round forms return integers using Number semantics. rad converts degrees to unitless radians; deg converts unitless radians to a degree quantity; wrap degree reduces a unitless or degree-valued angle to [0°, 360°). chance accepts a unitless percentage or numeric probability: integer inputs and numeric magnitudes outside [-1, 1] are interpreted as percentages, values at or below zero are false, values at or above one are true, and invalid, unit-bearing, or non-finite inputs produce nothing.

sin, cos, and tan accept unitless radians or degree quantities and return a unitless number. asin and acos accept unitless numbers in [-1, 1]; atan accepts any unitless number. Invalid domains, incompatible units, unsupported values, and nothing produce nothing. Transcendental portability follows Number semantics.

Multi-argument navigation intrinsics

The following forms require parentheses and comma-separated arguments. Incorrect arity is a compile error.

Form Accepted arities Result
atan2(y, x) 2 unitless angle in radians; (0, 0) is 0
hypot(x, y) / hypot(x, y, z) 2 or 3 Euclidean length retaining the common component unit
distance(a, b) 2 absolute scalar difference or distance between compatible vectors/points
distance(x1, y1, x2, y2) 4 two-dimensional distance
distance(x1, y1, z1, x2, y2, z2) 6 three-dimensional distance
distance squared(...) 2, 4, or 6 corresponding squared distance
length squared(value) 1 squared scalar magnitude or squared vector/point length
length squared(x, y) / length squared(x, y, z) 2 or 3 squared component length
normalize(value) 1 unitless vector from a vector or point
normalize(x, y) / normalize(x, y, z) 2 or 3 unitless normalized vector
dot(a, b) 2 dot product of compatible vectors/points
dot(x1, y1, x2, y2) 4 two-dimensional dot product
dot(x1, y1, z1, x2, y2, z2) 6 three-dimensional dot product
cross(a, b) 2 three-dimensional vector cross product
cross(x1, y1, x2, y2) 4 scalar two-dimensional cross product
cross(x1, y1, z1, x2, y2, z2) 6 three-dimensional vector cross product
angle between(a, b) 2 unitless angle in radians between compatible vectors/points
angle between(x1, y1, x2, y2) 4 two-dimensional angle
angle between(x1, y1, z1, x2, y2, z2) 6 three-dimensional angle

Numeric component overloads require numeric operands with one common unit. Object-pair overloads require matching spatial kinds and units. Dot, cross, squared-distance, and squared-length results are unitless because the language has no compound-unit representation. Normalizing a zero vector and computing an angle with a zero-length operand produce nothing. Unsupported operand shapes, incompatible units, and absent operands likewise produce nothing.

Declared constants and mathematical constants

A declared constant has program-wide scope and is replaced by its literal value during compilation:

constant $maximumHealth be 100
constant $readyLabel be 'ready'
constant $defaultRange be 25m
on Start {
emit Ready(label: $readyLabel, health: $maximumHealth)
}

The initializer must be one scalar literal: nothing, boolean, number, percentage, quantity, text, or tag. Unary minus is part of an otherwise numeric literal initializer. Lists, maps, records, calls, operators, and references to other constants are not constant initializers. Constant declarations from all sources in one program share one namespace; duplicate and unresolved names are static errors. Constants generate no runtime bindings, registers, or bytecode of their own.

Named scalar constants participate in constant folding and static operand-kind recognition like their literal values. In particular, multiplying two declared integer constants such as 48 and 128 folds to 6144; a declared scalar factor with a dynamic operand does not require Cartesian list dispatch. Debug source attribution for substituted or folded values identifies the expression’s use site, not the constant declaration, including after a binary roundtrip.

In particular, unary minus cannot prefix a text, tag, boolean, or nothing constant initializer; this is a syntax error rather than a runtime conversion.

Mathematical constants are built-in keywords, not declared $ names or tags:

infinity
0 - infinity
pi // aliases: π, ∏
e // alias: ℇ
tau // alias: τ

Tags use #name. #true, #false, #pi, and #infinity are ordinary tag values without boolean or numeric meaning. :name is reserved for selectors, types, and namespace-like extension references.

Types and Values

Built-in Types

The source language recognizes these built-in types:

  • :Nothing (value is nothing remains the dedicated nothing check)
  • :Boolean
  • :Number
  • :Percentage
  • :Quantity(none), :Quantity(m), :Quantity(meter), :Quantity(s), :Quantity(second), :Quantity(degree)
  • :Vector
  • :Point
  • :Series
  • :Range
  • :Message
  • :Handler
  • :Tag
  • :Text
  • :List
  • :Map
  • :Dice

Casts

An explicit as TypeReference cast reshapes a value when the target type has a defined representation. A cast that cannot produce that representation returns nothing, except that collection casts deliberately produce the empty target for unsupported scalar inputs as listed below. Built-in constructor syntax with one unlabeled argument, such as :Text(value) or :Dice(values), has the same semantics as the corresponding cast. :Vector and :Point additionally have component constructors.

Target Cast behavior
:Nothing always nothing
:Boolean true exactly when the source truth view is true; false otherwise
:Number uses the numeric view, parses numeric text according to Number semantics, or reads series term zero; failure is nothing
:Percentage interprets a finite unitless numeric view as a ratio, or parses numeric text, according to Number semantics; failure is nothing
:Quantity(unit) applies the unit to numeric or spatial input that is unitless or already has that unit; a conflicting unit is invalid; none removes any supported unit
:Text preserves existing Text and formats data values according to Text semantics; numeric spellings and roundtrip guarantees are defined in Number semantics
:Tag follows the strict tag rules below
:Vector, :Point follows the structural conversion rules below
:List preserves lists; expands text/tags into Unicode scalars, spatial values into three components, dice into rolls, and ranges into terms; unsupported values become []
:Map preserves maps; exposes record/external fields and spatial x, y, z; unsupported values become [:]
:Dice preserves dice; converts a list of positive Int32 integer rolls; a non-list becomes empty dice and an invalid list becomes nothing
:Range, :Series, :Message, :Handler preserves the same runtime kind and returns nothing for another kind
custom record preserves the same record type or invokes that record constructor from a map; other values become nothing

Tag casts are strict. A runtime tag name is valid only when it matches the same shape as # tag names: the first character must be a lowercase ASCII letter, followed by ASCII letters or digits. Empty tags, numeric text, unit text, punctuation, whitespace, brackets, and colons inside the value are invalid tag names. nothing as :Tag is nothing; numeric values and quantities cast to :Tag as nothing; formatted vector, point, list, map, dice, and range text also cast to :Tag as nothing because those strings are not valid tag names. Existing valid tags remain unchanged. Text casts to :Tag only when the text is a valid tag name; the text values 'true', 'True', 'false', and 'False' are normalized to #true and #false. Boolean casts to :Tag also write #true or #false.

Text casts are formatting casts and keep using the value’s text representation; they do not require the formatted text to be a valid tag. Numeric text casts use explicit as :Number and as :Percentage, with the grammar, suffix interpretation, and rounding rules owned by Number semantics. Invalid numeric text casts to nothing.

Vector and point casts are structural conversions. A vector can be cast to a point by copying x, y, z, and the optional unit; a point can be cast to a vector the same way. This is a type reshape, not vector/point arithmetic.

Spatial casts from a number, percentage, boolean, tag with a numeric view, dice, list, range, map, or record read up to three components and fill missing components with zero. List items and named x, y, z fields must be finite numeric values. Direct numeric inputs become the x component. An invalid component makes the whole result nothing.

Parse expression

parse is a reserved prefix keyword at unary precedence. Its operand may be parenthesized when a complete lower-precedence expression is required:

let value be parse input
let restored be parse (value as :Text)

For example, parse input as :Number means (parse input) as :Number. Literal recognition, quoted Text decoding, and the exact original-Text fallback are owned by Text semantics.

Explicit data constructors

Since: 0.2.0

Existing literal forms remain valid. Built-in type constructors are additional source expressions and their data-only argument forms are recognized by parse.

let measured be :Number(100.3, "s")
let percentage be :Percentage(0.1)
let absent be :Nothing()
let sequence be :Range(from: 1, to: 5, step: 2)
let seriesValue be :Series(fibonacci, offset: 3)
let signature be :Handler(Done(value, _, _))
let message be :Message(Done(value: 10) with #ready)
let snapshot be :Record("Hit", [amount: 100])

:Boolean(value), :Text(value), :Tag(value), :List(value), :Map(value), :Dice(value) and the one-argument numeric/spatial forms retain their cast behavior. The single argument may also use the label value (except spatial forms, which retain their component labels). :Dice[...] remains a deterministic result literal. :Number(value, unit) accepts the unit names/suffixes none/empty Text, second/s, meter/m, and degree/°; a conflicting existing unit yields nothing. Percentage construction uses the numeric ratio. :Range(from, to, step) defaults its step to 1. :Series(kind, offset) accepts fibonacci/factorial (bare or quoted), defaulting offset to zero; it must be a nonnegative unitless integer. Range and Series remain lazy. :Message(...) explicitly distinguishes a message instance, including an argumentless one, from :Handler(Name(labels)). Handler labels describe data, not local variable bindings; repeated _ labels are allowed.

:Record(type, fields) requires a PascalCase Text name and a Map. It creates immutable typed data directly. It does not require a Record definition or invoke its constructor. :Hit(...) retains the ordinary declared constructor behavior. Arguments in a source expression evaluate in source order. Invalid constructor shapes are compile diagnostics; invalid converted values produce nothing. Runtime recognition and execution from Text are owned by Text semantics.

Text splitting

Since: 0.2.0

text[:split on separator] evaluates a nonempty Text separator and splits by exact scalar sequence. Empty pieces become nothing; nonempty pieces remain Text without trimming or literal recognition. An empty/non-Text separator or a non-Text receiver yields nothing.

text[:split on whitespace] merges runs of the fixed Unicode White_Space set: U+0009–000D, 0020, 0085, 00A0, 1680, 2000–200A, 2028, 2029, 202F, 205F, 3000. Leading/trailing runs are discarded; empty or whitespace-only Text yields []. U+200B and U+FEFF are not separators. whitespace is contextual in this selector.

let parts be "A,,B,"[:split on ","] // ["A", nothing, "B", nothing]
let words be " Hello world "[:split on whitespace] // ["Hello", "world"]

This operation performs no CSV quoting, escaping or tabular interpretation.

Type checks

value is TypeReference and value is not TypeReference test without converting. is :Number and is numeric use the same numeric-view test. is integer and is fractional are numeric checks rather than type names. is :Map is true for plain maps and map-backed custom values. Quantity checks require the exact unit and accept numeric, vector, or point values. Other built-in checks require the corresponding runtime kind; a custom check requires the exact declared or external type name. nothing is written without : in casts and checks.

Custom record types are type references using the same :name syntax:

record :Unit as {
hp: :Number,
name: :Text
}

Numbers

The normative cross-language rules for Int64 overflow, binary64 special values, rounding, negative division/modulo, ULP comparison, transcendental functions, and canonical conformance JSON are defined in Number semantics. Implementations must not inherit these semantics from host-language overflow or formatting defaults.

:Number is the source-level numeric type. Runtime values are represented as integer when a finite result is exactly integral and fits signed 64-bit; otherwise they are represented as IEEE 754 double precision. Equality uses a strict numeric comparison for integer-only values and a two-ULP tolerant comparison wherever double precision values participate.

Numeric operations distinguish absence from invalid mathematics. If any operand of a numeric operation is nothing, the result is nothing. When all operands are present but the operation cannot be computed as valid mathematics, the result is numeric NaN.

Examples of invalid mathematics include non-numeric operands in numeric operators, incompatible quantity units, invalid vector or point arithmetic, and mod/rem by zero. Scalar / follows IEEE floating-point behavior, so division by zero can produce Infinity, -Infinity, or NaN.

Vector and point arithmetic is intentionally not symmetric. Vectors support vector + vector, vector - vector, vector * scalar, scalar * vector, and vector / scalar when units are compatible and the scalar is computable. Points support affine operations only: point + vector and point - vector produce points, while point - point produces a vector. Point scaling is not valid mathematics: point * scalar, scalar * point, point / scalar, scalar / point, and point operands in div, mod, or rem produce numeric NaN unless one operand is nothing, in which case the result is nothing. Unary negation follows the same model: -vector negates each component, while -point is invalid mathematics and produces numeric NaN.

Power with quantity units is intentionally narrow because compound units such as m² are not represented. A quantity may be raised only to a unitless numeric exponent of 0 or 1; booleans participate in the normal lenient numeric coercion, so 10m ^ true is 10m and 10m ^ false is unitless 1. An exponent carrying a quantity unit is invalid for every base, including exponents 0m and 1m and units matching the base. Other quantity powers produce numeric NaN unless an operand is nothing. These rules apply equally to literal operands, program constants, and values supplied at runtime; constant folding must preserve the same value and unit semantics.

Numeric checks are keyword constructs, not : type references:

value is numeric
value is integer
value is fractional

:integer and :float are not source-level type references. Use is integer or is fractional for checks, and as :Number for explicit numeric casts.

Numeric checks and implicit numeric views use the following rules. A runtime value has an AsNumeric value exactly when is numeric is true. The explicit as :Number cast uses the same numeric view, except that it may additionally parse text as described below.

Runtime value is numeric is integer is fractional Numeric view
nothing false false false none
:Boolean true true false false = 0, true = 1
integer number true true false integer value, including quantity unit
float number true true when finite and exactly integral; otherwise false true when finite and non-integral float value, including quantity unit
percentage true true when the stored ratio is finite and exactly integral; otherwise false true when finite and non-integral stored ratio
dice true true false sum of rolls
text, tag, list, map, range, vector, point, message, handler, series, custom values false false false none

Text values are not numeric for implicit mathematics or numeric checks: '100' is numeric is false. Addition is the deliberate exception: if either present operand is text, both '10' + 20 and 10 + '20' concatenate their text representations. For a formatter that writes these integers as 10 and 20, both produce '1020'; the permitted numeric spellings are owned by Number semantics. Other arithmetic operators do not parse text implicitly. An explicit as :Number cast parses text according to that document’s numeric text grammar; invalid text casts to nothing. Series are also not numeric for implicit mathematics or numeric checks, but an explicit as :Number cast reads the first term and casts that term to a number.

Dice have a numeric view for numeric checks, explicit numeric casts, equality, and numeric comparison: dice is numeric and dice is integer are true, and dice as :Number is the sum of the rolls. Dice-specific collection operations keep priority, so dice + integer adds a roll and dice - integer removes a roll instead of using the dice sum.

The runtime canonicalizes every finite binary64 result that is exactly integral and fits signed 64-bit to the integer representation. Consequently a value such as 12.0 normally becomes an integer value immediately. The float row above also defines checks at external/program boundaries where a noncanonical but representable binary64 value may still be presented.

Equality

Equality operators are = and <>/≠. If either operand is nothing, or an invalid numeric result that is observed as nothing, the equality result is nothing; nothing = nothing therefore produces nothing, not true.

For present operands, exact equality first tries the same numeric view used by numeric operators. Numeric values, percentages, booleans (false = 0, true = 1), and dice sums compare by numeric value when both operands have a numeric view. Quantity units must match exactly; unitless and unit-bearing values are not equal, including integer fast paths such as 10 = 10m, which is false. Internal NaN numeric values are never equal. When either numeric side is represented as double precision, finite values compare equal when their IEEE 754 values are within two ULPs. This handles binary floating-point artifacts such as 0.1 + 0.2 = 0.3 without adding a separate approximate-equality operator. Infinities compare equal only when they have the same sign. Text is not numeric for equality, so '10.3' = 10.3 is false while ('10.3' as :Number) = 10.3 is true.

If the numeric view does not apply, exact equality requires the same value kind: tags and text compare exact Unicode-scalar sequences within their own kind, vectors and points compare x, y, z, and unit, ranges compare from, to, and step, series compare signature and offset, messages compare signature id, arguments, and tag sequence, handlers compare signature id, lists compare ordered items, dice compare ordered rolls, and maps/records/custom map-like values compare their key/value pairs. Records also require the same declared record type. Nested collection, record, and message values use strict structural equality without top-level numeric coercion; the complete contract is in Determinism.

abs preserves the operand’s numeric family for percentages and quantities: absolute percentages remain :Percentage, and absolute quantities keep their unit. Finite numeric results that are exactly integral are represented as integer values.

Quantities

Quantities attach a numeric unit to an integer or float:

let distance be 10m
let duration be 2s
let angle be 90°
let a be 100 as :Quantity(m)
let b be 90 as :Quantity(°)
let unitlessDistance be distance as :Quantity(none)

:Percentage is not a quantity unit; it is a separate value kind. The names meter, second, degree, and seconds are not quantity type aliases. Use :Quantity(m), :Quantity(s), or :Quantity(°)/:Quantity(degree) for casts and checks. :Quantity(none) removes a supported numeric or spatial unit and produces the corresponding unitless value; as a check it matches only numeric or spatial values that are already unitless. none is a unit name only in this type form and is not the absence value nothing. Use #meter or #degree only when an ordinary tag value is meant.

Percentages

A percentage literal stores a ratio:

25% // ratio 0.25
100% // ratio 1.0

Explicit Percentage casts and their Number/Text roundtrip guarantees are owned by Number semantics.

Percentages can be combined with percentages. For value-plus-percentage forms, the non-percentage value must be on the left:

10m + 50% // 15m
10m - 50% // 5m
50% + 10m // invalid numeric result

In multiplication with a non-percentage scalar or quantity, percentages behave as their stored ratio and the result follows the other operand’s value family: 10% * 10 and 10 * 10% both produce numeric 1, while 10% * 10m and 10m * 10% both produce 1m.

Text and Tags

Text is quoted. Tags are lowercase symbolic values:

let name be 'Scout'
let state be #active

Tags are not empty. Tags are text-like symbolic values: they use the same raw text as quoted text values, but are written without quotes and must follow the tag naming rules. Text and tags can be used interchangeably for map keys, member lookup selectors, text containment, and text boundary operations.

Text preserves its exact Unicode scalar sequence without normalization. Text length, positional indexing, iteration, text-to-list conversion, and terminal operations count or return Unicode scalar values rather than UTF-8 bytes, UTF-16 code units, or grapheme clusters. Indexes remain 1-based. Equality is exact and ordering is lexicographic by Unicode scalar value; see Text semantics.

Vector and Point

Vectors and points have x, y, and z numeric components plus an optional unit.

let v be :Vector(x: 1, y: 2, z: 0)
let p be :Point(x: 10m, y: 20m, z: 0m)
let x be p.x

Vectors describe deltas and may be scaled by scalar numeric values. Points describe positions and may only be translated by vectors or subtracted from points; points are not scalar-multiplied or scalar-divided.

as :Text writes :Vector(x: ..., y: ..., z: ...) or :Point(x: ..., y: ..., z: ...), including each component’s unit when present. parse recognizes the numeric data forms, including positional components such as :Vector(10, 20, 0). Exact text reconstruction and recognition rules are owned by Text semantics.

Range

Ranges are finite numeric ranges. Bounds and step values can be integer or floating-point numeric values:

let r be from 1 to 10
let stepped be from 10 to 0 step -2
let fractional be from 1.5 to 3.5 step 0.5

Since: 0.2.0 — integral Range normalization

All three exactly integral, Int64-representable components normalize to an exact integer range, including inputs supplied through a Binary64 API. Otherwise finite components use the Binary64 range model. This guarantees that portable numeric text reconstructs the same sequence without exposing storage-kind tags.

Both bounds are inclusive. A zero step or a step pointing away from the end produces an empty range. Integer boundary handling is overflow-safe, and floating iterators emit exactly their precomputed finite range length. Element calculation and membership are defined in Determinism.

Series

Series are deterministic numeric series. They support zero-based :term lookup and forward :take first / :drop first selectors. Because series are not finite, :take last and :drop last evaluate to nothing for series.

let fib be series fibonacci
let fifth be fib[:term 5]
let later be fib[:drop first 3]
let sample be fib[:take first 5]

:term is a series-only selector. Lists, dice, ranges, and all other values return nothing for [:term n]. Direct :take first, :drop first, :take last, and :drop last selectors are defined for finite lists, dice, and ranges; range slices stay ranges instead of being materialized as lists.

Built-in series:

  • series fibonacci
  • series factorial

Message and Handler Values

Messages can be values when created with labeled arguments:

let msg be Damage(target: unit, amount: 10)

Handler values are created with message-style parameter declarations:

let callback be Done(value)

Presence and Emptiness

nothing is the absence value. NaN is not a DSL value or special tag. If an internal numeric operation or conversion produces NaN, the script-visible value behaves as nothing. The spelling #nan is an ordinary tag with no numeric meaning.

empty is true for:

  • nothing
  • empty text
  • empty lists, maps, dice, and ranges

It is false for ordinary values, including 0, false, Infinity, and -Infinity.

has value is the exact complement of empty.

default uses the same presence semantics as has value:

let displayName be unit.name default 'Unknown'

Collections and Selectors

Lists

Lists preserve order and can contain mixed values:

let values be [10, 'hello', [1, 2, 3]]

Generated lists use a selector-like form:

let squares be :List[:select x from 1 to 5 => x * x]
let evens be :List[:select x from 1 to 10 where x mod 2 = 0 => x]
let names be :List[:select unit in units => unit.name]

Maps

Maps are keyed by identifier names in literals. Key-only entries default to true.

let emptyMap be [:]
let unit be [name: 'Ada', hp: 10]
let flags be [enemy:, visible:, armed:]

Lookups use member syntax or bracket syntax. Literal text and tag keys are member lookups; dynamic selectors are resolved at runtime. The : prefix is reserved for structured selectors, type names, and extension namespaces, and is never an alternate spelling for a map key. Thus x[:select] invokes the select operation and x[#select] looks up the key named select.

unit.name
unit['name']
unit[#name]

Dynamic map lookup with a non-text and non-tag key returns nothing.

Lookup and Membership

x[y] performs positional index lookup for integer selectors and member lookup for text/tag selectors unless y starts with a structured selector. Positional indexes are 1-based.

let firstItem be items[1]
let hp be unit[#hp]
let hasEnemyFlag be #enemy in flags
let containsUnit be unit in values of units

x in y checks membership in y. Text and tags use ordinal substring matching over their raw text, without the leading # for tags. Lists and dice check whether one item equals x. Ranges check whether numeric x is one of the range terms. Maps and map-like values check whether text/tag x is a key; non-text keys are false. Vector and point values check their numeric components.

x in values of y checks values of map-like y. It is defined for maps, records/custom values, external map-like values, vectors, and points. For maps and records it compares all stored fields. For vectors and points it compares the x, y, and z components. nothing in values of y follows the same comparison rule; x in values of nothing is nothing. Lists, dice, ranges, text, tags, and scalar values are not value-membership containers and return false.

x starts with y and x ends with y are boundary checks. Text and tags compare raw text with ordinal rules. Lists, dice, and ranges compare sequence prefixes or suffixes; the right operand must also be a list, dice, or range. Empty right-hand sequences match. If the left sequence is shorter than the right sequence, the result is false. nothing starts with y and nothing ends with y produce nothing; other unsupported shapes return false.

Iterator-Backed Selectors

Selectors operate on lists, ranges, dice, maps, custom map-backed values, text, tags, and other iterable values. Selector chains lower to explicit iterator loops when no direct fast path exists.

units[:filter unit where unit.hp > 0]
units[:select unit => unit.name]
units[:any unit where unit.hp <= 0]
units[:all unit where unit.hp > 0]
units[:count unit where unit.hp > 0]
units[:count]

=> is the projection arrow. ↦ is an alias.

Iterator predicates are evaluated once per item in deterministic source order. Only a true truth view passes a filter or satisfies a predicate; false and indeterminate results do not. :any short-circuits on the first true result and is false for an empty source. :all short-circuits on the first non-true result and is true for an empty source. :select materializes projected values in source order. Iterator-backed operations are bounded by the host’s iterator and generated-collection limits.

Aggregates

units[:sum unit => unit.hp]
units[:average unit => unit.hp]
units[:sum]
units[:average]
units[:min unit => unit.hp]
units[:max unit => unit.hp]
units[:highest unit => unit.hp]
units[:lowest unit => unit.hp]

Bare :count, :sum, and :average are identity forms. items[:count] counts all finite items, items[:sum] is equivalent to items[:sum item => item], and items[:average] is equivalent to items[:average item => item].

sum and average accept exactly the values recognized by is numeric after projection: Number/Quantity, Percentage, Boolean and Dice. Boolean contributes integer 0/1; Dice contributes the exact integer sum of its rolls (zero for empty Dice), including singleton input. Number/Quantity and Percentage retain their existing numeric, unit and percentage arithmetic rules. Text, tags and other nonnumeric values are not converted. A nonnumeric value (including Nothing) or incompatible units makes the result Nothing, even for a singleton. Remaining projections still execute in order; an invalid intermediate result cannot recover. Empty valid input gives integer zero for sum and Nothing for average; invalid input gives Nothing for both. String concatenation and other overloaded additions remain available via fold. The first Number/Quantity or Percentage retains its storage kind and unit; no unitless-zero identity exception is introduced.

min/max and highest/lowest return the winning source item, selected by the projection.

Fold and Reduce Selectors

Since: 0.2.0

source[:fold acc be seed, value => expression] performs a left fold in the source’s ordinary iterator order. Source and seed are evaluated exactly once, including the seed for an empty or invalid source. The seed is the initial accumulator; the step runs once per element. Empty sources return the seed.

source[:reduce acc, value => expression] uses the first element as the initial accumulator and evaluates the step for each remaining element. Empty sources return nothing; a single element is returned unchanged without evaluating the step. Both operations accept the existing finite iterator sources used by projection selectors; an invalid iterator source produces nothing.

The accumulator and element names are distinct, cannot shadow visible bindings, and are visible only in the step. The step is an ordinary expression, including when ... otherwise ...; its result replaces the internal accumulator, even if nothing. This does not add mutable script bindings or implicit early termination. The operation does not materialize its source or construct an output collection. Only actually executed bytecode instructions consume opcode budget, using the same rules as other selectors. No synthetic cost equalization is performed.

let scores be [10, 20, 30]
let total be scores[:fold acc be 100, value => acc + value] // 160
let sum be scores[:reduce acc, value => acc + value] // 60
let seedOnly be [][:fold acc be 100, value => acc + value] // 100
let noResult be [][:reduce acc, value => acc + value] // nothing
let readings be [10, nothing, 20]
let validTotal be readings[:fold acc be 0, value =>
acc + value when value has value otherwise acc] // 30

First, Last, and Single

units[:first]
units[:last]
units[:single unit where unit.id = targetId]
units[:first unit where unit.hp > 0]

Without a filter, :first, :last, and :single are direct terminals for lists, dice, ranges, maps, custom map-backed values, text, tags, and iterator chains. Empty, unsupported, or invalid sources yield nothing; :single also yields nothing when more than one element is present.

Sorting, Distinct, and Grouping

units[:sort ascending]
units[:sort descending]
units[:order by unit => unit.hp ascending]
units[:distinct]
units[:distinct by unit => unit.kind]
units[:group by unit => unit.team]

:sort ascending and :sort descending are defined for lists, dice, ranges, and iterator chains. Lists and iterator chains materialize sorted lists. Dice also materialize a list so the requested order is preserved instead of being normalized back into dice order. Ranges stay ranges when the requested direction can be represented by swapping the range bounds and negating the step. Maps are already key-canonical and are not sort targets; sorting a map yields nothing.

:order by is defined for lists and iterator chains only. It orders the original items by the projected key and materializes a list. Dice, ranges, maps, scalars, and nothing yield nothing for :order by because projecting a sort key over those direct values is not a meaningful collection operation.

:distinct is defined for lists, dice, and iterator chains. Lists keep their first occurrence order, dice keep their dice result type, and iterator chains materialize a list. :distinct by is defined only for lists and iterator chains because the projection operates on structured items. Dice and other non-list values yield nothing for :distinct by.

:group by is defined for lists, maps, and iterator chains. Lists and iterator chains group their source items by the projected key and return a map from the projected key text to a list of matching source items. Maps group their visible values in stable key order. Direct dice, ranges, text, tags, scalars, and nothing yield nothing; dice and ranges are scalar-like direct values for grouping and must be iterated explicitly when per-element grouping is wanted.

Map Selector

The :map selector builds a map. Without a value projection, the selected item is used as the value.

let byName be units[:map unit by unit.name]
let hpByName be units[:map unit by unit.name => unit.hp]

Contains and Boundaries

items[:contains target]
items[:contains any candidates]
items[:contains all required]
text starts with 'A'
text ends with 'Z'
#active starts with 'act'
'active' ends with #ive

:contains value applies ordinary membership to the selected source. :contains any candidates and :contains all candidates require the right-hand source to contain respectively at least one or every item from the candidate list, dice, text/tag scalar sequence, range, or iterator. Empty candidates make any false and all true. If the selected source is nothing, the result is nothing; an unsupported candidate shape follows the same empty-candidate rule.

Slice, Shuffle, Draw, and Choose

items[:take first 3]
items[:take last 3]
items[:take highest 2]
items[:drop lowest 1]
items[:shuffle]
items[:draw 2]
items[:choose 1 at random]
items[:choose 3 unit where unit.hp > 0 weighted by unit => unit.priority]

:draw 1 is equivalent to :first; :draw n with n > 1 is equivalent to :take first n. Deterministic :choose follows the same rule: :choose 1 is equivalent to :first, and :choose n with n > 1 is equivalent to :take first n. Random choice uses random selection without replacement: :choose 1 at random returns one item or nothing, while :choose n at random returns up to n randomly selected items. Weighted choice evaluates the weighted by expression per candidate and chooses without replacement from positive finite weights. :choose 1 weighted by ... returns one item or nothing; :choose n weighted by ... returns a list.

The count in :choose must fit the V1 signed 16-bit immediate: 1 through 32767, inclusive. A larger count is rejected during compilation with compile.numericLimitExceeded; it must not wrap or truncate. This bound applies equally to deterministic, random, filtered, and weighted choice.

:reverse is defined for lists, dice, ranges, and iterator chains. Lists reverse into lists. Dice reverse into lists so the requested order is preserved instead of being normalized back into dice order. Ranges reverse into ranges by swapping the effective bounds and negating the step. Iterator chains materialize reversed lists. Maps, scalars, and nothing yield nothing.

:shuffle is defined for lists, dice, ranges, and iterator chains and always materializes a list. This allows game-oriented cases such as shuffling a card range with from 1 to 32[:shuffle]. Maps, scalars, and nothing yield nothing.

Dice Patterns and Object Matching

Dice and dice-like collections support pattern selectors:

roll[:has pair]
roll[:has three of a kind]
roll[:has pair of 6]
cards[:has four of 'King']
roll[:has full house]
roll[:has straight]
roll[:take pair]

Pattern selectors are defined for dice and lists. Other values are not pattern sequences: :has ... yields false, while :take ... yields nothing.

Count patterns use normal value equality. pair means at least two equal values. three/four/five/six/seven of a kind mean at least that many equal values. ... of expression checks the concrete evaluated face/value instead.

full house is exact: the sequence must contain exactly two distinct values with counts 3 and 2. For :take full house, the first triple in source order and then the first matching pair in source order are returned.

straight is numeric/integer based. It coerces each item to its integer value, ignores duplicate integers, sorts the unique integers descending, and requires the whole unique sequence to be consecutive with no gaps. A sequence containing a value without an integer representation does not match a straight.

:take ... returns dice for dice sources and lists for list sources.

Object matching checks map-like structures:

units[:has [hp: 10]]
units[:has [position: [x: 1, y: 2]]]

Collection Operators

Collection-level binary operators:

a | b
a & b
a zip b

Collection addition uses + for single-value insertion. list + any appends exactly one value and any + list prepends exactly one value, so [1, 2] + [3, 4] becomes [1, 2, [3, 4]]. Dice values preserve dice semantics only for unitless positive integers: dice + integer and integer + dice insert one roll and return sorted dice. Other dice/scalar additions produce nothing.

Collection union uses |. list | list combines both lists. list | dice and dice | list produce a list; dice | dice produces sorted dice. map | map merges keys and values with right-hand keys replacing left-hand keys. map | listOfKeys adds missing keys as flag entries with value true and keeps existing values. Other operand combinations produce nothing.

Collection intersection uses &. list & list, list & dice, and dice & list use multiset semantics and produce a list. dice & dice produces sorted dice. map & map keeps keys present in both maps and values from the left map. map & listOfKeys keeps only listed keys. dice & integer and integer & dice are not defined; write dice & :Dice([integer]) when a single-roll dice intersection is intended.

Collection subtraction uses -. list - scalar removes the first matching item, including when the scalar is nothing; an absent match leaves the list unchanged. For example, [1, nothing, 2, nothing] - nothing is [1, 2, nothing]. This collection rule takes precedence over scalar nothing propagation. list - list and list - dice remove matching items with multiset semantics and produce a list. dice - integer removes one roll and returns dice, dice - dice performs multiset subtraction and returns dice, and dice - list returns a list. scalar - list and list - map produce nothing. map - map removes keys present in the right map. map - tag, map - text, and map - listOfKeys remove matching keys. Other unsupported operand combinations produce nothing.

zip is defined for lists only. It pairs items by index up to the shorter operand length and returns a list of maps with left and right entries. Other operand combinations produce nothing.

listOfKeys means a list containing only text or tag values. A key list containing any other value produces nothing.

+ concatenates text when either operand is text. The non-text operand is formatted with the same text representation used by as :Text, so '100' + '200' is '100200' and 'hp: ' + 10 is 'hp: 10'.

Randomness, Dice, and Series

random is a built-in random expression form. Bounds are evaluated left to right. Two integral, unitless bounds draw a uniformly distributed signed-64-bit integer including both ordered bounds. Otherwise compatible finite numeric bounds draw a Binary64 value by scaling a [0, 1) source; final Binary64 rounding may nevertheless equal the upper bound. Reversed bounds are accepted. Equal bounds return that bound without consuming random state. Incompatible units, absent values, non-numeric values, and invalid non-finite bounds produce nothing:

let dieRoll be random from 1 to 6
let chance be chance 25%

Stored dice results have a deterministic data-literal form:

let results be :Dice[1, 3, 6, 3]
let noResults be :Dice[]

:Dice[...] accepts comma-separated, unitless numeric literals with values from 1 through 2,147,483,647. Decimal and underscore spellings follow the ordinary numeric-literal grammar and must decode to an integer, for example :Dice[1.0, 2, 1_000]. Signs, expressions, identifiers, constants, nested values, and trailing commas are not part of this literal form. Invalid source literals fail with parse.syntax. Source whitespace and comments follow the normal source rules. The result preserves duplicates and uses the existing descending dice order. Literal construction consumes no random values. Selectors and indexing apply normally after the closing bracket.

:Dice(values) and values as :Dice remain conversions of an existing value; they are the forms for computed lists of rolls. Text output and recognition of stored results are defined in Text semantics.

Dice rolls use roll dice NdM, where both N and M are positive integer literals. They draw N independent integers from 1 through M inclusive and produce a sorted dice value. This phrase consumes random. :Dice(...) remains the deterministic dice constructor and :Dice remains the dice type reference:

let diceRoll be roll dice 5d6
let hasFullHouse be diceRoll[:has full house]

series fibonacci and series factorial create deterministic, unbounded lazy series. series[:term index] uses a zero-based, unitless, non-negative integer index; an invalid index yields nothing. Series do not expose mutable position. Collection limits still apply when a selector materializes multiple values.

The exact generator transition, seed interpretation, integer rejection sampling, floating-point construction, nested seeded scopes, and known-answer vectors belong to Determinism.

Extensions

Extension references use :extension.function. Calls support the same ordered argument model as script callables and may use any of these source shapes:

:combat.damage(base: 10, multiplier: 2)
:math.maximum of left and right
:log.write value: result
:text.length value
:clock.now

The unlabeled unary form accepts any unary expression, including parse: :test.echo parse value has the same argument as :test.echo(parse value). A following binary operator remains outside that unary argument; for example, :test.echo parse value + 1 means (:test.echo(parse value)) + 1.

An extension call with explicit argument parentheses, including (), accepts postfix member access and collection selectors directly on its result: :board.top(zone: #discard).id and :board.cards(zone: #draw)[:count]. These suffixes chain and bind before surrounding arithmetic, just as after an ordinary function call. Parenthesis-free calls retain their existing argument binding; use (:board.top #discard).id to access the result of that form.

The compiler binds an extension by namespace, function name, and ordered argument labels. The declarative compiler registry must contain that exact signature. Runtime linking then resolves the same identity against the host registry; no reflection or host-language callable is part of source semantics. value is :extension.predicate is permitted only for a unary extension with one unlabeled argument, and its result is normalized to boolean or nothing.

Built-in math intrinsics are opcodes rather than standard extensions:

  • floor, ceil, truncate
  • round half even, round half up, round half down
  • rad, deg, wrap degree
  • abs, ln, exp, sqrt, cbrt, chance
  • sin, cos, tan, asin, acos, atan
  • atan2, hypot, distance, distance squared, length squared, normalize, dot, cross, angle between
  • series fibonacci
  • series factorial

Unknown extension signatures are static errors. A known compile-time signature that cannot be linked by a particular Host is a link error, not a language evaluation result.

Records and Custom Types

Records are immutable map-like values with declared fields:

record :Gauge as {
current: :Number clamped between 0 and maximum,
maximum: :Number clamped between 0 and infinity,
percentage: :Percentage computed by
0% when maximum <= 0,
otherwise (current / maximum) as :Percentage
}
let hp be :Gauge(current: 25, maximum: 100)

Field constraints:

  • Field names must be unique. The completed Record follows the map/record key order defined in determinism semantics.
  • A non-computed field is a constructor parameter. Omitted constructor arguments bind nothing; unknown labels, duplicate labels, or excess positional arguments are static errors.
  • A field type casts the input value. Fields are processed in declaration order.
  • clamped between min and max evaluates both bounds in the constructor frame, clamps the already cast numeric value, and casts the result to the field type again.
  • computed by expression derives the field from constructor fields and fields computed earlier in declaration order, then casts the result to its field type.
  • Computed fields are not constructor parameters and cannot be set by record construction.
  • Prefixing a non-computed field with _ makes that constructor parameter positional/unlabeled while keeping the field name for member access:
record :Super as {
_ xValue: :Number clamped between 1 and 10,
yValue: :Number,
zValue: :Number computed by xValue * yValue + 10%
}
let rec be :Super(10, yValue: 10)

Record values are immutable, expose fields through member access, participate in map ordering and iteration through their field map, and keep their exact custom type identity. Custom type checks use is :TypeName. Casting a map to a record invokes the same constructor by matching map keys to constructor labels.

Language and host boundary

The language defines handler matching, handler evaluation, emit, and publish. Program loading, dynamic linking, priorities, subscription snapshots, queue acceptance, scheduling, runtime observers, outbound sinks, and execution results are not source-language constructs. Their complete portable contract is owned by Host runtime. The transport representation produced by compilation is owned by Program model, Bytecode, and Binary format.

Evaluation Failures, Runtime Errors, and Limits

Ordinary script evaluation has no catchable exception mechanism. An unsupported value combination, failed cast, missing member/index, or invalid numeric domain evaluates to a defined value instead of aborting the handler. Depending on the operation that value is nothing, an explicitly documented boolean or empty collection, or an internal numeric NaN; internal NaN is normalized and observed by scripts as nothing.

Most ordinary operators propagate a nothing operand to nothing, but this is not a universal rewrite rule. Presence and recovery constructs intentionally inspect it: empty nothing is true, nothing has value is false, nothing is nothing is true, and nothing default fallback evaluates to the fallback. Each operator or cast retains its more specific documented behavior.

Actual runtime errors are host/VM failures rather than ordinary expression results. Resource-limit violations, invalid executable state, and failures at native, extension, or external boundaries can terminate the active handler and produce a structured runtime diagnostic. They do not become script values and cannot be caught in the language. The host resets the failing handler state and continues according to the deterministic dispatch contract.

Source processing reports distinct parse, validation, and compile diagnostics. Their stable language-neutral codes and locations are defined by Diagnostics. Compilation validates at least:

  • naming conventions
  • duplicate definitions and duplicate parameters
  • callable arity
  • direct and indirect cyclic calls (recursion is not part of the language)
  • predicate return type (:Boolean or nothing)
  • handler message-name shape
  • record field definitions
  • unknown or invalid type forms

Source nesting limits

Compilation accepts expression nesting up to 32 and statement-body nesting up to 32, inclusive. These are fixed portable source limits, independent of the native call stack, optimization, debug options, and runtime Host limits. Exceeding either limit fails compilation with parse.sourceNestingExceeded and a source location; it must not terminate the embedding process. A limit failure may stop parsing immediately without further syntax-error recovery.

Expression depth is measured before constant folding. A literal or name has depth one. A parenthesized expression adds one. Each unary or binary operation, call, constructor, collection literal, member/selector access, range, random expression, generated collection, or guarded choice adds one to the greatest depth of its contained expressions. Binary chains follow the language’s associativity, so a + b + c and a ^ b ^ c each have depth three. Negated tests/membership count the test and its negation separately. Nested object-match patterns add one per pattern; argument labels, map entries, selector metadata, and choice-branch separators add no level. Sibling expressions use the maximum, not the sum. Parentheses belonging to call syntax add no separate grouping level.

Statement-body depth counts enclosing if/else, for, and random with bodies, whether braced or single-statement. The top-level handler body does not count. Conditions and expressions inside statements retain their independent expression limit. Depth resets between sibling bodies and source documents. Whitespace, comments, and delimiters inside text literals do not add depth.

Runtime limits

Runtime limits cover:

  • maximum processed events
  • maximum queued messages
  • maximum execution steps
  • maximum register values
  • maximum loop iterations
  • maximum call depth
  • maximum range items
  • maximum generated collection items
  • maximum dice count and dice sides

Runtime limits are reset per handler, while a host frame budget is an independent scheduling constraint. Crossing a limit terminates the active handler through a structured runtime diagnostic; it must not escape as an implementation-language exception or corrupt later dispatch. Ordinary unsupported value operations use the explicitly documented nothing, boolean, empty-collection, or numeric NaN result instead.

Informative example

module robot
record :Scan as {
distance: :Quantity(m),
angle: :Quantity(°),
strength: :Percentage
}
predicate close(_ scan) be scan.distance < 30m
predicate strong(_ scan) be scan.strength > 50%
on Tick {
publish Scan
}
on Scanner(data as :Scan) {
if data is close and data is strong {
publish Fire
} else {
publish Turn(angle: 15°)
}
}
on undeliverable as message {
emit Heard(message: message, tags: message.tags)
}

Grammar

The grammar uses the extended BNF expression syntax understood by JetBrains Grammar-Kit so that bnf code fences receive useful syntax highlighting in IntelliJ-based editors. A rule uses ::=; adjacent expressions form a sequence; | separates alternatives; [ X ] is optional; and ( X )* / ( X )+ mean zero-or-more / one-or-more repetitions. Quoted text is a terminal. Uppercase bare names such as LOWER_NAME, VARIABLE_NAME, TYPE_TAG, MODULE_NAME, CONSTANT_REFERENCE, MESSAGE_NAME, NUMBER, and NL are lexical token categories governed by the lexical rules above.

This is only a notation contract. The specification does not adopt Grammar-Kit rule attributes, parser generation, recovery, pinning, or PEG conflict resolution. Separators are required only where shown; NL is LF, CRLF, or CR, and the parser also accepts it around punctuation and operators where token separation is unambiguous.

compilation_unit ::= separators [module_declaration separators] (top_level_declaration separators)* EOF
separators ::= (NL | ';')*
module_declaration ::= 'module' MODULE_NAME
top_level_declaration ::= constant_definition | record_definition | predicate_definition | function_definition | event_handler
constant_definition ::= 'constant' CONSTANT_REFERENCE 'be' scalar_literal
record_definition ::= 'record' custom_type_tag 'as' '{' separators [record_field (record_field_separator record_field)*] separators '}'
record_field_separator ::= ',' separators | separators
record_field ::= ['_'] LOWER_NAME ':' type_reference ['clamped' 'between' expression 'and' expression] ['computed' 'by' expression]
predicate_definition ::= 'predicate' LOWER_NAME definition_parameters 'be' expression
function_definition ::= 'function' LOWER_NAME definition_parameters 'be' expression
definition_parameters ::= '(' [parameter (',' parameter)*] ')'
parameter ::= LOWER_NAME ['as' type_reference] | '_' VARIABLE_NAME ['as' type_reference]
event_handler ::= 'on' handler_endpoint [handler_binding] handler_tag_filter* '{' separators [statement (statement_separator statement)*] separators '}'
handler_endpoint ::= MESSAGE_NAME | 'initialization' | 'undeliverable'
handler_binding ::= '(' [parameter (',' parameter)*] ')' | 'as' VARIABLE_NAME
handler_tag_filter ::= ('matching' | 'without') TAG_LITERAL (',' TAG_LITERAL)*
statement_separator ::= NL | ';'
statement ::= emit_statement | publish_statement | let_statement | if_statement | for_statement | seeded_random_statement | expression_statement
emit_statement ::= 'emit' (message_expression [tag_clause] | delayed_send)
publish_statement ::= 'publish' (message_expression [tag_clause] | delayed_send)
message_expression ::= MESSAGE_NAME [parenthesized_arguments] | expression
tag_clause ::= 'with' expression (',' expression)*
send_expression ::= ('emit' | 'publish') (send_message_expression [send_tag_clause] | delayed_send)
delayed_send ::= 'after' expression send_message_expression [send_tag_clause]
send_message_expression ::= MESSAGE_NAME [parenthesized_arguments] | unary_expression
send_tag_clause ::= 'with' unary_expression (',' unary_expression)*
let_statement ::= 'let' VARIABLE_NAME 'be' expression
if_statement ::= 'if' if_check (';' if_check)* [';'] statement_body ['else' statement_body]
if_check ::= expression | 'let' VARIABLE_NAME 'be' expression
binding_names ::= VARIABLE_NAME (',' VARIABLE_NAME)*
for_statement ::= 'for' for_clause ('and' for_clause)* statement_body
for_clause ::= binding_names ('in' expression | range_source)
seeded_random_statement ::= 'random' 'with' expression statement_body
expression_statement ::= expression
statement_body ::= statement | '{' separators [statement (statement_separator statement)*] separators '}'
expression ::= guarded_choice_expression
guarded_choice_expression ::= value_first_choice | condition_first_choice
value_first_choice ::= implication_expression ['when' implication_expression (',' ['or'] implication_expression 'when' implication_expression)* [','] 'otherwise' implication_expression]
condition_first_choice ::= 'when' implication_expression 'then' implication_expression (',' ['or'] 'when' implication_expression 'then' implication_expression)* [','] 'otherwise' implication_expression
implication_expression ::= default_expression [implication_arrow implication_expression]
implication_arrow ::= '->' | '→' | '⇒'
default_expression ::= collection_union_expression ('default' collection_union_expression)*
collection_union_expression ::= collection_intersection_expression ('|' collection_intersection_expression)*
collection_intersection_expression ::= collection_zip_expression ('&' collection_zip_expression)*
collection_zip_expression ::= or_expression ('zip' or_expression)*
or_expression ::= xor_expression (('or' | '∨') xor_expression)*
xor_expression ::= and_expression (('xor' | '⊕') and_expression)*
and_expression ::= equality_expression (('and' | '∧') equality_expression)*
equality_expression ::= membership_expression (('=' | '<>' | '≠') membership_expression)*
membership_expression ::= type_operation_expression (membership_operator type_operation_expression)*
membership_operator ::= 'in' | '∈' | '∉' | 'has' 'value' | 'in' 'values' 'of' | 'starts' 'with' | 'ends' 'with'
type_operation_expression ::= relational_expression type_operation*
type_operation ::= 'as' type_reference |
'is' [not_operator] ('nothing' | type_reference | numeric_check | 'empty' | extension_reference | LOWER_NAME |
'between' additive_expression 'and' additive_expression |
'at' ('least' | 'most') additive_expression | ('less' | 'more') 'than' additive_expression |
additive_expression ['or' ('less' | 'more')])
relational_expression ::= additive_expression (('<' | '>' | '<=' | '>=' | '≤' | '≥') additive_expression)*
additive_expression ::= multiplicative_expression (('+' | '-' | '−') multiplicative_expression)*
multiplicative_expression ::= unary_expression (('*' | '×' | '·' | '⋅' | '/' | '÷' | 'div' | 'mod' | 'rem') unary_expression)*
unary_expression ::= ('-' | '−' | not_operator | 'empty' | 'parse') unary_expression | unary_intrinsic_expression | power_expression
not_operator ::= 'not' | '!' | '~' | '¬'
power_expression ::= power_base_expression [('^' unary_expression) | '²' | '³']
power_base_expression ::= series_expression | extension_call_expression | intrinsic_call_expression | clamp_expression | variadic_expression | postfix_expression
unary_intrinsic_expression ::= unary_intrinsic unary_intrinsic_operand
unary_intrinsic_operand ::= unary_expression | '(' expression ')'
unary_intrinsic ::= 'abs' | 'ln' | 'exp' | 'sqrt' | '√' | 'cbrt' | '∛' |
'chance' | 'floor' | 'ceil' | 'truncate' |
'round' 'half' ('even' | 'up' | 'down') |
'rad' | 'deg' | 'sin' | 'cos' | 'tan' | 'asin' |
'acos' | 'atan' | 'wrap' 'degree'
intrinsic_call_expression ::= intrinsic_name '(' [expression (',' expression)*] ')'
intrinsic_name ::= 'atan2' | 'hypot' | 'distance' | 'distance' 'squared' | 'length' 'squared' | 'normalize' | 'dot' | 'cross' | 'angle' 'between'
clamp_expression ::= 'clamp' unary_expression 'between' expression 'and' expression
variadic_expression ::= ('min' | 'max') 'of' expression ('and' expression)*
postfix_expression ::= primary_expression postfix_suffix*
postfix_suffix ::= '.' LOWER_NAME | '[' collection_selector structured_selector* ']'
primary_expression ::= nullary_predicate_expression | literal | CONSTANT_REFERENCE | call_expression | uppercase_call_expression |
type_constructor_expression | VARIABLE_NAME |
'(' expression ')' | bracket_literal | generated_list_expression | combined_source_expression |
range_expression | random_expression | seeded_random_expression | dice_expression | send_expression
nullary_predicate_expression ::= 'is' [not_operator] LOWER_NAME
call_expression ::= LOWER_NAME parenthesized_arguments
uppercase_call_expression ::= MESSAGE_NAME parenthesized_arguments
type_constructor_expression ::= (built_in_type_tag | custom_type_tag) parenthesized_arguments
parenthesized_arguments ::= '(' [argument (',' argument)*] ')'
argument ::= [argument_label ':'] expression
argument_label ::= LOWER_NAME | 'to'
literal ::= NUMBER | PERCENTAGE | UNIT_NUMBER | TEXT_LITERAL | TAG_LITERAL | 'true' | 'false' | 'nothing' | math_constant
scalar_literal ::= literal | ('-' | '−') (NUMBER | PERCENTAGE | UNIT_NUMBER | math_constant)
math_constant ::= 'infinity' | '∞' | 'pi' | 'π' | '∏' | 'e' | 'ℇ' | 'tau' | 'τ'
bracket_literal ::= list_literal | map_literal
list_literal ::= '[' [expression (',' expression)*] ']'
map_literal ::= '[' ':' ']' | '[' map_entry (',' map_entry)* ']'
map_entry ::= LOWER_NAME ':' [expression]
generated_list_expression ::= ':List' '[' ':select' VARIABLE_NAME (range_source | 'in' expression) ['where' expression] projection_arrow expression ']'
range_expression ::= range_source
range_source ::= 'from' expression 'to' expression ['step' expression]
random_expression ::= 'random' ['from'] additive_expression 'to' additive_expression
seeded_random_expression ::= 'random' 'with' expression expression
dice_expression ::= 'roll' 'dice' POSITIVE_INTEGER 'd' POSITIVE_INTEGER
series_expression ::= 'series' ('fibonacci' | 'factorial')
extension_reference ::= ':' LOWER_NAME '.' LOWER_NAME
extension_call_expression ::= extension_reference (parenthesized_arguments postfix_suffix* |
['of' expression ('and' expression)* |
argument_label ':' expression (argument_label ':' expression)* |
unary_expression])
combined_source_expression ::= '[' combined_selector structured_selector* ']'
combined_selector ::= (':cartesian' | ':lockstep' | ':zip' | ':union' | ':intersect' | ':difference') expression (',' expression)*
collection_selector ::= structured_selector | expression
structured_selector ::= quantified_selector | pattern_selector | take_selector | drop_selector |
term_selector | count_selector | choose_selector | draw_selector |
shuffle_selector | reverse_selector | edge_selector | filter_selector |
sum_selector | average_selector | extrema_selector | projection_selector |
map_selector | contains_selector | distinct_selector | group_selector |
sort_selector | order_selector | fold_selector | reduce_selector | split_selector | foreach_selector | combined_selector | ':keys' | ':values' | ':entries'
quantified_selector ::= (':any' | ':all') binding_names 'where' expression
pattern_selector ::= ':has' (object_pattern | dice_pattern)
take_selector ::= ':take' (slice | dice_pattern)
drop_selector ::= ':drop' slice
term_selector ::= ':term' expression
count_selector ::= ':count' [binding_names 'where' expression]
choose_selector ::= ':choose' POSITIVE_INTEGER ['at' 'random'] [binding_names 'where' expression] ['weighted' 'by' binding_names projection_arrow expression]
draw_selector ::= ':draw' POSITIVE_INTEGER
shuffle_selector ::= ':shuffle'
reverse_selector ::= ':reverse'
edge_selector ::= (':first' | ':last' | ':single') [binding_names 'where' expression]
filter_selector ::= ':filter' binding_names 'where' expression
sum_selector ::= ':sum' [binding_names projection_arrow expression]
average_selector ::= ':average' [binding_names projection_arrow expression]
extrema_selector ::= (':min' | ':max' | ':highest' | ':lowest') binding_names projection_arrow expression
projection_selector ::= ':select' binding_names projection_arrow expression
map_selector ::= ':map' binding_names 'by' expression [projection_arrow expression]
contains_selector ::= ':contains' ['all' | 'any'] expression
distinct_selector ::= ':distinct' ['by' binding_names projection_arrow expression]
group_selector ::= ':group' 'by' binding_names projection_arrow expression
sort_selector ::= ':sort' sort_direction
order_selector ::= ':order' 'by' binding_names projection_arrow expression sort_direction
fold_selector ::= ':fold' VARIABLE_NAME 'be' expression ',' binding_names projection_arrow expression
reduce_selector ::= ':reduce' VARIABLE_NAME ',' binding_names projection_arrow expression
split_selector ::= ':split' 'on' ('whitespace' | expression)
foreach_selector ::= ':foreach' binding_names projection_arrow expression
projection_arrow ::= '=>' | '↦'
sort_direction ::= 'ascending' | 'descending'
slice ::= ('first' | 'last' | 'highest' | 'lowest') POSITIVE_INTEGER
dice_pattern ::= 'pair' ['of' unary_expression] |
dice_count_word 'of' ('a' 'kind' | unary_expression) |
'full' 'house' | 'straight'
dice_count_word ::= 'three' | 'four' | 'five' | 'six' | 'seven'
object_pattern ::= '[' [object_entry (',' object_entry)*] ']'
object_entry ::= LOWER_NAME ':' (expression | object_pattern)
type_reference ::= built_in_type_tag | custom_type_tag
built_in_type_tag ::= ':Boolean' | ':Number' | ':Percentage' |
':Quantity' '(' unit_name ')' | ':Vector' | ':Point' |
':Series' | ':Range' | ':Message' | ':Handler' | ':Tag' |
':Text' | ':List' | ':Map' | ':Dice' | ':Nothing'
custom_type_tag ::= TYPE_TAG
unit_name ::= 'none' | 'm' | 'meter' | 's' | 'second' | 'degree' | '°'
numeric_check ::= 'numeric' | 'integer' | 'fractional'

An uppercase call is resolved contextually because messages and handler values share the same leading syntax. An empty argument list, or a list containing only unlabeled parameter declarations (name or _ name), creates a handler value. A list containing a labeled argument (name: expression) creates a message value. A bare uppercase name in emit or publish is a zero-argument message.

Adjacent NUMBER VARIABLE_NAME tokens without whitespace are also accepted as implicit multiplication. This applies only to numeric literals followed by an identifier or math constant; ordinary expressions require an explicit multiplication operator.

Inline assembly

Inline asm { ... } blocks expose explicit instructions inside ordinary GES source. The normative syntax, binding and safety rules, operand forms and staged implementation status are defined in Inline assembly. Only standalone handler statements, let initializers, complete callable bodies and computed-field bodies accept this form. Ordinary expressions do not.

Compact selector chains and extension names

Since: Unreleased — compact selector syntax

A selector bracket may contain multiple colon-prefixed steps. For example, items[:filter x where x > 0 :select x => x * 2] is equivalent to items[:filter x where x > 0][:select x => x * 2]. Each step has its own ordinary binding scope. Types and qualified extensions inside expressions are not selector delimiters. [:] remains the empty Map literal.

An extension’s qualified name :namespace.name must be contiguous. Whitespace, comments and line breaks cannot occur between the colon, namespace, dot and function name. Whitespace may precede an argument list, as in :board.cards (zone: #draw).

Combined collection sources and component bindings

[:cartesian A, B, C] and A[:cartesian B, C] enumerate flat component Lists, with the last source advancing fastest. :lockstep (alias :zip) advances all sources together and stops at the shortest source. At least two sources are required, counting a leading source. Cartesian and lockstep accept normal iterable values under the existing for/iterator contract. Invalid source types yield Nothing; Maps supply values in key order. The infix A zip B retains its existing left/right Map elements. List multiplication A * B produces two-component Lists; ordinary left associativity makes A * B * C produce [[a, b], c] elements. Selectors are not automatically rewritten to the binary Cartesian instruction.

:union, :intersect and :difference accept the same source forms and apply the existing infix operand rules left to right, including duplicate counts, Map keys, Dice ordering and scalar-right subtraction. A standalone result retains its operator result kind; a following selector iterates that value. Selector source-type restrictions still apply to the combined result: direct projected :distinct and :order require List, while :group accepts List or Map. A preceding filter/select produces List and changes that boundary. Direct :take, :drop and multi-value :draw preserve Dice results and reject Map results, just as when the combined result is stored in a binding before applying the selector. Single-value :draw 1 retains :first semantics. For Dice minus a scalar, only unitless integer faces in 1..2147483647 are valid, matching infix subtraction; invalid faces return Nothing. A List operand changes a valid Dice/List combination to List, whose ordinary scalar subtraction rules then apply. All source expressions evaluate once from left to right before the combined operation validates them, including when an earlier source is Nothing or empty. Invalid source combinations return Nothing; they do not suppress effects in later source expressions. Ordinary faults and execution limits still stop execution. Short-circuiting inside each source expression is unchanged. Iterator creation validates the evaluated sources and releases any partially created child iterators on failure; no preliminary validation iterators are needed.

Multiple binding names in loops and selectors bind List positions or Map values in ascending key order. Missing components bind Nothing; extra components are ignored. A scalar supplies the first component and Nothing for the rest. One binding always receives the complete element. Filtering preserves the complete element, including unbound components; selecting replaces it. :entries retains its public key/value Map elements while allowing direct component transfer to multiple bindings without intermediate entry Maps. Implicit :sum and :average consume the complete element, equivalent to an explicit identity projection, including after filters with multiple bindings.

[:foreach item => expression] evaluates the expression for each element and returns Nothing, including for empty input. It accepts component bindings and follows the same short-circuit and effect ordering as other terminal selectors.