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 > 0function 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 scalarLiteralrecord :Name as { ... }predicate name(...) be expressionfunction name(...) be expressionon 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 + bonuspredicate alive(_ unit) be unit.hp > 0Callable 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.damagefunction takeDamage(unit, collision) be unit.hp - collision.forceThe 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 10let y be 20; let z be x + y // same statement separator modelNames 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;_01is 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#infinityare 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.functioncomponents. - 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 42let groupedInteger be 1_000_000let fraction be 12.5let negative be -7 // unary minus followed by an integer literallet probability be 25% // percentage literallet distance be 3.5m // meter quantitylet duration be 2s // second quantitylet angle be 90° // degree quantityLiteral 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 #aemit Something() with #a, #bThe 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 Doneemit 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 #cannonpublish 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 + bonuslet percent be rawPercent as :Percentagelet 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 ReadyEvery 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 123let 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:
- Postfix: member access
x.y, lookup/selectorx[...] - Power:
^,²,³ - Unary:
-,not,!,~,¬,empty,parse, prefix intrinsics - Multiplicative:
*,×,·,⋅,/,÷,div,mod,rem - Additive:
+,-,− - Relational:
<,>,<=,>=,≤,≥ - Type and predicate operations:
is,is not,as - Membership and text boundaries:
in,∈,∉,in values of,starts with,ends with - Equality:
=,<>,≠ - Boolean conjunction:
and,∧ - Boolean exclusive-or:
xor,⊕ - Boolean disjunction:
or,∨ - Collection zip:
zip - Collection intersection:
& - Collection union:
| - Presence fallback:
default - Implication:
->,→,⇒ - 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 #healthyThe condition-first spelling is an equivalent expression:
let status be when hp <= 0 then #dead, when hp < maxHp then #wounded otherwise #healthyBoth 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 truelet available be is drawableif is not drawable { emit Blocked() }Unary predicates can be used with is:
predicate high(_ value) be value > 50
let ok be value is highlet notOk be value is not highExtension predicates can use the same shape:
let visible be unit is :sensor.visibleDomain Phrases
The parser accepts several readable comparison forms:
value is at least 10value is at most 10value is between 10 and 20value is not between 10 and 20value is less than 10value is more than 10value is 10 or lessvalue is 10 or morevalue is emptyvalue has valueSince: 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 valueln valueexp valuesin valuecos valuetan valueasin valueacos valueatan valuesqrt value√ valuecbrt value∛ valuefloor valueceil(value)truncate valueround half even valueround half up valueround half down valuerad degreesdeg radianswrap degree valueclamp value between 0 and 100min of a and b and cmax of a and b and cx[: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 100constant $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:
infinity0 - infinitypi // 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 nothingremains 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 inputlet 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 numericvalue is integervalue 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 10mlet duration be 2slet 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.25100% // ratio 1.0Explicit 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% // 15m10m - 50% // 5m50% + 10m // invalid numeric resultIn 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 #activeTags 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.xVectors 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 10let stepped be from 10 to 0 step -2let fractional be from 1.5 to 3.5 step 0.5Since: 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 fibonaccilet 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 fibonacciseries 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.nameunit['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 flagslet containsUnit be unit in values of unitsx 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] // 160let sum be scores[:reduce acc, value => acc + value] // 60let seedOnly be [][:fold acc be 100, value => acc + value] // 100let 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] // 30First, 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 | ba & ba zip bCollection 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 6let 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 5d6let 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.nowThe 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,truncateround half even,round half up,round half downrad,deg,wrap degreeabs,ln,exp,sqrt,cbrt,chancesin,cos,tan,asin,acos,atanatan2,hypot,distance,distance squared,length squared,normalize,dot,cross,angle betweenseries fibonacciseries 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 maxevaluates both bounds in the constructor frame, clamps the already cast numeric value, and casts the result to the field type again.computed by expressionderives 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 (
:Booleanornothing) - 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 < 30mpredicate 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)* EOFseparators ::= (NL | ';')*module_declaration ::= 'module' MODULE_NAMEtop_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 | separatorsrecord_field ::= ['_'] LOWER_NAME ':' type_reference ['clamped' 'between' expression 'and' expression] ['computed' 'by' expression]
predicate_definition ::= 'predicate' LOWER_NAME definition_parameters 'be' expressionfunction_definition ::= 'function' LOWER_NAME definition_parameters 'be' expressiondefinition_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_NAMEhandler_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_statementemit_statement ::= 'emit' (message_expression [tag_clause] | delayed_send)publish_statement ::= 'publish' (message_expression [tag_clause] | delayed_send)message_expression ::= MESSAGE_NAME [parenthesized_arguments] | expressiontag_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_expressionsend_tag_clause ::= 'with' unary_expression (',' unary_expression)*let_statement ::= 'let' VARIABLE_NAME 'be' expressionif_statement ::= 'if' if_check (';' if_check)* [';'] statement_body ['else' statement_body]if_check ::= expression | 'let' VARIABLE_NAME 'be' expressionbinding_names ::= VARIABLE_NAME (',' VARIABLE_NAME)*for_statement ::= 'for' for_clause ('and' for_clause)* statement_bodyfor_clause ::= binding_names ('in' expression | range_source)seeded_random_statement ::= 'random' 'with' expression statement_bodyexpression_statement ::= expressionstatement_body ::= statement | '{' separators [statement (statement_separator statement)*] separators '}'
expression ::= guarded_choice_expressionguarded_choice_expression ::= value_first_choice | condition_first_choicevalue_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_expressionimplication_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_expressionnot_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_operandunary_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' expressionvariadic_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_expressionnullary_predicate_expression ::= 'is' [not_operator] LOWER_NAMEcall_expression ::= LOWER_NAME parenthesized_argumentsuppercase_call_expression ::= MESSAGE_NAME parenthesized_argumentstype_constructor_expression ::= (built_in_type_tag | custom_type_tag) parenthesized_argumentsparenthesized_arguments ::= '(' [argument (',' argument)*] ')'argument ::= [argument_label ':'] expressionargument_label ::= LOWER_NAME | 'to'
literal ::= NUMBER | PERCENTAGE | UNIT_NUMBER | TEXT_LITERAL | TAG_LITERAL | 'true' | 'false' | 'nothing' | math_constantscalar_literal ::= literal | ('-' | '−') (NUMBER | PERCENTAGE | UNIT_NUMBER | math_constant)math_constant ::= 'infinity' | '∞' | 'pi' | 'π' | '∏' | 'e' | 'ℇ' | 'tau' | 'τ'bracket_literal ::= list_literal | map_literallist_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_sourcerange_source ::= 'from' expression 'to' expression ['step' expression]random_expression ::= 'random' ['from'] additive_expression 'to' additive_expressionseeded_random_expression ::= 'random' 'with' expression expressiondice_expression ::= 'roll' 'dice' POSITIVE_INTEGER 'd' POSITIVE_INTEGERseries_expression ::= 'series' ('fibonacci' | 'factorial')
extension_reference ::= ':' LOWER_NAME '.' LOWER_NAMEextension_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 | expressionstructured_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' expressionpattern_selector ::= ':has' (object_pattern | dice_pattern)take_selector ::= ':take' (slice | dice_pattern)drop_selector ::= ':drop' sliceterm_selector ::= ':term' expressioncount_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_INTEGERshuffle_selector ::= ':shuffle'reverse_selector ::= ':reverse'edge_selector ::= (':first' | ':last' | ':single') [binding_names 'where' expression]filter_selector ::= ':filter' binding_names 'where' expressionsum_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 expressionprojection_selector ::= ':select' binding_names projection_arrow expressionmap_selector ::= ':map' binding_names 'by' expression [projection_arrow expression]contains_selector ::= ':contains' ['all' | 'any'] expressiondistinct_selector ::= ':distinct' ['by' binding_names projection_arrow expression]group_selector ::= ':group' 'by' binding_names projection_arrow expressionsort_selector ::= ':sort' sort_directionorder_selector ::= ':order' 'by' binding_names projection_arrow expression sort_directionfold_selector ::= ':fold' VARIABLE_NAME 'be' expression ',' binding_names projection_arrow expressionreduce_selector ::= ':reduce' VARIABLE_NAME ',' binding_names projection_arrow expressionsplit_selector ::= ':split' 'on' ('whitespace' | expression)foreach_selector ::= ':foreach' binding_names projection_arrow expressionprojection_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_tagbuilt_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_TAGunit_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.