Wirescript Language Reference
Wirescript is a high-level language that compiles to Brickadia wire graphs. It replaces manual gate-by-gate wiring with a readable, imperative syntax while preserving the underlying execution model of Brickadia’s wire system.
Table of Contents
- Syntax – Language syntax reference: declarations, statements, blocks, statement terminators, comments, and doc comments.
- Types – The type system: primitives (
int,float,bool,string,entity,controller,character,vector,rotator,color,exec), compound types (ref T,T[],Map<K, V>, tuples, unions, records, enums), and type coercion rules. - Expressions – Operators (arithmetic, comparison, logical, bitwise, string concatenation), operator precedence, string interpolation, conditional expressions, field access, index access, and function calls.
- Statements –
var,let,buffer, arrays, maps,in,out,if,match/if let/let else,on(handlers),event,emit, assignment, and expression statements; plus the module-level annotations a file opens with (@fold/@nofold,@layout("code")/@layout("cube"),@flat). - Builtin Functions – All built-in functions grouped by category: math/trig, vector, entity, controller/character, display, gamemode, raycasting, random, string formatting, and color.
- Chips – Anonymous chips (
chip {}),chip let,chip on, named chips with parameters,mod(inline expansion),ref/*params, nested chips, theopenmodifier, and compiling without microchips (@flat). - Execution Context – Pure vs exec context, what requires exec, handler exec chains, exec unions after handlers, and explicit exec parameters.
- Enums – Tagged unions: declaring
enumvariants (unit, positional, named payload), construction,.Discriminant,match,if let/let else, generic enums, and the built-inOption/Result. - Best Practices – Gate count and scaling: why every call site is a copy (for
modandchipalike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state. - Constant Folding – Compile-time evaluation of pure gates with constant inputs, guarded by an in-game-certified semantics table; fold barriers; the certification story and reproducibility guarantees.
- Testing – Writing a program that checks itself in game: the
ReadBrickGrid()trigger, acheckmod over reference counters, staying silent unless something fails, making the failure line diagnostic, comparing two paths rather than one path against a constant, and what an in-game run cannot prove. - Game Knowledge – Brickadia behaviour the language does not define but programs depend on: rich text markup, the fonts the game ships, input action and axis glyphs, and every action name.
- Diagnostics – Every
WSxxxdiagnostic code the compiler emits, grouped by category (context, names, types, calls, generics, config, …), with a one-line meaning and trigger for each. - Upgrading – Breaking changes and how to migrate existing
.wscode across versions; links the fullCHANGELOG.md.
Quick Example
// A simple counter that increments on each round start
var count: int = 0
on RoundStart() {
count = count + 1
}
out total = count
How It Works
Wirescript compiles down to Brickadia wire graph gates and wires. Every var becomes a variable gate, every operator becomes an expression gate, and every on handler becomes an exec chain rooted at an event gate. The compiler handles gate placement, port wiring, and type coercion automatically.
The key mental model: Wirescript has two execution contexts:
- Pure context – Expressions that define continuous signal-flow relationships (like wiring gates together). These evaluate whenever their inputs change.
- Exec context – Imperative code that runs in response to events (like a handler body). These execute sequentially when triggered.
Understanding this distinction is fundamental to writing correct Wirescript. See Execution Context for details.
Syntax Reference
Contents
- Source Structure
- Imports
- Comments
- Statement Terminators
- Line Continuation
- Blocks
- Identifiers
- Keywords
- Literals
- Operators
- Top-Level Declarations
Source Structure
A Wirescript file (.ws) is a sequence of top-level declarations. There is no required entry point or wrapper – declarations appear at the top level of the file.
in trigger: exec
var count: int = 0
on trigger {
count = count + 1
}
out total = count
Imports
Import declarations bring symbols from other .ws files into scope. The .ws extension is implicit.
import "lib" // import all exportable declarations
import { swap, clamp } from "lib" // selective import
import { swap as mySwap } from "lib" // aliased import
import * as utils from "lib" // namespace import — utils.swap()
Importable: mod, chip, fn, let, const, type, and a module’s
root-level var, array, map, buffer, in and out declarations. Imported
state is shared, not copied – every importer reads and writes the same
storage gate, so a library module can own state that several entry files drive.
A SELECTIVE import drops the module’s handlers. import "lib" and
import * as lib from "lib" both carry a module’s top-level on handlers;
import { helper } from "lib" does not, because it selects declarations BY NAME
and a handler has none to select. No error, no warning: the receiver gate is
never emitted, so the events it would have caught go nowhere.
The trap is the edit that narrows an import. Turning import "lib" into
import { helper } from "lib" to quiet an unused-name warning drops every
handler that module declared, and neither just check nor just compile says
so. Narrowing to import * as lib from "lib" keeps them.
The robust arrangement is to keep on handlers in the entry file regardless:
expose the logic from the library as a mod, and let each entry file declare the
thin handler that calls it. That way no import form can silently remove them:
// lib.ws -- the logic, reachable by importers
mod onPing(v: int) { total = total + v }
// main.ws -- the handler must be declared HERE
import { onPing } from "lib"
on CustomEvent("ping") -> (v: int) { onPing(v) }
To confirm the receivers survived, count them in the lowered graph – this is the only check that sees the problem:
just ir main.ws | grep -c WireGraphPseudo_CustomEvent
Paths are resolved relative to the importing file. Circular imports are an error.
Comments
Line Comments
Line comments start with // and extend to the end of the line.
// This is a line comment
var x: int = 0 // inline comment
Block Comments
Block comments are delimited by /* and */. They may be nested.
/* This is a block comment */
/* Block comments
can span
multiple lines */
/* And they /* can be */ nested */
Doc Comments
Doc comments start with /// (three slashes) and are attached to the declaration that immediately follows them. They are preserved by the compiler for documentation generation.
/// The player's current score.
/// Resets to zero each round.
var score: int = 0
Multiple consecutive doc comment lines are joined together. A single space after /// is consumed automatically.
Statement Terminators
Statements are terminated by newlines or semicolons. Both are interchangeable – you can use whichever style you prefer.
// Newline-terminated (typical style)
var x: int = 0
var y: int = 1
// Semicolon-terminated (compact style)
var x: int = 0; var y: int = 1
// Mixed
var x: int = 0; var y: int = 1
var z: int = 2
Multiple consecutive newlines and semicolons are consumed as a single statement boundary.
Line Continuation
Expressions can span multiple lines when split at an operator. The parser skips newlines when it encounters an infix operator, allowing natural line wrapping:
let total = a +
b +
c
let check = condition1 &&
condition2 &&
condition3
Newlines are also allowed inside delimited groups — call arguments ( ... ),
array literals [ ... ], and record literals { ... } — after the opener,
around commas, and before the closer, with an optional trailing comma:
var names: string[] = [
"alice",
"bob",
]
let point = {
x: 1,
y: 2,
}
Blocks
Blocks are enclosed in curly braces { } and contain a sequence of statements. They are used for handler bodies, chip bodies, if/else branches, and named chip declarations.
on RoundStart() {
count = count + 1
score = 0
}
Newlines inside blocks are consumed freely – blank lines are fine.
Identifiers
Identifiers start with a letter or underscore and continue with letters, digits, or underscores.
valid_name
_private
counter2
myVar
Identifiers are case-sensitive. count and Count are different names.
Keywords
The following words are reserved and cannot be used as identifiers:
| Keyword | Purpose |
|---|---|
var | Mutable variable declaration |
let | Immutable binding |
buffer | Buffered value (delayed one tick) |
array | Array declaration |
chip | Chip declaration (anonymous or named) |
mod | Inline chip (expanded at call sites) |
on | Event handler |
in | Input port declaration |
out | Output port binding |
emit | Emit a user-defined event |
if | Conditional (statement or expression) |
else | Else branch |
then | Used in if-then-else expressions |
match | Reserved; no expression form is implemented |
return | Early return from handler |
import | Import declarations from another file |
from | Used with import { } from "path" |
as | Alias in imports or namespace |
true | Boolean literal |
false | Boolean literal |
ref | Reference type or ref-of expression |
open | Modifier for anonymous chips (start expanded) |
type | Record type declaration |
static | Persistent-variable modifier (inside handlers/mods) |
await | Suspend an exec chain until a signal fires |
Using a reserved word as an identifier (eg. from) as a variable or parameter
name produces a cascade of confusing WSP001 expected Ident, got '<word>' (Kw) parse errors that mask the real cause.
Literals
Integer Literals
Decimal, hexadecimal, binary, and octal integer literals are supported. Underscores may be used as digit separators.
42
1_000_000
0xff // hexadecimal
0b1010 // binary
0o77 // octal
0xFF
0B1100_0011
Float Literals
Floating-point literals use decimal notation with an optional exponent.
3.14
0.5
1e10
2.5e-3
1_000.0
A float literal requires a digit after the decimal point – 1. alone is not a float literal (it would be parsed as integer 1 followed by a dot).
String Literals
String literals are delimited by double quotes " or single quotes '.
"hello world"
'hello world'
Escape Sequences
| Escape | Character |
|---|---|
\\ | Backslash |
\" | Double quote (in double-quoted strings) |
\' | Single quote (in single-quoted strings) |
\n | Newline |
\t | Tab |
\r | Carriage return |
\$ | Literal dollar sign (prevents interpolation) |
\0 | Null character |
String Interpolation
Both single- and double-quoted strings support ${expr} interpolation. Any expression can be embedded:
"Hello, ${name}!"
"Score: ${score + bonus}"
'Position: ${pos.x}, ${pos.y}'
Interpolated expressions are converted to strings. Use \$ to include a literal dollar sign.
Boolean Literals
true
false
Operators
Operators are listed here for reference. See Expressions for full details on precedence and behavior.
Arithmetic
+, -, *, /, %, ** (power)
Comparison
==, !=, <, <=, >, >=
Logical
&&, ||, !
Bitwise
&, |, ^, ~, <<, >>
String
.. (concatenation)
Other
= (assignment), -> (return type / outputs), => (fat arrow, reserved)
Top-Level Declarations
The following forms are valid at the top level of a script:
var name: type = expr– Mutable variablelet name = expr– Immutable bindingbuffer name = expr– Buffered valuearray name: type[]– Arrayin name: type– Input portout name = expr– Output portchip name(params) -> outputs { body }– Named chipchip { body }– Anonymous chipchip let name = expr– Anonymous chip with let bindingschip on trigger { body }– Anonymous chip with handlermod name(params) { body }– Inline chip (macro-like)on trigger { body }– Event handlerlet name = on trigger { body }– Captured event with handlerimport "path"– Import all declarations from fileimport { names } from "path"– Selective importimport * as ns from "path"– Namespace importif cond { body }– Conditional (in exec context)return– Early return from handlertarget = expr– Assignment (in exec context)expr– Expression statement
Types
Wirescript has a static type system that maps directly to Brickadia’s wire graph port types. The type checker validates that wires connect compatible ports and inserts coercion gates where needed.
Contents
Primitive Types
| Type | Description | Default Value |
|---|---|---|
bool | Boolean (true / false) | false |
int | 64-bit signed integer | 0 |
float | 64-bit floating point | 0.0 |
string | Text string | "" |
vector | 3D vector (x, y, z floats) | (0, 0, 0) |
rotator | Euler rotation (pitch, yaw, roll floats) | (0, 0, 0) |
quat | Quaternion (x, y, z, w); produced by the rotation conversion gates (dir.ToRotation(), …) | identity |
color | RGBA color (r, g, b, a floats) | (0, 0, 0, 0) |
entity | Reference to a game entity | null |
character | Reference to a player character | null |
controller | Reference to a player controller | null |
exec | Execution signal (trigger) | – |
exec Type
The exec type represents an execution trigger signal. It is not a data value – it represents “this event fired” or “this code path should execute.” Inputs of type exec are used as handler triggers:
in reset: exec
on reset {
count = 0
}
Special Types
| Type | Description |
|---|---|
any | Universal type – compatible with everything, but can’t back a variable gate’s storage. See any Type below. |
never | Bottom type – no value inhabits this type. Used internally. |
any Type
any is a wildcard annotation for a value that flows through a wire without the checker pinning down (or caring about) its concrete type: test & 1, test == "x", and every other operator overload still resolve against whatever operand type is on the other side, instead of erroring the way an actually-unknown type would. The tradeoff is spelled out by the name – an any value works anywhere, but its side effects are on you: the checker can’t warn you if the operator that ends up selected wasn’t the one you meant.
For a mod parameter that just passes a value through, prefer a generic type parameter (<T>) over any – it keeps the checker’s help instead of erasing the type. See any vs. a Generic Parameter.
any is valid wherever a value just passes through:
in test: any // input port
let value = test & 1 // let binding
mod f(v: any) { ... } // mod parameter
chip C(v: any) -> (r: any) { out r = v } // chip parameter / output
It is not valid as a variable gate’s storage type, because a Variable gate needs one concrete wire variant to hold – any has none:
var foo: any = 0 // ERROR: 'any' cannot be stored
static var foo: any = 0 // ERROR: same
var foo: any[] // ERROR: same
buffer foo: any = 0 // ERROR: same
An unannotated var or buffer is unaffected – its placeholder type is refined from the initializer (or left as the internal “unknown” fallback), never any, so it never trips this rejection.
Object references & assets
entity, character, controller are all object references – a wire carries a handle to a game object, not a copy of it. Asset references ($AssetType/AssetName, e.g. $BrickAudioDescriptor/BA_MUS_…) are object references too: each lowers to its own reference gate (an AudioReference brick, and so on) whose output is wired wherever you use it.
Because they share the same underlying object wire variant, an entity[] array (or an object-typed var) can hold any of them – including asset references:
var songs: entity[]
on load {
songs.push($BrickAudioDescriptor/BA_MUS_Component_Basil_CoffeeShop)
}
References can’t be inlined into an initializer. A constant var initializer (= [...]) only bakes value literals (int / float / bool / string / vector / …) into the gate. An object reference must be wired in from its own brick, so it can’t sit in a constant initializer – build the array with .push(...) inside an exec handler instead. Writing var songs: entity[] = [$Asset/…] silently drops the elements, and the compiler warns (WS024).
zone & teleport references
| Type | Description | Produced by | Consumed by |
|---|---|---|---|
zone | Reference to a Zone brick | a Zone brick’s output (wire it into an in z: zone port) | zone = … on the zone events; fillFromZone* |
teleport | Reference to a Teleport Destination (a “teleport point”) | a Teleport Destination brick (wire it into an in p: teleport port) | Teleport / RelativeTeleport dest/source |
These are reference-only types, exactly like a variable ref (ref T): a wire carries a handle to a component, not a value. They can be passed as in ports, mod/chip parameters, and rerouted anywhere – but, like a var ref, they can not be:
- stored in a
var/buffer(WS025) – a storage gate needs a concrete wire variant; - selected with an if-then-else (
WS031) – the Select gate routes a value, not a reference; - operated on (arithmetic, comparison, string-format).
in z: zone
in e: entity
in p: teleport
on ZoneEntered(zone = z) -> (character) { // wire the zone into the event
e.Teleport(p) // teleport `e` to the teleport point `p`
}
To teleport an entity to a raw position (a vector), use SetLocation – the Teleport gates require a teleport point, not a coordinate.
The null literal
null is a polymorphic literal that adopts its target type and produces that
type’s zero value: an unset object for entity/character/controller, 0 for
a number, false for bool, "" for string, the zero vector / rotation /
color. It needs a known target type – a var/out annotation, an assignment, a
call argument, or a record field:
var target: entity = null // an unset object reference
var score: int = null // 0
in go: exec
on go { target = null } // clear the reference
type Slot = { owner: entity, count: int }
var slot: Slot = { owner: null, count: null }
null is only valid for a value type. A container, record, or reference-only
type (int[], Map<K, V>, a record, *T, zone) has no null value and reports
WS051 – use its own empty form ([], {}) instead. A bare let x = null with
no target types as any.
Compound Types
Reference Types (ref T)
A ref T is a reference to a mutable variable of type T. Variables declared with var have type ref T internally – this is how the wire graph tracks that they are mutable storage rather than pure signal values.
var count: int = 0 // count has type ref int internally
You can write ref T explicitly in type annotations, particularly for chip parameters that need to mutate a caller’s variable:
chip Counter(n: ref int, step: int) {
on trigger {
n = n + step
}
}
The * prefix is an alternative syntax for ref:
// These are equivalent:
mod slide(a: ref int, b: ref int) { ... }
mod slide(a: *int, b: *int) { ... }
Ref<V> is an alias spelling of *V / ref V (Ref<int> == *int == ref int).
A ref to a record, tuple, or enum refs its parts
A record has no single wire to point at: stored, it becomes one backing gate per
field. So *T on a record distributes over the fields – *{ a: float, b: float } means { a: *float, b: *float }. This is what lets a chip or mod write
through to the caller’s record:
type Player = { score: int, lives: int }
chip AwardPoint(p: *Player) {
on trigger {
p.score = p.score + 1
}
}
The caller passes the record variable itself, and each field is wired as its own reference, so the write lands on the caller’s storage.
Tuples and enums work the same way, because they are stored the same way: *(A, B) refs each element, and *SomeEnum refs the enum’s discriminant and payload
slots, so a chip can reassign the caller’s enum outright.
A field that is already a reference stays as it is rather than becoming a
double reference. Note that a record whose fields are references still cannot be
stored in a variable, array, or map (WS049): storage needs a real value per
field.
Array Types (T[])
Arrays hold multiple values of the same element type. Declare them with a var whose type ends in []:
var scores: int[]
Array<V> is an alias spelling of V[] (Array<int> == int[]).
Array access uses bracket syntax and returns the element type directly:
let result = scores[i] // result: int
if result > 100 { } // works directly, no .value needed
Assignment also works directly: scores[i] = 42.
Map Types (Map<K, V>)
A Map<K, V> is a keyed collection, backed by a MapVar gate. Like an array,
it is stored in a var and starts empty — build it at runtime from an exec
handler:
var scores: Map<string, int>
The key type K must be int, string, or an object reference
(entity / character / controller) — a map is keyed by a hashed slot, and
only those types have a slot representation. Any other key type is a WS039
error. The value type V may be any storable variant.
Maps are read and written through their methods (get, set, has, remove,
clear, copyFrom, length, keys, values), which run in exec context —
see the map-method table in builtins.md and the
Maps statement section.
Tuple Types ((A, B, C))
Tuples are fixed-size ordered collections of potentially different types:
// A chip returning multiple outputs produces a record/tuple
chip Split(v: vector) -> (x: float, y: float, z: float) {
out x = v.x
out y = v.y
out z = v.z
}
Access tuple elements with .0, .1, .2 etc:
let pair = someTuple
let first = pair.0
let second = pair.1
Record Types
Record types are named structural types with labeled fields. Define them with the type keyword:
type Point = { x: int, y: int }
type State = { counter: *int, label: string }
A record value – a let binding, a record literal, a chip’s multi-output result – is a compile-time abstraction: it generates no gates, and each field resolves directly to its underlying binding (variable reference, local value, array, etc.). A record used as storage (a var, array, or map) is the exception – see Records as storage below.
Interior mutability with *T fields: A record field of type *int (or ref int) holds a reference to a mutable variable. Writing through the field mutates the original variable:
type State = { val: *int }
var n: int = 0
let s: State = { val: n }
on RoundStart() { s.val = 42 } // writes to n
Nested records work as expected – field access chains resolve through each level:
type Inner = { x: *int }
type Outer = { inner: Inner }
var x: int = 0
let i: Inner = { x }
let o: Outer = { inner: i }
on RoundStart() { o.inner.x = 42 } // writes to x
Records as storage
A record can back a var, an array, or a map. It decomposes into one storage
gate per field (recursing through nested records), and every operation fans
out across those per-field gates. This is what lets a record be mutated, indexed,
and kept across ticks – a plain record value cannot.
type Point = { x: int, y: int }
on RoundStart() {
// A record VARIABLE: one Variable gate per field.
var p: Point = { x: 1, y: 2 }
p.x = 10 // writes the x gate only, y untouched
p = { x: 7, y: 8 } // whole-record assignment writes every field
var q: Point = { x: 0, y: 0 }
q = p // copies each field into q's own gates, not an alias
p.x = 99 // ...so this does not change q.x
// A record ARRAY: parallel arrays, one per field.
var pts: Point[]
pts.push({ x: 3, y: 4 }) // pushes x into pts' x-array, y into its y-array
let first = pts[0].x // reads the x-array at index 0
pts[0] = { x: 9, y: 9 } // writes every field's array at index 0
let n = pts.length() // the fields share a length; length reads the first
// A record MAP: parallel maps, one per field (same key type).
var m: Map<int, Point>
m.set(0, { x: 5, y: 6 })
let v = m.get(0).x // reads the x-map at key 0
}
A constant initializer bakes per field, so a record array or map can be constructed up front:
type Point = { x: int, y: int }
var pts: Point[] = [{ x: 1, y: 2 }, { x: 3, y: 4 }] // x-array [1,3], y-array [2,4]
var grid: Map<int, Point> = { 0 => { x: 5, y: 6 } } // x-map {0:5}, y-map {0:6}
Struct-of-arrays access. Because a record array is stored as one array per
field, that field’s array is directly reachable as pts.field – a real
T[] you can index (pts.x[i]), read (pts.x.length(), pts.x.sum(),
pts.x.min(), pts.x.find(v)), or pass on. Sorting is special-cased so it stays
safe: pts.field.sort(descending?) sorts the WHOLE record BY that field,
reordering every sibling column to match, so rows stay intact (wide records sort
in groups against a copy of the key, so there is no field-count limit).
Everything else on a column acts on that
column alone, which is powerful but sharp: mutating one field’s array by itself
(pts.x.push(1) without a matching pts.y.push(...)) breaks the row
correspondence the whole-array ops rely on. Prefer the whole-record ops
(push/pop/pts[i]) unless you specifically want a single column.
Choosing between two records. A record has no single wire, so an if
expression or a match expression over records makes the choice per leaf
field: one Select per leaf, all reading the one lowered condition (or, for
a match, the one __disc read). It reads like an ordinary conditional value:
type Point = { x: int, y: int }
enum Pick { First, Second }
var a: Point = { x: 1, y: 2 }
var b: Point = { x: 3, y: 4 }
var chosen: Point = { x: 0, y: 0 }
var which: Pick = Pick.First
in go: exec
on go {
chosen = if which is Pick.First then a else b // 2 Selects, one per field
chosen = match which { First => a, Second => b }
let alias = match which { First => a, Second => b }
chosen.x = alias.y
}
The same holds for an enum value, whose leaves are its discriminant and payload
slots, so match outer { A => Shape.Circle(r), B => Shape.Empty } chooses the
tag and each slot alongside it.
An arm that is itself a call splits the same way. A record-returning mod
contributes its record’s fields, and a multi-output gate contributes one leaf
per output port – so let c = if hit then m.get(a) else m.get(b) gives c a
real Value and a real Found, each chosen by its own Select.
Which fields are allowed. Every leaf field must be a value the wire graph can
store – a number, bool, string, vector/rotator/color, an entity type,
or a nested record/array/map. A reference-only field (*T, zone, teleport, a
prefab reference) or an exec field cannot be stored, and a record with one is
rejected with WS049. (A record value may still carry a *T field for interior
mutability, as above; only storage is restricted.)
Container operations. A record array supports push, pop, insert,
remove, fill, resize, swap, reverse, clear, length, and element
access (pts[i], pts[i].field, pts[i] = rec, p = pts[i]). A nested-record
field is reachable to any depth, since the storage decomposes to leaf columns:
pts[i].inner.a reads or writes one leaf, and pts[i].inner reads or writes the
whole sub-record at that index. Operations that
reorder elements by value (sort, shuffle), fold over whole records
(sum/min/max/average), or need a matching second container
(append/copyFrom/slice) have no per-field meaning and are rejected with
WS050 – index a scalar field instead. A record map supports set, get,
has, remove, clear, length, keys, and m[k] access, with the same
depth of nested-field access (m[k].inner.a, m[k].inner). A map key
cannot be a record (WS039); keys must be a single wire value.
Tuple Types ((A, B, C))
Tuples are fixed-size ordered collections of potentially different types:
// A chip returning multiple outputs produces a record/tuple
chip Split(v: vector) -> (x: float, y: float, z: float) {
out x = v.x
out y = v.y
out z = v.z
}
Access tuple elements with .0, .1, .2 etc:
let pair = someTuple
let first = pair.0
let second = pair.1
Both Type::Record and Type::Tuple exist in the type system. Records use named fields ({ x: int, y: int }), while tuples use positional access ((int, float)).
Union Types (A | B)
Union types represent a value that can be one of several types. Write one directly in a type annotation:
let x: int | float = 42
Union syntax is also how a generic bound names
an ad hoc set of types (<T: int | vector>).
Note that an if-then-else expression does not produce a union of its
branch types – it widens them to a single common type instead (see
Widening Inference and the
if-expression note).
if condition then 42 else 3.14 has type float, not int | float.
Enum Types
An enum is a nominal type with a fixed set of named variants – unlike
Type::Union above (which is structural: any value of a matching type
qualifies), two enums stay distinct types even if their variants look the
same. A variant can be a bare unit or carry a payload, which makes enum how
Wirescript expresses a tagged union:
enum Shape { Empty, Circle(float), Rect(float, float) }
An enum value is represented at compile time as a record: a hidden
discriminant field plus one slot per payload field, so it follows the same
Records as storage rules when it backs a var.
match, if let, and let ... else are how you branch on a value’s variant
and pull its payload back out. See Enums for the full reference:
declaration, construction, .Discriminant, match, if let / let else,
generic enums, and the built-in Option/Result.
Generics
mod and chip declarations can take type parameters – one implementation
that specializes per call site instead of one copy per concrete type. A
generic mod inlines and monomorphizes at each call: the compiler infers
the concrete type(s) from the arguments and emits concrete gates for that
type, same as a hand-written non-generic mod would.
Generic chips work too: a generic chip is monomorphized per distinct
type instantiation into its own microchip template (Box<int> and
Box<vector> become two separate grids; two Box<int> calls share one), so
the wire-level behavior mirrors a hand-written non-generic chip at each type.
mod pick<T>(c: bool, a: T, b: T) -> T {
return if c then a else b
}
in go: exec
in i: int
on go {
let x = pick(true, i, i) // T = int, inferred from the arguments
}
Multiple type parameters are declared as <T, U, ...>, each inferred
independently from its own arguments:
mod first<T, U>(a: T, b: U) -> T {
return a
}
See examples/generics.ws for a complete,
just check-clean file exercising every form on this page.
Constraint Classes (Bounds)
A type parameter can be bounded to a named class of types with <T: Class>.
There are three built-in classes, and unbounded <T> means <T: Variant>:
| Class | Members |
|---|---|
Scalar | int, float |
Numeric | int, float, vector, rotator, quat, color |
Variant | Numeric + bool, string, entity, character, controller (all value variants) |
Scalar ⊆ Numeric ⊆ Variant. Note bool is only a member of Variant –
Scalar and Numeric are strictly numeric-math types, and a bounded call
with a bool argument is rejected:
mod addOne<T: Scalar>(v: T) -> T {
return v + 1
}
in flag: bool
let bad = addOne(flag) // ERROR WS033: 'T' = bool, which isn't allowed by its bound
An anonymous union bound (<T: A | B>) restricts T to exactly that
set of types instead of a named class:
mod pickAxis<T: int | vector>(v: T) -> T {
return v
}
Widening Inference
T is inferred as the join (least upper bound) of all the arguments’
types, over a widening-only lattice – there’s no narrowing:
- Numeric types widen toward the wider type:
intwidens tofloat, sopick(flag, 1, 2.0)infersT = float(theintargument casts up). - Object types widen toward
entity:characterandcontrollerboth widen toentity, sopick(flag, aCharacter, aController)infersT = entity. - Incompatible operands – e.g.
intandvector– have no common widening and are a compile error (WS033):
in n: int
in v: vector
let bad = pick(true, n, v)
// ERROR WS033: cannot infer 'T': it's int from one argument but vector
// from another -- all 'T' arguments must be the same type
The same join is used by the built-in blend-family gates (Blend, lerp,
Easing) and by if-then-else expressions –
see the widening note there.
Body Checking Is Per-Mask-Member
A generic mod’s body is type-checked against every type in its bound’s
mask – not just the types it happens to be called with – so the body must
be valid for the whole bound, not only your call sites. This is the most
common gotcha: <T: Numeric> includes rotator, and an operator that isn’t
defined between rotator and a bare int literal fails the definition
itself, even if you never call the mod with a rotator:
mod addOne<T: Numeric>(v: T) -> T {
return v + 1
}
// ERROR WS004: no overload for '+' on Rotator, Int -- rejected at the
// DEFINITION, because `Numeric` includes `rotator` and `rotator + int`
// has no overload, even though every actual call site below uses `int`.
Narrow the bound to Scalar when the body only needs int/float
semantics – v + 1 is valid for every Scalar member, so this is clean:
mod addOne<T: Scalar>(v: T) -> T {
return v + 1 // OK -- valid for both int and float
}
An operation that genuinely is valid across the whole Numeric family (a
same-type operator, for instance) is fine to write against Numeric
directly:
mod square<T: Numeric>(v: T) -> T {
return v * v // OK -- same-type multiply is defined for the whole family
}
Ref Parameters
A *T (or ref T) parameter infers T through the reference:
mod swap<T>(a: *T, b: *T) {
let tmp = a
a = b
b = tmp
}
The same per-mask-member body checking applies – a ref-param body that only
assigns is fine unbounded (valid for every Variant member), but one that
does arithmetic on the referenced value needs a Scalar bound for the same
reason addOne does above:
mod inc<T: Scalar>(v: *T) {
v = v + 1
}
any vs. a Generic Parameter
any erases the type entirely – the checker can’t validate
operators against it and can’t tell you when you’ve made a mistake. A
generic parameter keeps the type information and validates the body against
the bound, so prefer a generic mod (<T> or <T: Bound>) over any for
a value that flows through unchanged. Reach for any only when you
genuinely don’t care what flows through and don’t need the checker’s help.
Generic type aliases
A type alias can take type parameters and is instantiated by substitution:
type Pair<T> = { a: T, b: T }
let p: Pair<int> = { a: 1, b: 2 } // resolves to { a: int, b: int }
An alias must be fully applied (Pair alone, or Pair<int, float> on a
one-parameter alias, is a WS002 error) and non-recursive (type L<T> = { tail: L<T> } is rejected, not hung).
Explicit type arguments
T is normally inferred from the arguments, but you can pin it explicitly with
a <...> type-argument list at the call site:
let x = pick<int>(flag, a, b) // same as the inferred pick(flag, a, b)
let z = zero<vector>() // REQUIRED: T appears only in the return
Explicit type arguments are the only way to call a mod whose type parameter
can’t be inferred from its arguments – e.g. a T that appears only in the
return type (mod zero<T>() -> T). They are checked like inferred ones: the
count must match the type parameters, and each must satisfy its bound
(zero<string>() on a <T: Numeric> is a WS033 error); type arguments on a
non-generic function are an error too, and on a builtin they are ignored with a
WS037 warning (a builtin’s result type is derived from its arguments).
Parsing note: f<int>(...) is read as a type-argument list only when the
<...> is a valid list of types immediately followed by ( – a plain a < b
comparison (or a < b > c) is never mistaken for it.
Wirescript automatically inserts coercion gates when types don’t match exactly but are compatible. The coercion rules mirror Brickadia’s PortsAreCompatible behavior.
Numeric Coercion (Bidirectional)
All numeric types (bool, int, float) coerce to each other freely:
var x: float = 1 // int -> float: OK
var y: int = true // bool -> int: OK (true=1, false=0)
var z: float = false // bool -> float: OK
Because bool coerces to int automatically, you do not need if x then 1 else 0 – just use the bool directly where an int is expected. The if/then/else form is only needed when you want specific non-0/1 scalar values:
let count = a + b + c // bools coerce to 0/1 automatically
let weight = if heavy then 10 else 1 // need if/then for non-0/1 values
Rotation Coercion (Bidirectional)
Both carry their components as float fields — .pitch .yaw .roll on a
rotator, .x .y .z .w on a quat — read through the split gates
described in Field Access.
A rotator (euler) and a quat (quaternion) are interchangeable rotation values
at the wire level, so they coerce to each other freely. This is how a rotation
converts to a quaternion: feed an entity’s GetRotation() rotator straight into a
quaternion gate, or call quaternion methods on a Rotation(...) result.
let r = Rotation(0.0, 90.0, 0.0) // rotator (Make Rotation from euler degrees)
let back = r.Invert() // rotator coerces to quat for the gate → quat
let spun = aim.Rotate(r.Invert()) // rotate a vector by the inverse rotation
String Coercion (One-Way)
All primitive types can be coerced to string via an implicit format gate:
var label: string = 42 // int -> string: "42"
var pos: string = someVector // vector -> string: formatted
The following types format to string: bool, int, float, string, vector, rotator, color, entity, character, controller.
String Coercion to Bool – empty is false
A string coerces to bool wherever a bool is expected – a condition, a bool-typed let/var, a bool-typed port or chip/mod param. The semantics are exactly s != "", and the compiler inserts a real CompareNotEqual(s, "") gate at every such coercion point:
in name: string
if name {
// taken whenever `name` is non-empty — compiles as `if name != ""`
}
let hasName: bool = name // also `name != ""`
| String value | Bool |
|---|---|
"" | false |
anything else (including "0", "false", " ") | true |
String literals in bool positions (var v: bool = "0", array flags: bool[] = ["x", ""], Select("0", a, b)) are converted at compile time by the same != "" law – the baked value is already a bool, so "0" bakes as true.
The rule is deliberately simple: only the empty string is false. This differs from the game’s native bool-port behavior, which is content-aware – a string wired manually into a bool port (e.g. via an any-typed value, whose erased type skips the coercion, or through the logical operators’ native string overloads) is read by the gate itself, where "", "0", and "false" are all falsy. That native law is certified per build against the in-game gate-semantics table – the same certification that drives constant folding of conditions, see folding.md. If you want the content-aware behavior, wire it manually; if you write if someString, you get the deterministic != "".
This direction is one-way – bool to string still goes through the format gate above and renders "true"/"false" text, not the other way around.
Pulsing Coercion to Exec
Value types that “pulse” (change over time) can trigger exec inputs. This means bool, int, float, vector, entity, character, and controller values can be connected to exec inputs – the exec fires whenever the value changes:
// A bool value can trigger a handler
chip let moved = position != position.prev
on moved {
// Fires whenever 'moved' transitions
}
Reference Invariance
Reference types (ref T) do not coerce. A ref int cannot be passed where a ref float is expected, even though int and float coerce freely. This prevents accidentally wiring incompatible variable storage:
var x: int = 0
var y: float = 0.0
// This would be an error -- ref int != ref float
// someChip(x, y) // if both params expect ref int
Coercion Summary Table
| From | To | Rule |
|---|---|---|
int | float | Coerce |
float | int | Coerce |
bool | int | Coerce |
bool | float | Coerce |
int | bool | Coerce |
float | bool | Coerce |
character | entity | Coerce (subtype) |
controller | entity | Coerce |
entity | character | Coerce (wired directly — an entity wire can carry a player, e.g. a sweep hit) |
entity | controller | Coerce (wired directly) |
character | controller | Coerce (wired directly) |
controller | character | Coerce (wired directly) |
rotator | quat | Coerce (interchangeable rotation values) |
quat | rotator | Coerce |
| any primitive | string | Via format gate |
string | bool | Coerce (compiler inserts != "" – only empty is false) |
exec | bool | Coerce (true for one frame) |
bool/int/float/vector/entity/character/controller | exec | Pulsing coerce |
ref T | ref U (T != U) | Mismatch |
any | anything | Same |
| anything | any | Same |
Type Annotations
Type annotations appear after a colon in declarations:
var x: int = 0
in trigger: exec
var data: float[]
Type annotations are optional on var when an initializer is present (the type is inferred), but they are required on in declarations, and on an array or map var that has no initializer to infer its element/key-value types from.
Field Access on Types
Certain types have built-in fields accessible with dot notation:
Vector Fields
let v = Vec(1.0, 2.0, 3.0)
v.x // or v.X -> float
v.y // or v.Y -> float
v.z // or v.Z -> float
Color Fields
let c = Color(1.0, 0.5, 0.0, 1.0)
c.r // or c.R -> float
c.g // or c.G -> float
c.b // or c.B -> float
c.a // or c.A -> float
Rotator Fields
let r = someRotator
r.pitch // -> float
r.yaw // -> float
r.roll // -> float
Variable Fields
Variables (type ref T) have special fields:
var count: int = 0
count.Value // Current value (type T) -- delayed read, usable in pure context
count.prev // Previous tick's value (type T) -- useful for change detection
See Execution Context for when to use .Value vs direct access.
Enums
An enum is a named type with a fixed set of variants. Each variant can be
a bare unit (like a C enum member), or it can carry a payload – turning the
type into a tagged union. A value of an enum type always knows which variant
it currently is; match, if let, and let ... else are how you branch on
that and pull the payload back out.
Unlike type aliases, which are structural, enum
is nominal: two enums with identically-shaped variants are still different
types.
Contents
- Declaration
- Construction
.Discriminantis- Enum and int conversion
matchif let/let else- Changing a payload
- Payloads cannot hold containers
unsafe: unchecked payload access- Generic enums
- Built-in
OptionandResult - Built-in game enums
Declaration
Discriminants (the integer tag backing each variant) auto-number from 0. An
explicit = N resets the counter – the next unannotated variant continues
from N + 1. Two variants that resolve to the same value are a
WS064 error.
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Idle = 0, Running = 5, Done } // 0, 5, 6
enum Shape {
Empty, // unit variant
Circle(float), // positional payload
Rect(float, float),
Box { w: float, h: float }, // named payload
}
A single enum can freely mix unit, positional, and named variants. Whichever shape a variant is declared with is the shape it must be constructed and matched with – see Construction below.
Construction
Construct a variant with its qualified Enum.Variant path: a unit variant is
the bare path, a positional-payload variant takes (...), and a
named-payload variant takes { ... } (the shorthand { x, y } works in a
value position too, same as a record literal):
enum Shape { Empty, Circle(float), Box { w: float, h: float } }
let c = Shape.Empty
let s = Shape.Circle(5.0)
let b = Shape.Box { w: 1.0, h: 2.0 }
Using the wrong bracket form for a variant’s declared shape –
Shape.Circle { } for a positional variant, or Shape.Box(1.0, 2.0) for a
named one – is a WS065 error, so a variant’s shape stays
unambiguous for exhaustiveness checking and the LSP’s fill action.
The prelude variants
Some, None, Ok, and Err (the built-in Option/Result
variants) are available bare – no Option. / Result. qualifier needed:
let o = Some(42) // T inferred = int
let n: Option<int> = None // annotation fixes T when payload can't
None and Err carry no payload to infer their type parameters from, so a
bare let n = None has nothing to pin T. Give the binding a type
annotation (let n: Option<int> = None) or that’s a WS063
error.
.Discriminant
.Discriminant yields the variant’s tag as an int. On a variant path
(Enum.Variant.Discriminant) it’s a compile-time constant that costs no
gates; on a value it reads the tag currently stored in that value:
enum Shape { Empty, Circle(float), Rect(float, float) }
out d = Shape.Circle.Discriminant // compile-time int, = 1
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
out matches = s.Discriminant == Shape.Rect.Discriminant // runtime read vs const
.Discriminant (and match) on a value that isn’t an enum is a
WS066 error.
is
value is Enum.Variant asks whether the value currently holds that variant and
yields a bool. It is the discriminant comparison above, spelled the way it
reads:
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
in ready: bool
out round = s is Shape.Circle // same gates as the compare above
out other = !(s is Shape.Circle) // negate with `!`
out go = s is Shape.Rect && ready // binds like `==`
The test compares tags, so a payload variant answers on its tag alone and binds
nothing; match and if let are how a payload
comes back out. The right side must name a variant of the same enum: s is v
(two values) and s is Other.Empty (a different enum) are errors.
A test whose two sides are both compile-time constants folds away, so testing
a const value costs no gates.
Enum and int conversion
Two spellings move between an enum value and its integer tag.
value.ToInt() is an exact alias for .Discriminant: it yields the tag as
an int, on both a value and a variant path, and folds to the same
compile-time constant when the receiver is a variant path.
Enum.FromInt(n) goes the other way. It builds a value of Enum whose tag is
the int n, with every payload slot defaulted to its zero value. Here n
may be a runtime int, not just a constant. It sets only the tag, so it is
meaningful mainly for unit-only enums (C-like enums and the built-in game
enums). For a payload-carrying variant the payload reads back as zero. An n
that matches no variant’s discriminant leaves a value that no match arm
covers except a wildcard _.
enum Shape { Empty, Circle(float), Rect(float, float) }
in tag: int
static var s: Shape = Shape.Rect(1.0, 2.0)
// ToInt is Discriminant by another name.
out same = s.ToInt() == s.Discriminant // always true
out circle = Shape.Circle.ToInt() // compile-time int, = 1
// FromInt rebuilds a value from a (possibly runtime) tag.
let rebuilt = Shape.FromInt(tag)
out kind = match rebuilt {
Empty => 0,
Circle(r) => 1,
Rect(w, h) => 2,
}
EnumToInt(value) and IntToEnum(value, wrap?) are the gate-backed
twins of .ToInt() and Enum.FromInt(n), for routing through the game’s
“Enum to Integer” / “Integer to Enum” gates.
EnumToInt(value) requires an enum argument (an int or any non-enum
is a type error) and yields an int. When value is a compile-time-known enum
(a variant literal, or a const value) it folds to the discriminant literal
and emits no gate; a runtime enum value instead routes through the real
EnumToInt gate, fed by the value’s tag. For Wirescript’s record enums the
runtime gate is just reading the tag, but it is emitted so game/native enums go
through the real gate.
IntToEnum(value) is the reverse. Its result is an enum whose concrete
type comes from the use site (the annotated target or output type), exactly
like FromInt and null; with no enum-typed context it can’t tell which enum
the integer names, which is an error. A constant value folds to the enum
record directly; a runtime value routes through the real IntToEnum gate.
The optional wrap clamps an out-of-range tag into range.
enum Shape { Empty, Circle(float), Rect(float, float) }
in tag: int
static var s: Shape = Shape.Rect(1.0, 2.0)
// EnumToInt is the gate-backed twin of ToInt / Discriminant.
out folded = EnumToInt(Shape.Circle(1.0)) // compile-time: folds to 1, no gate
out live = EnumToInt(s) // runtime: routes through the gate
// IntToEnum is the gate-backed twin of FromInt; the result's enum type
// comes from the annotated target.
let back: Shape = IntToEnum(tag) // runtime int -> Shape via the gate
out kind = match back {
Empty => 0,
Circle(r) => 1,
Rect(w, h) => 2,
}
match
match branches on an enum value’s variant, binding any payload as it goes.
It works both as an expression (arms are values, comma-separated, and it
compiles to a Select tree) and as a statement (arms are blocks, and it
compiles to a Branch/Union tree). Arm patterns use bare variant names –
the scrutinee’s enum type is already known, so there’s no Enum. qualifier.
Expression form
enum Shape { Empty, Circle(float), Rect(float, float), Box { w: float, h: float } }
static var s: Shape = Shape.Circle(5.0)
out area = match s {
Circle(r) => 3.14159 * r * r,
Rect(w, h) => w * h,
Box { w, h } => w * h,
Empty => 0.0,
}
The arms’ result types follow the same
widening join as an if-then-else expression.
Statement form
Statement arms are blocks (no commas between them) and require exec context:
enum Shape { Empty, Circle(float) }
static var s: Shape = Shape.Circle(5.0)
var lastArea: float = 0.0
on ReadBrickGrid() {
match s {
Circle(r) => { lastArea = r * r }
Empty => { lastArea = 0.0 }
}
}
Exhaustiveness
A match must cover every variant of the scrutinee’s enum, or be capped with
a _ wildcard arm. An uncovered variant is a WS054 error
that names the missing pattern(s) in its message; an arm that can never run
because an earlier arm already covers everything it would match is a
WS061 warning.
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
out area = match s {
Circle(r) => 3.14159 * r * r,
_ => 0.0,
}
Nested patterns
A pattern can nest into a variant’s payload, including into another enum, and exhaustiveness checking follows it down:
enum Opt { Some(int), None }
enum Tree { Leaf(int), Node(Opt) }
static var t: Tree = Tree.Leaf(0)
out val = match t {
Node(Some(x)) => x,
Node(None) => 0,
Leaf(n) => n,
}
A named-payload pattern can ignore the fields it doesn’t need with ..:
Box { w, .. } binds only w and drops h.
Scrutinee must be a value, not a port
The scrutinee of a match (and of if let / let else, below) has to be a
var, let, static var, mod/chip parameter, or const – something the
compiler can see as a compile-time record of the tag plus its payload
slots. A top-level enum-typed in input port does not decompose that way
(it lowers to a single scalar wire, not a record), so matching on one directly
emits an unwired placeholder instead of a real Select/Branch tree. If an enum
value needs to arrive from outside the unit, copy it into a var first and
match on that:
enum Shape { Empty, Circle(float), Rect(float, float) }
var s: Shape = Shape.Empty
in setCircle: exec
in radius: float
on setCircle {
s = Shape.Circle(radius)
}
out area = match s {
Circle(r) => r * r,
Rect(w, h) => w * h,
Empty => 0.0,
}
(The same gap applies in the other direction: an enum value can’t yet drive a
top-level out port directly either – expose a derived scalar, such as
.Discriminant or a match result, instead.)
A let that ALIASES an enum in storage decomposes too, so an array element, a
map value, and a record field all match directly (match arr[i] { .. }, or
let e = h.shape first). The one spelling that does not is m.get(k): that
method returns a {Value, Found} result record rather than the value, which is
the WS066 “requires an enum scrutinee” error naming that
shape. Subscript it (m[k]) or read the member (m.get(k).Value).
if let / let else
These are single-variant refutable binds – shorthand for a match with one
real arm.
if let PATTERN = scrutinee { ... } runs the then block (with the pattern’s
bindings in scope) only when the scrutinee is that variant; the else is
optional:
enum Opt { Some(int), None }
static var o: Opt = Opt.Some(5)
var result: int = 0
on ReadBrickGrid() {
if let Some(x) = o {
result = x
} else {
result = -1
}
}
let PATTERN = scrutinee else { ... } binds into the surrounding scope
instead of a nested block, which is why its else is required to
diverge – it must end in return / emit, or be an if/match whose
arms all diverge. That’s what guarantees the binding is always available
after the statement. A non-diverging else is a WS062
error.
enum Opt { Some(int), None }
mod unwrapOr(v: Opt, fallback: int) -> int {
let Some(x) = v else { return fallback }
return x
}
static var o: Opt = Opt.Some(5)
var result: int = 0
on ReadBrickGrid() {
result = unwrapOr(o, -1)
}
Changing a payload
A payload field has no direct write. s.field = v on an enum value is a
WS007 error, because an enum value is stored as its tag
plus one slot per variant field, and the surface field name names no slot.
There are two ways to change one, and which you want depends on whether the value is already the variant you are writing.
Destructure and assign the capture. A match arm, an if let, or a
let ... else binds each capture directly to the matched value’s payload
slot, so writing the capture writes the payload in place. The other
fields, and the tag, are left alone:
type Track = { origin: vector, direction: vector }
enum Nav {
Idle,
Moving { track: Track, label: string, active: bool },
}
var nav: Nav = Nav.Moving {
track: { origin: Vec(4.0, 4.0, 4.0), direction: Vec(0.707, 0.707, 0.0) },
label: "start",
active: false,
}
in go: exec
on go {
if let Moving { track, label, active } = nav {
track = { origin: Vec(69.0, 69.0, 69.0), direction: Vec(1.0, 0.0, 0.0) }
label = "moved"
active = true
}
}
The write costs one Var_Set per scalar field, and one per leaf for a
record-typed field, so track above costs two, one for origin and one for
direction. That is the same price as writing a record var. Because the
assignment sits inside the arm, it only runs when the value really is that
variant, which is what makes an in-place payload write safe.
The scrutinee has to be writable storage for this: a var, a static var,
a *T parameter, or a var-backed record field. Captures off a let, a
const, or a by-value parameter stay read-only, since there is no storage gate
behind their slots for a write to land on.
A container element is not storage. arr[i], m[k], and any field reached
through one (hs[0].e) read the element out by value, so a capture off them is
read-only too and writing it is a WS007 error. To change an
element’s payload, copy it into a var, mutate that, and store it back:
enum Slot { Empty, Full { label: string, ready: bool } }
var slots: Slot[]
var cur: Slot = Slot.Empty
in go3: exec
on go3 {
slots.push(Slot.Full { label: "start", ready: false })
cur = slots[0]
if let Full { label, ready } = cur {
label = "moved"
ready = true
}
slots[0] = cur
}
Rebuild the whole variant. Assigning the enum itself sets the tag and every slot of the new variant at once, and is the only option when the value is changing variant:
enum Nav2 { Idle, Moving { label: string, active: bool } }
var nav2: Nav2 = Nav2.Idle
in go2: exec
on go2 {
nav2 = Nav2.Moving { label: "moved", active: true }
}
Reading works the same way round: pull a payload field out through a match
or if let capture, not through value.field.
Payloads cannot hold containers
A payload field cannot be an array or a map, and neither can a record used as
one. The declaration is a WS069 error:
enum Loadout {
Empty,
Carrying { items: int[] }, // WS069
}
A payload slot is a single storage gate, and it is filled by constructing
the variant. There is no way to construct a container into one: a declaration
initializer bakes an initial value, and an array has none, while a runtime
Loadout.Carrying { items: [1, 2, 3] } has no gate that assigns a whole array.
The slot would still be allocated, so a captured items.push(...) compiled to a
real gate writing storage that nothing ever filled, and the value read back
empty. Rejecting the declaration stops that at the source.
The same applies to a generic enum instantiated with a container, when the
parameter is one a variant actually stores, so Option<int[]> is a WS069 at
the annotation. A parameter no variant stores is unaffected.
Keep the container in its own var and put something scalar in the payload
that refers to it, such as an index, a key, or a length:
var items: int[]
enum Loadout {
Empty,
Carrying { first: int, count: int },
}
var loadout: Loadout = Loadout.Empty
in pickUp: exec
on pickUp {
items.push(7)
loadout = Loadout.Carrying { first: 0, count: items.length() }
}
unsafe: unchecked payload access
unsafe <value>.<Variant>.<field> reads or writes one payload slot without
proving the value is that variant. It costs no gate: the access resolves
straight to the slot.
enum Job { Idle, Running { label: string, ticks: int } }
var job: Job = Job.Running { label: "start", ticks: 0 }
var seen: string = ""
in tick: exec
on tick {
unsafe job.Running.ticks = unsafe job.Running.ticks + 1
seen = unsafe job.Running.label
}
The variant is required and is what selects the slot, so a field two variants
share is never ambiguous. A record-typed payload keeps projecting
(unsafe job.Running.spec.width). An unknown variant is a
WS060, an unknown field a WS010, a missing segment a
WS070.
The tag is asserted, never tested and never written:
- Reading a variant the value is not returns that slot’s stale contents, with no error and no default.
- Writing sets the slot only. If
jobisIdle,unsafe job.Running.ticks = 5fillsRunning’s slot whilejobstaysIdle, and a latermatchstill takes theIdlearm.
Prefer a destructure, which tests the tag first; reach for unsafe when you
have already established the variant.
unsafe is contextual, so a variable, parameter, or mod named unsafe keeps
working. (The tree-sitter grammar cannot express that and treats the word as a
keyword, so an editor using it flags such a name even though it compiles.)
Generic enums
An enum can take type parameters, instantiated per use exactly like a generic type alias:
enum Box<T> { Value(T), Empty }
static var b: Box<int> = Box.Value(42)
out val = match b {
Value(x) => x,
Empty => 0,
}
Built-in Option and Result
Wirescript ships Option<T> and Result<T, E> as prelude enums – no
declaration needed, and their variants are usable bare (Some/None/Ok/Err,
see Construction above). They’re built in as if declared:
enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }
Don’t redeclare them yourself – the prelude already registers both names, so
an enum Option<T> { ... } of your own is a WS013
duplicate-declaration error.
static var maybe: Option<int> = Some(7)
static var missing: Option<int> = None
out found = match maybe { Some(x) => x, None => -1 }
static var r: Result<int, string> = Ok(200)
out status = match r {
Ok(code) => code,
Err(msg) => -1,
}
Built-in game enums
A handful of enum types are built into the compiler with no enum
declaration at all. EasingFunction, Direction, ColorSpace,
DisplayTextJustification, TextTypeface, DisplayTextEasing, and
EasingDirection are the ones shipping today. They are not hand-maintained:
the compiler discovers them from the game’s own config enums (the same ones
that back a gate’s settings-menu fields), so the exact set and its variants
track whatever build the compiler was generated against.
A built-in game enum behaves like an ordinary unit-only enum (see
Declaration above): construct a variant with its qualified
path, store it in a var or static var, and read .Discriminant:
static var mode: EasingFunction = EasingFunction.Bounce
out disc = mode.Discriminant
The enum value is the default representation; .Discriminant gives back the
integer the game’s own schema assigns that member. That is the one place a
built-in game enum differs from a user-declared one: a user enum’s
discriminants auto-number from 0, but a built-in game enum’s discriminant is
the real schema value, since it round-trips through saved component data and
a renumbered tag would write the wrong value to the game.
A built-in enum value also passes directly as the matching gate config argument:
in t: float
let eased = Easing(0.0, 1.0, t, function = EasingFunction.Bounce, direction = EasingDirection.InOut)
out result = eased
The older bare-name form (an unqualified member name) still works side by side with the enum-qualified form, and both set the same config field:
in t: float
let eased = Easing(0.0, 1.0, t, function = Bounce, direction = InOut)
out result = eased
Expressions
Expressions compute values. In Wirescript, expressions map to wire graph gates – each operator or function call becomes one or more gates with their ports wired together.
Contents
- Operator Precedence
- Arithmetic Operators
- Comparison Operators
- Logical Operators
- Bitwise Operators
- String Concatenation
- String Interpolation
- Conditional Expressions (if-then-else)
- Atom Literals
- Record Literals
- Tuple Literals
- Field Access
- Index Access
- Tuple Pick
- Function Calls
- Ref and Deref
- Parenthesized Expressions
- Gotchas
- Asset References
- Prefab References
Operator Precedence
Operators are listed from lowest (loosest binding) to highest (tightest binding):
| Precedence | Operators | Associativity | Description |
|---|---|---|---|
| 2 | || ^^ | Left | Logical OR, Logical XOR |
| 3 | && | Left | Logical AND |
| 4 | | | Left | Bitwise OR |
| 5 | ^ | Left | Bitwise XOR |
| 6 | & | Left | Bitwise AND |
| 7 | == != is | Left | Equality, enum variant test |
| 8 | < <= > >= | Left | Comparison |
| 9 | << >> | Left | Bitwise shift |
| 10 | + - .. | Left | Addition, subtraction, string concat |
| 11 | * / % | Left | Multiplication, division, modulo |
| 12 | ** | Right | Exponentiation |
| – | - ! ~ * ref | – | Unary prefix operators |
| – | .field [i] (args) | Left | Postfix: field access, index, call |
Parentheses ( ) override precedence as usual.
// ** is right-associative:
let x = 2 ** 3 ** 2 // = 2 ** (3 ** 2) = 2 ** 9 = 512
// Standard math precedence:
let y = 1 + 2 * 3 // = 1 + (2 * 3) = 7
// String concat at same level as +/-:
let s = "x=" .. x + 1 // = "x=" .. (x + 1) -- careful!
Arithmetic Operators
| Operator | Operation | Operand Types | Result Type |
|---|---|---|---|
+ | Addition | int, int | int |
+ | Addition | float, float | float |
+ | Addition | int, float or float, int | float |
+ | Addition | int, bool or bool, int | int |
- | Subtraction | (same as +) | (same as +) |
* | Multiplication | (same as +) | (same as +) |
/ | Division | (same as +) | (same as +) |
% | Modulo | (same as +) | (same as +) |
+ - * / % | Vector math | vector, vector (or vector + scalar) | vector |
+ - * / % | Color math | color, color (or color + scalar) | color |
+ - * / % | Rotation math | quat, quat / rotator, rotator (or a mix) | quat/rotator |
+ - * / % | Object operand | int/float + an object (player, entity, …) | numeric |
** | Exponentiation | int/float (not vectors) | (same as +) |
-x | Negation (unary) | int | int |
-x | Negation (unary) | float | float |
Mixed int/float arithmetic promotes the result to float. bool values are treated as 0/1 when mixed with int.
+ - * / % also operate component-wise on two vector operands, lowering to
the same math gates (whose inputs accept the vector, f64 and i64 wire
variants). Mixing a vector with a scalar broadcasts the scalar across the
components (v * 2.0, 10.0 * v, v / 4); the result is a vector. The
Scale helper still works for explicit vector–scalar scaling.
Colors work the same way — the math gate’s variant set also covers color
(LinearColor), so + - * / % operate RGBA channel-wise on two color
operands, and mixing a color with a number broadcasts that number across the
channels: add one to lighten (c + 0.1), multiply to scale (tint * 0.5 dims
every channel including alpha), either direction (0.1 + c, 2 * c). The result
is a color.
The same gates also accept the rotation family (quat / rotator), so q1 * q2
composes two rotations. Same-type operands keep their type; a quat/rotator
mix yields a quat (freely coercible back to a rotator — see
types).
An object operand (a controller/character/entity —
e.g. a player) no longer coerces directly to an int on a math gate, so it is
routed through (obj || false) first: 1 + player lowers to
add(1, or(player, false)). The || gate coerces the object to a value the math
gate accepts. This is automatic — just write the arithmetic.
let a = 10 + 3 // 13: int
let b = 10.0 + 3 // 13.0: float
let c = 2 ** 10 // 1024: int
let d = -42 // -42: int (negative literal folded at parse time)
let p = Vec(1.0, 2.0, 3.0) + Vec(4.0, 5.0, 6.0) // (5, 7, 9): vector
let q = Vec(1.0, 2.0, 3.0) * 2.0 // (2, 4, 6): vector × scalar
let blend = c1 * 0.5 + c2 * 0.5 // RGBA channel-wise color blend: color
let lighter = tint + 0.1 // add a number to every channel: color
let dimmer = tint * 0.5 // multiply every channel: color
let spin = a1.ToRotation() * a2.ToRotation() // compose two rotations: quat
let n = 1 + player // object → (player || false)
Comparison Operators
All comparison operators return bool.
| Operator | Operation | Operand Types |
|---|---|---|
== | Equal | any wire variant pair |
!= | Not equal | (same as ==) |
< | Less than | (same as ==) |
<= | Less or equal | (same as ==) |
> | Greater than | (same as ==) |
>= | Greater or equal | (same as ==) |
Comparison accepts all wire variant types (int, float, bool, string, entity, controller, character) in any combination.
value is Enum.Variant tests which variant an enum value holds and also
returns bool; see enums.
let isZero = count == 0
let isPositive = score > 0
let sameTeam = teamA == teamB
Logical Operators
| Operator | Operation | Operand Types | Result |
|---|---|---|---|
&& | Logical AND | any wire variant pair | bool |
|| | Logical OR | any wire variant pair | bool |
^^ | Logical XOR | any wire variant pair | bool |
! | Logical NOT (unary) | any wire variant | bool |
Wire variant types are: bool, int, float, exec, string, entity, controller, character. The engine coerces all of these to bool on bool ports (truthy/falsy). This means exec values (true for one frame) work directly in logical expressions:
in reset: bool
in start: exec
on reset || start { ... }
let canMove = isAlive && !isFrozen
let either = a ^^ b
!(a && b) and !(a || b) are automatically fused into single NAND/NOR gates by the compiler.
Bitwise Operators
All bitwise operators produce int results. Operands are int; a float or bool operand coerces to int first (1.5 & 2 truncates 1.5 to 1), so only a non-numeric operand (string, vector, …) is a WS011 error.
| Operator | Operation |
|---|---|
& | Bitwise AND |
| | Bitwise OR |
^ | Bitwise XOR |
~ | Bitwise NOT (unary) |
<< | Left shift |
>> | Right shift |
~(a & b) and ~(a | b) are automatically fused into single NAND/NOR gates.
let mask = 0xFF
let high = (value >> 8) & mask
let combined = a | b
let flipped = ~flags
let shifted = 1 << bitIndex
String Concatenation
The .. operator concatenates strings. It automatically converts numeric types to their string representation.
| Left | Right | Result |
|---|---|---|
string | string | string |
string | int | string |
int | string | string |
string | float | string |
float | string | string |
int | int | string |
let greeting = "Hello, " .. name
let label = "Score: " .. score
let coords = x .. ", " .. y .. ", " .. z
String Interpolation
Strings (both "double" and 'single' quoted) support ${expr} interpolation. The embedded expression is evaluated and converted to a string.
let msg = "Player ${name} scored ${points} points"
let debug = 'pos=(${pos.x}, ${pos.y}, ${pos.z})'
let nested = "result: ${a + b * c}"
Interpolated expressions can be arbitrarily complex:
let status = "Health: ${if hp > 50 then "OK" else "LOW"}"
Use \$ to include a literal $:
let price = "Cost: \$${amount}"
Both arms evaluate. An
ifexpression compiles to aSelectfed by both results, so the arm that is not chosen is computed and discarded. The branch picks which value is used, it does not skip work. Two consequences: an expensive expression in the untaken arm still costs its gates every tick, and an arm that would fail on bad input (an out-of-range slice, a divide by zero) still runs – gates yield a default rather than faulting, which is why this is safe, but it means an arm cannot be used to guard another.
Conditional Expressions (if-then-else)
The if-then-else expression evaluates to one of two values based on a condition. It is a pure expression that compiles to a Select gate.
let abs = if x < 0 then -x else x
let label = if count == 1 then "item" else "items"
let clamped = if v > max then max else if v < min then min else v
Syntax: if <condition> then <true-expr> else <false-expr>
then and else may also start their own continuation lines:
let intel = if playerCount <= 6
then "You have a teammate"
else "You are alone"
Block Expressions
Branches can be block expressions { stmts...; value } with locally-scoped let bindings:
let result = if x > 0 then {
let doubled = x * 2
let offset = doubled + 1
offset
} else {
0
}
let bindings inside a block expression are scoped to that block — they are not accessible outside. The block’s value is its last expression.
Block expressions stay pure (Select gate) as long as they only contain let bindings. They can be used anywhere an expression is expected:
let norm = { let len = sqrt(x*x + y*y); len }
The result type is the widening join of both branches – the same rule
that infers a generic type parameter from its call-site
arguments: numeric branches widen to a common numeric type (int widens to
float), and object branches widen toward entity. The branches are not
required to already match:
let value = if flag then 42 else 3.14 // type: float (int widens to float)
If the branches have no common widening (e.g. int and vector), it’s a
compile error:
in v: vector
let bad = if flag then 1 else v
// ERROR WS003: if-then-else branch type mismatch: then is int, else is
// vector (no common widening)
Conditional expressions can be nested and used anywhere an expression is valid:
let score = baseScore + (if hasBonus then 100 else 0)
chip let emptyCount = (if c0 == 0 then 1 else 0) + (if c1 == 0 then 1 else 0)
Atom Literals
An atom literal :name is a compile-time int constant – the deterministic
xxHash64 (seed 0) hash of name. It’s a readable stand-in for a hand-picked
magic number: a key for an int-keyed map, or an enum-like tag.
var scores: Map<int, int> = { :red => 10, :blue => 20 }
if team == :red { ... }
The name may contain letters, digits, _, and - (the first character must
be a letter or _ – a leading digit or - doesn’t lex as an atom start).
:my-text is a single atom, not :my followed by a subtraction – write
:a - b, with spaces, when you mean :a minus b. Atoms only lex where a value is expected
(after =, an operator, (, ,, [, =>, …); a : immediately after
something that already reads as a value – a type annotation (x: int), a
record field ({ x: 1 }), or a map’s string/atom key separator ("red": 1,
:red: 1) – stays a plain colon instead.
There’s no runtime string-to-hash gate: an atom’s value is always resolved
at compile time, so :name can only appear as a literal, never built from a
runtime string.
Record Literals
Record literals construct values of a named record type. Fields are specified as name: expr pairs inside braces:
type Point = { x: int, y: int }
let p: Point = { x: 1, y: 2 }
Shorthand syntax: when a field name matches a variable in scope, you can omit the value:
var x: int = 0
var y: int = 0
let p: Point = { x, y } // equivalent to { x: x, y: y }
Spread operator: copy all fields from an existing record, then override specific fields:
let a: Point = { x: 1, y: 2 }
let b: Point = { ...a, y: 99 } // b.x == 1, b.y == 99
Later fields override spread fields. Multiple spreads are allowed.
A record value is a compile-time abstraction – it produces no wire graph gates, and each field resolves directly to the underlying binding of its value expression. A record used as storage (a var, array, or map) is the exception: it decomposes into one gate per field. See Records as storage.
Tuple Literals
Tuple literals construct tuple values with positional elements:
let pair = (1, 2)
let triple = (true, 3.14, "hello")
Access elements with .0, .1, .2, etc.
Field Access
Use dot notation to access fields on values:
let x = position.x // vector field
let r = myColor.r // color field
let p = myRotator.pitch // rotator field
Vector / color / rotation components
Every component-typed value can be read one component at a time:
| Type | Components |
|---|---|
vector | .x .y .z |
color | .r .g .b .a |
rotator | .pitch .yaw .roll |
quat | .x .y .z .w |
The names are also accepted upper-case. They work on any expression of that
type — a Vec(...) literal, an input, a stored variable, or a let binding —
and lower to the matching Split gate (SplitVector, SplitColor, SplitRotation,
SplitQuaternion), which outputs the single float component:
in a: vector
in b: vector
let sum = a + b // vector
let height = sum.z // float — the z component
in tint: color
let red = tint.r // float
in facing: rotator
let turn = facing.yaw // float — degrees
let spin = a.ToRotation() // quat
let scalar = spin.w // float
Reading several components of the same value costs one gate, not one per component: the split gates are identical and merge.
A component name belongs to exactly one type, so borrowing one (v.w,
facing.x, tint.z) is a WS010 error rather than a silent misread.
Variable Fields
Variables have special .Value and .prev fields:
var count: int = 0
// .Value -- reads the current value (delayed read, works in pure context)
out currentCount = count.Value
// .prev -- reads the previous tick's value (change detection)
chip let changed = count != count.prev
In exec context, a bare variable name auto-dereferences to its value when used in expressions (arithmetic, comparisons, etc.). When passed to a *T parameter, the variable stays as a reference. In pure context, the bare name refers to the ref T (the variable reference itself). Use .Value to read the value in pure context.
Record Fields
Functions and chips that return records allow field access on the result:
let input = InputReader(char)
let fwd = input.Forward // float
let rgt = input.Right // float
let jmp = input.Jump // bool
Exec Field (.exec)
.exec names the exec output of an exec-returning call. Use it whenever you
need to reference that completion signal — to sequence on it (await), handle it
(on), or wire it into another gate’s exec =.
Three cases resolve through .exec:
-
A call that returns a bare
exec. Some builtins produce an exec whose underlying port has a gate-specific name (e.g. aChangedetector’s exec port isOnChanged)..execdenotes that exec directly:let ch = Change(score) // exec — fires when `score` changes on ch.exec { // handle it ctrl.DisplayText("score changed") } -
A chip or mod called with
exec =. An exec chip/mod call returns its completion exec as an.execfield on the result record, so callers can sequence on the body having finished:let t = InitTables(exec = reset) on start { emit reset await t.exec // resumes after InitTables ran } -
An event that also carries data. A data-carrying event expression (a custom event with data outputs) is a record whose exec output comes first, so
.execnames that trigger explicitly and lets the event compose with other exec signals:in reset: exec on Union(reset, CustomEvent("ping").exec) { // fires on reset OR the event ctrl.DisplayText("announced") }
Union also takes an exec receiver, so a.Union(b) is Union(a, b) and a wide
fan-in reads as a left-associative chain instead of nested Union(Union(...)):
in reset: exec
on reset.Union(CustomEvent("ping").exec).Union(Change(score)) {
ctrl.DisplayText("announced")
}
See Explicit Exec Argument and Exec Chips.
Index Access
Use bracket notation to index into arrays:
let item = myArray[i]
// item.value -- the element value
// item.bOutOfBounds -- bool, true if index was out of range
Tuple Pick
Use .N (dot followed by an integer) to pick an element from a tuple:
let pair = someTupleExpr
let first = pair.0
let second = pair.1
Function Calls
Call functions and chips by name with parenthesized arguments:
// Positional arguments
let dist = Distance(posA, posB)
let s = sin(angle)
// Named arguments (kwargs)
DisplayText(ctrl, "Hello",
positionX = 0.0,
positionY = -100.0,
fontSize = 24
)
Named arguments use name = value syntax. They can be mixed with positional arguments, but positional arguments must come first (matching the parameter order).
Running an exec gate with exec = <trigger>. Any exec-gate call — a builtin
like Random, an array/map method, or a user-defined exec chip/mod — accepts a
reserved exec = <trigger> named argument that drives its exec input. This lets
an exec gate run outside a handler: it fires each time the trigger’s value
changes rather than riding the surrounding exec chain, so the call becomes a leaf
that is legal in a pure binding.
// Pure output binding — the get fires whenever `i` changes (i + 1 is never 0):
out c: color = lut.get(i, exec = i + 1).Value
// Outside any handler — provide the trigger explicitly:
let r = Random(0, 10, exec = someTrigger)
Pair it with .exec to sequence on the result. See
Explicit Exec Argument.
// First positional args, then named
let v = Vec(1.0, 2.0, 3.0)
// All named
DisplayText(target = ctrl, text = msg, fontSize = 30)
Ref and Deref
The ref keyword creates a reference to a value. The * prefix operator dereferences a reference.
// ref creates a reference
let r = ref someVar
// * dereferences
let val = *r
In practice, ref and * are primarily used in chip parameter passing. When a chip parameter has type ref T (or *T), you pass a variable and the chip can read/write it:
mod increment(counter: *int) {
counter = counter + 1
}
var n: int = 0
on tick {
increment(n) // passes ref to n; n is mutated
}
*var — Explicit Deref in Exec Context
Inside exec context, prefixing a variable with * explicitly reads its current-tick value via a Var_Get gate. This is identical to the implicit auto-deref that happens with a bare variable name in exec context — it exists for clarity or to disambiguate:
var x: int = 0
on tick {
let a = x // implicit deref (Var_Get)
let b = *x // explicit deref (same Var_Get, same result)
}
*var is not allowed in pure context — it produces error WS006 (“use .Value for pure reads”). In pure context, use x.Value instead.
Variable Read Modes Summary
| Expression | Context | Gate | Meaning |
|---|---|---|---|
x (bare) | Exec | Var_Get | Current tick’s value |
*x | Exec | Var_Get | Current tick’s value (explicit) |
*x | Pure | — | Error WS006 — use .Value |
x.Value | Pure or Exec | .Value port | Previous tick’s value (delayed) |
x.prev | Pure or Exec | .Value port | Previous tick’s value (same as .Value) |
x (bare) | Pure | — | Variable reference (ref T, not the value) |
Parenthesized Expressions
Parentheses group expressions to override precedence:
let result = (a + b) * c
let check = !(x && y)
Gotchas
Bitwise & is lower precedence than ==/!=
This matches C. x >> 31 & 1 != 0 parses as x >> 31 & (1 != 0). Always parenthesize:
let bit = ((x >> 31) & 1) != 0 // correct
let bad = x >> 31 & 1 != 0 // wrong — compares 1 != 0 first
Chips encapsulate state
Chip internal var/array are not accessible from outside. Only declared -> outputs are visible. For shared mutable state, use top-level declarations and mod macros.
The reverse works, though: a chip body can reference top-level vars,
arrays, and buffers freely — wire refs cross chip boundaries.
Asset References
$AssetType/AssetName references an external asset the world embeds by name —
weapons, pickups, projectiles, and audio/font descriptors:
let weapon = $BRItemBase/Weapon_Pistol
let beep = $BrickOneShotAudioDescriptor/BOSA_Buttons_Button_1_Press
The editor completes asset references: typing $ offers the asset types, and
$Type/ offers that type’s asset names (from the embedded asset catalog). An
asset reference is meant to be passed to a gate that takes an asset (e.g. an
inventory or audio gate).
Prefab References
A $ reference whose path begins with . or / is a prefab file
reference — it points at a .brz prefab archive rather than a named catalog
asset:
$./file.brz— relative to the current source file’s directory.$/abs/path/file.brz— a filesystem-absolute path.
A reference ending in .ws is a source prefab: the file is compiled at
reference time and the result embedded, so a prefab can be kept as readable
source next to the program that spawns it. Its own $./… references resolve
relative to that .ws file. Source prefabs may nest 8 deep.
Pass either form to SpawnPrefab:
on spawn {
SpawnPrefab(prefab = $./turret.brz, offset = Vec(0.0, 0.0, 50.0))
SpawnPrefab(prefab = $./minion.ws, offset = Vec(0.0, 0.0, 50.0))
}
A prefab can also be written inline, as a $ followed by a triple-backtick
block of Wirescript compiled as a nested prefab. The spawned chip’s source sits
at the spawn site, and it closes over nothing — it communicates by targeted
custom events like any other prefab. Inline blocks may nest 8 deep.
var subject: character
on CharacterSpawned() -> (who) {
let e = SpawnPrefab(prefab = $```
var owner: character
on CustomEvent("init") -> (p: character) {
owner = p
on ServerUptime() {
if owner.GetUserId() == "" { ReadBrickGrid().DestroySpawnedPrefab() }
}
}
```, lifetime = 0.0, limit = 64)
subject = who // a var: a constant argument emits no wire
e.SendCustomEvent("init", subject)
}
Why this form exists. A loop advances one iteration per tick, so a central chip sweeping N entities every tick costs N ticks per pass. Spawning one chip per entity moves that work into parallel gate instances, and the per-tick cost stops depending on N. Note that the nested
on ServerUptime()above is not registered by the init handler — the wire graph is static, so its trigger is live from the moment the prefab spawns, beforeinitlands, which is why real code guards it on an init-set value. See per-entity fan-out and Loops.
At compile the referenced .brz is read and embedded into the output
bundle (content-addressed at Prefabs/Uploads/<hash>.brz), and the gate’s
Prefab property is set to that embedded path — so the compiled program is
self-contained. Typing $./ completes available .brz files (the editor scans
the project directory; the web playground has a Prefabs panel where you upload
or drag in .brz files).
Statements
Statements are the building blocks of Wirescript programs. They declare data, define behavior, and control execution flow.
Contents
var– Mutable Variablelet– Immutable Bindingconst– Compile-Time Bindingbuffer– Buffered Value- Arrays –
var name: elementType[] - Maps –
var name: Map<K, V> in– Input Portout– Output Port@left/@right/@top/@bottom– Outer Rerouter Pinsif– Conditional Statementmatch– Branch on an Enum Variantif let/let else– Refutable Enum Bindson– Event Handlerlet on– Event Declarationemit– Emit Signalawait– Suspend Exec Chain- Assignment
- Expression Statement
- Built-in Events
return
var – Mutable Variable
Declares a mutable variable backed by a wire graph variable gate. In exec context (inside handlers or mods), the variable is reset to its initial value each time the code path executes.
var name: type = initializer
var name: type // default-initialized
var name = initializer // type inferred from annotation or usage
The type annotation and initializer are both optional (but at least one should be provided for the typechecker to determine the type).
var count: int = 0 // resets to 0 each handler invocation
var score: float = 0.0
var alive: bool = true
var label: string = "hi" // strings persist in vars too
var dir: vector = Vec(0.0, 0.0, 1.0)
A variable is backed by a wire-graph Variable gate, whose value is a wire
variant, so a var can hold any variant member type: int, float, bool,
string, vector, and object types (entity, controller, character).
static var – Persistent Variable
A static var keeps its value across handler/mod invocations. The initial value is set once when the save loads. Use this for accumulators, counters, or state that must survive across calls.
static var total: int = 0 // persists across calls
static var highScore: int = 0
on RoundStart() {
total = total + 1 // accumulates over time
}
Top-level (module-scope) var declarations are always persistent — static is only meaningful inside handlers and mods, so static var at top/root level is a no-op (just use var).
Variable Identity
Internally, a var x: T has type ref T. This means:
- In exec context (inside
onhandlers):xauto-dereferences to typeTwhen used in expressions. When passed to a*Tparameter, it remains a reference. - In pure context (outside handlers):
xrefers to the variable reference itself (ref T). Usex.Valueorx.prevto read the value.
var count: int = 0
// Pure context -- use .Value for the current value
out currentCount = count.Value
// Exec context -- direct access auto-derefs
on RoundStart() {
count = count + 1 // reads and writes the int value directly
}
See Execution Context for full details.
let – Immutable Binding
Binds a name to a computed value. Unlike var, a let binding is not mutable storage – it is a pure wire connection to an expression’s output.
let name = expression
let name: type = expression
let doubled = count * 2
let isAlive = hp > 0
let greeting = "Hello, " .. playerName
An optional type annotation can follow the name. The annotation does not change the binding’s type – it is a checked assertion. If the expression’s inferred type does not match, the compiler emits a WS016 warning.
let x: int = 42 // ok — types match
let y: float = 42 // ok — int coerces to float
let z: string = 42 // WS016 warning — int does not match string
let bindings can appear at the top level, inside blocks, and inside chip bodies. They are evaluated in pure context.
// Top-level let
let maxScore = 100
// Let inside a handler (evaluated in the exec context of the handler)
on RoundStart() {
let r = Random(0, 15)
if r == 0 { count = count + 1 }
}
Record Destructuring
Destructure a record into individual bindings with let { field1, field2 } = record:
type Point = { x: int, y: int }
let p: Point = { x: 10, y: 20 }
let { x, y } = p
let sum = x + y // 30
Each destructured name becomes an independent let binding that resolves to the same underlying value as the original record field.
Tuple Destructuring
Destructure a tuple into named bindings:
let (first, second) = someTuple
A rest pattern captures remaining elements:
let (head, ...rest) = longTuple
Spread in Call Arguments
Spread a tuple or record into a function’s positional arguments:
let args = (1, 2, 3)
foo(...args) // equivalent to foo(1, 2, 3)
const – Compile-Time Binding
const binds a name to a value, exactly like let:
const name = expression
const name: type = expression
const width = 8
const area = width * width // 64
const greeting = "Score: " .. "0" // string concatenation folds too
const vs let
let folds its initializer opportunistically: when the value happens to
be computable at compile time it becomes a literal, and when it isn’t, let
falls back to an ordinary runtime wire. Either way the program compiles.
const makes the opposite promise: the initializer must evaluate at
compile time, and one that can’t is a compile error (WS046) instead of a
silent runtime wire.
in live: int
let n = live + 1 // fine -- falls back to a runtime wire
in live: int
const n = live + 1 // WS046 -- 'live' is a runtime value, not a compile-time constant
Reach for const when a value’s shape drives something that has to be known
at compile time: a gate config field, a custom-event channel name, or the
contents of a baked array. A typo that turns it into a runtime read is
caught immediately instead of shipping a build where that position silently
went empty or default.
Where const is allowed
Anywhere let is allowed: at the top level, inside a block, and inside any
mod/chip body, at any nesting depth.
const TOTAL_SLOTS = 4 // top level
mod f() -> int {
const doubled = TOTAL_SLOTS * 2 // inside a mod body
return doubled
}
One scope limit is worth knowing, and it is reported, never silent. A NAMED
chip’s body constants reach that chip’s own code, including its handlers, its
outs and its constant-only config slots. A chip declared inside it is built
against a constant scope of its own, so a name it would inherit from the outer
body does not resolve there. Rather than drop the value, that reports
WS028; pass the value in as a const parameter instead.
chip Outer(t: exec) {
const ch = "evt"
chip Inner(u: exec) { on u { send(ch) } } // WS028 -- ch does not reach Inner
chip Ok(u: exec, c: const string) { on u { send(c) } } // pass it in instead
let q = Ok(t, ch)
}
What’s const-evaluable
-
Literals, and any other
const/named constant. -
Arithmetic, bitwise, shift, comparison, logical, and
..string concatenation over constant operands, with the same semantics as the gates they would otherwise have compiled to: 64-bit wrapping integers, divide-by-zero as0, and a non-finite float as0. -
String interpolation (
"a${1 + 1}b") and the certified string/math builtin methods (.ToUpper(),.Trim(),.Length(),sin,sqrt, …), when every operand is constant. -
abs,min,max,clampfold on FLOAT constants but not INT ones.min(3.0, 7.0)folds;min(3, 7)emits a gate. This is a coverage gap, not a language rule: the certified fold pass only folds a gate on an input shape the in-game probe actually recorded, and these four were only ever probed with float inputs (unlike+/-/*, which were probed with ints and fold). Until the probe is rerun with int inputs, either use a float literal (max(n, 0.0)) where a float result is fine, or write the constant directly. A single non-folding op strands everything downstream of it. -
The
Vec/Rotation/Colorconstructors. -
Array and map literals, indexing (
arr[i],m[k]), and.length(). -
Record literals and field access (
.field), including nested records. -
A call to a
const mod(seeconstParameters andconst mod), including one nested inside an operator, a unary operator, or aVec/Rotation/Colorconstructor argument (positional or named):const mod double(n: int) -> int { return n * 2 } const seven = double(3) + 1 // 7 -- nested in an operator const negSix = -double(3) // -6 -- nested in a unary operator const v = Vec(double(1), y = 2.0, z = 3.0) // nested in a named constructor argumentA call nested one level deeper than that, as an argument to a call whose own callee is not itself a
const mod, still is not evaluated, because that callee has no compile-time form of its own to descend through:const mod double(n: int) -> int { return n * 2 } mod scaleUp(n: int) -> int { return n } const total = scaleUp(double(3)) // WS046 -- scaleUp is not itself a `const mod`Bind the call first, then pass the result:
const d = double(3), thenscaleUp(d).
An out-of-range array index, and a missing map key or record field, are refused outright rather than falling back to a stale or default value – unlike a runtime out-of-range array read, which keeps the gate’s previous value, there is no previous value to fall back on at compile time.
A const value reaches every position that requires a literal, not just
another const binding: gate config fields, a custom event’s channel name
on both the sending and the receiving side, and the contents of a baked array
or map:
const CHANNEL = "evt_" .. "died"
in go: exec
var n: int = 0
on go { SendCustomEvent(CHANNEL, n) }
on CustomEvent(CHANNEL) -> (v: int) { n = v }
A const array built at the top level can be indexed there too, in both a
baked initializer and a runtime wire operand:
const t = [10, 20]
const z = t[1] // 20
var counts: int[] = [z, 12345] // baked into the array's initial contents
var rv: int = 0
in go: exec
on go {
if z == rv { BroadcastChatMessage("match") } // baked as a literal operand
}
const containers at runtime
A const array or map is a compile-time value and a runtime container. It
folds wherever the answer is known at compile time (t[1] above costs nothing),
and the first runtime read builds a real container gate with the constant
contents baked into its initial value:
const t = [10, 20, 30]
const m = { "a": 1, "b": 2 }
mod pick(ys: int[], at: int) -> int { return ys[at] }
var i: int = 1
var k: string = "b"
in go: exec
on go {
BroadcastChatMessage(t[i]) // 20, read at a runtime index
BroadcastChatMessage(t.length()) // 3
BroadcastChatMessage(pick(t, i)) // 20, passed as a `T[]` argument
BroadcastChatMessage(m[k]) // 2, read at a runtime key
}
The container is built only where something needs it, and only once, so a
const table used purely at compile time costs no gates and many runtime reads
of one table share a single gate.
Outside a const mod body a const container is immutable: t.push(4),
t.clear() and t[0] = 4 are all rejected, so the compile-time value and the
runtime contents can never disagree. Declare it var to make it mutable.
Compile-time mutation
Inside a const mod body, a const array or map can be mutated in
place using push/set/clear/append on an array and
set/remove/clear on a map, so a collection can be assembled conditionally and still bake
with zero gates:
const mod rooms(n: int) -> int[] {
const t = [10]
if n >= 2 { t.push(20) }
if n >= 3 { t.push(30) }
return t
}
const layout = rooms(2) // [10, 20], computed entirely at compile time
See const Parameters and const mod
for calling const mods, const parameters, and how a const-evaluable if
condition drops its untaken branch.
Diagnostics
| Code | Meaning |
|---|---|
| WS046 | Not a compile-time constant. The value names a runtime value, a call to a mod that isn’t const, an unsupported syntactic form, an out-of-range index, or a missing map key/record field. The message names the actual offender. |
| WS047 | The certified evaluator refuses to compute the value even though every operand IS constant: overflow, a non-ASCII string operand, or a constructor declining its arguments. The fix is different from WS046: the value isn’t a stray runtime read, the evaluator just won’t guess it. |
| WS048 | Const evaluation gave up because the call chain is too deep or took too many steps. Guards a runaway or self-referential const mod call chain, which fails with this diagnostic rather than a stack overflow. |
| WS028 | Reused from ordinary constant-config checking: a value that IS fully constant but has no scalar form for the slot it’s used in, such as a const record used as a gate’s config field, which has no wire representation and must be consumed at compile time (read a field off it instead of handing the whole record to the slot). |
| WS044 | Reused from the container-method backstop: a mutating method (push, clear, set, sort, …) called on a const array or map, which is immutable. Declare it var, or do the mutation inside a const mod body, where it happens at compile time. |
| WS007 | Reused from the writable-target check: an index write (t[0] = 4) to a const array or map, rejected for the same reason as a mutating method. |
buffer – Buffered Value
Declares a value that is delayed by one tick. Buffers are useful for creating feedback loops where a value depends on its own previous state without creating a circular dependency.
buffer name = expression
buffer name: type = expression
buffer prevScore = score
buffer delayed: int = count
The optional type annotation is useful when the expression type needs clarification (e.g., for self-referential buffers).
Arrays – var name: elementType[]
An array holds multiple values of the same element type. Declare one as a var
whose type ends in [] (there is no separate array keyword):
var name: elementType[]
var scores: int[]
var positions: vector[]
var names: string[]
var flags: bool[]
The type annotation must end with [] to indicate it is an array type. The
element type selects the backing array variant (int -> Int64 array, float ->
double array, bool, string, vector, and object types each map to their
matching array kind), so elements keep their declared type rather than all
being stored as doubles.
An array can be given constant initial contents with an = [ ... ]
initializer. At the top level (outside an exec handler) the contents are baked
straight into the array gate, so every element must be a compile-time
constant. The array loads pre-populated with no runtime setup:
var scores: int[] = [100, 50, -10]
var names: string[] = ["alice", "bob"]
A constant is a literal (numbers — including negatives — strings, and bools), or
any expression built from literals and top-level let constants. So a table can
name its constants instead of restating their values:
let C_FROZEN = 3
let WIDTH = 8
var masks: int[] = [1 << C_FROZEN, 1 << C_FROZEN | 1]
var cells: int[] = [WIDTH * WIDTH, WIDTH - 1]
Constants resolve through chains (let B = A + 1) and in any declaration order.
Arithmetic, bitwise, shift, comparison, logical and .. string concatenation all
fold, using the same semantics as the gates they would otherwise have compiled to
— 64-bit wrapping integers, divide-by-zero as 0, and a non-finite float as 0.
Initializers may span multiple lines — newlines are allowed after [, around
commas, and before ], with an optional trailing comma:
var names: string[] = [
"alice",
"bob",
]
An element that is not a compile-time constant — a runtime value such as an
in port, a call, or a ...spread — is an error at the top level, because there
is no exec context in which to populate it. Build the array from runtime values
inside a handler instead (see below).
Inferred element type
The element type is taken from the annotation, or inferred from the literal when there’s no annotation:
var queue: int[] = [1, 2, 3] // annotated
var queue = [1, 2, 3] // element type inferred -> int[]
Building an array at runtime (assignment + spread)
Inside an exec handler you can assign an array literal to an array variable. It
desugars to clear -> push each item -> append each spread, so the elements may
be any runtime value, and a ...spread splices another array’s contents in
place:
var base: int[] = [3, 4]
var work: int[]
on tick {
let n = score + 1
work = [n, 1, ...base, 5] // clear, push n, push 1, append base, push 5
// -> [n, 1, 3, 4, 5]
}
The assignment always clears first, so it replaces (not appends to) the previous contents. Spreads are only valid here, not in a top-level initializer.
Access elements with bracket notation:
let item = scores[i]
// item.value: int (the element)
// item.bOutOfBounds: bool (bounds check)
Maps – var name: Map<K, V>
A map is a keyed collection backed by a MapVar gate. Declare one as a var
whose type is Map<K, V> (there is no separate map keyword):
var scores: Map<string, int>
var owners: Map<int, entity>
The key type K must be int, string, or an object reference
(entity / character / controller) — a map is keyed by a hashed slot, and
only those types have a slot representation. Any other key type is a WS039
error. The value type V may be any storable variant.
Like an array, a map starts empty and is built at runtime from an exec handler — read/write access goes through its methods, which are exec-only:
in tick: exec
on tick {
scores.set("alice", 10) // insert / overwrite
let r = scores.get("alice") // r.Value + r.Found (auto-unwraps to Value)
if scores.has("bob") { }
}
A map literal can seed a map at declaration or in an assignment
(scores = { "a": 1, "b": 2 }); a map literal used anywhere else is a WS026
error, and assigning a whole map from another map (m = m2) is unsupported —
use m.copyFrom(src) (WS027). See
Builtin Functions for the full map-method table (get, set,
has, remove, clear, copyFrom, length, keys, values).
in – Input Port
Declares an input port for the current scope. At the top level, in creates an external input that other wire graphs can connect to. Inside a chip, in creates a chip input port.
in name: type
in trigger: exec
in player: character
in speed: float
in enabled: bool
Input values are read-only within the script. They are provided by the external wire graph environment.
out – Output Port
Declares an output port that exposes a value externally.
Value outputs
The value form is a pure expression – continuously computed from its inputs.
out name = expression
out score = count
out isAlive = hp > 0
out doubled = value * 2
out greeting = "Score: ${count}"
Typed value outputs
An output port can have both a type annotation and a value expression. The annotation is a checked assertion (like on let):
out name: type = expression
out score: int = count.Value // type asserted + value
out ratio: float = hits / total
out ref: *int = myVar // ref output — exposes the variable reference
This form is required when you want to expose a variable reference (*T) rather than its computed value, or to disambiguate the type when the compiler would otherwise warn.
Exec outputs
The typed form without a value declares an exec output port. Use emit inside a handler to connect the current exec chain to it.
out done: exec
on RoundStart() {
count = count + 1
emit done // fires the 'done' output after incrementing
}
This is useful for chips that need to signal completion:
chip Counter(bump: exec) -> (value: int, done: exec) {
var n: int = 0
on bump {
n = n + 1
emit done
}
out value = n.Value
}
Value output bindings are evaluated in pure context. Exec outputs are wired via emit in exec context.
WS017 – Ambiguous variable output type
When out foo = someVar is used and someVar has no explicit type annotation, the compiler emits WS017 because it cannot determine whether you want the variable’s value or a reference to it:
warning WS017: output type inferred from untyped variable
suggest: `out foo: T = var` for value or `out foo: *T = var` for ref
Fix by adding a type annotation:
out foo: int = myVar // exposes the value (uses .Value)
out foo: *int = myVar // exposes the variable reference
@left / @right / @top / @bottom – Outer Rerouter Pins
Annotating a top-level in or out with a side places a physical Rerouter
brick on the outside of the compiled microchip, pre-wired to that port.
Placed chips can then be wired up like an IC: wire into an input pin’s
rerouter, and from an output pin’s rerouter.
@left in go: exec // same line
@left
out done: exec // or on the line directly above
@right out score = 1
@top in players: int
Rules:
- Valid sides are exactly
left,right,top,bottom; one annotation per declaration. - Only top-level
in/outof the compiled file may be annotated. Insidechip {}ormodbodies the annotation is an error (WS023). - Unannotated ports get no rerouter — the feature is fully opt-in.
Placement:
- Rerouters sit flush against the chosen side of the chip brick, bottom-aligned with it, spaced 2 grid units apart and starting from the top corner (left/right sides) or left corner (top/bottom sides) of the edge.
- Ports on the same side appear in declaration order, with
inandoutfreely interleaved. Left/right sides run top to bottom; top/bottom sides run left to right. - Each rerouter is coloured by its port’s value type and carries a floating label with the port’s name; a side’s input and output labels read opposite ways so the two are easy to tell apart.
@top ports (left to right)
┌──[d]────────────┐
@left ports │ │ @right ports
(top to bottom) │ │ (top to bottom)
[a] ────┤ microchip ├──── [c]
[b] ────┤ │
└─────────────────┘
@bottom ports (left to right)
@label – Port Display Label
@label("text") overrides the floating display label on a port’s gate
(and its rerouter pin label, if the port also has a side annotation). The
port’s wiring-UI name always stays the declared identifier – @label
only changes what’s shown floating in the world.
Unlike @left/@right/@top/@bottom, which are top-level only,
@label works on in/out declarations at any nesting level, and it
stacks with a side annotation in either order:
@left @label("Fire!") in trigger: exec
@label("Fire!") @left in trigger: exec // order doesn't matter
Expression labels (@label(<expr>))
The argument may be an expression, not just a string literal. A
compile-time constant expression is folded and its value baked as the
label text (a float renders the same 3-decimal way FormatText shows one):
let title = "Score"
@label(title) out v: int = 0 // baked "Score"
@label(1 + 2) out w: int = 0 // baked "3"
@label also applies to a var, overriding the name it would otherwise
show. On a top-level var the expression may be a runtime value — this
is a dynamic label: the value is coerced to text and wired live into the
variable’s floating label, so the label updates as the value changes. The
common form is a variable labelling itself with its own value:
@label(score) var score: int = 0 // the label shows score's live value
@label(hp * 2) var shown: int = 0 // any runtime expression works
A runtime expression is only dynamic on a top-level var (the one element
that carries a wireable text component). A runtime @label on a port
(in/out), a chip, or a nested var has nowhere to host the wire and is
a compile error — use a constant there.
Module-level @label (the root microchip)
A @label(<expr>) at the top of the file, separated from the first
declaration by a blank line, labels the root microchip itself rather than
any declaration — the same blank-line placement rule as @invisible/@nofold.
A constant bakes the chip’s title text; a runtime value labels the chip
dynamically (wired into the root shell’s label). The expression may
forward-reference declarations below it, so a chip can label itself with one of
its own variables:
@label(status) // labels the whole chip with `status`'s live value
var status: string = "idle"
on tick { status = "running" }
Without the blank line, @label(status) would instead attach to the status
declaration directly (a variable self-label, above).
@nofold – Suppress Constant Folding
@nofold— suppress constant folding/elision for everything lowered from this declaration (let/out/var/chip/on, including captured eventslet e = on trigger { … }and await bindings); legal at any nesting depth. Placed at the very top of the file (after any module doc comment) and separated from the first declaration by a blank line, it applies to the whole module — the same blank-line rule as module doc comments. Sites where it can have no effect (anonymous chips,indeclarations) emit a warning. Used by semantics-verification circuits that need real gates for known values.- Two module-level gotchas: leave a blank line between a module doc block and
a module-level
@nofold(a directly-adjacent pair registers as neither), and a module-level@nofoldapplies only to the file compiled as the entry — an imported library’s own module-level@nofolddoes not carry into the importer (annotate the individual declarations instead). - A module-level
@nofolddisables the entire constant-fold pass for that compile — the same effect as--no-foldon the CLI. @nofoldalso preserves literal-conditionifbranches. Normally anifwhose condition is a literaltrue/falsehas its dead side stripped during lowering as a shortcut, ahead of the fold pass proper — but under@nofold(including a module-level one) that shortcut is suppressed too, so both branches stay real gates. See Constant Folding for the full pass.
@fold – Constant Folding (on by default)
- Constant folding runs on every compile by default, so
@foldis now redundant — it is still accepted for backward compatibility but enables nothing that isn’t already on. To turn folding off, use@nofold(above) or--no-fold. - Placement, if you do write it:
@foldat the very top of the entry file (after any module doc comment), separated from the first declaration by a blank line — the same module-level, blank-line rule as@nofold, and module-level only (there’s no decl-scoped@fold). A directly-adjacent module-doc /@foldpair registers as neither and produces a module-level-only error. - If both a module-level
@foldand@nofoldare present,@nofoldwins and the parser warns that the two conflict. --foldon the CLI likewise just re-affirms the default;--no-folddisables folding. See Constant Folding for the full enable/disable story.
@layout("code") – Source-Shaped Gate Layout
- By default, gates are placed with a flat topological layout: nodes are
ordered by dependency depth into columns, with no relationship to where
they appear in the source.
@layout("code")at the very top of the entry file (after any module doc comment), separated from the first declaration by a blank line, switches the whole compile to a source-shaped layout instead: each occupied source line becomes a row (earlier lines sit higher), and a node’s horizontal position follows its source column, so indentation is visible in the placed gates. Same blank-line rule as module-level@fold/@nofold, and it participates in the same top-of-file annotation run –@foldand@layout("code")can appear together. - Entry-file-only, same as module-level
@fold/@nofold: an@layout("code")at the top of an imported file does not carry into the importer and has no effect. - Nodes with no real position in the entry file (values from an imported file, or synthetic nodes the compiler generates without a source range) adopt the row of whichever node consumes or produces them; a node with no such neighbor lands on an overflow row below the last source line.
- Three wrapping tiers keep large modules inside their placement budgets: a line wider than the line-width budget soft-wraps into an indented continuation row; lines stack into vertical bands capped at a height budget; a band that would push a page past its width budget starts a new page, stacked above the previous one. Each page is centered and flipped independently, so it reads top-down on its own.
- Nested chips inherit the mode from their parent, so a chip’s interior also
renders source-shaped when the entry file has
@layout("code"). - Values that many rows read run down a gutter bus rather than fanning out as one long diagonal wire per reader. Such a value – a variable, an input port, a value handed into or read back out of a chip – gets a lane: a column of rerouter bricks standing in the gutter between the input pins and the code body, chained downward from beside the value’s own producer. At every row that reads the value the lane branches off sideways and runs straight across into that row’s gates, so a read reads as a right angle instead of a diagonal. Lanes are packed so a value only holds a column for as long as it is read, and the longest-lived, most-read values take the outermost columns. A value read on a single row keeps its direct wire unless it is stored state, a port, or crosses a chip wall. An exec chain running from one statement to the next stays a direct wire either way – it belongs on the spine of its own rows, not out in the gutter.
- Own-line
//comments render into the plane as floating text, on a row of their own between the surrounding code rows and left-aligned at the comment’s own indentation – so the notes read where they sit in the source. A comment lands on exactly one plane: the innermost chip whose own rows bracket its line, or the outermost plane for a comment no chip’s rows bracket (a file’s leading or closing notes). Only entry-file comments render, and only on planes whose own rows come from the entry file – an imported chip’s interior numbers its rows against its own file, so it carries no comments and takes its indentation from its nodes’ columns. Trailing comments – code and then//on the same line – are not rendered; the code already occupies that row. Doc comments (///) are unaffected: they keep rendering on the plane header of what they document. - There is no CLI-flag equivalent (unlike
@fold/--fold) –@layoutis a source annotation only. "code"and"cube"are the accepted arguments; an unknown name (@layout("grid")) or a missing/malformed argument (@layout,@layout(5)) is a compile error. Naming a layout twice warns and the last one wins.@layout("cube")emits no per-gate labels. A cube packs gates shoulder to shoulder, so the floating name on a var, an I/O gate, a var tag, or a chip brick cannot be read there, and each one costs a text component. Dropping them typically removes most of the components in the save. The shell label and every plane header stay, so the block is still identifiable and a chip still shows its title when opened, as does a runtime@label(expr), whose text is a value the program computes rather than decoration.
@layout("code")
var total: int = 0
in tick: exec
// This note gets a row of its own in the plane.
on tick {
total = total + 1 // ...but this one is not rendered.
}
@layout("cube") – Compact 3D Packing
@layout("cube") packs gates into a cube – rows of bricks, stacked into
layers along the plane’s depth axis – without analysing the wire graph at
all. The compiler already falls back to this arrangement on modules too large
for the default layout to place economically; the annotation asks for it at
any size.
It is the opposite trade from @layout("code"): wires are ignored entirely,
so nothing about the picture tells you how signals flow, but the brick mass is
as small as it can be and placement cost does not grow with the number of
wires. Reach for it when a module is too big to read anyway and you want it to
occupy as little space as possible.
Same placement rules as the other module annotations: at the very top of the entry file, separated from the first declaration by a blank line, and inert in an imported file. It applies to nested chip interiors too.
@layout("cube")
var total: int = 0
in tick: exec
on tick {
total = total + 1
}
@flat – Inline Every Chip
@flat compiles the program onto a single grid. Every gate that would have
lived inside a chip is placed alongside the rest, and every wire that would
have crossed a chip wall becomes an ordinary same-grid wire. The result has no
microchip bricks and no nested planes to open.
This is a placement decision, not a semantic one. Chips have never been a scoping boundary – wire references cross them freely either way – so a flattened program computes exactly what the nested one computed. What changes is that you get one plane to look at instead of a tree of them, and the per-boundary rerouter pins a crossing would otherwise need are gone.
It is independent of @layout(...) and composes with it. @flat with
@layout("cube") is the natural pairing: one plane, packed as tightly as the
gates allow. @flat on its own, or with @layout("code"), works too.
Because the chip bricks no longer exist, @label and @closed on a chip have
nothing to apply to under @flat. They are not an error – they simply have
no effect.
Same placement rules as the other module annotations: at the very top of the entry file, separated from the first declaration by a blank line, and inert in an imported file.
@flat
@layout("cube")
chip Step(a: int) -> (r: int) {
return a * 2
}
var total: int = 0
in tick: exec
on tick {
total = Step(total)
}
if – Conditional Statement
The if statement executes a block conditionally. It requires exec context – you can only use if statements inside on handlers or after handlers in the exec chain.
if condition {
// then branch
}
if condition {
// then branch
} else {
// else branch
}
on RoundStart() {
if score > highScore {
highScore = score
}
if lives == 0 {
gameOver = true
} else {
lives = lives - 1
}
}
For pure conditional values, use the if-then-else expression instead:
// Expression (pure, no exec needed)
let clamped = if x > max then max else x
// Statement (exec required)
on trigger {
if x > max { x = max }
}
match – Branch on an Enum Variant
match branches on an enum value’s variant, with each arm’s
pattern binding the variant’s payload (if any). It works as an
expression (comma-separated value arms, compiles to a Select tree) or
as a statement (block arms, compiles to Branch/Union, exec context
required):
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
out area = match s {
Circle(r) => 3.14159 * r * r,
Rect(w, h) => w * h,
Empty => 0.0,
}
enum Shape { Empty, Circle(float) }
static var s: Shape = Shape.Circle(5.0)
var lastArea: float = 0.0
on ReadBrickGrid() {
match s {
Circle(r) => { lastArea = r * r }
Empty => { lastArea = 0.0 }
}
}
A match must cover every variant, or end in a _ wildcard arm – an
uncovered variant is a WS054 error. See Enums for
exhaustiveness, nested patterns, and the scrutinee limitation (a match
target must be a var/let/param/const, not an enum in port).
if let / let else – Refutable Enum Binds
Single-variant shorthand for a one-armed match. if let PATTERN = scrutinee { ... } else { ... } runs the block only when the scrutinee is that variant,
with the pattern’s bindings in scope; the else is optional:
enum Opt { Some(int), None }
static var o: Opt = Opt.Some(5)
var result: int = 0
on ReadBrickGrid() {
if let Some(x) = o {
result = x
} else {
result = -1
}
}
let PATTERN = scrutinee else { ... } binds into the surrounding scope
instead of a nested block, so its else must diverge (return/emit,
or an if/match whose arms all diverge) – a non-diverging else is a
WS062 error:
enum Opt { Some(int), None }
mod unwrapOr(v: Opt, fallback: int) -> int {
let Some(x) = v else { return fallback }
return x
}
See Enums for the full reference.
on – Event Handler
Handlers run code in response to events or triggers. The handler body executes in exec context.
on trigger {
// body (exec context)
}
Triggering on Built-in Events
on RoundStart() {
score = 0
}
on CharacterDied() -> (character) {
lives = lives - 1
}
Event data is bound with a trailing -> (…) tuple capture (or -> { field: local } record capture) after the call — see Binding Event Data below for the full capture model. The number and types of values available are determined by the event (see Built-in Events below).
Some events also accept config args that configure the event gate itself, written inside the call parens. String literals (and Name = value named args) set the gate’s config fields — the parens hold config/inputs only, never event data. ChatCommand uses this for its command name and help text:
on ChatCommand("greet", "Greets the player") -> (player, args) {
// "greet" -> command name, "Greets the player" -> help text
// player -> controller output, args -> arguments output
player.DisplayText("Hello ${args}")
}
// the help text can also be named, and bindings are optional:
on ChatCommand("wave", Description = "Wave at everyone") { }
The zone events — ZoneEntered, ZoneLeft, EntityZoneEntered, EntityZoneLeft, ProjectileZoneEntered, ProjectileZoneLeft, BrickChanged, BrickRemoved — accept a zone = <value> named arg that wires its value into the gate’s Zone input port (rather than setting a static config field). Pass an in port bound to a zone brick so one wire selects the zone the gate watches — and the same port can drive several of these gates:
in room: entity // wire to a Zone brick in-game
on ZoneEntered(zone = room) -> (character) { } // room feeds the gate's Zone input
on ZoneLeft(zone = room) -> (character) { }
Frozen entities still fire entity zone events —
SetFrozen(true)does not suppressEntityZoneEntered. But an entry only fires on a boundary crossing:SetLocation-ing an entity to a zone it is already inside does not re-fire the event. To force a fresh entry, move it out of the zone and back in.
Binding Event Data
on <call>(config…) -> <pattern> { } is the general capture form: the call’s
parens hold config/inputs only, and a trailing -> binds whatever data
the call produces. It works the same way whether <call> is a built-in event,
a custom event, or an ordinary mod/chip call.
Tuple capture — -> (a, b) — binds outputs positionally under local
names of your choosing. It works for any call that produces data, built-in
or custom, and is the cleanest form when you don’t need to rename or skip
fields:
on CharacterSpawned() -> (who) {
who.ShowStatusMessage("Welcome!")
}
When you’re binding a single, untyped output, the parens are optional —
-> who is shorthand for -> (who):
on CharacterSpawned() -> who {
who.ShowStatusMessage("Welcome!")
}
(Annotating the slot still needs the parenthesized form, -> (who: character).)
Record capture — -> { field: local } — binds outputs by field name
instead of position, for events with named data. Rename a field with
field: local, or write the bare name (field) to keep it as-is; list only
the fields you need — it’s fine to bind a subset:
on CharacterDied() -> { character: victim, killer } {
victim.ShowStatusMessage("You were killed")
killer.ShowStatusMessage("You got a kill!")
}
Record capture only works for events whose data outputs are named fields (built-in events); a call with positional-only data (e.g. a custom event or a plain chip/mod) requires tuple capture.
Binding inside the call parens is an error. The old on Event(a, b) { }
form — where identifiers inside the parens bound data — no longer parses;
the parser points you at -> instead. Parens are reserved for config and
wired inputs: a literal/named config arg ("greet", Description = "..."),
or a name = value wired input (zone = room, interval = secs).
Custom events write their data types in the tuple capture:
on CustomEvent("dmg") -> (amount: int, source: character) {
// ...
}
A slot’s type can be omitted and is then inferred from a matching in-unit
SendCustomEvent/SendGlobalCustomEvent on the same channel; when no sender
supplies a type either, the slot defaults to float and emits WS042. See
Custom Events for the full send/receive contract.
General triggers extend the same -> <pattern> capture to any
exec-producing call — not just built-in/custom events. on auto-extracts the
call’s exec output, so this works for a mod/chip call outside an exec
context, driven by an explicit exec = ... input (the same convention as
Random(0, 10, exec = trigger)):
var log: string[]
chip Note(msg: string) -> (count: int) {
log.push(msg)
out count = log.length()
}
in go: exec
var last: int = 0
on Note("hello", exec = go) -> (count) {
last = count
}
If the callee has no exec-typed output for on to auto-extract — a plain
value-only call with no exec = ... — that’s WS043. See
Exec Chips for how exec = ... and the resulting
.exec field work on user-defined chips/mods.
Triggering on Input Ports
in trigger: exec
on trigger {
count = count + 1
}
Triggering on Boolean/Pulsing Values
Any bool, int, float, or vector value can trigger a handler when its value changes:
chip let moved = position != position.prev
on moved {
// Fires whenever the 'moved' signal transitions
}
Triggering on Arbitrary Expressions
The trigger can be any expression, not just a bare name — a comparison, a
method or index result, or a builtin call. It desugars to a hidden let bound
to the expression, and the handler fires when that value changes:
on health <= 0 { respawn() } // comparison
on a.Dot(b) > 0.0 { faceTarget() } // method call inside the expression
on arr[i] > 0 { ... } // index result
on ServerUptime() > 5.0 { ... } // builtin call in an expression
A builtin call that returns an exec (on ServerUptime(), on Change(v))
fires the handler on that exec — distinct from an event with config args
(on Clock(...)), whose name resolves as an event and keeps its config form.
Triggering on Let Bindings and Buffers
let signal = someExpression
on signal {
// Fires when signal changes
}
Triggering on Chip Result Execs
A chip call result’s exec fields work as triggers — including the exec
completion field returned by a call with an exec = ... trigger (see
Exec Chips):
let r = InitTables(exec = reset)
on r.exec {
// Fires after the chip body ran
}
Negated Triggers
Prefix a trigger with ! to trigger on the negation (falling edge for booleans):
on !running {
// Fires when 'running' becomes false
}
Union Triggers
To fire a handler on any of several execs, prefer the Union(...) builtin — it
reads as an ordinary call in the unified on <expr> model and composes with
-> output capture and exec = inputs:
on Union(eventA, eventB) {
// Fires on either exec
}
The older | trigger-union syntax still parses but is discouraged in favor of
Union(...):
on eventA | eventB { // deprecated — use `on Union(eventA, eventB)`
// Fires on either event
}
Field Triggers
Trigger on a field of an object using dot notation:
on obj.field {
// Fires when obj.field changes
}
let on – Event Declaration
Event declarations create named triggers using let ... = on .... The event keyword is also accepted as a legacy alias.
Event Alias
Creates a new name for an existing event or trigger:
let died = on CharacterDied()
The alias can then be used as a trigger:
on died(character) {
// ...
}
Captured Event
Wraps a trigger with a handler body that defines the event’s behavior:
let bumped = on Bumped {
// This body executes when Bumped fires
// 'bumped' becomes a trigger in its own right
}
emit – Emit Signal
Fires an exec signal to an output port or local exec signal. Bare emit requires exec context; emit target = expr also works in pure context.
emit eventName // bare exec signal (exec context only)
emit sig = value // fire a signal carrying a value (payload for `await`)
out scored: exec
on CharacterDied() -> (c) {
score = score + 1
emit scored
}
on scored {
DisplayText(ctrl, "Score!", fontSize = 24)
}
Setting an output value
To set a data output’s value, use out name = value (in a handler or a
chip/mod body) or return value (a single-output mod). emit is for exec
signals, not data — don’t use it to assign a plain output.
out result: int
on trigger {
out result = computed_value // set the output's value
}
The emit name = value form is reserved for carrying a payload alongside an
exec signal — a local signal you later await (see below): it fires the signal
and ferries the value, so use it only when you actually want the exec to route.
Local Exec Signals
let name: exec declares a local synchronization point that can be targeted by emit and used with await or on:
let ready: exec
on compute { emit ready } // fires the signal
on start { await ready } // continues when ready fires
Buffered Emit
buffer emit sig routes the emit’s exec through a Buffer gate, delaying delivery by one tick. This is the tick-crossing barrier that makes emit/await loops legal: a back-edge emit after an await closes a wire-graph cycle, and every cycle must cross a Buffer or the compile errors (WS005).
buffer emit loop // 1 tick (default)
buffer(3) emit loop // 3 ticks (BufferTicks)
buffer(0.5s) emit loop // 0.5 seconds (BufferSeconds)
buffer(d) emit loop // variable delay — wired into TicksToWait
buffer(0, 1s) emit sig // delay 0, hold output 1s after the input drops
- The first duration is the delay (
TicksToWait/SecondsToWait); the optional second is the hold (ZeroTicksToWait/ZeroSecondsToWait— how long the output stays up after the input drops; omitted =-1= same as delay). - An
ssuffix selects the seconds gate; unadorned durations are ticks. - Constant durations bake into the gate; variables/expressions wire into the duration port.
Payload Ferrying
emit sig = value on a local exec signal ferries the value with the signal: each emitted value is written into a hidden per-signal store var on the emit chain, and await sig reads it back on the resumed chain — so the value survives the buffered tick crossing.
let loop: exec
emit loop = 0 // scalar payload
let index = await loop // read it back
emit loop = { sum: 0, index: 0 } // record payload: one store per field
let { sum, index } = await loop // destructure the fields
Loops
emit/await on a local signal plus a buffered back-edge forms a loop that advances one iteration per buffer period. Loop state can live in vars (they persist across iterations; non-static vars reset on the entry chain, once per call):
mod sumItems(arr: int[]) -> int {
var sum = 0
var index = 0
let loop: exec
emit loop
await loop
if index < arr.length() {
sum += arr[index]
index += 1
buffer emit loop // back-edge: crosses 1 tick, re-arms the await
} else {
return sum
}
}
or ride the signal as a ferried payload (no mutable vars):
mod sumItems(arr: int[]) -> int {
let loop: exec
emit loop = { sum: 0, index: 0 }
let { sum, index } = await loop
if index < arr.length() {
buffer(1) emit loop = { sum: sum + arr[index], index: index + 1 }
} else {
return sum
}
}
Semantics worth knowing:
-
An emit on the same exec chain as an unconditional
awaitof that signal is sequenced through aVar_Set(armed = true)before entering the signal’s union — so the awaitingVar_Getcan never race the arm, and a loop back-edge re-arms the await every iteration. -
Emits from other handlers enter the signal directly and are guarded by the armed flag: the continuation only runs if the awaiting chain has reached the
await. -
An
awaitinside anifbranch keeps pure flag semantics (its arm only fires when the branch is taken). -
A back-edge loop whose
awaitsits inside anifruns exactly ONE iteration. This follows from the rule above and is the single easiest way to write a loop that looks fine and is not. The first pass consumes the arm; the buffered back-edge arrives on the next tick, finds the branch untaken and the await unarmed, and the continuation never runs. There is no error, no warning, and no hang - the program carries on with one iteration’s worth of work done, so a loop meant to fill 25 entries leaves 1.Measured, same loop body both ways: 5 of 5 iterations with the
awaitat its handler’s top level, 1 of 5 with it inside a branch.This bites hardest when a loop lives in a
modcalled from a step machine, because themodinlines into the caller and inherits its branch:// BROKEN - the call site puts the await inside an `if` on tick { if step == 4 { fill() } // fill()'s await inlines into this branch } // WORKS - the await is at its own handler's top level let fillSig: exec on fillSig { idx = 0 let loop: exec emit loop await loop if idx < 25 { dest.push(idx) idx += 1 buffer emit loop } } on tick { if step == 4 { emit fillSig } // pulse it instead of calling it }The
ifguarding the loop’s own continuation (if idx < 25) is fine and required - it is the exit test. What must not be branched is theawaititself. -
Loop state must outlive the tick. A back-edge buffer crosses a tick, and a tick is a new call of whatever handler the loop sits in - so a non-static
vardeclared inside the loop’s own scope is reset before the next iteration reads it. A counter declared there sticks at its initial value forever, and the loop rewrites element 0 on every pass while looking like it is running. Put the counter (and any accumulator) at module level, or make itstatic, and reset it upstream of theemit:var idx: int = 0 // module level: survives the tick boundary mod fill(dest: int[]) { idx = 0 // reset BEFORE the emit, not inside the loop body let loop: exec emit loop await loop if idx < 25 { dest.push(idx) idx += 1 buffer emit loop } }The failure is silent: the program runs, the loop terminates, and the collection simply holds one element.
-
A loop advances one iteration per tick. The back-edge is a buffer, and a buffer crosses a tick, so walking N elements takes N ticks. That is fine for work that runs once (a reset sweep, a one-off rebuild) and wrong for work that has to happen every tick for every element: a per-tick sweep over a roster of N costs N ticks per pass and degrades as the roster grows. When you need per-tick work per entity, give each entity its own chip instance instead of looping a central one – see per-entity fan-out.
Gate Cost
| Construct | Gates added |
|---|---|
emit sig (bare) | 0 — joins the signal’s union |
buffer(...) emit sig | 1 Buffer (Ticks/Seconds) |
emit sig = scalar | 1 Var_Set per emit (+1 hidden store var per signal) |
emit sig = { F fields } | F Var_Set per emit (+F store vars per signal) |
await sig (per await) | ~5: armed-flag var, arm + reset Var_Set, Var_Get, Branch |
let { F fields } = await sig | +F Var_Get |
| per signal | 1 Union hub (+1 arm Var_Set when same-chain emits exist); a single-input hub is spliced away |
await – Suspend Exec Chain
Suspends the current exec chain and resumes from the awaited expression’s exec output. Everything after the await runs when that exec fires. Only valid in exec context.
await signal // resume when signal fires
let val = await signal // capture the signal's ferried payload
let { a, b } = await signal // destructure a record payload
let val = await value on trigger // capture value when trigger fires
let n: int = await CustomEvent("c") // wait for an event, capture its data
await a || b // race -- first signal wins
await Sleep(_, delay = 1.0) // sleep 1 second using _ armed flag
await SleepTicks(_, delay = 5) // sleep 5 ticks
Each await creates an armed flag (static var bool) that guards the continuation. The continuation only fires once per arming, preventing repeated triggers.
Awaiting a Custom Event
await CustomEvent("chan") (or GlobalCustomEvent) suspends until a matching SendCustomEvent fires, and a binding captures its data:
in go: exec
var last: int = 0
on go {
let amount: int = await CustomEvent("dmg") // resume + capture DataOut1
last = amount
}
Annotate the binding’s type (let amount: int = ...): the event’s data ports are untyped in-game, so the annotation is what makes the wire carry the right variant. Without it the value defaults to a float and mis-delivers non-float data (WS055). A tuple let (p, t) = await CustomEvent("c") captures the data outputs positionally (p = DataOut1, t = DataOut2) but has no place to annotate them, so prefer one typed binding per value, or the handler form on CustomEvent("c") -> (p: int, t: float) { ... } when you want several typed at once. A bare await CustomEvent("c") with no binding just waits for the event.
The _ Placeholder
Inside an await expression, _ refers to the await’s armed flag – a bool that becomes true when the exec chain reaches the await point. Use _ with Sleep/SleepTicks to wire the armed flag as the buffer gate’s input:
on start {
doSetup()
await SleepTicks(_, delay = 60) // _ = armed flag, delayed 60 ticks (~1s)
doAfterDelay() // runs after the delay
}
Sleep / SleepTicks
Sleep(input, delay, hold) and SleepTicks(input, delay, hold) are buffer gates that delay a value passing through.
| Function | Gate | Delay unit | Params |
|---|---|---|---|
Sleep | BufferSeconds | seconds (float) | input, delay, hold |
SleepTicks | BufferTicks | ticks (int) | input, delay, hold |
input– the value to delay (use_for the await armed flag)delay– how long to wait before the output follows the input (optional)hold– how long to hold the output after the input drops to zero (optional, set to -1 to use delay instead)
Examples
in start: exec
in done: exec
var count: int = 0
on start {
count = 1
await done // exec chain pauses here
count = 2 // runs when 'done' fires
}
// Capture a value when a signal fires
on start {
let val = await score on done
use(val)
}
// Sleep for 2 seconds
on start {
await Sleep(_, delay = 2.0)
count = 99
}
Assignment
Assigns a new value to a mutable variable. Requires exec context.
target = expression
on RoundStart() {
count = count + 1
score = 0
name = "Player " .. playerId
}
Only var declarations are valid assignment targets. Attempting to assign to a let binding, parameter, or other non-variable produces a type error.
Indexed assignment works for arrays:
on trigger {
scores[i] = newScore
}
Compound Assignment
Compound assignment operators combine an operation with assignment:
| Operator | Equivalent | Gate Used |
|---|---|---|
+= | x = x + expr | IncVar |
-= | x = x - expr | Var_Get + Sub + Var_Set |
*= | x = x * expr | Var_Get + Mul + Var_Set |
/= | x = x / expr | Var_Get + Div + Var_Set |
%= | x = x % expr | Var_Get + Mod + Var_Set |
&= | x = x & expr | Var_Get + AND + Var_Set |
|= | x = x | expr | Var_Get + OR + Var_Set |
^= | x = x ^ expr | Var_Get + XOR + Var_Set |
<<= | x = x << expr | Var_Get + Shl + Var_Set |
>>= | x = x >> expr | Var_Get + Shr + Var_Set |
+= compiles to the dedicated IncVar gate (one gate instead of three). All others desugar to x = x OP expr (Var_Get + operation + Var_Set).
on tick {
counter += 1 // IncVar gate
health -= damage // Var_Get + Sub + Var_Set
mask &= 0xFF // Var_Get -> BitAnd -> Var_Set
bits <<= 1 // Var_Get -> Shift -> Var_Set
}
Expression Statement
Any expression can be used as a statement. This is primarily useful for calling exec functions that have side effects:
on RoundStart() {
DisplayText(ctrl, "Round Started!", fontSize = 30)
SetLocation(entity, newPos)
}
Built-in Events
These events are available as handler triggers. Parameters listed can be bound using the on Event() -> (param) tuple capture (or -> { field: local } record capture) — see Binding Event Data.
| Event | Parameters | Description |
|---|---|---|
RoundStart | roundNumber: int | Game round started |
RoundEnd | roundNumber: int | Game round ended |
CharacterSpawned | character: character | A character spawned |
CharacterDied | character: character, killer: character, killerWeapon: entity, killerWeaponName: string | A character died (killer / killerWeapon are who/what killed it) |
ControllerJoined | controller: controller, userId: string, userName: string | A player joined |
ControllerLeft | controller: controller, userId: string, userName: string | A player left (userId / userName stay valid even as the controller is torn down on disconnect) |
ControllerJoinedTeam | controller: entity, team: entity, userId: string, userName: string | A player joined a team (team is the team they joined) |
ControllerLeftTeam | controller: entity, team: entity, userId: string, userName: string | A player left a team |
ZoneEntered | character: character | A character entered a zone |
ZoneLeft | character: character | A character left a zone |
EntityZoneEntered | entity: entity | An entity entered a zone |
EntityZoneLeft | entity: entity | An entity left a zone |
ProjectileZoneEntered | character: character, projectile: entity, weapon: entity, weaponName: string | A projectile entered a zone (character is the shooter) |
ProjectileZoneLeft | character: character, projectile: entity, weapon: entity, weaponName: string | A projectile left a zone |
CharacterDamaged | character: character, damage: float, attacker: character, attackerWeapon: entity, attackerWeaponName: string | A character took damage |
CharacterFiredWeapon | character: character, direction: vector, start: vector, weapon: entity, weaponName: string | A character fired a weapon (start / direction are the shot ray’s origin and aim) |
BrickChanged | (none) | A brick was changed in a zone |
BrickRemoved | (none) | A brick was removed from a zone |
ChatCommand | controller: controller, arguments: string | A registered chat command was run. Takes config args for the command name + help text — see above |
return
The return statement terminates the current exec chain early. It can be used inside:
onhandlerschip onhandlersifblocks within handlersmodbodies (when called from exec context)
on RoundStart() {
if score > 100 {
return // skip the rest of this handler
}
score = score + 1
}
chip on CharacterDied() -> (character) {
lives = lives - 1
if lives <= 0 {
return // don't process further
}
health = 100
}
mod process(v: *int) {
if v < 0 { return } // early exit from mod
v = v * 2
}
return expr – Return with Value
For mods with a single output (declared with -> (name: type)), return expr sets the output value and exits:
mod clamp(v: int, lo: int, hi: int) -> (result: int) {
if v < lo { return lo }
if v > hi { return hi }
return v
}
let clamped = clamp(x, 0, 255) // clamped is directly an int
Single-output chips and mods auto-unwrap: let f = Foo(5) gives f the output type directly (e.g. int), no .result field access needed.
How Multiple Returns Compile
A single return expr wires the value directly to the output port (pure, zero-tick).
When a mod has multiple return expr statements, the compiler inserts a variable to hold the return value. Each return expr becomes a Var_Set before jumping to the return union, and a Var_Get after the union reads the result. This means multi-return mods have a one-tick latency on the return value (the var write is visible on the next tick for pure reads, but available immediately for subsequent exec-chain reads via Var_Get).
return is not allowed in pure context (outside exec chains).
Chips
Chips are Wirescript’s primary mechanism for organizing and reusing wire graph logic. They map to physical microchip bricks in Brickadia – a separate grid of gates contained within a single brick. (A module-level @flat compiles the same program onto one grid instead, with no microchip bricks at all.)
Contents
- Anonymous Chips
- Named Chips
constParameters andconst mod- Open and Closed Chips
- Compiling Without Microchips (
@flat) - Chip Labels and Headers
mod– Inline Chips- Boundary Pins
- Nested Chips
- Inline
modwith Mixed Parameters - Complete Example: Named Chip with Handlers
Anonymous Chips
An anonymous chip creates a physical microchip for visual organization while sharing the parent scope. Variables, buffers, and arrays declared inside an anonymous chip are accessible from outside it (and vice versa).
Basic Anonymous Chip
chip {
var x: int = 0
var y: int = 0
on trigger {
x = x + 1
}
}
// x and y are accessible here because anon chips share parent scope
out xValue = x.Value
Anonymous chips are purely organizational – they group related gates into a microchip brick without creating an isolated scope.
open Modifier
Chips compile open by default now (see Open and Closed Chips
below), so the open keyword is a redundant no-op kept for backward compatibility:
open chip {
var state: int = 0
// ...
}
chip let – Compact Let Bindings
chip let is shorthand for an anonymous chip containing one or more let bindings, separated by commas:
chip let score = c0 + c1 + c2 + c3
chip let doubled = score * 2, halved = score / 2
// Equivalent to:
chip {
let score = c0 + c1 + c2 + c3
}
chip {
let doubled = score * 2
let halved = score / 2
}
This is commonly used to wrap pure computations in their own microchip for layout clarity.
// From the 2048 example -- each chip let gets its own microchip
chip let moved = c0 != c0.prev || c1 != c1.prev || c2 != c2.prev
chip let score = c0 + c1 + c2 + c3 + c4 + c5
chip let emptyCount = (if c0 == 0 then 1 else 0) + (if c1 == 0 then 1 else 0)
chip on – Compact Handler
chip on is shorthand for an anonymous chip containing a single handler:
chip on trigger {
count = count + 1
}
// Equivalent to:
chip {
on trigger {
count = count + 1
}
}
This is the most common pattern for associating a handler with its own microchip:
chip on goLeft {
slide(c0, c1, c2, c3)
slide(c4, c5, c6, c7)
}
chip on goRight {
slide(c3, c2, c1, c0)
slide(c7, c6, c5, c4)
}
Negated and Combined Triggers in chip on
All trigger forms work with chip on:
// Negated trigger
chip on !running {
DisplayText(ctrl, "Game Over", fontSize = 48)
}
// Regular trigger
chip on moved {
// ...
}
Named Chips
Named chips define reusable components with explicit inputs and outputs. They create an isolated scope – the chip body cannot see the parent scope’s variables (except what is passed through parameters).
chip Name(params) -> outputs {
// body
}
Basic Named Chip
chip Counter(bump: exec, reset: exec) -> (value: int, overflow: bool) {
var n: int = 0
on bump {
n = n + 1
}
on reset {
n = 0
}
out value = n.Value
out overflow = n > 255
}
Parameters
Parameters are declared as name: type pairs in parentheses:
chip Adder(a: int, b: int) -> (sum: int) {
out sum = a + b
}
Destructured Parameters
Parameters can destructure record or tuple values directly in the signature, using the same { field, ... }: Type syntax as let destructuring. This is most useful for mod declarations that work with record types:
// Destructured parameters — fields are directly in scope in the body
mod distance({ x, y }: Point) -> int { return x + y }
mod process({ data, ...opts }: Config) { ... }
Each destructured name is bound directly, just as if written let { x, y } = param at the start of the body. The synthetic parameter name (_p0, _p1, …) is generated by the compiler and is not accessible in the body.
Tuple destructuring is also supported:
mod sum((a, b): (int, int)) -> int { return a + b }
Outputs
Outputs are declared after ->. A single output can omit the parentheses — it defaults to the name _:
// Single output (defaults to name "_")
chip Double(x: int) -> int {
out _ = x * 2
}
// As a mod with return
mod Double(x: int) -> int {
return x * 2
}
// Single-output chips auto-unwrap: f is directly an int
let f = Double(21) // f == 42, no .result needed
// Multiple named outputs (still use record access)
chip MinMax(a: int, b: int) -> (min: int, max: int) {
out min = if a < b then a else b
out max = if a > b then a else b
}
let mm = MinMax(3, 7) // mm.min == 3, mm.max == 7
Exec Outputs
Chips can have exec-typed outputs for signaling completion. Use out name: exec in the signature and emit name inside a handler:
chip Counter(bump: exec) -> (value: int, done: exec) {
var n: int = 0
on bump {
n = n + 1
emit done
}
out value = n.Value
}
Using Named Chips
Call named chips like functions. Each call creates a new physical microchip instance with independent internal state:
chip Double(x: int) -> int {
out _ = x * 2
}
let result = Double(21) // result = 42
// Multi-output chips return a record — access fields by name
chip MinMax(a: int, b: int) -> (min: int, max: int) {
out min = if a < b then a else b
out max = if a > b then a else b
}
let mm = MinMax(3, 7)
let lo = mm.min // 3
let hi = mm.max // 7
// Multiple calls create separate instances
let mm1 = MinMax(1, 10)
let mm2 = MinMax(5, 20) // independent from mm1
Note: The
-> (...)outputs declare the chip’s public interface. Useout name = exprin the body to wire values to those outputs. Do not redeclare the same name in both the signature and the body — this produces a warning.
Exec Chips
A chip whose body contains statement-level exec calls (array methods,
DisplayText, emit signal, …) is an exec chip — the body runs
as one exec chain. How it gets its trigger depends on the call site:
- Called inside a handler, the body joins the caller’s exec chain automatically and execution continues after it.
- Called outside an exec context (e.g. a top-level
let), pass the trigger as anexec = ...named argument — the same convention as builtin exec calls likeRandom(0, 10, exec = trigger).
in reset: exec
var vals: int[]
chip InitTables() -> (count: int) {
vals.clear()
vals.push(1)
out count = 1
}
let t = InitTables(exec = reset) // body runs whenever reset fires
A call with exec = also returns the chip’s completion exec as an exec
field on the result record (unless the chip declares its own exec output),
so callers can sequence on the body having run:
on start {
emit reset
await t.exec // resumes after InitTables finished
}
on t.exec { } // or treat it as an event
To bind body work to a specific exec parameter instead, declare the param
and use an on handler — the param does not implicitly invoke the body:
chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
Note: chip bodies reference top-level
vars,arrays, buffers, and record bindings freely — wire refs cross chip boundaries, so the body’s gates connect to the outer nodes directly (valsabove).
Reference Parameters (ref T / *T)
When a chip parameter has type ref T (or equivalently *T), the caller passes a variable by reference. The chip can both read and write the variable.
chip Increment(counter: ref int, amount: int) {
on trigger {
counter = counter + amount
}
}
When a chip has ref parameters, its body is automatically placed in exec context (because ref access implies mutable state operations).
The *T syntax is an alternative shorthand:
// These declarations are equivalent:
chip Swap(a: ref int, b: ref int) { ... }
chip Swap(a: *int, b: *int) { ... }
At call sites, pass variables directly – the compiler handles the reference wiring:
var x: int = 10
var y: int = 20
Swap(x, y) // x and y are passed by reference
const Parameters and const mod
A mod or chip parameter can be marked const, requiring the caller’s
argument to be a compile-time constant. Inside the body the parameter reads
as one – usable anywhere a const binding is (see
const – Compile-Time Binding):
mod ping(channel: const string, v: int) {
SendCustomEvent(channel, v)
}
in go: exec
var hp: int = 0
on go { ping("died", hp) } // "died" bakes as the event's channel name
A non-constant argument in a const slot is WS046:
in name: string
mod ping(channel: const string) { SendCustomEvent(channel) }
on go { ping(name) } // WS046 -- 'name' is a runtime value
v above stays an ordinary wired parameter – a mod/chip can freely mix
const and non-const parameters. A const parameter costs no pin: it
never becomes a MicrochipInput, so it doesn’t shift the pin index of any
parameter that follows it. For a chip (not a mod), each distinct
constant value passed to a const parameter gets its own compiled instance
– two call sites passing the same constant share one instance, since the
body is cached by a template key that includes the const value.
const mod
const mod marks every parameter of an inlined chip const in one step –
equivalent to writing const on each parameter individually:
const mod double(n: int) -> int { return n * 2 }
A call to a const mod is itself const-evaluable (see
What’s const-evaluable), so it can be
used to build a const value out of one or more calls – including calls to
other const mods and reads of module-level constants.
const chip is a parse error – a chip compiles to one shared physical
microchip template reused across call sites, so “every parameter is const”
isn’t a property the declaration can have the way it is for an inlined
mod. Mark the parameters you need constant individually instead:
const chip C(v: int) -> (r: int) { out r = v } // error -- use a mod, or const params
chip C(name: const string, v: int) -> (r: int) { out r = v } // fine
A const BINDING inside a named chip’s body is compile-time throughout that
chip – its handlers, its outs, and its constant-only config slots – the
same as inside a mod. Two places it is not: the body of an anonymous
chip { }, and a chip declared inside another chip, which does not inherit
the enclosing chip’s body constants (pass them in as const parameters). Both
are reported rather than silently dropped; see
Where const is allowed.
A const mod may declare multiple outputs, and its result destructures exactly
as an ordinary multi-output mod’s does. One rule is const-specific: the wire
graph keeps whichever assignment to an output comes FIRST in source order, so a
valued return placed after an out to the same output would disagree with
it, so it is rejected (WS046) rather than shipping a mismatch.
const mod pick(n: const int) -> (r: int) {
out r = 111
if n > 0 { return 222 } // WS046 -- disagrees with the wire graph
}
The mixed-parameter macro pattern
Because const and ordinary parameters mix freely, a mod can behave like a
small macro that both configures a gate at compile time and wires a runtime
value through it in the same call:
mod tagged(label: const string, amount: int) {
SendCustomEvent("evt_" .. label, amount)
}
in go: exec
var score: int = 0
on go {
tagged("score", score) // "evt_score" bakes; score stays a live wire
}
const if and tree-shaking
An if whose condition is const-evaluable is resolved at compile time: the
taken branch is checked and lowered normally, and the untaken branch is
dropped before type-checking ever looks at it.
const MODE = 1
var x: int = 0
in go: exec
on go {
if MODE == 1 {
x = 1
} else {
x = someModeTwoOnlyFunction() // never type-checked -- MODE is always 1
}
}
This is what lets one mod/chip serve call sites that share no common API:
a branch that wouldn’t even compile for a given constant value is simply
never checked for it. The editor’s hover on a dropped block explains why it
was dropped (e.g. `MODE == 1` is true here).
This does not work for a const parameter. A mod/chip body is
type-checked exactly once, before any call site exists – there is no real
argument yet to decide the branch with. A const parameter is seeded with a
type-shaped placeholder during that one-time check, and the placeholder is
deliberately never allowed to decide a branch: letting it choose would ship a
branch the placeholder happened to select while the OTHER branch – the one a
real call actually takes – goes unchecked. So both branches of an if on
a const parameter (or a const derived from one) are type-checked, and
each must be independently valid code, even though only one of them lowers
per call site:
var x: int = 0
mod f(mode: const int) {
if mode == 1 {
x = 1
} else {
x = someOtherModesFunction() // IS type-checked, even if every call site passes mode = 1
}
}
Both arms must compile for every possible constant value, not just the ones
any call site actually passes. A top-level const – or one read from inside
a mod body that is not itself a parameter – does not have this restriction;
only the parameter itself, and anything derived from it, carries the
placeholder.
Open and Closed Chips
Non-root chips compile open by default: each chip’s inner grid renders
as an upright plane facing the same side of the chip as its @bottom
rerouter pins, stacked in a wall above the placed microchip brick. The
root module’s plane sits at the bottom, its bottom edge just above the
brick; directly-nested chips occupy a row above it; deeper nesting stacks
higher still. Rows are centred and packed side by side, ordered first by
parent position, then by source order within a parent. Within a plane the
dataflow axis runs bottom-to-top: input gates sit at the bottom edge,
outputs at the top.
Annotate a chip with @closed to collapse it instead. A closed chip still
reserves its slot in the wall, so opening it later in-game reveals it in
place:
@closed chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
@closed chip {
var scratch: int = 0
}
@closed works on every chip form – chip Name(...), chip { ... },
chip on, and chip let. open chip { ... } still parses, but is now a
redundant no-op; combining @closed with open on the same chip is an
error.
Compiling Without Microchips (@flat)
A module-level @flat at the top of the entry file inlines every chip body
into the module that instantiates it. The program then emits no microchip
bricks and no nested grids – every gate lands on one grid, and a wire that
would have crossed a chip wall becomes an ordinary same-grid wire instead of a
pair of wires through a boundary pin.
Nothing about the program’s behavior changes. A chip is not a scoping or timing boundary, so a flattened program computes exactly what the nested one did; what goes away is the tree of planes to open and the per-crossing pin.
Because the microchip brick and its plane no longer exist, @closed and
@label have nothing to describe under @flat. They are inert rather than an
error, so a file can be compiled both ways without editing its chips.
See Statements for @flat’s placement rules and how it
composes with @layout(...).
Chip Labels and Headers
@label("text") overrides the display text on a chip’s shell-brick label
and its plane header:
@label("Score Tracker") chip {
var score: int = 0
}
@label also applies to in/out declarations at any nesting level, and
stacks with a @left/@right/@top/@bottom side annotation in either
order – see Outer Rerouter Pins.
There it overrides the port’s floating display label (and its rerouter pin
label); the port’s wiring-UI name is unaffected either way.
The @label argument may also be an expression: a constant folds to baked
text, and on a top-level var a runtime expression becomes a dynamic
label that shows the value live – see
Expression labels. A chip
label must be a constant (its shell brick has no wired text component).
Each opened plane with a title or doc comment gets a header at its top
edge: the title rendered as <size="96">...</>, with the chip’s /// doc
comment on the line below. The title is the chip’s @label text if
present, otherwise its name (the root chip’s title is the module name); an
anonymous, undocumented chip gets no header at all. Header text is passed
through raw, so rich-text tags inside a name or doc comment render as rich
text:
/// Counts ticks forever.
chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
mod declarations always inline into their callers and never become a
physical chip, so any annotation – @closed, @label, or a side pin –
on a mod is an error.
mod – Inline Chips
The mod keyword declares a chip that is always inlined at call sites. Instead of creating a separate microchip brick, the mod’s gates are expanded directly into the caller’s grid.
mod Name(params) {
// body -- no output
}
mod Name(params) -> (result: type) {
// body -- single output, use `return expr` to set it
}
Mod Outputs and return value
Mods can declare a single output with -> (name: type). Use return expr to set the output and exit:
mod clamp(v: int, lo: int, hi: int) -> (result: int) {
if v < lo { return lo }
if v > hi { return hi }
return v
}
let clamped = clamp(x, 0, 255) // clamped is directly an int
Note: A single
return exprwires the value directly (pure, zero-tick). Multiplereturn exprstatements cause the compiler to insert a hidden variable — each branch does aVar_Set, and aVar_Getafter the return union reads the result. This adds exec gates but is correct within the same tick’s exec chain.
When to Use mod
Use mod when:
- You want to reuse logic without the overhead of a physical microchip
- The logic is small and called many times
- You need the expanded gates visible in the parent grid
mod slide(a: *int, b: *int, c: *int, d: *int) {
if a == 0 { a = b; b = c; c = d; d = 0 }
if b == 0 { b = c; c = d; d = 0 }
if c == 0 { c = d; d = 0 }
if a == b { a = a + b; b = 0 }
if b == c { b = b + c; c = 0 }
if c == d { c = c + d; d = 0 }
}
Variadic Parameters (...rest)
A mod (only a mod, never a chip) can end its parameter list with a ...rest
variadic that captures every argument past the fixed parameters. Because a mod is
inlined at each call site, rest is a compile-time tuple of the actual extra
arguments – there is no runtime variadic. A ...rest in the body then splats that
tuple onward into another call, one element per positional slot:
mod broadcast(name: const string, ...rest) {
SendGlobalCustomEvent(name, ...rest)
}
in amount: int
in kind: float
in go: exec
on go {
broadcast("hit", amount, kind) // -> SendGlobalCustomEvent("hit", amount, kind)
}
The fixed parameters are still required (passing fewer is an arity error); only the
trailing count is open. The same ...tuple splat works on any call – spread a bound
tuple, a tuple literal, or a multi-output result into consecutive arguments:
mod add3(a: int, b: int, c: int) -> (r: int) { return a + b + c }
in n: int
let pair = (2, 3)
out total = add3(n, ...pair) // -> add3(n, 2, 3)
Because the body is type-checked once (at the declaration, where the captured arity
isn’t known yet), a ...rest forward is not statically arity-checked against a
fixed-arity target – it is validated when the mod inlines at each call. Forwarding
into SendCustomEvent/SendGlobalCustomEvent (whose data slots accept any type) is
the common case and always sound.
mod vs chip
| Feature | chip | mod |
|---|---|---|
| Physical microchip | Yes | No (inlined) |
| Isolated scope | Yes | Expanded into caller |
Outputs with -> | Yes (multi) | Yes (single, with return expr) |
ref/* params | Yes | Yes |
| Reusable | Yes (separate instance per call) | Yes (expanded per call) |
mod declarations do not support output declarations with ->. Instead, their effect is through mutating ref parameters.
Boundary Pins
Every wire that crosses a chip wall – a value, an exec trigger, or a
variable reference alike – routes through a labeled MicrochipInput or
MicrochipOutput rerouter at that wall, one pin per external source, even
if the source isn’t a declared parameter. Multiple consumers inside the
chip fan out from the same pin rather than each getting their own. A
constant argument is the one exception – it’s inlined directly into the
gate that uses it rather than wired in, so it never costs a pin.
Nested Chips
Chips can be nested inside handler bodies and other chip bodies:
on trigger {
chip {
// This anonymous chip is inside the handler
let localCalc = a + b
// ...
}
chip {
// Another chip in the same handler
if condition { x = localCalc }
}
}
Nested anonymous chips still share their enclosing scope. Named chip declarations inside blocks create locally-scoped chip types.
// From the 2048 example -- deeply nested chips
if running && moved {
chip {
chip {
let b0 = 0
let b1 = b0 + if c0 == 0 then 1 else 0
// ...
}
let r = Random(0, emptyCount - 1)
chip {
let p0 = c0 == 0 && r == b0
// ...
}
chip {
if p0 { c0 = 2 }
// ...
}
}
}
Inline mod with Mixed Parameters
A mod can take both reference and value parameters. Value parameters are pure inputs; reference parameters allow mutation:
mod renderCell(c: *int, tid: *int, p: bool, px: float, py: float) {
let t = if p || c == 0 || c == c.prev then 0.0 else 0.15
let bucket = if c <= 4 then 0
else if c <= 16 then 1
else if c <= 64 then 2
else 3
let col = Fmt('{' .. bucket .. '}', 'eee4da', 'f2b179', 'f65e3b', 'edcf72')
let txt = '<color="${col}">${c}</>'
DisplayText(ctrl, txt, positionX = px, positionY = py, fontSize = 40, transition = t, textId = tid)
}
Complete Example: Named Chip with Handlers
chip Timer(tick: exec, reset: exec, limit: int) -> (value: int, expired: bool) {
var elapsed: int = 0
on tick {
if elapsed < limit {
elapsed = elapsed + 1
}
}
on reset {
elapsed = 0
}
out value = elapsed.Value
out expired = elapsed >= limit
}
Built-in Functions
Wirescript provides built-in functions that map directly to Brickadia wire graph gates. Each function is either pure (returns a value, no exec context needed) or exec (requires exec context and chains into the current execution flow).
Contents
- Notation
- Math / Trigonometry (Pure)
- Bitwise (Pure)
- Vector (Pure)
- Rotation / Quaternion (Pure)
- Color (Pure)
- Stateful Exec Values
- Select / Swap (Pure)
- Edge / Change Detectors
- Logical XOR (
^^) - String Operations (Pure)
- String Formatting (Pure)
- Array Methods (Exec)
- Player Input (Exec)
- Controller / Character Conversions (Exec)
- Camera / Aim (Exec)
- Display (Exec)
- Entity Getters (Exec)
- Entity Manipulation (Exec)
- Gamemode (Exec)
- Character (Exec)
- Controller (Exec)
- Broadcast Messaging (Exec)
- Audio (Exec)
- Entity Tags (Exec)
- Misc (Pure / Exec)
- Gate config properties
- Clock (Event)
- ChatCommand (Event)
- Custom Events
- Prefab Spawning (Exec)
- Raycasting (Exec)
- Random (Exec)
- Sleep / Delay (Pure)
- Exec Override
- Newer builtins
- Generic type syntax
- Maps (
var m: Map<K, V>) - Exec-flow gates (Union / Branch)
- Callable gate builtins
Notation
- Pure functions are expressions – they produce a value and can be used anywhere.
- Exec functions require an active exec context (inside an
onhandler). They are called as statements or in exec expressions. - Parameters marked with
?are optional.
Receiver Method Syntax
Many functions support receiver method syntax, where the first parameter is written before the dot instead of as a positional argument. Both forms are equivalent:
// Receiver form (preferred)
entity.SetLocation(pos)
// Traditional form
SetLocation(entity, pos)
Functions that support receiver syntax show both forms in the documentation below.
Math / Trigonometry (Pure)
All trig functions take and return float. Angles are in radians unless converted.
| Function | Signature | Description |
|---|---|---|
sin(x) | (x: float) -> float | Sine |
cos(x) | (x: float) -> float | Cosine |
tan(x) | (x: float) -> float | Tangent |
asin(x) | (x: float) -> float | Arc sine |
acos(x) | (x: float) -> float | Arc cosine |
atan(x) | (x: float) -> float | Arc tangent |
atan2(y, x) | (y: float, x: float) -> float | Two-argument arc tangent |
sinh(x) | (x: float) -> float | Hyperbolic sine |
cosh(x) | (x: float) -> float | Hyperbolic cosine |
tanh(x) | (x: float) -> float | Hyperbolic tangent |
asinh(x) | (x: float) -> float | Inverse hyperbolic sine |
acosh(x) | (x: float) -> float | Inverse hyperbolic cosine |
atanh(x) | (x: float) -> float | Inverse hyperbolic tangent |
exp(x) | (x: float) -> float | e^x |
ln(x) | (x: float) -> float | Natural logarithm |
sign(x) | (x: float) -> float | Sign (-1, 0, or 1) |
abs(x) | (x: float) -> float | Absolute value |
sqrt(x) | (x: float) -> float | Square root |
pow(x, exponent) | (x: float, exponent: float) -> float | Power |
clamp(x, min, max) | (x: float, min: float, max: float) -> float | Clamp to range |
round(x) | (x: float) -> float | Round to nearest integer |
floor(x) | (x: float) -> float | Round down |
ceil(x) | (x: float) -> float | Round up |
min(a, b) | (a: float, b: float) -> float | Minimum of two values |
max(a, b) | (a: float, b: float) -> float | Maximum of two values |
log(x, base) | (x: float, base: float) -> float | Logarithm with arbitrary base |
lerp(a, b, t) | (a: T, b: T, t: float) -> T | Linear interpolation; T is any math variant (see Easing/Tween) |
fmod(a, b) | (a: float, b: float) -> float | Floored modulo |
Deg2Rad(x) | (x: float) -> float | Degrees to radians |
Rad2Deg(x) | (x: float) -> float | Radians to degrees |
let angle = atan2(dy, dx)
let clamped = clamp(value, 0.0, 1.0)
let dist = sqrt(dx * dx + dy * dy)
let radians = Deg2Rad(90.0)
Bitwise (Pure)
| Function | Signature | Description |
|---|---|---|
BitCount(x) | (x: int) -> int | Count set bits (popcount) |
BitNand(a, b) | (a: int, b: int) -> int | Bitwise NAND (same as ~(a & b)) |
BitNor(a, b) | (a: int, b: int) -> int | Bitwise NOR (same as ~(a | b)) |
Note: ~(a & b) and ~(a | b) are automatically fused into single NAND/NOR gates by the compiler.
let bits = BitCount(flags)
Vector (Pure)
| Function | Signature | Description |
|---|---|---|
Vec(x, y, z) | (x: float, y: float, z: float) -> vector | Construct a vector |
Dot(a, b) | (a: vector, b: vector) -> float | Dot product |
Cross(a, b) | (a: vector, b: vector) -> vector | Cross product |
Normalize(v) | (v: vector) -> vector | Normalize to unit length |
Magnitude(v) | (v: vector) -> float | Length of vector |
MagnitudeSq(v) | (v: vector) -> float | Squared length (avoids sqrt) |
Distance(a, b) | (a: vector, b: vector) -> float | Distance between two points |
DistanceSq(a, b) | (a: vector, b: vector) -> float | Squared distance (avoids sqrt) |
ScaleVec(v, s) | (v: vector, scalar: float) -> vector | Scale vector by scalar |
RotToDir(rot) | (rot: vector) -> vector | Convert rotation to direction |
v.SplitVec() | (v: vector) -> {x, y, z: float} | Decompose vector (receiver on vector) |
Vector Receiver Methods
DistanceSq, MagnitudeSq, and RotToDir support receiver syntax on vector:
// Receiver form
let dsq = a.DistanceSq(b)
let msq = v.MagnitudeSq()
let dir = rot.RotToDir()
// Traditional form
let dsq = DistanceSq(a, b)
let msq = MagnitudeSq(v)
let dir = RotToDir(rot)
let pos = Vec(1.0, 2.0, 3.0)
let dir = Normalize(target - origin)
let dist = Distance(posA, posB)
let scaled = ScaleVec(velocity, 0.5)
Rotation / Quaternion (Pure)
Two rotation types: rotator is euler (pitch/yaw/roll, used by entity rotation),
quat is a quaternion produced by the conversion gates. Methods use the concise
receiver form.
| Function | Signature | Description |
|---|---|---|
Rotation(pitch, yaw, roll) | (float, float, float) -> rotator | Construct an euler rotator |
r.ToEuler() | (rotator) -> {Pitch, Yaw, Roll: float} | Split a rotator into components |
dir.ToRotation() | (vector) -> quat | Quaternion that points along dir |
q.ToDirection() | (quat) -> vector | Forward direction of q |
v.Rotate(q) | (vector, quat) -> vector | Rotate a vector by a quaternion |
q.Invert() | (quat) -> quat | Inverse rotation |
from.RotationTo(to) | (vector, vector) -> quat | Quaternion rotating from onto to |
a.AngleTo(b) | (quat, quat) -> float | Angle between two quaternions |
a.Slerp(b, alpha) | (quat, quat, float) -> quat | Spherical interpolation |
axis.RotationByAngle(angle) | (vector, float) -> quat | Quaternion from axis + angle (radians) |
q.ToAxisAngle() | (quat) -> {Axis: vector, Angle: float} | Decompose into axis + angle |
Quat(x, y, z, w) | (float, float, float, float) -> quat | Construct a quaternion from raw components |
q.SplitQuat() | (quat) -> {X, Y, Z, W: float} | Decompose into raw components |
a.QuatDot(b) | (quat, quat) -> float | Quaternion dot product |
let q = forward.ToRotation()
let spun = velocity.Rotate(q)
let mid = a.Slerp(b, 0.5)
let r = Rotation(0.0, 90.0, 0.0) // euler rotator
let yaw = r.ToEuler().Yaw
Color (Pure)
| Function | Signature | Description |
|---|---|---|
Color(r, g, b, a?) | (r: float, g: float, b: float, a?: float) -> color | Construct a color (linear RGBA, 0-1 range) |
ColorSRGB(r, g, b, a) | (int, int, int, int) -> color | Construct from sRGB bytes (0-255) |
ColorHex(hex) | (string) -> color | Construct from a hex string ("#ff8800") |
c.SplitColor() | (c: color) -> {r, g, b, a: float} | Decompose into linear components |
c.ToSRGB() | (color) -> {R, G, B, A: int} | Decompose into sRGB bytes |
c.ToHex() | (color) -> string | Hex string |
a.ColorBlend(b, alpha) | (color, color, float) -> color | Blend two colors (colour-space aware) |
SplitColor, ToSRGB, ToHex, and ColorBlend support receiver syntax on color.
Blend is a different gate – the math blend (an alias for lerp), which takes colours
as one of its variants but has no colour-space selection.
let red = Color(1.0, 0.0, 0.0)
let orange = ColorSRGB(255, 128, 0, 255)
let hex = orange.ToHex()
let parts = red.SplitColor() // parts.r = 1.0, parts.g = 0.0, ...
let mixed = red.ColorBlend(orange, 0.5)
Stateful Exec Values
| Function | Signature | Description |
|---|---|---|
Cycle(count) | (count: int) -> int exec | Returns 0,1,…,count-1 advancing each exec pulse |
Toggle() | () -> bool exec | Flips between false/true each exec pulse |
Select / Swap (Pure)
| Function | Signature | Description |
|---|---|---|
Select(cond, a, b) | (cond: bool, a: any, b: any) -> any | Returns a if false, b if true |
Swap(cond, a, b) | (cond: bool, a: any, b: any) -> {Output, OutputB: any} | Conditionally swap two values |
let bigger = Select(x > y, y, x)
let result = Swap(shouldSwap, left, right)
// result auto-unwraps to Output; result.Output and result.OutputB
// are swapped if shouldSwap is true
Edge / Change Detectors
| Function | Signature | Description |
|---|---|---|
Edge(input) | (input: bool) -> {Rising, Falling: bool} | Bool pulses on boolean transitions |
EdgeExec(input) | (input: float) -> {Rising, Falling: exec} | Exec pulses when a value rises/falls |
Changed(input) | (input: any) -> bool | Bool pulse when the input changes |
Change(input) | (input: any) -> any | Pulse the input value through when it changes |
Edge and Changed are pure: they produce a one-tick bool pulse (Rising on
false→true, Falling on true→false; Changed on any change). EdgeExec and
Change are their exec-flavored siblings — EdgeExec’s outputs fire exec
chains directly (use with on/await, like Timer(...).Expired), and
Change pulses the new value through whenever the input changes.
let edges = Edge(button)
on edges.Rising { count = count + 1 }
let health = EdgeExec(hp)
on health.Falling { ctrl.ShowStatusMessage("taking damage!") }
Logical XOR (^^)
The ^^ operator is boolean XOR — returns true if exactly one operand is true.
let either = a ^^ b // true if a or b but not both
Note: !(a && b) and !(a || b) are automatically fused into single NAND/NOR gates.
String Operations (Pure)
| Function | Signature | Description |
|---|---|---|
All string functions support receiver syntax on string: |
| Function | Signature | Description |
|---|---|---|
s.Length() | (s: string) -> int | String length |
s.Contains(search, caseSensitive?) | (s: string, search: string, caseSensitive?: bool) -> bool | Check if string contains substring |
s.StartsWith(prefix, caseSensitive?) | (s: string, prefix: string, caseSensitive?: bool) -> bool | Check prefix |
s.EndsWith(suffix, caseSensitive?) | (s: string, suffix: string, caseSensitive?: bool) -> bool | Check suffix |
s.Find(search, caseSensitive?, start?) | (s: string, search: string, caseSensitive?: bool, start?: int) -> int | Find substring index (-1 if not found) |
s.Substring(start, length) | (s: string, start: int, length: int) -> string | Extract substring |
s.Replace(search, replacement, caseSensitive?, maxReplacements?, start?) | (s: string, search: string, replacement: string, caseSensitive?: bool, maxReplacements?: int, start?: int) -> string | Replace occurrences |
s.Split(delimiter, occurrence?, caseSensitive?) | (s: string, delimiter: string, occurrence?: int, caseSensitive?: bool) -> {Left, Right: string, Found: bool} | Split at the delimiter |
s.ToLower() | (s: string) -> string | Convert to lowercase |
s.ToUpper() | (s: string) -> string | Convert to uppercase |
s.Trim() | (s: string) -> string | Remove leading/trailing whitespace |
s.ParseInt() / ParseInt(s) | (s: string) -> int | Parse an integer from text |
s.ParseNumber() / ParseNumber(s) | (s: string) -> float | Parse a number from text |
let name = "Hello World"
let len = name.Length() // 11
let has = name.Contains("World") // true
let low = name.ToLower() // "hello world"
let sub = name.Substring(6, 5) // "World"
let parts = name.Split(" ") // parts.Left = "Hello", parts.Right = "World"
String Formatting (Pure)
| Function | Signature | Description |
|---|---|---|
Fmt(format, a?, b?, c?, d?, e?, f?, g?) | (format: any, a-g?: any) -> string | Format text with placeholders |
The Fmt function wraps the FormatText gate. The format string uses {0} through {6} placeholders corresponding to inputs a through g.
let label = Fmt("{0}: {1}", "Score", score)
let coords = Fmt("({0}, {1}, {2})", x, y, z)
// Also works for palette selection:
let col = Fmt('{' .. bucket .. '}', 'eee4da', 'f2b179', 'f65e3b')
Array Methods (Exec)
Methods on an array variable. All run in exec context (they lower to ArrayVar
exec gates), so call them inside on handlers / mods. Declare arrays with
var name: T[] (see statements).
The element type can be a record (var pts: Point[]): the array is stored as
one parallel array per field and each method fans out. sort/shuffle, the
aggregates, and the dual-array ops have no per-field meaning there and are
rejected with WS050. See Records as storage.
| Method | Signature | Description |
|---|---|---|
arr.push(value) | (value: T) | Append an element |
arr.pop() | () -> T | Remove and return the last element |
arr.get(index) | (index: int) -> {Value: T, OutOfBounds: bool} | Read the element at index (auto-unwraps to Value); .OutOfBounds flags a bad index — the explicit form of arr[i] |
arr.length() | () -> int | Number of elements |
arr.remove(index) | (index: int) | Remove the element at index |
arr.insert(index, value) | (index: int, value: T) | Insert before index |
arr.clear() | () | Remove all elements |
arr.find(value) | (value: T) -> {Index: int, Found: bool, Value: T} | Find the first match (auto-unwraps to Index); Index is -1 when Found is false |
arr.sort(descending?) | (descending?: bool) | Sort in place |
arr.sortMultiple(other, ..., descending?) | (other: T[], ..., descending?: bool) | Sort in place, reordering up to 7 parallel arrays through the same permutation |
arr.reverse() | () | Reverse in place |
arr.shuffle() | () | Randomly reorder |
arr.swap(a, b) | (a: int, b: int) | Swap two elements |
arr.fill(value) | (value: T) | Set every element to value |
arr.resize(size, value) | (size: int, value: T) | Grow/shrink, filling new slots with value |
arr.sum() | () -> T | Sum of elements |
arr.min() / arr.max() | () -> T | Smallest / largest element |
arr.average() | () -> float | Mean of elements |
arr.append(source) | (source: T[]) | Append all elements of another array |
arr.copyFrom(source) | (source: T[]) | Replace contents with a copy of another array |
arr.slice(source, start, count) | (source: T[], start: int, count: int) | Copy source[start..start+count] into this array |
arr.fillFromPlayers() | () | Fill with all current players |
arr.fillFromTeam(team) | (team: entity) | Fill with the members of a team |
Element access uses bracket syntax: arr[i] reads (with .value / .bOutOfBounds),
arr[i] = x writes.
exec =. Any exec-gate call — an array method, a builtin, or a mod/chip call —
accepts an exec = <trigger> argument that drives its exec input, firing the gate each
time the trigger’s value changes. A per-index, always-nonzero trigger like index + 1
turns an array into a single-gate lookup table read straight from a pure binding:
var lut: color[] = [ /* ...constant entries... */ ]
out c: color = lut.get(i, exec = i + 1).Value
var scores: int[]
on RoundEnd() {
scores.push(currentScore)
scores.sort(true) // descending
let best = scores.max()
let count = scores.length()
}
sortMultiple sorts the receiver and drags parallel arrays along, which is how
you sort records by one field and keep the others attached:
var scores: Map<string, int>
var names: string[]
var points: int[]
in show: exec
on show {
scores.keys(names) // string[]
scores.values(points) // int[]
points.sortMultiple(names)
// points is sorted ascending and names[k] still owns points[k]
}
Sorting a copy and searching back with find is the alternative, and it ties
duplicate values to whichever entry matched first.
Player Input (Exec)
InputReader
character.InputReader() -> { Forward, Right, Up, Pitch, Yaw, Roll, MouseWheel, PressedC, PressedE, PressedQ, PressedLeftMouse, PressedRightMouse }
InputReader(character: character) -> { ...same fields... }
Read player input axes and pressed keys. Receiver on character.
Returns a record with fields:
Forward: float– forward/backward movement axis (-1 to 1)Right: float– left/right movement axis (-1 to 1)Up: float– up/down movement axis (-1 to 1)Pitch: float/Yaw: float/Roll: float– look axesMouseWheel: float– mouse wheel deltaPressedC/PressedE/PressedQ/PressedLeftMouse/PressedRightMouse: bool– key/button states
let input = char.InputReader()
let moving = input.Forward != 0.0 || input.Right != 0.0
let interacting = input.PressedE
GetInputs
character.GetInputs() -> { ...same fields as InputReader... }
GetInputs(player: character) -> { ...same fields... }
Sample the same twelve controls once, at the point the exec chain reaches this
call, rather than reading them continuously. The field names are identical to
InputReader’s, so only the context differs. Its operand also accepts a
persistent player, which wires straight into the character parameter.
Being exec form, it must sit on an exec chain; in pure position it reports
WS007.
on Clock(interval = 0.1) {
let input = char.GetInputs()
if input.PressedQ { char.ShowStatusMessage("q") }
}
Controller / Character Conversions (Exec)
These functions convert between entity types. They require exec context and support receiver syntax.
ControllerOf
entity.ControllerOf() -> controller
ControllerOf(entity: entity) -> controller
Get controller from entity. Receiver on entity.
CharacterOf
controller.CharacterOf() -> character
CharacterOf(controller: controller) -> character
Get character from controller. Receiver on controller.
on CharacterSpawned() -> (character) {
let ctrl = character.ControllerOf()
ctrl.DisplayText("Welcome!", fontSize = 24)
}
Camera / Aim (Exec)
GetAim
character.GetAim() -> { Origin: vector, Direction: vector }
GetAim(character: character) -> { Origin: vector, Direction: vector }
Reads the character’s camera/aim in a single gate. Returns a record:
Origin: vector— aim origin positionDirection: vector— aim direction vector
Receiver on character. Access the fields with .Origin / .Direction; both
share one gate, so reading both costs a single GetAim.
on trigger {
let aim = char.GetAim()
let origin = aim.Origin
let dir = aim.Direction
}
Display (Exec)
DisplayText
target.DisplayText(text, ...) -> int
DisplayText(target: controller, text: any, ...) -> int
Display HUD text to a player. Receiver on controller. Returns the resolved
textId (an int) so a later call can update or clear the same on-screen text.
The 2D layout ports are Vector2D composites, and the call feeds them one axis
at a time: positionX / positionY, anchorX / anchorY, and so on, each a
float. There is no vector-typed position or anchor – passing one is a
WS041 error. A constant axis bakes into the parent Vector2D data field; a
runtime value wires the matching sub-port. The call also exposes the scalar
styling below, plus fontSize / justify / easing / typeface / font, which
are constant-only data fields (not wire inputs).
DisplayText Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
target | controller | Yes | Player to display to |
text | any | Yes | Text content (auto-converted to string) |
positionX / positionY | float | No | 2D screen position, per axis |
anchorX / anchorY | float | No | 2D anchor point, per axis |
scaleX / scaleY | float | No | 2D scale, per axis |
pivotX / pivotY | float | No | 2D pivot point, per axis |
shadowOffsetX / shadowOffsetY | float | No | 2D drop-shadow offset, per axis |
angle | float | No | Rotation angle |
outlineSize | int | No | Text outline size |
outlineColor | color | No | Outline color |
fontColor | color | No | Font color |
shadowColor | color | No | Drop-shadow color |
miteredOutline | bool | No | Sharp (mitered) outline corners |
letterSpacing | float | No | Extra spacing between letters |
lineHeight | float | No | Line-height multiplier |
wrapWidth | float | No | Wrap width (0 = no wrap) |
skew | float | No | Italic-style skew |
zOrder | int | No | Draw order |
lifetime | float | No | Display duration (seconds) |
transition | float | No | Seconds to interpolate to the new state when re-emitted with the same textId |
textId | int | No | Unique ID for updating text in-place |
fontSize | int | No | Font size (constant only) |
justify | int | No | Justification: DisplayTextJustification.Left / .Center / .Right, or bare Left / Center / Right (constant only) |
easing | int | No | Transition curve: DisplayTextEasing.Linear / .EaseIn / .EaseOut / .EaseInOut, or bare Linear / EaseIn / EaseOut / EaseInOut (constant only) |
typeface | int | No | Typeface: TextTypeface.Regular / .Bold / .Italic / .BoldItalic, or bare Regular / Bold / Italic / BoldItalic (constant only) |
font | asset ref | No | Font asset reference (constant only) |
on RoundStart() {
ctrl.DisplayText("Round Start!", fontSize = 48, lifetime = 3.0)
}
// Update text in-place: capture the id, then re-display with the same textId.
on trigger {
let id = ctrl.DisplayText("Score: ${score}", fontSize = 24, lifetime = 10.0)
ctrl.DisplayText("Score: ${score}", textId = id, transition = 0.25)
}
Entity Getters (Exec)
All entity getter functions require exec context and support receiver syntax on entity.
GetLocation
entity.GetLocation() -> vector
GetLocation(entity: entity) -> vector
Get entity’s world position.
GetRotation
entity.GetRotation() -> rotator
GetRotation(entity: entity) -> rotator
Get entity’s world rotation.
GetLocationRotation
entity.GetLocationRotation() -> {Vector: vector, Rotation: rotator}
GetLocationRotation(entity: entity) -> {Vector: vector, Rotation: rotator}
Get both position and rotation at once.
GetLinearVelocity
entity.GetLinearVelocity() -> vector
GetLinearVelocity(entity: entity) -> vector
Get entity’s linear velocity.
GetAngularVelocity
entity.GetAngularVelocity() -> vector
GetAngularVelocity(entity: entity) -> vector
Get entity’s angular velocity.
GetVelocity
entity.GetVelocity() -> {Vector: vector, Rotation: rotator}
GetVelocity(entity: entity) -> {Vector: vector, Rotation: rotator}
Get both linear and angular velocity at once.
on trigger {
let pos = entity.GetLocation()
let rot = entity.GetRotation()
let vel = entity.GetLinearVelocity()
}
Entity Manipulation (Exec)
All entity manipulation functions require exec context and support receiver syntax on entity.
SetLocation
entity.SetLocation(pos: vector)
SetLocation(entity: entity, pos: vector)
Set entity position. Use this (not Teleport) to move an entity to world
coordinates. pos is a real vector port.
SetRotation
entity.SetRotation(rot: rotator)
SetRotation(entity: entity, rot: rotator)
Set entity rotation.
SetLocationRotation
entity.SetLocationRotation(pos: vector, rot: rotator)
SetLocationRotation(entity: entity, pos: vector, rot: rotator)
Set both position and rotation.
AddLocationRotation
entity.AddLocationRotation(pos: vector, rot: rotator)
AddLocationRotation(entity: entity, pos: vector, rot: rotator)
Add to position and rotation.
Teleport
entity.Teleport(dest: any)
Teleport(entity: entity, dest: any)
Teleport entity to destination.
RelativeTeleport
entity.RelativeTeleport(source: any, dest: any)
RelativeTeleport(entity: entity, source: any, dest: any)
Relative teleport between two points.
SetVelocity
entity.SetVelocity(linear?: vector, angular?: vector)
SetVelocity(entity: entity, linear?: vector, angular?: vector)
Set velocity. Both linear and angular are optional – pass whichever components you want to set.
AddVelocity
entity.AddVelocity(linear?: vector, angular?: vector)
AddVelocity(entity: entity, linear?: vector, angular?: vector)
Add to velocity. Both linear and angular are optional.
SetLinearVelocity
entity.SetLinearVelocity(vel: vector)
SetLinearVelocity(entity: entity, vel: vector)
Set linear velocity only.
SetAngularVelocity
entity.SetAngularVelocity(vel: vector)
SetAngularVelocity(entity: entity, vel: vector)
Set angular velocity only.
SetGravityDirection
entity.SetGravityDirection(rot: rotator)
SetGravityDirection(entity: entity, rot: rotator)
Set gravity direction for entity.
SetFrozen
entity.SetFrozen(frozen: bool)
SetFrozen(entity: entity, frozen: bool)
Freeze or unfreeze an entity’s physics.
on trigger {
entity.SetLocation(Vec(0.0, 0.0, 100.0))
entity.SetVelocity(linear = Vec(0.0, 0.0, 500.0))
entity.AddVelocity(linear = direction, angular = Vec(0.0, 90.0, 0.0))
}
Gamemode (Exec)
SetLeaderboard
controller.SetLeaderboard(key: string, value: any)
SetLeaderboard(controller: controller, key: string, value: any)
Set a leaderboard value. Receiver on controller.
IncLeaderboard
controller.IncLeaderboard(key: string, value: any)
IncLeaderboard(controller: controller, key: string, value: any)
Increment a leaderboard value. Receiver on controller.
GetLeaderboard
controller.GetLeaderboard(key: string) -> any
GetLeaderboard(controller: controller, key: string) -> any
Get a leaderboard value. Receiver on controller.
GetTeam
character.GetTeam() -> any
GetTeam(character: character) -> any
Get a character’s team. Receiver on character.
IsBuilderTeam / IsUnaffiliatedTeam
team.IsBuilderTeam() -> bool
IsBuilderTeam(team: entity) -> bool
team.IsUnaffiliatedTeam() -> bool
IsUnaffiliatedTeam(team: entity) -> bool
Pure predicates over a team entity: IsBuilderTeam is true for the builder
team, IsUnaffiliatedTeam for the unaffiliated (no-team) group. Receiver on the
team entity.
PlayerWins / TeamWins
player.PlayerWins(teamWinsInstead?: bool)
PlayerWins(player: controller, teamWinsInstead?: bool)
team.TeamWins()
TeamWins(team: entity)
End the current round by declaring a winner. (The old imperative EndRound
gate was removed; a round now ends via a win.) PlayerWins declares a player
the winner, or their team if teamWinsInstead is true; TeamWins declares a
team the winner.
GetCurrentRound
GetCurrentRound() -> int
The current round number.
GetTeamByName / GetTeamName
GetTeamByName(name: string) -> entity
team.GetTeamName() -> string
GetTeamName(team: entity) -> string
Look up a team by name, or get a team’s display name.
SetTeam
controller.SetTeam(team: entity, pin?: bool)
SetTeam(controller: controller, team: entity, pin?: bool)
Assign a player to a team, optionally pinning them to it.
Team leaderboards
team.GetTeamLeaderboardValue(key: string) -> int
team.SetTeamLeaderboardValue(key: string, value: int)
team.IncrementTeamLeaderboardValue(key: string, value: int)
Read, set, or add to a team-scoped leaderboard value. Receiver on the team
entity (also callable as free functions with team as the first argument).
on CharacterDied() -> (character) {
let ctrl = character.ControllerOf()
ctrl.IncLeaderboard("deaths", 1)
let score = ctrl.GetLeaderboard("score")
}
Character (Exec)
ShowHint
character.ShowHint(title: string, text: string)
ShowHint(character: character, title: string, text: string)
Display a hint popup to a character. Receiver on character.
on CharacterSpawned() -> (character) {
character.ShowHint("Welcome", "Press E to interact")
}
Damage
character.GetDamage() -> { Damage: float, DamageLimit: float }
character.SetDamage(damage: float)
character.IncDamage(amount: float)
Read, set, or add to a character’s accumulated damage. Receiver on character.
GetDamage() auto-unwraps to Damage where a float is expected (e.g.
if char.GetDamage() > 50.0), and .DamageLimit gives the death threshold.
SetTempPermission
character.SetTempPermission(permission: string, enable: bool)
Grant or revoke a temporary permission tag on a character. Receiver on character.
Inventory
character.GiveWeapon(weapon, slot?) // set a slot to an item asset
character.AddInventoryItem(item) // append an item
character.SetInventoryItem(item, slot?) // set a slot to an item
character.AddInventoryBrick(brick, size?) // append a placeable brick
character.SetInventoryBrick(brick, slot?, size?)
character.AddInventoryEntity(entityType) // append a spawnable entity
character.SetInventoryEntity(entityType, slot?)
character.AddInventoryItemAdv(item, damage?, speed?, scale?, itemName?, projectile?)
character.SetInventoryItemAdv(item, slot?, damage?, speed?, scale?, itemName?, projectile?)
Give items, procedural bricks, or spawnable entities to a character’s
inventory. Asset args are $Type/Name references — $BRItemBase/... for
items, a brick asset for bricks, an entity type for entities — inlined into
the gate’s data. The Adv variants add per-item overrides: damage/weapon
speed/scale multipliers, a display-name override, and a projectile override.
All receive on character.
on CharacterSpawned() -> (character) {
character.GiveWeapon($BRItemBase/Weapon_Pistol, 0)
character.AddInventoryItemAdv($BRItemBase/Weapon_Bow,
damage = 2.0, itemName = "Longbow of Doom")
}
Controller (Exec)
ShowStatusMessage
controller.ShowStatusMessage(message: string)
ShowStatusMessage(controller: controller, message: string)
Display a status bar message to a player. Receiver on controller.
on RoundStart() {
ctrl.ShowStatusMessage("Round started!")
}
ShowChatMessage
controller.ShowChatMessage(message: string)
ShowChatMessage(controller: controller, message: string)
Send a chat message that only this player sees (a whisper). Receiver on
controller.
ShowMessageBox
controller.ShowMessageBox(message: string, title?: string)
Pop up a modal message box for this player. Receiver on controller.
Player info
controller.GetUserName() -> string
controller.GetUserId() -> string
controller.GetDisplayName() -> string
controller.IsTrusted() -> bool
controller.HasPermission(permission: string) -> bool
controller.HasRole(role: string) -> bool
controller.SetCanRespawn(canRespawn: bool)
controller.ForceRespawn()
controller.SetTeamPinned(pinned: bool)
Read a player’s account name, persistent user id, or current display name;
check whether they are trusted by the brick owner, hold a named permission, or
have a named role (HasRole); toggle their ability to respawn or immediately
respawn them (ForceRespawn, also callable as ForceRespawn(player)); or pin
them to their team. All receive on controller.
Broadcast Messaging (Exec)
BroadcastChatMessage(message: string)
BroadcastStatusMessage(message: string, flash?: bool)
Send a chat message or status-bar message to every player. flash
re-flashes the status message even when its text is unchanged.
on roundEnd {
BroadcastChatMessage("Red team wins!")
BroadcastStatusMessage("Round over", flash = true)
}
Audio (Exec)
entity.PlayAudioAt(audio, volume?, pitch?, innerRadius?, maxDistance?, spatialized?)
PlayGlobalAudio(audio, volume?, pitch?)
player.PlayClientAudio(audio, volume?, pitch?)
Play a one-shot sound at an entity’s location (spatialized by default),
globally for all players, or non-spatially for a single player
(PlayClientAudio, receiver on the player — a character or persistent player
reference; also callable as PlayClientAudio(player, audio, ...)). The audio
arg is a $BrickOneShotAudioDescriptor/... asset reference. PlayAudioAt
receives on entity (characters work too).
on ZoneEntered() -> (character) {
character.PlayAudioAt($BrickOneShotAudioDescriptor/BOSA_Buttons_Button_1_Press)
}
Entity Tags (Exec)
entity.SetTag(tag: string)
entity.GetTag() -> string
Attach an arbitrary string tag to any entity and read it back later — handy
for marking players/entities with game state (team, slot index, role). Zone
components can also filter on tags. Receiver on entity.
Misc (Pure / Exec)
| Function | Signature | Description |
|---|---|---|
FindPlayer(query) | (query: string) -> character (exec) | Look up a player by name; emits their character |
PrintToConsole(text) | (text: any) -> () (exec) | Print a value to the game console (debugging) |
Opaque(value) | (value: any) -> any (pure) | Identity rerouter; the permanent constant-fold barrier — the wrapped value always stays a real runtime wire, never folded or seen through (probe/test circuits; see Constant Folding) |
DeltaTime() | () -> float | Seconds elapsed since the previous tick |
ServerUptime() | () -> float | Seconds the server has been running |
ReadBrickGrid() | () -> entity | The brick grid this gate’s microchip is on, as an entity |
NearlyEqual(a, b, tolerance) | (a: float, b: float, tolerance: float) -> bool | Approximate float equality |
Dampen(target, smoothTime) | (target: float, smoothTime: float) -> float | Critically-damped smoothing toward a target |
Easing(a, b, blend, fn?, dir?) | (a: T, b: T, blend: float, fn?: any, dir?: any) -> T | Ease from a to b by blend |
Tween(target, duration, fn?, dir?) | (target: T, duration: float, fn?: any, dir?: any) -> T | Stateful eased value toward target |
Timer(limit, restart?, pause?, resume?) | (limit: float, restart?/pause?/resume?: exec) -> {Time: float, Expired: exec} | Stateful countdown timer |
Blend/lerp/Easing/Tween interpolate any one math variant T: float|int|vector|rotator|quat|color.
The result is whatever T the inputs carry.
Easing/Tween take an easing fn and dir: an int, a bare enum-name
literal, or an EasingFunction / EasingDirection value (see
Built-in game enums). function = EasingFunction.Bounce and function = Bounce set the same field. Functions:
Linear, Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back,
Elastic, Bounce. Directions: In, Out, InOut. Omitted, they default to
Linear/In.
Timer is a function-call instance. The restart/pause/resume exec
controls are optional; its outputs are a value (Time) and an exec (Expired):
in trigger: exec
let t = Timer(10.0, restart = trigger)
out elapsed = t.Time
on t.Expired { /* fired when Time reaches the limit */ }
ReadBrickGrid() is pure and takes no arguments — it returns the brick grid
that this gate’s microchip is placed on as an entity, ready to pass to entity
getters/setters or wire into gates that expect a brick grid:
let grid = ReadBrickGrid()
let origin = grid.GetLocation()
Gate config properties
Some gate settings are not wire inputs — they are the checkboxes, dropdowns, and values in a gate’s in-game settings menu. Wirescript sets them as optional, constant-only call arguments; a constant is baked into the gate’s data, and anything you omit keeps the game default. (A non-constant value is a compile error — these can’t be wired.)
Enum values are bare member names, validated against the game’s own enum
member list at compile time. An unknown name is a WS028 error; a raw int is
also accepted (and range-checked).
let e = Easing(0.0, 1.0, t, function = Bounce, direction = InOut)
let c = ColorBlend(a, b, t, blendSpace = Oklab, clampAlpha = true)
p.DisplayText("hi", typeface = Bold, justify = Center, easing = EaseInOut)
Each enum used this way is also a built-in game enum type of its own, so its qualified value form works too, side by side with the bare name, and the two set the same field:
let e = Easing(0.0, 1.0, t, function = EasingFunction.Bounce, direction = EasingDirection.InOut)
let c = ColorBlend(a, b, t, blendSpace = ColorSpace.Oklab, clampAlpha = true)
p.DisplayText("hi", typeface = TextTypeface.Bold, justify = DisplayTextJustification.Center, easing = DisplayTextEasing.EaseInOut)
Every config field is settable — not just the aliases below. In addition to
the friendly names, each gate exposes each bool/int/float/string/enum
settings-menu field under its raw game name, so any of the ~60 config gates
works even without a curated alias:
SweepSimple(500.0, Direction = X_Negative, bOnlyHitPlayerBodyParts = true)
p.DisplayText("hi", FontSize = 40, Typeface = Bold, Justification = Center)
Completion offers these raw field names, and hovering one shows its type (and enum members). The friendly alias and the raw name set the same field, so pick either; the table below lists the ergonomic aliases for the common gates.
Config attributes by gate:
| Gate | Config attributes |
|---|---|
Sweep | bodyPartsOnly |
SweepSimple | direction (EBrickDirection), spreadTowardCenter, detectBricks, detectPlayers1–4, bodyPartsOnly, detectPhysics, detectMap |
Blend | clampAlpha |
ColorBlend | blendSpace (EBRColorSpace), clampAlpha |
Slerp | shortestPath, clampAlpha |
Easing | function (EasingFunction, schema EBREasingFunction), direction (EasingDirection, schema EBREasingDirection) |
ConvertColor | fromSpace, toSpace (EBRColorSpace) |
DisplayText | fontSize, justify (DisplayTextJustification, schema EBRDisplayTextJustification), easing (DisplayTextEasing, schema EBRDisplayTextEasing), typeface (TextTypeface, schema EBRTextTypeface), font (a $Font/... asset ref) |
GetAim | localAim |
AddInventoryItemAdv / SetInventoryItemAdv | overrideColors, meshColors (a color array), ammoOverride |
Enum member names: EBRColorSpace Linear Srgb Oklab Hsv; EBREasingFunction
Linear Sine Quad Cubic Quart Quint Expo Circ Back Elastic Bounce;
EBREasingDirection In Out InOut; EBRTextTypeface Regular Bold Italic BoldItalic;
EBRDisplayTextJustification Left Center Right; EBRDisplayTextEasing
Linear EaseIn EaseOut EaseInOut; EBrickDirection X_Positive X_Negative Y_Positive Y_Negative Z_Positive Z_Negative.
Each of these schema enum types is also usable directly, under its clean
Wirescript name, as a built-in game enum –
EBREasingFunction as EasingFunction, EBREasingDirection as
EasingDirection, EBRColorSpace as ColorSpace, EBRTextTypeface as
TextTypeface, EBRDisplayTextJustification as DisplayTextJustification,
EBRDisplayTextEasing as DisplayTextEasing, and EBrickDirection as
Direction.
Clock (Event)
The Clock gate emits a periodic execution pulse forever; it reads as an event.
It takes interval and enabled, both wire inputs: a constant bakes into
the gate and a variable wires in, so the rate and the on/off state can change at
runtime. The handler body runs on each pulse.
in running: bool
var ticks: int = 0
on Clock(interval = 2.0, enabled = running) {
ticks = ticks + 1
}
ChatCommand (Event)
Registers a chat command. The call parens hold only config args (the
command name and an optional description); the event’s data outputs are bound
by a trailing -> (…) tuple capture (or -> { field: local } record):
- String literals fill the config fields in order:
CommandName, thenHelpText. The description can also be given by name asDescription = "...". - The
->capture binds the event’s data outputs, in order:controller(the player who typed it), thenarguments(the command text as a string).
on ChatCommand("greet", "Greets the player") -> (controller, arguments) {
// CommandName = "greet", HelpText = "Greets the player"
// controller: the player who typed the command
// arguments: the command text as a string
controller.ShowStatusMessage("You said: ${arguments}")
}
The description is optional and can use the named form. Binding params are also optional — omit the ones you don’t need:
on ChatCommand("wave", Description = "Wave at everyone") {
// no bindings needed
}
Custom Events
A named, cross-gate event channel that carries up to 8 data values. Each comes
in two flavours: personal (same-owner) and global (ownership-agnostic), on
separate channel namespaces — a personal "x" and a global "x" never mix.
SendCustomEvent (Exec)
SendCustomEvent(name, data1, … data8, target = …) — name is the channel, a
constant string baked into the gate (a variable or computed value is a WS028
error), followed by up to 8 optional data values of any type. target is an
optional entity whose grid receives the matching object events. Delivery is
same-owner; use SendGlobalCustomEvent for the ownership-agnostic version. Fires
all matching receivers.
A targeted send needs
isObject = trueon the receiver. Giving atarget(including the receiver spelling,ent.SendCustomEvent(...)) makes it an object event, and only an object-scoped receiver matches one. A plainon CustomEvent("x")is scoped grid-wide and silently never fires for it - no error, no warning, at check or compile. The two spellings pair up:ent.SendCustomEvent("ui.init", who) // targeted -> object event on CustomEvent("ui.init", isObject = true) -> (who: character) { } SendCustomEvent("ui.init", who) // untargeted -> grid-wide on CustomEvent("ui.init") -> (who: character) { }This is the usual way to talk to a spawned prefab: keep the entity
SpawnPrefabreturned and target it, withisObject = trueon the prefab’s receivers.
on hit {
SendCustomEvent("damage", 7, attacker) // send an int + a character
}
on CustomEvent (Event)
The receiver’s call parens hold only the channel name (positional) and any
config (isObject = true); the typed data outputs are bound by a trailing
-> (…) tuple capture. Each data slot’s type can be given explicitly
(amount: int) or inferred from a matching in-unit
SendCustomEvent on the same channel; when neither is available the slot
defaults to float and a WS042 warning is emitted (the game stores each
data slot as a typed value, not any). Unused slots default to float.
isObject = true is constant config that scopes the receiver to a specific
grid/object (an object event) instead of firing grid-wide.
var lastDamage: int = 0 // a top-level var is already persistent — no `static`
on CustomEvent("damage") -> (amount: int, attacker: character) {
lastDamage = amount
attacker.ShowStatusMessage("You took ${amount} damage")
}
Global variants — SendGlobalCustomEvent / on GlobalCustomEvent
SendGlobalCustomEvent and on GlobalCustomEvent are the ownership-agnostic
counterparts: delivery ignores the owner, reaching every matching global receiver.
They have the same shape (constant channel name, up to 8 typed data values,
optional target entity, isObject config), on a channel namespace that is
separate from the personal one — SendGlobalCustomEvent("x") reaches
on GlobalCustomEvent("x") but never on CustomEvent("x"), and vice versa.
var total: int = 0
on GlobalCustomEvent("score") -> (points: int) { total = total + points }
on hit { SendGlobalCustomEvent("score", 10) }
One-tick delay. A
CustomEventreceiver fires on the tick after theSendCustomEventruns — the pulse is delivered on the next frame, not synchronously within the sender’s exec chain. Anything that must observe the event’s effect immediately has to account for that one-tick latency (and a send → receive → send round trip costs a tick each hop).
Signature checking (
WS030). When a send targets a constant channel name, the compiler compares each data value’s wire type against the matching receiver’s declared param types and warns (WS030) on a mismatch — e.g. sending afloatwhere the receiver declaredint. This runs for both the personal (SendCustomEvent/on CustomEvent) and global (SendGlobalCustomEvent/on GlobalCustomEvent) pairs, each within its own namespace. Types that share a wire variant are interchangeable and never flagged (any two entity kinds —character/entity/controller/… — are all the sameObjectvariant). The channel name must be a constant literal, so every send’s receiver set is known at compile time. In the editor, go-to-definition on a send’s channel-name string jumps to the receiver.A non-constant data value is still typed as
floaton the wire at emit rather than from the value’s real type — full end-to-end typing waits on generics. For now the receiver’s annotations are the source of truth, and constant sends carry their type.
Prefab Spawning (Exec)
SpawnPrefab
| Parameter | Type | Required | Description |
|---|---|---|---|
prefab | prefab ref | No | The prefab to spawn — a $./file.brz archive, a $./file.ws source compiled on reference, or an inline $ triple-backtick block (prefab reference). Embedded into the bundle at compile. |
offset | vector | No | Spawn position offset |
rotation | rotator | No | Spawn rotation offset |
velocity | vector | No | Initial velocity of the spawned entity |
lifetime | float | No | Lifetime in seconds (0 = permanent) |
limit | int | No | Max concurrent instances |
destroyAll | exec | No | Wire an exec here; pulsing it destroys every entity this gate has already spawned. Independent of the spawn Exec, so one gate both spawns and clears. |
Returns: entity – the spawned entity. Give the prefab with a $…brz
reference; omit prefab to configure it on the placed gate in-game instead
(copy a prefab onto the Spawn Prefab brick).
on trigger {
let spawned = SpawnPrefab(
prefab = $./turret.brz,
offset = Vec(0.0, 0.0, 50.0),
lifetime = 10.0,
limit = 5
)
spawned.SetVelocity(linear = launchDir)
}
destroyAll is a secondary exec trigger (like Timer’s restart): wire a reset /
round-start signal into it to remove every entity this spawner has produced, without
re-spawning. The gate spawns when its own exec chain fires and clears when
destroyAll fires:
in reset: exec
on trigger {
let cube = SpawnPrefab(prefab = $./msg.brz, limit = 64, destroyAll = reset)
cube.SetTag(payload)
}
// pulsing `reset` destroys every cube this gate has spawned
A $./file.brz reference reads the .brz at compile and embeds it into the
output bundle (content-addressed at Prefabs/Uploads/<hash>.brz), so the
compiled program carries its prefab. A $./file.ws reference compiles that
source first and embeds the result, and a prefab can also be written inline as a
$ followed by a triple-backtick block. See
Prefab References and the per-entity
fan-out section in best practices.
SpawnExplosion
Spawns an explosion of a given projectile/explosion class.
| Parameter | Type | Required | Description |
|---|---|---|---|
projectileType | class ref | Yes | The explosion/projectile class — a $… asset reference (or a wired value) |
instigator | entity | No | The character/entity that caused it (kill credit, etc.) |
offset | vector | No | Spawn position offset |
scale | float | No | Explosion scale multiplier |
damage | float | No | Damage multiplier |
on hit {
SpawnExplosion($BRWeaponProjectile/Grenade, instigator = attacker, scale = 2.0, damage = 1.5)
}
SpawnExplosionAt
Like SpawnExplosion, but at an absolute world position instead of an offset
from the gate’s brick.
| Parameter | Type | Required | Description |
|---|---|---|---|
worldPosition | vector | Yes | Absolute world position to spawn the explosion at |
projectileType | class ref | Yes | The explosion/projectile class — a $… asset reference (or a wired value) |
instigator | entity | No | The character/entity that caused it (kill credit, etc.) |
scale | float | No | Explosion scale multiplier |
damage | float | No | Damage multiplier |
on hit {
SpawnExplosionAt(Vec(0.0, 0.0, 200.0), $BRWeaponProjectile/Grenade, instigator = attacker)
}
Raycasting (Exec)
Sweep
| Parameter | Type | Required | Description |
|---|---|---|---|
origin | vector | Yes | Ray start position |
direction | vector | Yes | Ray direction |
Distance | float | Yes | Maximum ray distance |
radius | float | No | Sphere radius (0 = line trace) |
relative | bool | No | Interpret origin/direction in the owning grid’s local frame |
ignore | entity | No | A single entity to exclude from hits |
ignoreList | entity[] | No | An array var of additional entities to exclude (on top of ignore) |
ignoreOwningGrid | bool | No | Exclude the grid this gate sits on (prevents self-hits) |
collisionChannel | int | No | Collision channel to sweep on (EBRSweepCollisionChannel: 0 Physics, 1 Weapon, 2 Interaction, 3 Tool, 4–7 Player1–4, 8 NoAdditionalRestriction) |
detectBricks | bool | No | Detect brick grids, including spawned prefabs — default false |
detectMap | bool | No | Detect the static world / environment — default false |
detectPhysics | bool | No | Detect physics-simulating objects — default false |
detectPlayers1–detectPlayers4 | bool | No | Detect players on collision channels 1–4 — default false |
Detection is opt-in. Every
detect*flag defaults tofalse, so a Sweep with none set detects nothing and always firesMiss. Enable the channel you want:detectBricksfor brick grids / spawned prefabs,detectPlayers1for players,detectPhysicsfor loose physics objects.
Returns a record with fields:
HitDistance: float– Distance to hit pointHitEntity: entity– Entity that was hitHitLocation: vector– World position of hitHitNormal: vector– Surface normal at hitHit: exec– Fires if something was hitMiss: exec– Fires if nothing was hit
on trigger {
let aim = char.GetAim()
// Run the Sweep INSIDE the exec handler; handle it with nested Hit/Miss branches.
// A top-level `Sweep(..., exec = t)` does NOT fire.
let r = Sweep(aim.Origin, aim.Direction, 10000.0,
radius = 5.0, ignore = char, detectPlayers1 = true)
on r.Hit { r.HitEntity.ShowStatusMessage("hit!") }
on r.Miss { /* nothing in range */ }
// If should also work here
if r.Hit { r.HitEntity.ShowStatusMessage("hit!") }
if r.Miss { /* nothing in range */ }
}
Random (Exec)
| Function | Signature | Description |
|---|---|---|
Random(min, max) | (min: int, max: int) -> int | Random integer in [min, max] |
Random(min, max) | (min: T, max: T) -> T, T ∈ vector/rotator/quat/color | Per-component random of the same type |
on RoundStart() {
let r = Random(0, 15)
if r == 0 { specialEvent = true }
}
Random rides the same PrimMath variant as the arithmetic operators, so its min/max may be a vector, rotator, quat, or color — it then rolls each component independently and returns that same type. Random(Vec(0.0, 0.0, 0.0), Vec(1.0, 1.0, 1.0)) is a random point in the unit cube; Random(a, b) on two colors is a random color between them (all four RGBA channels). Both bounds share the type of the result.
Note: Random is an exec function because it requires sequential execution to produce a new random value each time.
Sleep / Delay (Pure)
Buffer gates that delay a value passing through. Most useful with await and the _ armed flag placeholder.
| Function | Signature | Description |
|---|---|---|
Sleep(input, delay?, hold?) | (input: any, delay?: float, hold?: float) -> any | Delay by seconds (BufferSeconds gate) |
SleepTicks(input, delay?, hold?) | (input: any, delay?: int, hold?: int) -> any | Delay by ticks (BufferTicks gate) |
input– the value to delay. Use_insideawaitto wire the armed flag.delay– seconds/ticks to wait before the output follows the input.hold– seconds/ticks to hold the output after the input drops to zero. Set to -1 to use delay instead.
// Sleep 2 seconds using await
on start {
await Sleep(_, delay = 2.0)
doAfterDelay()
}
// Sleep 60 ticks (~1 second at 60Hz)
on start {
await SleepTicks(_, delay = 60)
doAfterDelay()
}
// Pure usage: delay a signal by 5 ticks
let delayed = SleepTicks(rawSignal, delay = 5)
Exec Override
Exec functions that are called outside of an exec context can be given an explicit exec named argument to provide the execution trigger:
// Outside a handler -- provide exec explicitly
let r = Random(0, 10, exec = someTrigger)
This wires someTrigger as the exec input of the gate, bypassing the requirement for an enclosing handler context.
Newer builtins
Player-reference gates (DisplayText, ShowChatMessage, HasRole, leaderboard and
team setters, the join/left/chat events, ControllerOf/CharacterOf) target the
persistent player-state on the current build. The controller type is unchanged and
still wires straight into them — existing scripts keep working.
Entity (Exec)
entity.GetSpeed() -> float // scalar speed
entity.GetVelocityAtPoint(point: vector) -> vector
entity.GetEntityTeam() -> entity // team of any entity (grid/prefab)
entity.SetEntityTeam(team: entity)
entity.IsFrozen() -> bool // whether the entity / brick grid is frozen
entity.DestroySpawned() // despawn a spawned entity
entity.DestroySpawnedPrefab() // despawn a spawned prefab
Character ammo (Exec)
character.GetAmmo(resource: entity) -> int
character.GrantAmmo(resource: entity, amount: int)
character.SetAmmo(resource: entity, amount: int)
character.GetInventoryEntry(slot: int) -> { Item, BrickAsset, EntityType }
character.GetCurrentInventorySlot() -> int
character.GetWeaponChamberAmmo(resource: entity, slot: int) -> int
character.IncWeaponChamberAmmo(resource: entity, slot: int, amount: int)
character.SetWeaponChamberAmmo(resource: entity, slot: int, amount: int)
The CharacterFiredWeapon(character, direction, start) event fires when a player
fires a weapon (direction/start are vectors). Sweep/SweepSimple results also
carry a HitColor field (the color of the surface hit).
Date / time (Pure)
GetUnixTime() -> int
FormatDate(unixTime: int, format: string, useUTC?: bool) -> { Output: string, Success: bool }
Value conversions (Pure)
Remap(value, inMin, inMax, outMin, outMax) -> float // rescale a value between ranges
LogicalShiftRight(a: int, b: int) -> int // logical (unsigned) >>
EnumToInt(value: enum) -> int // enum tag; folds a known variant, else uses the gate
IntToEnum(value: int, wrap?: bool) -> enum // enum type from context; const folds, runtime uses the gate
ItemToPickup(item: entity) -> entity // pickup asset for an item
color.ConvertColor(fromSpace?: int, toSpace?: int) -> color
"A".ToCharCode() -> { Codepoint: int, Success: bool }
FromCharCode(codepoint: int) -> { Character: string, Success: bool }
EnumToInt / IntToEnum are the gate-backed twins of .ToInt() (=
.Discriminant) and Enum.FromInt(n). EnumToInt requires an enum
argument (a non-enum is a type error); a compile-time-known enum folds to its
discriminant literal, a runtime enum routes through the gate. IntToEnum’s
result is an enum whose concrete type comes from the annotated target (like
FromInt); a constant tag folds to the enum record, a runtime tag routes
through the gate, and wrap clamps an out-of-range tag. See
enums.md.
ParseInt / ParseNumber likewise now expose a Success flag: they auto-unwrap to
their parsed Value in arithmetic/comparisons (ParseInt(s) == 5), and .Success is
false when the string wasn’t a valid number.
Self transform + simple raycast (Exec)
GetOwnTransform() -> { Location: vector, Rotation: rotator }
SweepSimple(distance: float, radius?: float, spreadConeAngle?: float)
-> { HitDistance, HitEntity, HitLocation, HitNormal, Hit, Miss }
SweepSimple sweeps from its own brick (the containing microchip’s brick, or the
gate’s own brick if not in a microchip), in the configured direction. It has no origin
input and takes no receiver. To sweep from an arbitrary point, use
the full Sweep(origin, direction, distance, ...) gate, which has a vector origin input.
distance is positional (SweepSimple(500.0, ...)).
Zone array fills (Exec) — array methods
arr.fillFromZoneEntities(zone, tagFilter?) // entities inside a zone
arr.fillFromZonePlayers(zone, tagFilter?) // players inside a zone
The character/entity zone enter/leave events also accept a tagFilter = argument
(alongside zone =) to restrict them to tagged entities.
Generic type syntax
Types may be written in generic form:
var nums: Array<int> = [1, 2, 3] // same as int[]
mod inc(v: Ref<int>) { v = v + 1 } // same as *int
Array<V> and Ref<V> are exact aliases of V[] and *V.
Maps (var m: Map<K, V>)
A map is a keyed variable collection (the MapVar gate family), declared as a var
of the generic Map<K, V> type. Keys must be int, string, or an object reference
(entity/character/controller) — any other key type is a WS039 error; values may
be any wire-storable scalar (int/float/bool/string/vector/rotator/quat/color/object).
A map starts empty unless given a constant literal initializer (= {} is the explicit
empty form).
A map value can also be a record (var m: Map<int, Point>): the map is stored
as one parallel map per field and set/get/has/length/remove/clear/keys fan
out (values/copyFrom are WS050). A record can never be a map key. See
Records as storage.
var scores: Map<string, int>
var names: string[]
on tick {
scores.set("alice", 10) // insert / overwrite
let g = scores.get("alice") // { Value, Found } — auto-unwraps to Value
if g.Found { PrintToConsole(g.Value) }
if scores.has("bob") { ... }
scores.remove("bob") // -> bool (was present)
let n = scores.length()
scores.keys(names) // fill an array with the keys
scores.clear()
}
Methods (exec context, like array methods): set(key, value), get(key),
has(key), remove(key), clear(), copyFrom(otherMap), length(),
keys(destArray), values(destArray).
Map literals
A var of Map<K, V> type can be given literal contents with { ... }.
Entries use => for any key expression, or : for a string / atom / int
literal key (or a bracketed [expr] computed key):
var m: Map<int, int> = { :red => 10, 7 => 0 } // arrow -- any key
var s: Map<string, int> = { "red": 1, "blue": 2 } // colon -- string literal key
var a: Map<int, int> = { :red: 1, :blue: 2 } // colon -- atom literal key
var e: Map<int, int> = {} // explicit empty map
on tick { m = { [runtimeKey] => x } } // computed key -- desugars
A constant map literal (every key and value a compile-time constant) in a
var initializer bakes straight into the map at rest – no runtime
gates, the map loads pre-populated. An initializer with any non-constant
entry doesn’t bake – its entries are dropped (with a compiler warning) and
the map loads empty; build it at runtime instead. Inside an exec handler,
m = { ... } (or a literal with runtime keys/values) desugars to clear()
followed by one set(key, value) per entry, in source order – the same
clear-then-populate shape as array literal assignment.
{ foo: 1 } with a bare identifier key is a record literal, not a map –
: only introduces a map key for a string/atom/int literal or a [expr]
computed key. Use foo => 1 or [foo]: 1 to key a map by an identifier’s
value.
Assigning a whole map from another map variable (m = m2) is not supported
– there is no whole-map-copy gate. Use m.copyFrom(m2) instead.
Exec-flow gates (Union / Branch)
Two exec-signal combinators, callable like any other builtin:
Union(a: exec, b: exec) -> exec
Branch(cond: bool, exec: exec) -> (A: exec, B: exec)
-
Union(a, b)merges two exec signals into one — the result fires whenever either input fires. Handy for running one handler from several triggers:let go = Union(init, Change(team)) on go { rebuild() } -
Branch(cond, exec)routes an incoming exec to.Aor.Bdepending oncondat the instant it fires (a runtimeiffor exec flow). Bind or trigger on the named outputs:on Branch(isRed, tick).A { redTick() } on Branch(isRed, tick).B { blueTick() }
Callable gate builtins
Every variable / array / map wire gate is also exposed as a plain function named after the in-game gate, alongside its method / operator / assignment form. The two forms are identical — the call desugars to the method or assignment at parse time — so pick whichever reads better. Named after the game gates for discoverability (and completion).
Variables
| Builtin | Same as |
|---|---|
GetVariable(v) | v |
SetVariable(v, x) | v = x |
IncrementVariable(v, n) | v = v + n |
Arrays — the function name maps to the array method of the same operation; the receiver is the first argument:
| Builtin | Same as | Builtin | Same as | |
|---|---|---|---|---|
GetArrayElement(a, i) | a.get(i) | SortArray(a, desc?) | a.sort(desc?) | |
SetArrayElement(a, i, x) | a[i] = x | ReverseArray(a) | a.reverse() | |
PushToArray(a, x) | a.push(x) | ShuffleArray(a) | a.shuffle() | |
PopFromArray(a) | a.pop() | SwapArrayElements(a, i, j) | a.swap(i, j) | |
InsertArrayElement(a, i, x) | a.insert(i, x) | SliceArray(a, src, s, n) | a.slice(src, s, n) | |
RemoveArrayElement(a, i) | a.remove(i) | AppendArray(a, src) | a.append(src) | |
GetArrayLength(a) | a.length() | CopyArray(a, src) | a.copyFrom(src) | |
FindArrayElement(a, x) | a.find(x) | SumArray(a) | a.sum() | |
ClearArray(a) | a.clear() | AverageArray(a) | a.average() | |
FillArray(a, x) | a.fill(x) | ArrayMaximum(a) | a.max() | |
ResizeArray(a, n, x) | a.resize(n, x) | ArrayMinimum(a) | a.min() |
Array fills (Gamemode / Zone gates — the function-call twins of the
fillFrom* array methods):
| Builtin | Same as |
|---|---|
FillArrayFromPlayers(a) | a.fillFromPlayers() |
FillArrayFromTeamMembers(a, team) | a.fillFromTeam(team) |
GetPlayersInZone(a, zone, tagFilter?) | a.fillFromZonePlayers(zone, tagFilter?) |
GetEntitiesInZone(a, zone, tagFilter?) | a.fillFromZoneEntities(zone, tagFilter?) |
Maps — map to the map methods of the same name:
| Builtin | Same as | Builtin | Same as | |
|---|---|---|---|---|
GetMapElement(m, k) | m.get(k) | ClearMap(m) | m.clear() | |
SetMapElement(m, k, v) | m.set(k, v) | CopyMap(m, src) | m.copyFrom(src) | |
HasMapElement(m, k) | m.has(k) | GetMapLength(m) | m.length() | |
RemoveMapElement(m, k) | m.remove(k) | GetMapKeys(m, out) | m.keys(out) | |
GetMapValues(m, out) | m.values(out) |
All array/map operations remain exec-context only (they run on the exec chain), exactly like their method forms.
Game Knowledge
Brickadia behaviour that the language does not define but that programs depend on. The language reference tells you what compiles; this page collects what the game does with the result, plus the practical tips that follow from it.
Contents
Rich Text Markup
Any string the game renders as text accepts markup: DisplayText,
BroadcastChatMessage, ShowStatusMessage, chat, and the text components a
@label writes. Markup is ordinary string content, so it composes with
interpolation and .. concatenation like any other text.
| Tag | Effect |
|---|---|
<b> | Bold |
<i> | Italic |
<br> | Line break. It is not a container, so it takes no close |
<color="RRGGBB"> | Text colour, hex. # optional, #RGB shorthand works |
<size="N"> | Point size |
<font="Name"> | Typeface, from the font list below |
<icon>Name</> | The named game icon, inline at text size |
<inputAction>Name</> | The key the reader has bound to that action |
<inputAxis>Name</> | The same, for an axis |
</> closes the most recently opened tag, so nesting reads as it does in HTML
apart from the closing form:
in go: exec
let hex = "ff4040"
on go {
BroadcastChatMessage('<b><color="${hex}">RED TEAM</></> wins')
}
Prefer single-quoted strings for anything containing markup. Both quote styles interpolate, and single quotes let the attribute’s own double quotes stand without escaping.
Input Glyphs
<inputAction>Jump</> renders the key each player has bound to that action, so
a prompt reads correctly for someone on a remapped keyboard or a controller
rather than hardcoding a key name. Axes take <inputAxis> instead.
in go: exec
on go {
BroadcastChatMessage('press <inputAction>Interact</> to draw a card')
}
Fonts
The 25 typefaces the game ships. Anything else falls back.
Aaaiight BadComic BlackOpsOne
Bungee CherryBombOne Cinzel
EagleLake GlacialIndifference GlacialIndifferencePlus
Gotfridus IosevkaTerm IosevkaTermSlab
Kurland MonaspaceArgon MonaspaceKrypton
MonaspaceNeon MonaspaceRadon MonaspaceXenon
MostWasted NotoSans NotoSerif
Orbitron PirataOne Roboto
RobotoMono
IosevkaTerm, IosevkaTermSlab, and the five Monaspace faces are monospaced,
which is what makes grid and table rendering line up.
Input Action Names
Every name accepted by <inputAction> and <inputAxis>. A * marks an axis,
which takes <inputAxis>; everything else takes <inputAction>.
Special Functions
OpenEscapeMenu SelfDestruct FreeMouse
PlayerList OpenEnvironmentDialog OpenAvatarCustomization
OpenOptions HideHUD ToggleSmoothCamera
ToggleFreezeCamera HoldToZoomCamera Teleport
TakeScreenshot TakeScreenshotNoUI
Movement
Turn * LookUp * TurnRate *
LookUpRate * MoveForward * MoveLeft *
Jump Sprint ToggleFlying
ToggleGhostFlying MoveUp * SwitchCameraMode
EmoteMenu ToggleFlashlight Duck
HoldToWalk Reload Inspect
Fire AltFire AltFire2
AltFire3
Chat
OpenChatBox ChatHistoryPageUp ChatHistoryPageDown
ChatHistoryLineUp ChatHistoryLineDown ChatHistoryStart
ChatHistoryEnd
Tools
OpenToolPieMenu UseBrickTool UseHammer
Paint UseResizeTool UseSelectionTool
UseApplicator UseManipulator Paint_PaintMaterial
Paint_FillPaint Paint_PickColor Hammer_CheckOwner
Selector_SplitGrid Selector_AddSelect Selector_ToggleSelect
Selector_SelectContraption Selector_SwitchMode Selector_SelectBox
Selector_DeselectBox Applicator_SwitchMode PasteSelectionWithOwnership
Resizer_ExtendToMax Resizer_ShrinkToMin Resizer_FindSize
ToolAlt Connector_BulkConnect Connector_SwitchMode
Connector_ReselectLastPort ManipulatorLaunch Manipulator_SoftPick
Manipulator_Detach Manipulator_HoldToRotate Manipulator_DoNotAttach
Building
ToggleBuilding BrickRotate OrbitMode
UndoAction RedoAction CopySelection
PasteSelection CutSelection DeleteSelection
FineSelection LockBrickAlignmentPlane BrickChangePlacementMode
BrickChangeAlignmentMode BrickSuperAlignmentMode BrickAlignToWorld
DetachedMode Placer_PastePhysics DeleteBrick
Builder_PickBrick Builder_PickColor Builder_PickBrickIntoQuickbar
Building (Keyboard)
ToggleDetachedMode BrickMoveAway * BrickMoveLeft *
BrickMoveUp * BrickDetachedTurn * BrickMoveUpPlate *
BrickPlant BrickDetachedReorient BrickDetachedRotate
Quickbar
Quickbar_Slot0 Quickbar_Slot1 Quickbar_Slot2
Quickbar_Slot3 Quickbar_Slot4 Quickbar_Slot5
Quickbar_Slot6 Quickbar_Slot7 Quickbar_Slot8
Quickbar_Slot9
Misc
Rename Find
Vehicles
LeaveSeat Vehicle_ZoomIn Vehicle_ZoomOut
DisplayText Screen Placement
DisplayText positions text with an anchor and a position offset, and
the two use different units. Getting this wrong puts the text off-screen, where
it looks identical to the text not drawing at all.
anchorX/anchorYare fractions of the screen.0is left / top,0.5is center,1is right / bottom. This is the only pair that takes a0..1value.positionX/positionYare an offset from that anchor in slate units, which are roughly pixels at 1080p – not a fraction. A realistic offset is in the tens or hundreds.positionX = 0.98is not “98% across the screen”, it is one pixel from the anchor.
Anchor to the corner you want, then offset inward with the sign that moves you away from that edge:
on show {
// top-right, inset 40 units from the right edge and 40 down from the top
ctrl.DisplayText("LAP 3",
anchorX = 1.0, anchorY = 0.0,
positionX = -40.0, positionY = 40.0,
fontSize = 28, justify = "Right")
// bottom-right, 300 in from the right and 460 up from the bottom
ctrl.DisplayText("STATUS",
anchorX = 1.0, anchorY = 1.0,
positionX = -300.0, positionY = -460.0,
fontSize = 24)
}
Defaults worth knowing
Unset parameters bake the gate’s own defaults, which are sane – an invisible overlay is a placement bug far more often than a scale or color one:
| Field | Default | Note |
|---|---|---|
Scale | (1, 1) | not zero, so unset scale never hides text |
FontColor | opaque white | not transparent |
Anchor | (0.5, 0.5) | screen center |
Position | (0, 0) | no offset from the anchor |
Pivot | (-1, 0.5) | |
FontSize | 16 | |
OutlineSize | 2 | text has an outline unless you pass 0 |
Lifetime | 5.0 | seconds; 0 means infinite, not “vanish now” |
TextId | 0 | 0 uses the brick’s persistent handle; a non-zero id is shared by every gate using it, which is how several gates update one piece of text |
lifetime interacts with how often you redraw. Text redrawn every tick wants a
short lifetime so it disappears if the drawing chip stops; text redrawn only on
a change wants 0 (infinite), or it blanks itself between updates.
The 2D layout ports are Vector2D composites and the call feeds them one axis
at a time, so every one of these is a float named ...X / ...Y. There is no
vector-typed position or anchor; passing one is a WS041 error.
Execution Context
Understanding exec vs pure context is fundamental to writing correct Wirescript. This distinction reflects how Brickadia’s wire graph engine actually executes: some gates define continuous signal relationships (pure), while others execute imperatively in response to events (exec).
Contents
- Two Contexts
- What Requires Exec Context
- How to Enter Exec Context
- Reading Variables: Exec vs Pure
- Handler Exec Chains
- Exec Context in Conditional Expressions vs Statements
- Summary of Context Rules
- Await
Two Contexts
Pure Context
Pure context is the default. Code in pure context defines continuous signal-flow relationships – like wiring gates together. Pure expressions re-evaluate whenever their inputs change.
What runs in pure context:
- Top-level
letbindings varinitializersoutbindingsbufferexpressions- Chip output expressions
// All of these are pure context:
var count: int = 0 // initializer is pure
let doubled = count.Value * 2 // let binding is pure
out result = doubled // out binding is pure
buffer prev = count.Value // buffer expression is pure
Exec Context
Exec context represents imperative, sequential execution triggered by an event. Code in exec context runs once per trigger, in order, along an exec chain.
What runs in exec context:
onhandler bodies- Code after
onhandlers at the same scope level (exec union) - Named chip bodies when the chip has
refparameters - Exec function calls (
DisplayText,Random,SetLocation, etc.)
// This is exec context:
on RoundStart() {
count = count + 1 // assignment requires exec
let r = Random(0, 10) // Random is an exec call
if r == 0 { // if statement requires exec
emit special // emit requires exec
}
DisplayText(ctrl, "Go!") // DisplayText is an exec call
}
What Requires Exec Context
The following operations are only valid in exec context:
| Operation | Why |
|---|---|
var = expr (assignment) | Writing to a variable gate requires an exec chain |
if { ... } (statement) | Conditional execution requires an exec branch gate |
emit event | Firing an event requires an exec signal |
| Exec function calls | Functions like Random, DisplayText, SetLocation have exec pins |
*var (explicit deref) | Reads the variable’s current-tick value via Var_Get; not allowed in pure context (WS006) |
The typechecker emits error WS007 when any of these appear outside exec context:
var n: int = 0
n = 1 // ERROR: WS007 -- var write 'n' outside an exec context
How to Enter Exec Context
1. Handler Bodies
The primary way to enter exec context is through an on handler:
on RoundStart() {
// Everything in here is exec context
count = 0
score = 0
}
2. Exec Union After Handlers
After a handler at the same scope level, subsequent statements automatically enter exec context. This is called “exec union” – the combined exit of all preceding handlers flows into later statements.
var count: int = 0
on RoundStart() {
count = 0
}
on CharacterDied() -> (c) {
count = count + 1
}
// This code runs in exec context -- after EITHER handler fires,
// the exec chain continues here:
if count > 10 {
// ...
}
This models how the wire graph works: the exec output of each handler merges into a union that feeds into subsequent exec nodes.
3. Chips with Ref Parameters
When a named chip has any ref T parameters, its body runs in exec context automatically. This is because reading/writing variable references requires exec:
chip Increment(n: ref int) {
// Body is automatically exec context because 'n' is ref
n = n + 1
}
4. Explicit Exec Argument
Exec functions called outside a handler can receive an explicit exec named argument:
// No enclosing handler, but providing exec explicitly
let r = Random(0, 10, exec = myTrigger)
This wires myTrigger as the exec input of the Random gate.
The same convention works for any exec gate — array and map methods
included. Because the trigger, not the surrounding chain, drives the gate, the
call becomes a leaf and is legal in a pure sink. A per-index,
always-nonzero trigger like i + 1 turns an array/map read into a single-gate
lookup wired straight from an out binding:
out c: color = lut.get(i, exec = i + 1).Value // pure — no handler needed
let v = scores.get(k, exec = Change(k).exec) // re-reads when k changes
The same convention applies to user-defined exec chips and mods: outside an
exec context, pass their trigger as exec = .... The call then also returns
the completion exec as an .exec field on the
result record, so callers can await r.exec or on r.exec { }. See
Exec Chips.
Reading Variables: Exec vs Pure
The behavior of a bare variable name depends on context:
In Exec Context
A var x: int has type ref int internally, but in exec context, the bare name x auto-dereferences to type int when used in expressions. When passed to a *T parameter (e.g., inc(x) where inc takes *int), it stays as a reference:
on RoundStart() {
// x auto-derefs: reads as int, writes as int
x = x + 1
let doubled = x * 2 // x is int here
}
Note: Even pure expressions like
x * 2use aVar_Getgate (exec) to readxwhen inside an exec context. This ensures the read is sequenced correctly in the exec chain — theVar_Getfires at the right point and its value output feeds the pure*gate. In pure context,xreads directly from the PseudoVar’sValueport (no exec gate).
You can also use *x as an explicit deref — it compiles to the same Var_Get gate and is equivalent to the bare name in exec context:
on tick {
let a = x // implicit deref via Var_Get
let b = *x // explicit deref — identical result
}
*x in pure context is an error (WS006): use .Value for pure reads.
In Pure Context
In pure context, the bare name x refers to the variable reference (ref int), not the value. To read the value, use .Value or .prev:
var count: int = 0
// Pure context:
out current = count.Value // .Value reads the current int value
out previous = count.prev // .prev reads the previous tick's value
// This would be an error (WS006) if the context expected int:
// out bad = count + 1 // count is ref int, not int, in pure context
.Value vs .prev vs *var
| Access | Context | Gate | Meaning |
|---|---|---|---|
x (bare) | Exec | Var_Get | Current tick’s value (auto-deref) |
*x | Exec | Var_Get | Current tick’s value (explicit deref, same as bare) |
*x | Pure | — | Error WS006 — use .Value |
x (bare) | Pure | — | Variable reference (ref T) |
x.Value | Either | .Value port | Previous tick’s value (delayed read) |
x.prev | Either | .Value port | Previous tick’s value (same as .Value) |
.prev is essential for change detection:
// Detect when count changes
chip let changed = count != count.prev
on changed {
DisplayText(ctrl, "Count changed!", fontSize = 24)
}
Handler Exec Chains
Handlers create exec chains – sequences of exec gates connected by their exec output pins. Each statement in a handler body is a link in the chain:
on RoundStart() {
count = 0 // Exec node 1
score = 0 // Exec node 2 (chained after 1)
let r = Random(0,5) // Exec node 3 (chained after 2)
if r == 0 { // Exec branch node (chained after 3)
bonus = 100 // Then-branch exec
}
// Exec continues after the if
}
The wire graph executes these in sequence: event fires, then node 1, then node 2, then node 3, then the branch.
Exec Context in Conditional Expressions vs Statements
There are two different if constructs with different context requirements:
if-then-else Expression (Pure)
The if-then-else expression is pure – it selects between two values based on a condition and produces a value. No exec context needed.
// Pure -- works anywhere
let abs = if x < 0 then -x else x
let label = if count == 1 then "item" else "items"
if { } Statement (Exec)
The if statement conditionally executes a block. It requires exec context.
// Exec -- must be inside a handler or after one
on trigger {
if score > highScore {
highScore = score
}
}
Summary of Context Rules
| Construct | Context | Notes |
|---|---|---|
var x = expr initializer | Pure | Initializer is pure even though var is mutable |
let x = expr | Pure (at top level) | Inside handlers: shares handler’s exec context |
out x = expr | Pure | Always pure – outputs are continuous signals |
buffer x = expr | Pure | Always pure |
on trigger { ... } body | Exec | Primary way to enter exec |
After on at same level | Exec | Exec union from all preceding handlers |
chip { ... } body (anon) | Inherits parent | Shares parent’s context |
chip Name(ref params) { ... } | Exec | Auto-exec from ref params |
chip Name(value params) { ... } | Pure (by default) | Unless handlers inside create exec |
mod Name(params) { ... } | Inherits call site | Inlined, takes caller’s context |
await expr | Exec | Rewires exec continuation to expr |
emit name | Exec | Bare emit requires exec |
emit name = expr | Either | Value emit works in pure or exec |
buffer(...) emit name | Exec | Buffered emit — delays delivery a tick+, legalises loops |
let name: exec | Either | Declares local exec signal |
Await
await suspends the current exec chain and resumes when the awaited expression fires. It rewires ctx.current_exec – no state machine, just exec redirection.
on start {
doSetup()
await ready // pause until 'ready' fires
doMain() // resumes here
}
Capture Values
on start {
let pos = await entity.GetLocation() on moveComplete
use(pos)
}
let x = await val on trigger sets exec continuation to trigger and binds val to x.
Race (first trigger wins)
on start {
await timeout || userInput || cancel
}
|| in the expression creates a Union gate – first exec wins.
Local Exec Signals
let name: exec declares a local synchronization point. emit name fires it from any handler. await name or on name listens for it — the listener runs whenever the signal fires, regardless of source order or which handler emits it.
let done: exec
on compute { emit done }
on start { await done }
An on name handler is the fan-out form — its body runs every time the signal
fires, from any emitter (handy for menu-style actions):
let up: exec
on tick { if doubleTapped() { emit up } }
on up { ctrl.DisplayText("menu up") } // runs on every `emit up`
Loops (buffered back-edge)
An emit after an await of the same signal is a loop back-edge. It must be
buffered — buffer emit (1 tick) or buffer(N) / buffer(0.5s) — because
every wire-graph cycle must cross a tick barrier (WS005). Each iteration
then advances one buffer period. Loop state lives in vars (persist across
iterations, reset per call) or rides the signal as a ferried payload
(emit loop = { ... } / let { ... } = await loop). See
statements — Loops for the full pattern and its
per-gate cost.
var index = 0
let loop: exec
buffer emit loop
await loop
if index < arr.length() {
BroadcastChatMessage(arr[index])
index += 1
buffer emit loop // next iteration, one tick later
}
The entry kick must be buffered too, not just the back edge. emit fires
immediately, while the await on the next line is what ARMS the resume, so a
plain emit loop is gone before anything is listening: the chain parks at the
await, neither branch is ever reached, and no completion signal is emitted.
There is no diagnostic, and the statements before the await still run, so it
reads as half-working rather than stopped. buffer emit lands on the following
tick, after the await has armed.
Sleep / SleepTicks
Sleep and SleepTicks are buffer gates that delay a value. Combined with await and _, they create timed delays:
on start {
await Sleep(_, delay = 2.0) // wait 2 seconds
doAfterDelay()
}
on start {
await SleepTicks(_, delay = 120) // wait 120 ticks (~2s at 60Hz)
doAfterDelay()
}
_ inside an await expression is the armed flag – a bool that becomes true when the await is armed. The buffer gate delays this bool, and the await resumes when the delayed output transitions to true.
Important Notes
awaitonly works in exec context – it modifies the exec chain.awaitworks insideifblocks naturally.- Each
awaitcreates an armed flag (static bool) that guards the continuation. The continuation fires exactly once per arming. _insideawaitis typed asbool(the armed flag). It is only valid insideawaitexpressions.let foo = await 1is valid but dangerous: the pure value pulses once, so the continuation runs immediately and never again.- Multiple sequential awaits chain: each one gets its own armed flag and redirects exec.
Best Practices: Gate Count & Scaling
Wirescript makes it easy to write logic that reads like a normal program but compiles into an enormous number of gates. The patterns below came out of shrinking a real game circuit from roughly 300,000 gates to about 8,000 – the logic was unchanged, only its shape.
Everything here follows from one fact, so start there.
Contents
- The one thing to internalize: every call site is a copy
- The call-site multiplier
- 1. Funnel many producers through ONE dispatch site
- 2. Merge per-variant mods into one parameterized mod
- 3. Defer hot shared work behind a flag
- 4. Bitmasks instead of per-slot arrays
- 5. Resolve once, pass down
- 6. Prefer pure
letchains over exec ladders - 7. Reduce with a native array aggregate, not an unrolled fold
- 8. Keep a derived array to unlock an aggregate
- 9. Derive in pure output bindings, not on the exec chain
- 10. Per-entity fan-out: one chip each, not one loop over all
- What actually costs anything: measured
- Profiling: find the hot spots
- Gotchas worth knowing
- Checklist
- See also
The one thing to internalize: every call site is a copy
A mod is inlined. Its entire body is copy-pasted into the caller’s grid at every
call site, and that expansion is transitive – everything the mod reaches is
copied too.
mod heavy(x: int) { /* 500 gates of logic */ }
mod a() { heavy(1) }
mod b() { heavy(2) }
mod c() { heavy(3) }
// heavy is now built THREE times: 1500 gates
A chip does not fix this. A chip is not a shared subroutine you jump into – each
call builds a new instance. It emits the same gates a mod would, plus an
input/output rerouter per boundary port and the microchip container itself. Chips can be
pure (no exec involved at all); they are a structural and visual boundary, not a
deduplication mechanism.
So three calls cost three copies either way:
chip F(n: int) -> (y: int) { out y = n * 2 + 1 }
let c1 = F(a)
let c2 = F(a)
let c3 = F(a)
// Three F instances. Same six logic gates the mod version emits,
// plus 3 input rerouters + 3 output rerouters + 3 microchip containers.
mod | chip | |
|---|---|---|
| Compiles to | Inline gates in the caller’s grid | The same gates, in a microchip instance |
| N call sites | N copies of the whole subtree | N copies of the whole subtree |
| Extra gates | None | One rerouter per boundary port, plus the container |
| Pure (no exec) | Yes | Yes |
| Named multi-outputs | Via a returned record | Yes (-> (a: int, b: int)) |
ref/* params | Yes | Yes |
Constant arguments are free. F(1) folds the 1 into the instance itself and drops
the input pin it would have crossed, so the constant lands as inline gate data on whatever
consumes it – exactly what the mod version does. Arguments that are already a wire (an
input, a var, another gate’s output) cross the boundary through a rerouter as usual.
Captured outer variables work normally through the boundary – a chip that writes an outer
var wires to that one real variable gate, it does not get a private copy per instance.
Choose between them on organization, never on gate count. A chip buys you a visible
microchip boundary in-game and named outputs; a mod keeps the gates in the parent grid.
Both support ref/* params, and a ref crosses a chip boundary as a direct wire to the
one real variable gate. Neither one shares logic between call sites.
That means there is no keyword that rescues you from a gate explosion. The only lever is reducing the number of call sites – which is what the rest of this page is about.
The call-site multiplier
The damage is multiplicative, not additive. If some heavy shared subsystem is reachable
from N call sites, you pay for it N times:
// 10 slots x 3 inputs = 30 call sites, each inlining the ENTIRE state machine
mod onInput(slot: int, code: int) { /* whole phase machine */ }
if (mask & BIT_0) { if a0 { onInput(0, 0) } if b0 { onInput(0, 1) } if c0 { onInput(0, 2) } }
if (mask & BIT_1) { if a1 { onInput(1, 0) } if b1 { onInput(1, 1) } if c1 { onInput(1, 2) } }
// ... x10
That single shape is what produced the 300k-gate build. The fixes below, in order of impact:
1. Funnel many producers through ONE dispatch site
Do not call the logic from every producer. Have producers push a small encoded integer into a queue, and dequeue one per tick at a single call site. The state machine then inlines exactly once.
var queue: int[]
// Producers are now trivial -- they inline almost nothing.
mod enqueue(slot: int, code: int) {
if queue.length() < 32 {
queue.push(phase * 64 + slot * 4 + code) // pack: phase | slot | code
}
}
on tick {
// THE only dispatch site: everything downstream is built once.
if queue.length() > 0 {
let ev = queue[0]
queue.remove(0)
if ev / 64 == phase { // stale-intent guard, see below
handle((ev % 64) / 4, ev % 4)
}
}
}
Two details that matter:
- Tag events with the phase at enqueue and drop mismatches at dequeue. An input queued during one phase must not execute a tick later in the next one.
- Cap the queue (
length() < 32) so a burst can’t grow it without bound. One event per tick at 60 Hz drains fast enough for human input.
2. Merge per-variant mods into one parameterized mod
Three near-identical entry points each inline their whole downstream tree:
// Before: 3 call trees
mod onA(slot: int) { /* ... */ }
mod onB(slot: int) { /* ... */ }
mod onC(slot: int) { /* ... */ }
Collapse them into one and make the variant a computed argument, so each downstream mod is instantiated once instead of two or three times:
// After: 1 call tree; the variant is data, not a separate code path
mod onInput(slot: int, code: int) {
if phase == PHASE_PICK {
// the per-variant difference becomes an argument, not another call site
pick(if code == CODE_A then -1 else 1)
return
}
// ...
}
3. Defer hot shared work behind a flag
If a heavy shared routine is called from many mutation sites, each site inlines it. Instead, set a boolean and make one real call per tick:
var dirty: bool = false
// 18 different mutation sites do only this:
dirty = true
on tick {
if dirty {
dirty = false
refresh() // built ONCE
}
}
This also removes a class of bug: the deferred call runs after the exec chain settles, so consumers never observe mid-update state.
Ordering caveat: if you defer more than one thing, run them in the order the state machine requires. A deferred advance should typically run before a queued-event dequeue, so an event queued for the old state doesn’t re-trigger the thing that just advanced.
4. Bitmasks instead of per-slot arrays
Every arr[i] compiles to an array-get gate, and array reads are exec-only. Per-slot
boolean state is far cheaper as a single integer bitmask.
// Instead of: array flagged: bool[] (an array-get per read, per slot)
var flagged: int = 0 // bit i = slot i
flagged = flagged | (1 << i) // set
flagged = flagged & ~(1 << i) // clear
if (flagged & (1 << i)) { /* ... */ } // test -- already truthy, no `!= 0` needed
let n = BitCount(flagged) // popcount builtin, not a 10-way sum
This was the single biggest late win. It compounds:
- Derived sets are free:
BitCount(active & ~disabled)replaces a loop-and-count. - Two masks beat a tri-state array: store
votedMaskandyesMaskrather than an array of-1/0/1; “voted no” isvotedMask & ~yesMask. - Pass masks (plain
int) to pure helpers instead of arrays – helpers stay pure and cheap to inline. - Bit outputs drive hardware directly. If an output expects one bit per slot, publish the mask itself; no pack loop needed.
- Entity-ish ports coerce to 0/1 in arithmetic, so
a0 + a1 + a2 + ...is a cheap pure occupancy count with no array and no exec.
5. Resolve once, pass down
Re-deriving the same handle inside a callee means re-deriving it in every inlined copy. Resolve it once at the top and pass it as a parameter:
// Before: each callee re-derives the same thing
mod draw(i: int) { let e = lookup(i) /* ... */ }
mod tag(i: int) { let e = lookup(i) /* ... */ }
// After: derived once, handed down
mod service(i: int) {
let e = lookup(i)
draw(i, e)
tag(i, e)
}
A free running counter (buffer tick) also makes a good round-robin cursor – tick % 10
services one slot per tick instead of building ten service chains.
6. Prefer pure let chains over exec ladders
A predicate written as an early-return ladder becomes exec gates; the same predicate
written as boolean lets stays pure:
// Prefer
mod allowed(i: int, mask: int, blocked: int) -> bool {
let live = (mask & (1 << i)) != 0
let free = (blocked & (1 << i)) == 0
return live && free
}
7. Reduce with a native array aggregate, not an unrolled fold
arr.sum(), arr.max(), arr.min(), and arr.average() are one gate each. An
unrolled max/sum over N slots is N compares/adds plus the accumulator plumbing.
// Before: an N-way unrolled scan
mod maxTotal(t: int[]) -> int {
var m = t[0]
if t[1] > m { m = t[1] } // ... repeated per slot
return m
}
// After: one gate
let over = totals.max().Value >= 100
- Gotcha:
.max()/.min()return a record{ IsEmpty: bool, Value: int }– read.Value(and checkIsEmptywhen the array can be empty)..sum()/.average()return a bare scalar. - If the slots you want to exclude already hold the identity value (
0for a sum, or a value below every real one for a max), just reduce the whole array – no masking needed.
8. Keep a derived array to unlock an aggregate
To reduce a computed value over a sub-range, don’t decode-and-add per element every time.
Maintain a parallel array holding the precomputed per-element value in lockstep with the
source, then slice the window into a scratch array and sum() it – 2 gates instead of
~N.
var packed: int[] // source (e.g. state+value packed per element)
var vals: int[] // derived mirror: the summable value per element
var scratch: int[] // reusable slice target
// keep the mirror in lockstep at EVERY write to `packed`
mod setCell(i: int, v: int) { packed[i] = encode(v) vals[i] = v }
// reduce a 12-wide window in 2 gates, not 12 decodes + 11 adds
mod windowSum(base: int) -> int {
scratch.slice(vals, base, 12) // slice REPLACES scratch with vals[base..base+12]
return scratch.sum()
}
The cost is one extra write per source mutation; it pays back at every reduction – and reductions are usually called from many slots. Audit that every write to the source has a paired write to the mirror, or the aggregate silently drifts.
9. Derive in pure output bindings, not on the exec chain
Array reads are exec-only, so an on <update> handler that reads a buffer and also
computes display values inlines the whole decode/format on the exec chain (a Union /
Branch / Var_Set per step) and needs a cached-result var per output. Cache only the raw
read; derive everything else in the pure output binding.
// Before: decode + format run on the exec chain, cached into a result var
var vC: color = ...
on update { vC = colorOf(buf[0]) }
out color0: color = vC.Value
// After: cache only the raw cell; the binding derives it, pure
var vP0: int = 0
on update { vP0 = buf[0] } // the only exec-only step (the array read)
out color0: color = colorOf(vP0.Value) // pure: no exec chain, no result var
Requirement: a mod called from a pure binding must be expression-if (a single
return if ... then ... else ...), not a statement-if with early returns.
10. Per-entity fan-out: one chip each, not one loop over all
A loop advances one iteration per tick. So a central chip that sweeps a roster of N entities every tick does not cost “a loop” – it costs N ticks per pass, and it gets slower exactly as the thing succeeds and N grows. Anything the player perceives as continuous (a ticking timer, a follow camera, a per-player HUD) is unusable built that way.
Give each entity its own chip instance instead. The per-tick work then happens in parallel gate instances, one per entity, and the wall-clock cost stops depending on N:
var minions: Map<character, entity>
on CharacterSpawned() -> (who) {
let e = SpawnPrefab(prefab = $./minion.ws, lifetime = 0.0, limit = 64)
minions.set(who, e)
owner = who // a var: a constant argument emits no wire
e.SendCustomEvent("minion.init", owner)
}
and in minion.ws, the per-tick handler nests inside the init handler so it
closes over a reference that is set:
var owner: character
var ready: bool = false
on CustomEvent("minion.init") -> (who: character) {
owner = who
ready = true
on ServerUptime() {
if ready {
// per-tick work for exactly this one entity
if owner.GetUserId() == "" { ReadBrickGrid().DestroySpawnedPrefab() }
}
}
}
Three things that bite:
- The nested handler is not registered by the outer one. The wire graph is static, so its trigger is live from the moment the prefab spawns, a tick or more before the init event lands. It needs a guard on an init-set value. Nesting scopes the reference readably; it does not sequence the two.
- Each instance should own only its own state. Shared state stays in the central chip, which stays event-driven. The moment an instance needs to read another’s data you have rebuilt the roster sweep by mail.
- Instances must clean themselves up. A spawned chip whose subject is gone
keeps running; check for that in the tick handler and
DestroySpawnedPrefab().
Keep the bookkeeping central and event-driven, and push only the per-tick rendering or sampling out to the instances.
What actually costs anything: measured
Numbers below were measured in game, not reasoned. They matter because most optimisation instinct here aims at the wrong target.
Almost nothing costs what you think
At 512 operations per tick against a 4.2ms frame, only one thing moved the tick time at all:
| Operation | Cost per op |
|---|---|
Building a string ("a" .. pad(x) .. "b") | ~1us |
| Reading a var | free |
Map.get + Found check | free |
| Reading a pre-built string from a map | free |
DisplayText call | free |
512 DisplayText calls per tick cost 3us. If a HUD redraws twenty elements
at 10Hz, drawing is not your problem and rate-limiting it will not help.
Map reads are free and O(1) – a 1024-entry map reads no slower than an
8-entry one. A permanent record store can grow forever without pruning, and
keying per-entity state by character or account id costs nothing at runtime.
So: the only operation worth caching is a built string. If the same text is rebuilt every frame and only occasionally changes, store it in a map and read it back. Everything else is noise – and a cache with one reader is a net loss.
Gates and ticks are one currency
There is no construction that gives concurrency from a single body:
| Construction | Bodies (gates) | Ticks for N items |
|---|---|---|
| Unrolled calls | N | 1 |
Back-edge loop (await) | 1 | N |
| Self-event fan-out | 1 | N |
| One send to N distinct receivers | N | 1 |
| N sends on N DISTINCT channels | N | 1 |
Firing THE SAME event N times gives one invocation per tick. Repeating one channel is a loop with nicer ergonomics, not a way to parallelise.
The limit is per channel, not global. Sends on DIFFERENT channels in one exec chain all land in the same tick. N distinct channels move N payloads in one tick, and an event carries up to eight data fields, which is 8N values per tick without serialising anything.
That matters because the obvious way around “an event cannot carry an array” is to concatenate values into one string and split them apart at the receiver. That packing is expensive and usually unnecessary: six rows of three fields measured 95 gates packed-and-unpacked against 27 over three parallel channels carrying six fields each.
But one send reaching many receivers costs one tick total. Send count is expensive; receiver count is free. So broadcast one packed payload and let each receiver filter, rather than tailoring a message per receiver.
Practical rule: unroll hot paths (N gates buys one tick), loop or fan out cold ones (one gate body, and nothing is waiting).
Where gates actually go
- A
modinlines per call site, so its cost is roughlybody x call sites. That is an upper bound – the compiler shares loop-invariant subexpressions across the copies, so hand-hoisting a repeated read out of an unrolled mod typically buys very little. - A
mod’s LOCALvarbecomes a storage gate at every call site. Three locals in a helper called from a 30-wide unroll is ninety gates. Hoisting them to module scope collapses that to three – but only do it when every call site is alone in its statement, because module scratch is shared and two calls in one expression will race. - A
mod’s return path depends on its SHAPE. A mod whose body is a single expression returns through a wire and costs about one gate per call site. A mod with an earlyreturnreturns through a storage gate, which is opaque to the constant folder, and costs several times more per site. Rewriting an early-return helper as one expression is usually the cheapest change available to it. - Storage survives everything. Guarding a feature behind a false constant
removes its logic but not its
vardeclarations. Nothing prunes unused state, and the compiler never warns about it.
Feature flags fold completely
A body guarded by a compile-time-false constant is deleted, not skipped:
const FEATURE = false
on tick { if FEATURE { expensiveThing() } } // compiles to nothing
Measured: a 1056-gate body behind const FEATURE = false compiled to 3 gates.
Shipping two builds of a chip – one with a feature, one without – is therefore
close to free, and cuts both gate count and artifact size for anyone who does
not need it.
Size is usually the real constraint
Runtime headroom is large; artifact size is not.
- Every
.brzhas a ~25KB floor, whatever it contains. A 10-gate helper chip and a 500-gate chip cost about the same. Splitting work across many small chips pays that floor every time. - A
SpawnPrefabreference embeds the whole prefab into the spawning chip. Shrinking the prefab shrinks every chip that spawns it, and each spawner carries its own copy. - On-screen text is capped at roughly 32 elements per player, shared across every circuit that player has loaded – not per chip. Past the cap, one element silently never renders, and which one shifts as you change unrelated things. Budget elements, not draw calls.
Before optimising, check you have a problem
Compute what your circuit actually does per second and compare it to the numbers above. A full 30-player HUD system doing ~8,000 string builds per second spends under 1% of wall clock on the only operation that costs anything.
Gate count still matters for paste size, world load and the text-element budget. It mostly does not matter for per-tick CPU. Optimise for the one that is actually binding.
Profiling: find the hot spots
--dump-ir prints, to stderr, a node count per module and every gate with its
@ line:col source anchor. Measure before you refactor – attack the dominant gate kind or
source line, not a guess.
# per-module node counts + the full node list (IR is on stderr)
cargo run -p wirescript-cli -- compile foo.ws --dump-ir 2>&1 1>/dev/null
# total node (gate) count
... 2>&1 1>/dev/null | grep -cE '^\s*\[(Input|Output|Gate)\]'
# which gate KINDS dominate
... 2>&1 1>/dev/null | grep -oE 'BrickComponentType[A-Za-z_]+' | sort | uniq -c | sort -rn | head
# which SOURCE LINES emit the most gates (the @ line anchor)
... 2>&1 1>/dev/null | grep -oE '@ [0-9]+:' | grep -oE '[0-9]+' | sort -n | uniq -c | sort -rn | head
A dominant MathModulo / MathDivide count usually means a decode called too often; a
large ArrayVar_Get count means reads that could be cached or aggregated; a single source
line with a big share is your first refactor target.
Gotchas worth knowing
- An expression-
ifis aSelectgate, so BOTH arms evaluate. Guard possibly-out-of-bounds array reads with a statement-if, never a ternary. - Delete dead arithmetic. If a value’s range makes an op a no-op, drop it:
(x / 16) % 4wherex / 16is already proven<= 2is justx / 16; a mask that can never clear a set bit is nothing. Every op you remove is a gate you don’t build. - Don’t recompute inside one expression. Bind a repeated (possibly expensive) call to a
letonce and reuse it – each call is its own gate subtree, sof(x) + f(x)buildsftwice. - A
mod-localstatic varis per-copy, not shared – each inlined instance gets its own. Hoist shared state to a rootvar. - Hover a
mod,chip,on, oriffor its gate count. The estimate covers the whole construct (following its calls), and amodreports that it is inlined per call site – so you can see what a refactor costs without compiling. - Don’t optimize prematurely. Gate count only matters once something is instantiated
many times. A leaf helper called twice is fine as a
mod.
Checklist
When a build is unexpectedly huge, walk this list:
- Profile with
--dump-irfirst – refactor the dominant gate kind / source line, not a guess. - How many call sites reach the biggest
mod? Multiply – that’s your bill. - Can many producers be funneled through one queued dispatch site?
- Can near-identical entry points collapse into one parameterized
mod? - Can a hot shared routine be deferred behind a dirty flag to one call per tick?
- Is any per-slot boolean state an array that should be a bitmask?
- Is anything being re-derived inside a callee that could be passed in?
- Is an unrolled max/sum/count really a one-gate
.max()/.sum()/.average()? - Would a derived parallel array turn a per-element decode-and-add into a
slice+sum? - Is display/derived logic running on the exec chain when it could be a pure output binding?
- Is a per-tick roster sweep really one chip instance per entity?
See also
- Chips –
modvschipsemantics,ref/*params, nested chips - Execution Context – pure vs exec, and why array reads need exec
- Builtin Functions –
BitCountand the other cheap primitives
Testing
A Wirescript program can check itself. The pattern below compiles to a circuit that runs its own assertions when the grid loads, says nothing when everything passes, and prints what it needs to diagnose the failure when something does not.
Type checking cannot do this job. It proves a program is well formed, not that a gate computes what you expected, and the gates are the part with the surprises.
Contents
- The shape
- Compare two paths, not one path against a constant
- Include a control
- What an in-game test cannot prove
The shape
var pass: int = 0
var total: int = 0
mod assert<T: int | float | string>(want: T, got: T, label: string) {
total = total + 1
let ok = want == got
pass = pass + ok
if !ok {
BroadcastChatMessage("FAIL: ${label} want ${want} got ${got}")
}
}
on ReadBrickGrid() {
var xs: int[]
xs.push(10)
xs.push(20)
assert(2, xs.length(), "length after two pushes")
assert(20, xs[1], "second element")
BroadcastChatMessage("array checks: ${pass}/${total}")
}
The file is examples/assert.ws, verified against
just check. The later examples reuse this assert, pass, and total without
repeating them. Five things are doing work.
on ReadBrickGrid() as the trigger. It fires when the grid loads, so the
test runs by pasting it in. No separate binding: the handler reads the grid
event directly.
Module-level counters. pass and total live at module scope, so assert
updates them in place and each call is a bare assert(want, got, label). The
summary line at the end is the whole result. pass = pass + ok relies on bool
coercing to int, so there is no if ok then 1 else 0.
One generic assert. <T: int | float | string> lets the same mod check
ints, floats, and strings; the compiler monomorphizes each call to its argument
type. == compares any variant, but the failure line stringifies the values
with ${...}, and only those three types support that, which is what the bound
names. Widen it to plain <T> for a check that never prints the value.
It carries the value out. A wrong value and a value that never arrived look
identical when all you print is a label. want 1 got 0 tells them apart, and
assert gives you that on every check without writing the interpolation by hand.
Silence on success. Only failures print. A run that says
array checks: 12/12 and nothing else is a pass you can read at a glance;
twelve lines of OK: ... is noise you have to scan. BroadcastChatMessage,
not PrintToConsole, so it lands in chat where you are already looking.
Compare two paths, not one path against a constant
A check against a hardcoded expected value only proves the program agrees with what you typed. The stronger form computes the same answer two ways and compares those, so the test fails when the two disagree even if you were wrong about both:
mod byAddition(n: int) -> int { return n + n }
on ReadBrickGrid() {
assert(byAddition(21), 21 * 2, "addition agrees with multiplication")
BroadcastChatMessage("math checks: ${pass}/${total}")
}
This is how to test anything with two implementations: an optimized path against an obvious one, a compile-time answer against the runtime gates, a lookup table against the formula that generated it.
Include a control
A test that reads 0 when a value fails to arrive will pass any check whose
expected answer is also 0. Give at least one check a value nothing else in the
program produces, so a whole channel going dead cannot slip through:
on ReadBrickGrid() {
var xs: int[]
xs.push(12345)
assert(12345, xs[0], "control literal survives the round trip")
BroadcastChatMessage("checks: ${pass}/${total}")
}
12345 cannot be confused with a default, an empty read, or an off-by-one.
What an in-game test cannot prove
Running the circuit proves the values are right. It cannot prove how they were produced, and for anything about compilation that is the whole question. A compile-time evaluation that quietly fell back to emitting gates still computes the correct answer and still passes every check above.
So the in-game program is one half. The other half is a compiler-side test that asserts the structure: that the gate count is what it should be, that a particular gate class is absent, that a value was baked into a component rather than wired. Together they cover both questions, and neither covers both alone.
Constant Folding
The compiler constant-folds pure gates before layout, guarded by an in-game-certified semantics table so nothing folds on a guess. Folding sees through chip boundaries and iterates to a fixpoint – a fold can unlock another fold, so a chain of constant math or nested conditionals collapses in a single compile.
Contents
- Enabling folding
- What folds
- Constant inlining into gate data (always on)
- Strings and composite values (wave 2)
- What never folds (barriers)
- The certification story
- Disabling the pass
- Guarantee
- See also
Enabling folding
Folding is on by default – every compile folds unless you opt out. An unannotated program folds:
let x = 2 + 3 * 4 // folds to the literal 14
Turn it off for a whole program with a module-level @nofold: on the first line of the
entry file (after any module doc), separated from the first declaration by a blank line.
@nofold
let x = 2 + 3 * 4 // stays as gates; nothing folds or is elided
--no-foldon the CLI’scompilecommand disables folding for that compile, equivalent to a module-level@nofold.--fold, and a module-level@fold, still parse but are redundant now that folding is the default. They never override a@nofold.- If both a module-level
@foldand@nofoldare present,@nofoldwins and the parser warns that the annotations conflict. - A
@nofoldin an imported file has no effect – only the entry file’s module-level annotation is consulted.
What folds
Value folding. A gate whose class and input-variant combination is certified in
the semantics table, with every live input a known constant (int/float/bool/string),
is replaced by a literal carrying the computed result. Unwired inputs count as known
– their certified variant default (0, 0.0, false, "").
let x = 2 + 3 * 4 // folds to the literal 14
Constant-selector Select. An if-expression compiles to a Select gate; when
its condition folds to a known bool, the gate is removed and consumers rewire
directly to the chosen arm’s source – even when that arm is not itself a constant.
let y = if true then f() else g() // folds to f()'s output; g() is dropped
Dead exec-branch truncation. An if statement compiles to a Branch gate; when
its condition folds to a known bool, the branch is removed and incoming exec wires
rewire straight to the taken side. This happens across chip boundaries too – a
constant fed into a chip’s input can truncate a branch inside it. Anything on the dead
side still exec-reachable from elsewhere survives; a follow-up sweep also removes pure
gates left feeding only the deleted branch.
if false { heavy() } // heavy()'s whole exec chain is dropped
Annihilators. && with either side certified false folds to false; || with
either side certified true folds to true – even when the other side is unknown at
compile time. This is the only case where a non-constant operand still lets a gate
fold, and it draws solely from the table’s whitelisted rules (nothing derived).
Constant inlining into gate data (always on)
Independent of the fold pass (it runs even under @nofold): a bare constant wired
into a gate’s data field whose type matches the constant’s type is written straight into that field and the
wire dropped — no separate carrier gate. This runs before the fold pass and only
on source literals (never fold-produced ones), so folding stays a structural
no-op.
- A constant gate setting bakes in:
on Clock(interval = 0.25)stores0.25in the Clock’sIntervalSecondsdata, and a constantSweepdistance,DisplayTextfontSize, etc. do the same. A variable value still wires (so the setting stays dynamic). - A matching-type constant operand inlines too: the
0inx | 0, afalseoperand of||, and string literals intostrfields (Contains’s search text, an interpolation template) all bake into the gate instead of spawning a throwaway carrier gate. - A type mismatch (e.g. a float constant into an
i64field) does not inline — it keeps a converting carrier gate. Wire-variant math inputs (+/-/*) andVector/Rotator/Quat/Colorfields inline as wire variants instead.
Strings and composite values (wave 2)
Folding now also covers string operators/methods and vector/rotator/color/quat
math and constructors – certified the same way as everything else: an in-game probe
records the real output, and only a (gate class, input-variant) pair the probe actually
observed is eligible to fold.
Strings. .. concatenation, Length/Contains/StartsWith/EndsWith/ToLower/
ToUpper/Trim/Substring/Find/Replace/ParseInt/ParseNumber, and
${...}-interpolated templates (FormatText) all fold when every operand is a known
constant.
let s = "hello".ToUpper() .. "!" // folds to the literal "HELLO!"
let n = " 42 ".Trim().Length() // folds to the literal 2
Interpolation folding is held to a render-exactness guarantee: the folded literal
text must be byte-identical to what FormatText would print in-game for the same
values, not just numerically equivalent. The certified render law: ints comma-group
every 3 digits from 1,000 up; floats round to 3 decimals (ties to even), comma-group
their integer part, and trim trailing fractional zeros (and a bare trailing .); bools
print 1/0; vectors print X=%.3f Y=%.3f Z=%.3f. .. concatenation’s own operand
stringification differs on one point – a bool operand prints true/false, not 1/0
– matching the game’s generic to-string conversion there instead of FormatText’s.
Vector/rotation/color/quaternion math. Vec(...) construction, component-wise and
scalar-broadcast + - * / on vectors, .Scale()/.Dot()/.Cross(), and Quat(...)
construction all fold when every component is known.
let v = Vec(1.0, 2.0, 0.0) + Vec(0.0, 0.0, 3.0) * 2.0 // folds to Vec(1.0, 2.0, 6.0)
Quat(...) and the 3-argument (RGB, no alpha) form of Color(...) fold too, but their
own certification is transitive, not direct: a quaternion or an RGB color never
renders through FormatText (its probe case prints blank), so nothing directly proves
the constructor packs its fields correctly. Instead, each is certified through a
different, value-bearing gate that consumes it and produces an observable result –
RotateVector and a quaternion dot product for Quat(...), hex-string conversion for
3-argument Color(...). A wrong field order or a transposed component would visibly
diverge those gates’ certified outputs, so their exact replay certifies the constructor
by proxy. The 4-argument (alpha-carrying) form of Color(...) has no such transitive
proof – alpha never survives into any value-bearing consumer the probe covers – and
does not fold.
Refusals specific to this family:
- Any non-ASCII string operand or result – the certified behavior was only ever probed with ASCII text, so it never folds regardless of which model (character count vs. UTF-16 code units) the game actually uses.
- A string result longer than 8,192 characters.
- Any float operand whose magnitude exceeds
1e15in a string/interpolation context – the game cannot print a float that large at all (the console line is silently dropped), so folding refuses past that bound rather than bake unreproducible text.1e15itself is certified to print fine; the bound is exclusive. - A handful of composite-constructor gates whose only table evidence is a blank
render and that have no transitive proof either (unlike
Quat(...)/3-argumentColor(...)above): a bare rotator constructor, the sRGB-byte and hex-string color constructors, and rotation inversion. These hard-refuse regardless of how determined their inputs are, until a future probe wave chains them through a value-bearing gate. - A fixed list of gates the probe recorded but deliberately never folds: vector magnitude/normalize/distance, spherical interpolation, axis-angle construction, angle- and rotation-between queries, direction/rotation conversions, and color blending. Their math is meaningfully harder to reproduce exactly (square roots, trig, arbitrary-axis construction) and wasn’t certified for folding.
Per-build recertification is unchanged from the workflow below – these families ride the same probe/table/replay/verifier loop as everything else.
What never folds (barriers)
These are always treated as unknown, and folding never elides or sees through them:
Opaque(x)– the permanent, explicit fold barrier.@nofold-annotated declarations, including a module-level@nofoldat the top of a file, which disables the whole fold pass for that compile.- Rerouters,
var/Var_Getreads, arrays, buffers, events,ReadBrickGrid(), and any wire carrying an object-typed value. - Any gate class or input-variant combination the probe never certified — absent from the table means permanently unknown, never folded.
- Certified signatures whose specific VALUES the evaluator declines to compute, as a
safety net layered on top of coverage: math involving a
stringoperand (the recorded observations rule out every parsing model), integer overflow, mixed-sign division/modulo with a nonzero remainder (truncation direction was never probed), and any result that would be non-finite. These are refused rather than guessed at, even when every input is constant.
The certification story
Every fold decision traces back to data/gate_semantics.json, a table built by
probing each gate combination in-game and recording its real output. Two things hold
the compiler to that table:
- Replay gate – a build-time test feeds every table case through the compiler’s evaluator and asserts the recorded output; the table cannot drift from what the compiler does without breaking the build.
- Coverage gate – a gate only folds when its (gate class, input-variant signature) pair is present in the table. A combination the probe never ran stays unknown, even if the evaluator could compute an answer for it.
A companion probe invariant test compiles the certification circuits themselves with folding on and off and asserts identical gate and wire counts – proof the pass cannot touch the instruments that certify it.
When a game build changes gate behavior: re-run the probe, regenerate the table, and re-paste the generated verifier in-game. The replay gate then re-certifies (or fails to) the evaluator against the fresh table automatically, so a stale assumption fails the build instead of silently folding wrong.
Disabling the pass
- A module-level
@nofold, placed at the top of the entry file and separated from the first declaration by a blank line, turns folding off for the whole program – and always wins over a module-level@fold. --no-foldon the CLI’scompilecommand has the same effect for that compile.- Folding is on by default everywhere it runs – CLI compile, the LSP, and the wasm
build – so all tools fold consistently unless a program opts out with
@nofold.
Guarantee
A gate is only value-folded when its class and input-variant signature are certified,
and only using a law the evaluator implements for it; anything else is left as a real
gate. Opaque(...), @nofold, rerouters, variables, arrays, buffers, events,
ReadBrickGrid(), and object-carrying wires are never folded, elided, or seen
through. Nothing is removed that the certified table does not license.
See also
- Statements –
@nofoldsyntax and placement rules - Builtin Functions –
Opaque
Diagnostics
Every problem the compiler reports carries a stable WSxxx code. This page
lists every code the type-checker and lowering passes emit, grouped by the kind
of problem. Codes are errors (they stop compilation) unless the entry marks
them (warning) — warnings compile but flag something likely unintended.
Diagnostics run in a fixed order — parse, resolve/import, type-check, lower, wire-graph analysis — so an early error can suppress later ones on the same construct.
Some numbers in the
WS0xxrange are not used:WS009andWS018have no emit site.WS034was once a generic-chip cross-wiring guard and has since been removed.
Contents
- Execution context
- Names, declarations & imports
- Types & operators
- Calls & arguments
- Generics
- Ports, outputs & labels
- Collections & shapes
- Compile-time constants (
const) - Gate & event config
any
Execution context
Wirescript has a pure context (continuous signal-flow) and an exec context
(imperative code inside on handlers). These codes fire when a construct is
used in the wrong one, or when a feedback loop has no tick barrier. See
Execution Context.
| Code | Meaning | Trigger |
|---|---|---|
WS005 | Wire-graph cycle with no barrier — a feedback loop must cross a Buffer/Queue/EdgeDetector; break it with buffer emit. | a gate loop with no buffer on any edge |
WS006 | *x deref used in pure context — use x.Value for a pure read. | out v = *count |
WS007 | Exec-only construct outside an exec context — assignment, emit, await, an array index read, or an exec-returning call with no enclosing exec chain. Also a write to something that is not writable storage: a let, an enum payload field named directly (destructure it instead), a misspelled record field, or a field of a scalar. | count = 1 at the top level / state.field = v on an enum |
Names, declarations & imports
| Code | Meaning | Trigger |
|---|---|---|
WS001 | Unknown event or trigger — the name after on isn’t a known event, input, let, buffer, var, or param. | on Nope { } |
WS002 | Unknown name or type — an undefined variable, an unknown type, an undefined namespace base, a namespace base shadowed by a local binding of the same name, or a misused generic alias (bare, wrong arity, or recursive). | let x = undefinedVar / var x: Widget / import * as u then mod g(u: int) { u.f() } |
WS012 | Import error — a circular import, an unresolvable file, or a named binding not found in the target module. | import { nope } from "utils" |
WS013 | Duplicate declaration, or an output that is never assigned. | two var x: int = 0 in one scope |
WS014 | (warning) Unused import. | import { clamp } from "u", clamp never used |
WS021 | Use before declaration — a chip/mod is called above the point where it’s declared (declarations register in source order). | helper() above mod helper() { } |
WS043 | An on <call> -> <pattern> general (non-event) trigger’s call has no exec-typed output for on to auto-extract — it needs an event, or a call whose result includes an exec field (e.g. via exec = ...). | on pair(5) -> (p, q) { } where pair has two plain (non-exec) outputs and no exec = arg |
WS060 | Unknown variant on an enum path (Enum.Variant) - the enum has no variant with that name. | enum Shape { Empty } then Shape.Nope |
WS064 | Duplicate discriminant value in an enum declaration - each variant’s tag must be unique. | enum E { A = 1, B = 1 } |
WS065 | Wrong bracket form for an enum variant’s payload - a named-payload variant called with (...), or a positional-payload variant called with { ... }. | enum S { Box { w: float } } then S.Box(1.0) |
WS054 | Non-exhaustive match - the arms don’t cover every value of the scrutinee enum; each uncovered pattern is named in the message. Add the missing arm(s) or a _ catch-all. | enum S { A, B, C } then match s { A => 1, B => 2 } |
WS061 | (warning) Unreachable match arm - an earlier arm already matches every value this one could, so it never runs. | match s { _ => 0, A => 1 } |
WS062 | A let <pattern> = ... else { } whose else block can fall through - it must diverge on every path (return/emit, or an if/match whose arms all diverge), since the binding is unavailable when the pattern doesn’t match. | let Some(x) = o else { let y = 1 } |
WS063 | A generic enum’s type parameter can’t be inferred - a variant with no payload to pin it (Option.None) constructed with no annotation supplying it. Annotate the target (out n: Option<int> = Option.None) or use a variant whose payload determines it. | enum Option<T> { Some(T), None } then out n = Option.None |
Types & operators
WS003 is the general type-mismatch code; the others are narrower.
| Code | Meaning | Trigger |
|---|---|---|
WS003 | Type mismatch — a value doesn’t coerce to the expected type (assignment, argument, output, array element, if-branch join, event input). | var n: int = "hi" |
WS004 | No operator overload for the operand type(s) (arithmetic / comparison / logical). | "a" + 1 |
WS008 | Taking &/ref of a non-reference — only a variable, ref parameter, or array/map element can be referenced, not a temporary. | &(a + b) |
WS011 | No overload for a bitwise/shift operator (&, |, ^, ~, <<, >>), or a builtin call with the wrong number of positional arguments. Bitwise/shift accept int, and float/bool coerce to int; only non-numeric operands (string, vector, …) have no overload. | "a" & 2 |
WS016 | (warning) let / out annotation doesn’t match the inferred type (a checked assertion; string-format coercion is exempt). | let n: int = s where s: string |
WS025 | A non-storable type used as storage — any, zone, teleport, or prefab in a var / buffer / array / map. | var x: any = 0 |
WS031 | A reference (zone / teleport / var ref) used in an if-then-else — a Select routes a value, not a reference. | if c then zoneA else zoneB |
WS066 | .Discriminant, .ToInt(), or a match targets a value that isn’t an enum. | var x: int = 0 then x.Discriminant |
WS067 | A bare variant name for a variant that has a payload (Circle instead of Circle(_)) in a match arm binds the whole value like a catch-all, which can leave later arms unreachable. | match s { Circle => .., Empty => .. } |
WS068 | A custom-event data param is a record or an enum. A data slot is one wire and those span several, so the value cannot travel through it. Pass the fields as separate params, or send a key and read the value from shared storage on the other side. | on CustomEvent("c") -> (p: Point) { } |
WS069 | An enum payload field would need container storage. A payload slot is filled by constructing the variant, and a container cannot be constructed into one, so the value would read back empty. Fires on the declaration, or on a generic instantiation that binds a stored parameter to one (Option<int[]>). | enum E { B { xs: int[] } } |
WS070 | An unsafe payload access does not name both a variant and a field. The form is unsafe <value>.<Variant>.<field>; the variant is what selects the slot. | unsafe e.B = x |
WS071 | A record value used where a single value is expected. A record is several wires, so it cannot drive one port, including a string concatenation, which has no way to render the whole bundle. Read a field, or concatenate the fields individually. | let p = { x: 1, y: 2 } then "p=" .. p |
WS072 | An expression that produces no value read as one: a void container mutation (a.push(x), m.set(k, v)) or a mod/chip with no output. There is no wire to pass, so the consumer would read its port default. Use the call as a statement, or give the mod an output. | mod f(x: int) { } then o = f(1) |
Calls & arguments
| Code | Meaning | Trigger |
|---|---|---|
WS020 | Recursive chip/mod call — chips and mods inline at compile time and can’t call themselves, directly or mutually. | mod f() { f() } |
WS022 | Wrong argument count in a user mod/chip call. | mod f(a: int) {} then f(1, 2) |
WS035 | A self-receiver mod shadows a builtin receiver-method of the same name/receiver — it could never be reached as a method; rename it. | mod Dot(self: vector, o: vector) -> float { ... } |
WS036 | A non-self mod called with method syntax x.f(…) — call f(x, …) directly, or rename its first param to self. | mod f(a: int) {} then x.f() |
WS038 | The callee isn’t callable — it’s a var/let/array/param, not a mod/chip/fn (often an index typo). | xs(i) for xs[i] |
WS041 | Unknown named argument — it matches no parameter and no settings-menu config field, so it does nothing (exec = and a variadic call’s trailing options are exempt). | p.DisplayText(t, positionX = 0.0) |
Generics
See Generics.
| Code | Meaning | Trigger |
|---|---|---|
WS033 | Generic inference failure — T can’t be inferred (conflicting args, unpinnable, or out of its bound), an explicit type-arg count/bound is wrong, or type args were given to a non-generic function. | pick(true, anInt, aVector) |
WS037 | (warning) Explicit type arguments on a builtin are ignored — a builtin’s result type comes from its arguments. | Random<int>(0, 5) |
Ports, outputs & labels
| Code | Meaning | Trigger |
|---|---|---|
WS017 | (warning) Ambiguous variable output type — out foo = someVar with an untyped var; annotate out foo: T (value) or out foo: *T (ref). | out o = myVar |
WS019 | A prefab reference must end in .brz. | $./level |
WS023 | A side annotation (@left/@right/@top/@bottom) is only valid on a top-level port of the compiled file, not inside a chip/mod body. | chip { @left in go: exec } |
WS040 | A @label(<expr>) isn’t a compile-time constant, in a position that requires a baked label (a port, chip, or nested var). | @label(hp) in x: int |
Collections & shapes
| Code | Meaning | Trigger |
|---|---|---|
WS010 | Destructure / field-access shape mismatch — wrong destructure arity, no such record field, or a tuple index out of range. | rec.missingField |
WS024 | (warning) An asset/prefab reference inlined into a constant array initializer is silently dropped — build the array with .push(...) in an exec handler. | var s: entity[] = [$Type/Name] |
WS026 | A map literal used somewhere other than initializing or assigning a Map variable. | foo({ "a": 1 }) |
WS027 | Assigning a whole map from a non-literal is unsupported — use m.copyFrom(src). | m = otherMap |
WS039 | Invalid map key type — a Map<K, V> key must be int, string, or an object (entity/character/controller). | var m: Map<float, int> |
WS044 | An array/map method (.push, .set, .remove, …) called on something that isn’t an array or map — the receiver didn’t resolve to a container, so the operation would otherwise be silently dropped. Also reported for a MUTATING method whose receiver is a const array or map: a const container is immutable, so its compile-time value and its runtime contents can never disagree. | let x = 5 then, in an exec handler, x.push(1); or const t = [1, 2] then t.push(3) |
Compile-time constants (const)
See const – Compile-Time Binding.
| Code | Meaning | Trigger |
|---|---|---|
WS046 | Not a compile-time constant — the value names a runtime value, a call to a mod that isn’t const mod, an unsupported syntactic form, an out-of-range compile-time array index, or a missing compile-time map key/record field. The message names the actual offender. | in live: int then const n = live + 1 |
WS047 | The certified evaluator refuses to compute the value even though every operand IS constant — integer overflow, a non-ASCII string operand, or an uncertified gate/operand combination. The value is computable in principle; the compiler will not guess it. | const s = "café".ToUpper() |
WS048 | Const evaluation gave up — the call chain is too deep or took too many steps (guards a runaway or self-referential const mod chain against a stack overflow, since a const mod calling itself type-checks fine). | a const mod that calls itself, reached from a constant-only position such as a custom-event channel name |
WS049 | A record used as a variable, array, or map has a field whose type can’t be stored — a reference (*T), zone, teleport, prefab reference, or exec. A stored record decomposes into one backing gate per field, and those types have no storable value. | type T = { z: zone } then var t: T |
WS050 | A method with no per-field meaning was called on a record array or map. A record container is stored as parallel per-field gates, so sort/shuffle (would desync the fields), the aggregates (sum/min/max/average), find, and the dual-container ops (append/copyFrom/slice/values) are not available. Use pts[i] / m[k] for element access. | var pts: Point[] then pts.sort() |
WS051 | null was used for a type that has no null value. null adopts its target type and produces that type’s zero / unset — valid for a number, bool, string, vector/rotator/quat/color, or an entity/character/controller, but not for a container, record, or reference-only type (which have their own empty forms). | var a: int[] = null |
WS052 | A ...rest variadic parameter was declared on a chip. Only a mod may be variadic: a mod inlines per call site (so the trailing args are captured into a compile-time tuple), while a physical microchip instantiates once and cannot vary its pin count per call. Change the chip to a mod. | chip C(a: int, ...rest) |
WS053 | A plain emit X is followed, in the same chain, by an await X on the same signal. The emit fires in the current tick, but the await is what arms the resume and only arms afterward, so the await never catches that kick and the chain parks forever. Use buffer emit X, which lands the next tick after the await is armed. | emit go then await go |
WS055 (warning) | let x = await CustomEvent(...) captures the event’s data but its type could not be determined, so the wire defaults to a float and mis-delivers non-float data. Annotate the binding (let x: int = await CustomEvent("c")); a tuple let (p, t) = ... positional capture has no annotation surface, so capture typed values one at a time or receive them with a handler (on CustomEvent("c") -> (p: T, ...)). | let foo = await CustomEvent("c") |
WS056 | let x = await sig binds a value from a signal that carries no payload. A bare emit sig fires the signal without a value, so there is nothing to capture and the binding would read the signal’s exec pin as garbage. Emit a value (emit sig = ...), capture a live value at resume (let x = await expr on sig), or drop the let. | let sig: exec then let v = await sig |
WS057 | emit X targets something that is not an out port or a let ...: exec signal reachable here, so it would compile to nothing. An input port, a var, or an out/signal declared outside an enclosing named chip (whose fresh scope can’t see it) is not a valid target. | in go: exec then emit go |
WS058 (warning) | An exec statement never runs because nothing triggers it. An assignment (or other exec work) in a pure position is dropped: a mod or chip body used as a value (let x = f()), or a statement outside any on handler. The structural form of the same problem is a gate whose Exec trigger input has no incoming wire. Run it in an exec context, or pass exec = <trigger> to the call. | mod f() { x = x + 1 } then let d = f() |
WS059 | Change/Changed (or an Edge detector) is watching a value it can’t observe. A change/edge detector watches a single wire value; a reference or container (a Map, an array, a *T ref, a zone/teleport) has none, so Change(m) compiled to a dead gate. Watch a scalar the container produces instead. | in m: Map<int,int> then on Change(m) {} |
Gate & event config
Some gates and events take config args that bake into gate data rather than wiring in as ports — see Built-in Events and Custom Events.
| Code | Meaning | Trigger |
|---|---|---|
WS028 | Invalid gate/event config — an unknown enum member, an out-of-range int, a missing required config field, or a non-constant value for constant-only config. Note that a DESTRUCTURING let binds runtime names even when its source is itself constant, so only the const spelling of one satisfies a constant-only slot; the let is rejected here rather than silently baking an empty value. | on ChatCommand("greet", description = someVar); let { chan } = src then SendCustomEvent(chan, n) |
WS030 | (warning) Custom-event sender/receiver type mismatch — a send’s data value type disagrees with the receiver’s declared type on the same channel. | sender sends float, receiver declares amount: int |
WS042 | (warning) A CustomEvent/GlobalCustomEvent handler param has no type annotation and no in-unit sender to infer its type from — data defaults to float. Annotate the param, or add a matching SendCustomEvent/SendGlobalCustomEvent in the same unit, to silence. | on CustomEvent("dmg") -> (amount) { } with no in-unit SendCustomEvent("dmg", …) |
WS045 | (warning) A custom-event send’s data argument has no concrete type (any, or an Opaque(...) that erased its input’s type) — the send emits the float variant regardless, so it won’t match a receiver that declares a real type. Give the value a typed binding (e.g. a var of that type) instead of any/Opaque(...). Unlike WS030 this needs no in-unit receiver to visit. | SendGlobalCustomEvent("a", who, Opaque(true)) |
any
| Code | Meaning | Trigger |
|---|---|---|
WS032 | (warning) An any annotation on a non-storage position (port, param, let, output) — prefer a generic type parameter, which keeps the type. | mod f(a: any) -> any { return a } |
Parse- and lexer-level problems (an unexpected token, an unterminated string or
comment, a construct lowering doesn’t yet support) are reported under the
separate code WSP001, outside the WS0xx numbering above.
Upgrading
Wirescript’s syntax and gate catalog evolve as the game updates. This page
collects the breaking changes and how to migrate existing .ws code. The
complete, per-version log — every change, not only breaking ones — lives in the
repository’s CHANGELOG.md.
1.1.0
Event handlers bind outputs with ->
An event’s data outputs are captured in a trailing -> (…) (tuple, positional —
the cleanest form) or -> { … } (record, by field name), not inside the
event call. The event call’s parens now hold config/inputs only (a
custom-event channel, isObject, zone, interval, a ChatCommand name/help).
Binding outputs inside the call is an error.
// before
on CharacterDied(character, killer) { … }
on CustomEvent("dmg", amount: int) { … }
// after
on CharacterDied() -> (character, killer) { … }
on CustomEvent("dmg") -> (amount: int) { … }
Config stays in the parens; only data moves to ->:
on ChatCommand("greet", "Greets you") -> (controller, args) { … }
on ZoneEntered(zone = z) -> (character) { … }
Event triggers are calls — on RoundStart()
An event trigger is written with (), uniform with the on <call> -> (…)
model. The no-parens form — a handler head (on RoundStart { }) or a
captured-event alias (let x = on RoundStart) — is now an error. The () makes
it clear the event is a gate/call.
on RoundStart { … } // before -> on RoundStart() { … } // after
let x = on RoundStart // before -> let x = on RoundStart() // after
Custom-event data types move to the capture (and are inferred)
Write a slot’s type in the tuple capture, or omit it and let the compiler infer
it from the matching in-unit SendCustomEvent. When no sender supplies a type,
the slot defaults to float and warns WS042 (replacing the old WS029
“annotate this param” lint).
on CustomEvent("dmg") -> (amount: int, source) { … }
// ^annotated ^inferred from the sender
on CharacterSpawned() -> (ch) { SendCustomEvent("dmg", 5, ch) }
emit is for exec signals, not data
Set a data output with out name = value (in a handler or a chip/mod body)
or return value (a single-output mod). Reserve emit for firing an exec
signal (emit done) or ferrying a payload with a local signal you await
(emit loop = value … await loop).
General-call triggers
on <call> -> (…) works for any exec-producing call — a mod/chip call, or a
gate driven by an exec = input (on Foo(exec = go) -> (out)). on
auto-extracts the call’s exec output; a general call with no exec output (or an
exec = with nowhere to attach) is WS043.
New whole-grid events
on WholeGridInteracted() -> (character, held) fires when the grid is interacted
with; on WholeGridTargeted() -> (character, damage, weapon, weaponName) fires
when it is hit.
For the full, per-version history — features, fixes, and migration notes — see
CHANGELOG.md at the repository root.
Wirescript Changelog
1.10.1
Fixes
- A
matchexpression whose arms are records or enum values compiles, choosing per leaf field. The record spelling dropped the statement silently; the enum spelling was aWS071per arm. let m = if c then a else b/let m = match s { .. }bind a record per field, calls in an arm included.- A multi-output result chosen by an
if/matchpicks each port separately. One Select over port 0 meantc.Foundread the value andbFoundwas wired nowhere. - A
mod/chipwith one record-typed output hands back the record itself, as typecheck already reported. Keyed by the output name,r = mk(1)wrote no field. (match s { .. }).fieldreads that field, distributing over the arms likeifdoes.- A
modrecord argument accepts a container element or a record-valuedif/match, as itschiptwin already did. - An expression with no value read as one is a
WS072error. A no-outputmod/chiptyped asany, soo = noret(1)wired the caller’s exec continuation in as data. - A namespace reached two levels deep resolves. The seal over a namespaced body also hid the alias that module imported for itself.
- A namespaced
letinitialized through such an alias carries its type, so reading it is no longer aWS002. - An
import * asalias is file-local, so an importer and the module it imports may both use the same one. Both tables were keyed by name alone, so the inner namespace was dropped and every reference through it was an unknown identifier. - A namespaced record type (
Ns.Point) resolves to its fields, so a var or array declared with one decomposes and keeps its element type. It typed asanyand loaded empty. - Two
import * asin one file under the same alias is aWS012; unreported, the second shadowed the first and every reference read whichever module came last.
Editor
- The LSP watches the workspace’s
.wsfiles, so a module changed or created on disk while closed refreshes the files importing it. Only edits to OPEN documents did, leaving stale diagnostics about a version that was gone. - A record-literal key hovers wherever the literal is written, including a
var, an array initializer, and a literal nested in another; onlylet x: T = {resolved before.
1.10.0
value is Enum.Varianttests which variant an enum value holds, the shorthand for comparing discriminants. It binds like==, and a test whose two sides are both constant folds away.
Fixes
on Clocktakes onlyintervalandenabled.pulseOn,onTimeandoffTimeare inert fields of the gate’s data struct, so passing them baked config the gate never reads; naming one is now aWS041error.
1.9.0
- Const-heavy programs compile about a third faster. Lowering deep-copied the whole constant table on every expression it lowered.
- A program with many enum variants and match arms compiles dramatically faster. Exhaustiveness checking and match lowering were each cubic in the arm count.
- Large programs compile 10% to 15% faster, from fewer allocations in the fold and CSE passes and faster hashing in emit and layout.
- A source prefab referenced from several sites is compiled once and embedded once, so a program that spawns the same
$./file.wsrepeatedly produces a much smaller bundle. - Deeply nested chips, deeply nested record types, and files with many imports no longer slow down faster than they grow.
brdb
- Updated to brdb 0.11.0, which writes a save roughly three times faster.
- Each schema struct compiles into a write plan once and replays per instance, instead of re-resolving every field’s type for every component.
- Brick and wire chunks use specialized encoders when the loaded schema matches the expected shape, and fall back to the generic writer when it does not.
- Archive blobs are compressed in parallel.
Fixes
- A union-trigger handler (
on a | b { emit r = v }) compiles. Its body lowers once per part, so one emit became two drivers on the output and failed at emit with no earlier diagnostic.
1.8.1
- Refreshed the baked gate inventory from the current game build: the string gates carry their clearer display names (
Containsis nowString Contains,Format Textis nowFormat String,Lengthis nowGet String Length). No gates, ports, or behavior changed. - Added an external-asset catalog (
data/asset_inventory.simple.json) covering 832 assets across 8 descriptor types, generated byscripts/gen_assets.mjsfrom the in-game dump. Weapon one-shot audio is renamedBOSA_*toOSA_*, the smoke grenade is gone, and aircraft wheel-engine audio plus an electrical-arc particle system are new.
Fixes
- Reading a reference-typed record field yields its value, like reading a
var.var f: float = state.aon a*Tparameter reportedexpected float, got *float, and an if-then-else over two such fields reported a branch mismatch. rec.field.Valuereads the field’s backing variable. Onlyx.Valueon a plain identifier resolved, so the field spelling lowered to a placeholder and left its consumer unwired.- A record literal passed to a
*Tparameter is aWS008error when a reference field has no variable behind it. Aninport or expression has no storage to write back to, and the call previously compiled with the caller wiring a value where the body read a reference. - A
chipwith a*Tparameter binds each call site to its own argument. Every call after the first reused the first instance’s captured variables, so writes through the reference landed on the first argument and the later arguments’ variables were left unwired. &xandref xbind achip’s reference, array, record, or map parameter to the caller’s storage. Only themodform stripped the sigil, so the chip form left the parameter’s pin unfed and silently dropped every write through it.
1.8.0
*Ton a record, tuple, or enum parameter distributes over its fields, so a chip or mod can write through to the caller’s storage (chip M(s: *T) { s.a = x }). None of these has a single wire to reference, so the ref now names each backing gate: a record’s fields, a tuple’s elements, or an enum’s discriminant and payload slots.- Tuple variables get one backing gate per element, so
g.0 = vwrites. A tuplevarcollapsed to a single gate and element writes emitted nothing.
Fixes
- A field read directly on a multi-output chip call (
Chip(x).field) wires the named output. Only the two-steplet r = Chip(x)/r.fieldspelling resolved it; the inline form compiled to a placeholder wired to nothing. - A record used where one value is expected is a
WS071error instead of a silent placeholder. A record is several wires, so"x=" .. rechad no value to concatenate and dropped the operand.
1.7.4
unsafe <value>.<Variant>.<field>reads or writes one enum payload slot directly, without testing the tag. Reading a variant the value is not returns stale contents, and writing leaves the tag alone. Contextual keyword, so the name stays usable.
Fixes
- A declaration inside a
chip { }nested in a handler initializes on that handler’s chain when its initializer calls something. It was held off the chain like a top-level chip’s, which dropped the call and then reported a missing exec context.
1.7.3
Fixes
- Assigning a
match/if let/let elsecapture writes the matched enum’s payload slot in place, when the scrutinee is storage. A container element is read by value, so its captures stay read-only. - Assigning a field with no storage behind it (an enum payload field, a misspelled record field, a field of a scalar) is a
WS007error instead of compiling to nothing. - An enum payload field that needs container storage is a
WS069error, at the declaration or at a generic instantiation that picks one (Option<int[]>). The slot could never be filled, so the value read back empty.
1.7.2
Fixes
- A record or enum value spans several wires, and every position that collapsed one to a single wire is fixed. Most of these compiled clean and emitted nothing at all.
- Producing one: a record-valued
if/then/elseselects per leaf, in assignment,outand chip-argument position alike; a record-returning call used as a nested record-literal field writes every leaf, where the array and map forms had left the columns at different lengths; an enum payload that is itself a record or another enum is constructed field by field; a declaration initializer bakes a record payload instead of zeroing it. - Storing one: an enum-element array or map decomposes into parallel columns, one per tag and payload slot, so pushes and element reads carry the value; an enum-typed record field gets its own tag and payload storage rather than collapsing to one gate.
- Crossing a boundary: a nested record
in/outport creates a pin per leaf instead of stopping one level down; a record-typed chip signature output gets one pin per field, so the body emits and the caller’s pins are wired;&pon a record variable passed to a*Pparameter binds the caller’s record instead of dropping the whole body. - Copying one: a whole-record copy carries array and map fields instead of skipping them.
- Matching on one: a
matchorif letaccepts a container element (match m[k],match arr[i]) or a.Valueprojection off a mapgetas its scrutinee. Only a named binding of the same read resolved before, so the inline form hit a placeholder. - Referencing one:
&p.aon a record field is accepted, since each field is its own storage gate. It lowered correctly and was rejected byWS008anyway. - A record or enum as a custom-event data param is now
WS068, since a data slot is one wire and cannot carry one.
- Producing one: a record-valued
- A scalar initializer is baked in the declared type, so
var x: float = 0builds a float Variable gate instead of an integer one. Applies to every bool/int/float pair, to record and enum-payload fields, and to a var-backed output’s default (out y: float = 0). - A component read straight off a call (
d.ToRotation().ToEuler().Yaw,v.SplitVec().y) reads that call’s own port; it used to emit a second Split gate fed by the first one’s primary output, yielding the wrong component. - A local
execsignal consumed inside achipcompiles. The union cleanup redirected and pruned using one chip’s wires at a time, deleting a node the chip’s own wire still named, which failed the compile with a dropped-wire error. .Value/.prevopens a variable that spans several storage gates (a record, an enum); it used to emit a placeholder.- A
varinitializer naming a constant (var x: float = K) bakes that constant.
1.7.1
enumis a contextual keyword: it opens a declaration only when a type name follows, so it stays usable as a variable, parameter, or field name.- A
rotator’s.pitch/.yaw/.rolland aquat’s.x/.y/.z/.wnow read their components, lowering to a Split gate the way avector’s.xalready did; several components of one value share a single gate. - Each language-reference page opens with a Contents list of its sections, regenerated by
just doc-tocand gated byjust doc-check.
Fixes
- A constant
Rotation(...)passed to a quaternion port (v.Rotate(Rotation(0, -90, 0))) is converted and baked into the gate, instead of panicking the compiler.
1.7.0
enumtypes (tagged unions):enum Shape { Empty, Circle(float), Box { w: float, h: float } }supports C-like members, positional and named payloads, explicit= Ndiscriminants (later members auto-number from there; a collision isWS064), and generics (enum Box<T> { Value(T), Empty }). Enums are nominal, so two identically-shaped ones are different types..Discriminantreads a value’s tag as anint, and on a variant path (Shape.Circle.Discriminant) it folds to a compile-time constant.matchbranches on a variant and binds its payload, both as an expression (comma-separated value arms, lowering to aSelecttree) and as a statement (block arms, lowering toBranch/Union). Coverage is checked: an uncovered variant isWS054and names the missing patterns, an arm that can never run isWS061, and patterns nest into payloads including other enums. A wrong bracket form for a variant’s shape isWS065, and.Discriminantormatchon a non-enum isWS066.if letandlet elseare single-variant refutable binds:if let Some(x) = o { ... } else { ... }runs the block only for that variant, andlet Some(x) = o else { return }binds into the surrounding scope with a required divergingelse(WS062).- Built-in
Option<T>andResult<T, E>prelude enums, with their variants usable bare (Some/None/Ok/Err); a payload-less bare variant needs a type annotation to pin its parameters (WS063). - Built-in game enums for the game’s own config enums (easing function and direction, brick direction, color space, text justification and typeface): they need no declaration,
.Discriminantgives their real integer value, and a variant such asEasingFunction.Bouncepasses directly as the matching gate config argument alongside the older bare-name form. - Enum and int conversion:
value.ToInt()is an alias for.Discriminant, andEnum.FromInt(n)builds a value from a (possibly runtime) int tag with payloads defaulted to zero. TheEnumToInt/IntToEnumbuiltins (renamed fromEnumToInteger/IntegerToEnum) are the gate-backed twins: they now require an enum-typed value instead of acceptingany, fold a compile-time-known value to its tag, and use the game gate at runtime.IntToEnum’s result enum type is pinned by the target (WS063when it can’t be). - Editor support for enums: completion of variants, enum type names, and
.Discriminant; a fill-missing-arms code action; and hover and go-to-definition on enum types, variants, and named payload fields.
Fixes
- Calling a
modthat reads or writes a container in its body (m.get(k),arr[i],m.set(...)) from a pure position now reportsWS007, instead of silently wiring the container reference into the caller. This now also covers a container reached through themod’s own parameter (mod f(m: Map<int, int>) { m.get(0) }) or a body-local, not just a top-level one. - A multi-return
modwhose single output is a record now yields the branch actually taken at runtime; previously one return’s record literal leaked to the caller and folded the result to a fixed, often wrong, value. - A reference inside an
emit,await, orbufferstatement now counts as a use, so a genuinely-used import is no longer falsely reported unused (WS014) or dropped by Organize Imports.
1.6.2
- Raise microchips so nested ones are not underground
Fixes
- Reading a namespaced
outorinmember (L.count,L.level) now resolves to the port’s value instead of reporting “not found in namespace” and lowering to a placeholder. - A namespaced defaulted output with no
emit(out count = counter) is a single direct drive again, instead of also getting a backing variable that fanned two wires into its port (which failed to load). - Indexing a record-array or record-map column (
pts.x[i],m.x[k]) now reads the field’s value instead of typinganyand dropping to a placeholder; it works in expressions (a comparison, arithmetic), not just a barelet.
1.6.1
.execnames an event’s exec output, so a data-carrying event composes intoUnion(...);Unionalso takes an exec receiver, soa.Union(b)chains left-associatively.
Fixes
- An inline record-returning
modcall read by field (f(x).sum) now projects the field instead of an_Unsupportedplaceholder. - A record-typed module output (
out p: Point = { .. }) now dissolves into per-field boundary pins instead of dangling. - A whole-array assignment from a record-array literal (
arr = [rec, rec]) now rebuilds the array instead of being silently dropped. - A constant read through an
import * as nsnamespace (ns.NAME) now folds, so it works in constant-only positions (array initializer, channel name, label). - Spreading a tuple that came from a multi-output
modcall (f(n, ...g()), or a boundlet t = g()thenf(n, ...t)) now expands its elements instead of dropping them. - A field or index access on an aggregate-typed
ifexpression ((if c then a else b).x,(if c then a else b)[i]) now distributes over the branches instead of dropping to a placeholder. - A nested record passed as a chip parameter now wires every field, instead of collapsing the inner record to one unwired input.
- A tuple parameter on a chip (
chip f((a, b): (int, int)) { }) now binds its elements, instead of leaving the body reading unresolved values. - LSP: hovering a record field written through an array index (
arr[i].field = v) now shows the field’s type. - An
onhandler in animport * asmodule no longer resolves a free name against the importing file’s state; an undefined name there is now reported instead of silently wiring to a same-named importer variable. - State imported from one file both plainly and through
import * as(or viaimport { g }plusimport { g as h }) now shares one storage gate instead of duplicating into two that drift apart. - A namespace a module imports privately (
import * as B) no longer leaks to files that import that module; naming it from another file is an undefined identifier. - A namespaced output written by more than one
emit, or by a conditional or defaulted one, now routes through a backing variable instead of fanning two wires into its port (which failed to load).
1.6.0
- A
...tuplespread now expands into a call’s positional arguments.SendGlobalCustomEvent(name, ...t)splats a tuple across the event’s data slots, andf(...t)/f(a, ...rest)binds each element to consecutive parameters (a tuple literal...(a, b), a bound tuple, or a field chain reaching one). Over-filling a fixed-arity callee reports the usual arity error, and spreading a non-tuple is aWS003. - A
modcan take a trailing...restvariadic parameter that captures every argument past its fixed params. Each call site bindsrestto a compile-time tuple of the extra args, and a...restin the body splats them onward, somod broadcast(name: const string, ...rest) { SendGlobalCustomEvent(name, ...rest) }forwards any number of values. A call must still supply the fixed params (fewer isWS022). Onlymods may be variadic; a...reston achipisWS052. await CustomEvent("c")can now capture the event’s data inline:let n: int = await CustomEvent("c")suspends until the event fires and binds the first data output (typed by the annotation). A tuplelet (p, t) = ...captures data outputs positionally. Annotate the type or the wire defaults to a float (WS055warns when it can’t be determined). Bareawait CustomEvent("c")(no binding) just waits.- A tuple return’s parentheses are optional:
return a, b, creturns the same tuple asreturn (a, b, c), and.0/.1access the elements.
Fixes
- Compile progress now accounts for embedded prefabs: the reported step total grows by one per
$./filereference and inline$```...```block, so the editor indicator advances through them as it compiles. - The editor’s compile status indicator shows only while a compile is actually running.
SpawnPrefab(..., destroyAll = sig)fed a locallet sig: execsignal wires the signal into the spawner’s destroy pin, so those prefabs are destroyed (a localexecletfolds to a placeholder0, so it needs this dedicated wiring).wirescript-checkruns through lowering, so it surfaces the same_Unsupported-placeholder warningscompileemits.- New
WS053: a plainemit Xin the same chain as a followingawait Xwarns that the kick fires before the await arms (parking the chain forever); usebuffer emit X. - A record passed to a
chipwires its fields: a record literal argument (Foo({ x: a, y: 2 })), a field whose value is avar(read by value, not through the reference port), and a destructured chip parameter (chip f({ x, y }: P)) all bind correctly. - Assigning a field of a
let, input-port, or literal record (p.x = 5wherepis not avar) reportsWS007. Avar-backed record field stays assignable. ==and!=between two whole record values reportWS004. Comparing a multi-output result to a scalar (arr.pop() == 5) still works.- A
...restparameter in a destructuredmodparameter types as the record of the remaining fields, and a tuple-typed field inside a record annotation (type T = { pair: (int, int) }) type-checks correctly. - A record literal used as a spread source (
{ ...{ ...p, y: 2 }, x: 9 }) or read directly (({ x: 1, y: 2 }).x) resolves its fields. - A nested record inside a record array or map decomposes to leaf columns like a flat field:
arr[i].inner.a/m[k].inner.aread and write a single leaf,arr[i].inner/m[k].innerread and write the whole sub-record, andlet row = arr[i]binds the element as a record value. - Namespaced member access is stricter. Reading a missing or non-value member (
L.nope, or a namespacedoutport) and a nested path (A.B.bar()) reportWS002; a named-argument call through a namespace reportsWS022when the positional count is wrong. A namespaced container read (S.scores.get(k)),&S.g, and an import used only as a handler trigger type-check cleanly. - An
ifexpression written directly inside a string interpolation ("${if n < 10 then "0" else ""}${n}") emits its comparison and select. - Passing a non-lvalue (a literal, expression,
let,arr[i], or call result) to a*T/refparameter reportsWS008. - A statement written after a nested
onhandler inside a handler body stays on the outer exec chain rather than binding to the nested handler’s trigger. - New
WS057:emit Xis an error whenXis not anoutport or alet ...: execsignal (an input port, avar, or anout/signal declared outside an enclosing namedchip). - New
WS056:let v = await sigon a signal that carries no payload is an error. Emit a value (emit sig = ...) or capture one withawait <expr> on sig. - New
WS058(warning): an exec statement that never runs is flagged - A tuple
return (a, b)to amodwith named multi-outputs wires each element to the matching output in declaration order, solet (a, b) = f()reads the returned values. - Three constructs that type-checked but built no working circuit now report an error instead of compiling to a silent miscompile: a component field that isn’t on its type (
v.ron a vector,color.x) reportsWS010rather than emitting a wrong-typedSplitColor/SplitVector; a negated-union or double-negation handler trigger (on !(a | b),on !!x) reportsWS001rather than dropping the whole handler; and assigning to a non-lvalue (f() = 5) reportsWS007rather than dropping the assignment. - Emit rejects a wire fan-in (two sources driving one input port, such as a duplicate
out o) with a compile error, rather than writing a format-valid save the game refuses to load. - Unary negate (
-x) resolves correctly during generic-modmonomorphization, so a generic-xemits a negate gate with the right numeric type. - A constant
Substringwith a very large length clamps to the string end instead of overflowing into a panic. - New
WS059:Change/Changed(and theEdgedetectors) on a reference or container (aMap, an array, a*Tref, azone, or ateleport) is an error, since a change/edge detector watches a single wire value and those carry none. Watch a scalar the container produces instead. - A map access gate (
m.get(k)and friends) is colored by its value type, like the var and array access gates, rather than a neutral grey. - A get/set on a reference-passed container input (
in m: Map<K,V>/in xs: T[]) is tagged with the input port’s name, matching how an access on a storedvaris tagged.
1.5.0
- Added a
nullliteral. It adopts its target type and produces that type’s zero/unset value (entity/character/controller-> an unset object, a number ->0,bool->false,string->"", vector/rotation/color -> zero) in any typed position: avar/outinitializer, an assignment, a call argument, or a record field.nullfor a container, record, or reference-only type has no value and reportsWS051; a barelet x = nullwith no target types asany. - Records can now be stored. A record variable, array, or map (
var p: Point,var pts: Point[],Map<K, Point>) decomposes into one backing gate per field, so field reads/writes, whole-record assignment (p = { x, y },p = q), and the container methods all lower to real gates: arrays getpush/pop/insert/remove/fill/resize/swap/reverse/clear/lengthplus element access (pts[i],pts[i].x,pts[i] = rec,p = pts[i]); maps getset/get/has/remove/clear/length/keysplusm[k]access. A record variable used to collapse to a single gate whose fields read a bogus vector swizzle, and a record pushed into an array lowered to a placeholder that did nothing. - A stored record with a field that has no storable value (a
ref,zone,teleport, prefab reference, orexec) now reportsWS049instead of silently backing it with an unusable gate. - A record-container method with no per-field meaning (
sort/shuffle, the aggregatessum/min/max/average,find, and the dual-containerappend/copyFrom/slice/values) now reportsWS050instead of silently lowering to a no-op placeholder. - A constant record-array/map initializer (
var pts: Point[] = [{ x: 1, y: 2 }]) now bakes each field’s column into its backing container; it used to compile clean but silently start empty. A record array’s per-field arrays are also reachable directly aspts.field(struct-of-arrays access) — index, read, and aggregate a single column (pts.x[i],pts.x.sum(),pts.x.min()).pts.field.sort(descending?)sorts the whole record by that field, keeping rows intact.
1.4.5
- An operator or sibling call inside a namespaced module’s
onhandler (import * as L from "lib"where lib hason ReadBrickGrid() { arr.push(n << 10) }) is now type-checked, so it lowers to a real gate instead of_Unsupported. Namespaced handlers began lowering in 1.4.4 but typecheck never descended into their bodies, so operators got no resolution. --dump-irnode locations now include the source file (@ lib.ws:3:1) and render each node’s snippet from that file. Imported nodes used to show the entry file’s text at their offset and an ambiguous@ line:col; the IR ranges themselves were already correct, only the dump’s preview read the wrong source.
1.4.4
- A module imported as a namespace (
import * as L from "lib") now runs its top-levelonhandlers. - Two
import * asnamespaces (or a local declaration plus an imported one) that share a state name now get distinct storage gates. - A
var/array/map/bufferdeclared inside a handler,if, or block now gets its own storage gate instead of silently reusing an outer same-named one. &xpassed to a*T/ref parameter now binds the caller’s var, so writes through it land.- An or-triggered handler (
on a | b { ... }) now runs its body when either trigger fires instead of silently dropping the whole handler. - Two different modules declaring the same top-level name, merged via
import "m"orimport { x }, now reportWS013instead of silently collapsing both onto one storage gate. - A container mutation (
arr.push(x),m.set(k, v)) outside an exec context now reportsWS007instead of silently lowering to a placeholder that does nothing. - A parse error in an imported file now surfaces instead of being silently swallowed; the identical source only errored when compiled as the entry file.
- An anonymous chip (
chip { … }/chip on t { … }) in an imported module now runs; it was filtered out before the merge and silently dropped along with its writes. - A single
emit out = <expr>inside anifis now gated by the branch instead of wired unconditionally to the output; a guarded write no longer becomes permanent. - A
letthat shadows an in/out port of the same name now reportsWS013instead of silently hijacking it (let gobeforeon go, within go: exec, left the exec input dead). Ordinary value shadowing (let a = 1; let a = 2) is unaffected. - An output reached by an
emitplus a default initializer (out r = 0thenemit r = …), or by emits split across a handler and an anonymouschip { … }, is now var-backed instead of driven by two wires - a load-breaking fan-in on the output rerouter. - A
letaliasing an input-port array or map (in a: int[]thenlet x = a) now resolves forx[i]and container methods, like avaralias; it used to lower the index to a placeholder. - A namespaced mod’s body now resolves its OWN module’s siblings, vars, and constants. Two imported modules with a same-named private
helperno longer make one module’s public functions run the other’s code, and a namespaced mod mutatinggnow writes its own module’s storage gate instead of the last-imported namespace’s. - A container method READ outside an exec context (
out r = arr.length()) now reportsWS007instead of silently lowering to a placeholder, matching the pure index-read rule. Aconstreceiver or an explicitexec = <trigger>arg is exempt. - A captured handler with a non-Event trigger (
let e = on go { … }wheregois an exec input,var, orlet) now runs its body and captures its exit ase; the body used to be dropped entirely unless the trigger was a built-in event.
1.4.3
- An imported
letno longer clobbers a same-named declaration in the importing file. An importedlet startoverwrote the file’s ownin start: exec, soon startbound the imported value instead of the input and silently dropped the handler body. - Void container operations (
push/clear/set, thekeys/valuesfills) now type asnever, so using their result as a value (let r = a.push(x)) is a type error instead of silently accepted. - Top-level
onhandlers in an imported file now run; they were silently dropped, and anon <expr>handler additionally left a dangling trigger gate with no body. - A
letaliasing an array or map (let ar = a) now resolves for indexing, writes, and methods (ar[0],ar[0] = x,ar.push(...)) instead of lowering to a placeholder or silently dropping the write. - A namespaced member (
ns.name) whose bare name the importing file also owns is now reachable asns.name, sons.tuple.0resolves instead of falling through to the local binding.
1.4.2
- Folding enabled by default now that it’s stable
- Inline nested-prefab blocks now compile in the browser build. An inline nested-prefab block passed to
SpawnPrefab(the$-fenced source form) is compiled to its own prefab and embedded, matching the native CLI. The browser previously rejected any inline nested block with “no nested compiler configured for this compile”; dragged-in$./file.brzprefab references already worked. Blocks nested past a fixed depth fail with a clear error instead of hanging. - An import read only in the config of an event handler inside a chip body is no longer reported as unused. The 1.4.1 fix covered handlers at module level but not the statement path a chip body takes, so
on CustomEvent(CH)inside a chip warnedWS014while the identical handler outside one did not, and Organize Imports would then delete the import and leave the handler naming nothing.
1.4.1
- A record assigned to a mod output (
mod f() -> (o: Rec) { out o = rec }) now reaches the caller. A record has no single value port, so the output silently carried a placeholder that read a default, and everything downstream of it did too, including a record result passed straight to another mod (Take(Make())). The warning only appeared when the result went unused, so the broken cases were the quiet ones. - An imported module’s root-level
inandoutports are now declared and reachable asns.name. They were dropped entirely, soon ns.trigger { ... }matched nothing and silently discarded the whole handler body while both stages reported the file clean. Root-levelvar,array,mapandbuffermembers already worked. - An import read only in an event handler’s config (
on Clock(interval = TICK)) is no longer reported as unused. The scan walked the handler body but not its config args, so a constant used only to configure the gate warnedWS014, and Organize Imports would then delete it. - Two
import * asnamespaces that export the same member name now stay distinct.A.fooandB.foohad both resolved to whichever module was imported last, so a field access on the other read the wrong value and lowered to a placeholder while type-checking reported the file clean.
1.4.0
constcompile-time evaluation:constbindings,constparameters (f(name: const string, v: int)) andconst moddeclarations are evaluated at compile time and can be used anywhere a literal is required, such as gate config and custom-event channel names. Const expressions compose freely (operators, constructor arguments, destructuring, indexing, collection assembly) and emit no gates. Anifon a const condition drops its untaken branch, and aconstthat fails to be compile-time is reported at the binding.@layout("cube")no longer emits per-gate name labels, var tags, or chip-brick labels. Plane headers, the shell label, and a runtime@label(expr)are kept.GetInputs: samples a player’s twelve controls once when the exec chain reaches it, the exec-form counterpart ofInputReader. Same field names, sochar.GetInputs().PressedQreads like the splitter, and its operand also accepts a persistent player.- An exec builtin called with receiver syntax in pure position now reports
WS007. The check only covered the plain spelling, soGetLocation(e)was an error whilee.GetLocation()compiled to a placeholder that read a default. - A local binding that shadows an
import * as nsalias now reportsWS002at the call. A parameter orletnamednsmade everyns.f(...)in that scope resolve against the local value, which has no such member, so the call typed asanyand compiled to a placeholder that did nothing while type-checking reported the file clean. The failure then surfaced wherever theanywas finally consumed, on a line that was not the mistake. - LSP: Hover gate estimates now refresh as you type, so a mod added or renamed since the last save shows a count instead of none.
- LSP: Hovering a namespaced call (
ns.f()) now shows its gate estimate.
1.3.0
- An array/map method on a record field (
g.ready.sum()) now types its result instead ofany, so arithmetic on it works. Args are checked through the chain too. - A type alias whose body names another alias (
type Rect = { a: Point, ... }) now expands all the way down when imported, sons.rect.a.xkeeps its type instead of reading asany. - Namespace members (
import * as ns) written without an annotation, or bound by a destructuringlet, now carry their record type across the import too. - Compiler source reorganised
1.2.0
map[key]subscript syntax -m[k]andm[k] = vnow work on aMap<K, V>, desugaring to the same get/set them.get(k)/m.set(k, v)methods use (m[k]reads the value, auto-unwrapping the found flag;m[k] = vwrites it). The read types as the value typeVand, like array indexing, only works in an exec context. Previously this parsed and type-checked but silently did nothing (the read produced 0, the write was dropped).- Fill record fields (editor code action) - inside a record literal whose expected type is a record (
let x: Card = { … }), the lightbulb /Ctrl+.offers Fill record fields, inserting every missing field with a type-appropriate default and recursing into nested records — e.g.{ foo: "", bar: { baz: 0 } }. Present fields are kept, so a partial literal completes; nested, aliased, and imported record types are resolved server-side.
Fixes
- Output values (
return,emit out = …, statement-levelout name = …) are now checked against the declared output type (WS003) instead of baking a mismatch into the wire. - Conflicting generic-builtin arguments (
Select(c, 5, "hello"),Swap) now error (WS033) instead of widening toany. let t: (A, B) = …andlet x: R = { … }now check the value against the annotation (WS003), not just field names.- A multi-output result used as a scalar unwraps only through its first field — a later-field match wired the wrong port (now
WS003). - A tuple literal no longer collapses to a scalar (
let x: int = (1, "abc")isWS003). - A heterogeneous array literal in a handler (
xs = [1, "hello", 2]) reportsWS003on the odd element. - An
any[]/Map<any, …>parameter now accepts a concrete array/map argument. ==/!=on twovector/rotator/quat/colorvalues is now accepted (ordering stays scalar-only).- Assigning to a scalar
letreportsWS007instead of silently emitting no gate. - An unknown named argument on a
mod/chipcall reportsWS041instead of being dropped. - A typo’d event config/input name (
on Clock(intreval = …)) reportsWS041instead of no-opping. - An out-of-range integer literal reports a parse error instead of compiling to
0. - A captured event inside a handler (
let x = on Clock(1.0) { … }) reports one clear “top level only” error instead of misleading tail-parse errors. - A
varread afterawaitre-reads fresh, so a value changed during the wait is visible. ns.myValueon animport * as nsnow reads the real value and type — it typed asanyand compiled to a placeholder reading 0, while onlyns.f(...)calls worked.- A chip that writes a global no longer leaves a stale read after the call; the next read is fresh. (Inline
mods were already correct.) - A chip called in two contexts (pure-then-exec, captured-vs-passed arg, or same name in two modules) compiles a separate body per context instead of reusing the first’s mis-wired one.
- A chip with multiple
returns, and an output emitted from multiple sites, each route through one holder variable instead of fanning two wires into one pin (a load failure). - A
chip … -> (sig: exec)that doesemit signow wires the emit to the output (was silently dropped). - A wire whose endpoint can’t be resolved is now a compile error, not a silently dropped wire in a shipped save.
- The formatter no longer splits a
:kebab-caseatom literal at its hyphen. - A field access on a scalar (
x.whateveron anint) reportsWS010instead of silently reading the whole value. Projecting a single-output result by its output name (f.result) still works; a mis-typed one is caught.
Performance
- Repeated calculations build one gate and share it. Write
x + 1in three outputs, or call the samemodtwice, and you get one gate instead of a copy per use. (State-holding and@nofoldgates are left alone.) - A variable read is reused across an
ifwhen the branch doesn’t touch it, instead of being re-read afterward. A variable a branch writes still re-reads fresh, so it’s never stale. - The editor keeps up better while you type — ~20% less work per keystroke, and no longer multiplied by your open tab count. It parses the file once instead of twice, leaves hover’s gate-count estimates to open/save, and re-analyzes only the open files that import what you changed.
- Imports resolve against unsaved edits. A file importing something you’re editing was analyzed against the last saved bytes on disk until you hit save.
1.1.1
- Scope-aware rename & find-references - rename/
textDocument/referencesnow resolve the identifier under the cursor to its exact binding instead of matching the name as text, so they never touch comments, strings, a same-named type, another scope’s binding, or an unrelated file; renaming an exported symbol updates its importers (and a local alias stays local). (This replaced the old textual scan.) - Semantic type highlighting - the LSP emits semantic tokens so a name in type position highlights as a type (including user
typealiases the grammar’s builtin list can’t know), while every value identifier is highlighted uniformly - so a binding whose name collides with a type keyword (acharactercapture) or a builtin function (a value namedround) reads as a plain identifier, not a type or a call. Genuine builtin calls are unaffected. Atom literals (:name, kebab-case allowed like:kebab-casea, including a:name:map key) highlight as the integer constants they compile to; hovering an atom shows the compile-time xxHash64 value it resolves to, and find-references gathers every use of that atom across the workspace. .wssource prefabs - a prefab reference may now point at a.wssource file,SpawnPrefab(prefab = $./control.ws, …), which compiles that file into a prefab and embeds it on the spot - the file form of an inline$```…```block. Errors in the referenced file surface on the$./control.wsreference itself, so a broken prefab underlines where it’s used..brzarchives work as before; any other extension isWS019.- Fixed: a no-receiver gate called with a receiver -
x.SweepSimple(…)/x.Sweep(…)(the sweep gates act on their own brick and take no receiver) now reports a clearWS036error instead of silently type-checking asanyand lowering to a do-nothing placeholder. - Fixed:
@folddropping a runtime@label- a runtime@label(<expr>)(on avaror, blank-line-separated, on the root microchip) is wired into its text at emit time, so a folded module’s dead-code sweep no longer prunes the label’s source as “unused” and silently drops the label.
1.1.0
- Events as expressions - an event called as an expression emits its gate and yields its exec, so an event composes in expressions like
Union(RoundStart(), other). It takes the same inputs as theonform (Clock(interval = 2.0)), and a data-carrying event exposes its outputs by field access (CharacterSpawned().character); a bare call auto-unwraps to the exec. Theon E { ... }trigger form is unchanged. - LSP: named-argument values complete in-scope identifiers - typing the value of a named argument (
itemName = <here>) now offers the in-scope identifiers, not the call’s other argument names (those are only offered when completing an argument name). Enum/asset value slots still complete their specific members. - Event output capture (
on ... -> ...) - an event handler binds its data outputs in a trailing capture instead of inside the event call:on Foo() -> (a, b)(tuple, positional - the cleanest form, for any event) oron Foo() -> { field: local }(record, by field name - named events, for subset/rename).on <call> -> ...also triggers on any exec-producing call - amod/chipcall, or a gate driven byon Foo(exec = x) -> (...)- auto-extracting the call’s exec output; a general call that exposes no exec (or anexec =with nowhere to attach) is aWS043error. A single untyped output may drop the parens:-> whois shorthand for-> (who). - Custom-event data-type inference - an unannotated custom-event receiver slot takes its type from the matching in-unit
SendCustomEventon that channel; when none is inferable the slot defaults tofloatand warnsWS042(which replaces the oldWS029“annotate this param” lint). A written type goes in the tuple capture:on CustomEvent("ch") -> (x: int). - Whole-grid interaction events -
on WholeGridInteracted() -> (character, held)fires when the grid is interacted with;on WholeGridTargeted() -> (character, damage, weapon, weaponName)fires when it is hit. - Maps work wherever arrays do - a
Map<K, V>can now be amod/chipparameter, anin/outport, or a record field (not just a file-scopevar). A container method whose receiver isn’t a container isWS044, not a silent no-op. - Generic value builtins -
Select,Swap,Sleep,SleepTicks, andTweencarry their argument’s type instead ofany, soSelect(c, 1, 2)is anint. - Tuple arguments match tuple parameters - passing a tuple value or literal to a
(A, B)-typed parameter type-checks. - Ref-insensitive record fields - a record value matches a record type when fields differ only in ref/array exposure (a
*Tfield and a plainTare interchangeable at a call boundary, as parameters already are). - Tighter builtin argument types -
DisplayText/PrintToConsole/Fmt/SetTagtakestring,SetLeaderboard/IncLeaderboardtakeint, andSpawnPrefabtakes aprefabreference. - Fixed: exec after an emitting mod - a statement following a
modwhose body ends inemitno longer drops to an unsupported placeholder.
Migrating to 1.1.0
The on handler form now binds event outputs with ->, and no longer accepts data params (or their types) inside the event call.
- Move data outputs to
->-on CharacterDied(character, killer) { ... }becomeson CharacterDied() -> (character, killer) { ... }. Config and inputs stay inside the parens:on ChatCommand("greet", "help") -> (controller, args),on ZoneEntered(zone = z) -> (character). Config-only handlers are unchanged (on Clock(interval = 2.0) { ... },on RoundStart() { ... }). - Custom-event types move to the capture -
on CustomEvent("dmg", amount: int) { ... }becomeson CustomEvent("dmg") -> (amount: int) { ... }; an omitted type is inferred from the sender, or warnsWS042. - Event triggers are calls - an event trigger is written with
():on RoundStart(), noton RoundStart. The no-parens form - a handler head (on RoundStart { }) or a captured-event alias (let x = on RoundStart) - is now an error. The()marks the event as a gate/call, uniform with theon <call> -> (...)model. WS029is removed - the “annotate your custom-event param” lint is replaced by inference plusWS042.
1.0.0
- Maps (
Map<K, V>) - a keyed variable collection paralleling arrays:var scores: Map<string, int>, keyed byint/string/object reference and holding any wire-storable value, with exec-context methodsset/get/has/remove/clear/copyFrom/length/keys/values(getgives{ Value, Found }, auto-unwrapping toValue). A non-int/string/object key type is aWS039error. - Map literals -
{ k => v }keys by any expression,"s": v/:atom: vby a string/atom/int literal, and[expr] => vby a computed key; a fully-constant literal bakes the map pre-populated at rest,{}is an empty map, andm = { ... }in a handler desugars toclear()plus onesetper entry in source order. - Atom literals (
:name) - a compile-timeintconstant (the deterministic xxHash64 hash of the name), a readable stand-in for a magic number as anint-map key or enum-like tag; it only ever resolves at compile time, never from a runtime string. - Generic type syntax -
Array<V>andRef<V>are exact aliases ofV[]and*V. - Gate config properties - a gate’s non-wire settings-menu fields (checkboxes, dropdowns, values) are now settable as optional, constant-only call args, both by friendly alias and by raw game name (
SweepSimple(Direction = X_Negative, ...),p.DisplayText("hi", typeface = Bold)); enum args take bare member names validated against the game’s enum list, and an unknown name or non-constant value is aWS028error. - Custom events -
SendCustomEvent(name, data...)pulses everyon CustomEvent("name", a: int, b: float, ...)receiver on that channel with up to 8 typed data values; a receiver fires the tick after the send, and an untyped receiver param warns (WS029). - Custom-event signature check and navigation - a
SendCustomEventon a constant channel whose data types disagree with the matching receiver’s declared params warns (WS030), and go-to-definition on a send-site channel-name string jumps to the receiver. zone&teleportreference types - rerouter-only component references (like a var ref): passable through ports and parameters but not storable in a var/array/buffer (WS025) or selected with if-then-else (WS031). Zone events’zoneinput is nowzone-typed, andTeleport/RelativeTeleportdest/sourceare nowteleport-typed - teleporting to a raw position usesSetLocation.Clockreads as an event -on Clock(interval = 2.0, enabled = running) { ... }runs its body on each pulse;intervalandenabledare wire inputs (constant or dynamic, so the clock toggles at runtime) andpulseOn/onTime/offTimeare constant-only config.- New entity/character builtins -
GetSpeed,GetVelocityAtPoint,GetEntityTeam/SetEntityTeam,IsFrozen,DestroySpawned/DestroySpawnedPrefab, a character ammo family (GetAmmo/GrantAmmo/SetAmmo, weapon-chamber ammo,GetInventoryEntry,GetCurrentInventorySlot),GetOwnTransform, andForceRespawn(player)(alsoplayer.ForceRespawn()). - New date/time and conversion builtins -
GetUnixTime,FormatDate,Remap,LogicalShiftRight,EnumToInteger/IntegerToEnum,ItemToPickup,ConvertColor, andToCharCode/FromCharCode;ParseInt/ParseNumbergained a.Successflag and auto-unwrap to their parsed value. - Zone array fills -
arr.fillFromZoneEntities(zone, tagFilter?)/fillFromZonePlayers(...)populate an array from a zone, andarr.sortMultiple(other, ...)sorts a value array plus up to seven parallel arrays together. SpawnExplosion- an exec gate spawning an explosion of a given projectile/explosion class, with optional instigator, offset, scale, and damage.PlayClientAudio-player.PlayClientAudio(audio, volume?, pitch?)plays a non-spatial one-shot for a single player (alsoPlayClientAudio(player, audio, ...)); the descriptor is an inlined$BrickOneShotAudioDescriptor/...asset reference, like the other Play* audio gates.- Expanded
InputReader- reads look axes and key/button states (Up,Pitch/Yaw/Roll,MouseWheel,PressedC/E/Q/LeftMouse/RightMouse) alongsideForward/Right. DisplayTextreturns atextId- capture the returnedintto update or clear the same on-screen text later, with new color/outline/shadow/spacing/wrap styling params;position/anchor/scale/pivot/shadowOffsetareVector2Dlayout properties on the reworked gate.GetDamagegives{ Damage, DamageLimit }- auto-unwraps toDamagewhere a float is expected, and.DamageLimitreads the death threshold.- Richer event outputs - the fired-weapon event exposes the
weaponand its name,CharacterDiedthe killer’s weapon and name, andControllerJoined/ControllerLeftthe player’s user name;Sweep/SweepSimpleresults carry aHitColor. - Player-reference gates target persistent player-state -
DisplayText, chat/leaderboard/team setters, and the join/left/chat events resolve the current build’s persistent player-state; existingcontroller-typed scripts keep working unchanged. - Fixed fixed-size component arrays failing to load - weapon ammo resources and mesh colors are native fixed-length arrays; emit now pads them to their full length so a save that sets one loads instead of rejecting on size.
- LSP: config-aware completions and hovers - enum sibling completion, hovers for events, enum values,
Clock, and settings-menu config fields, and asset-type dropdown completions for$Type/Nameconfig refs. @label(<expr>)labels -@labelaccepts an expression, not just a string literal. A constant folds to baked text; on a top-levelvara runtime value becomes a dynamic label, wired live into the variable’s floating text - including a variable labelling itself with its own value (@label(x) var x). A blank-line-separated@label(<expr>)at the very top of the file labels the root microchip rather than a declaration, and may forward-reference declarations below it. A runtime@labelon a port/chip (which has no wireable text) is aWS040error.@invisible- hide a port’s rerouter brick and label, or - as a top-of-file annotation - the whole microchip shell (hidden, non-colliding, no labels), for microchips that spawn other microchips.- Inline nested prefabs - a triple-backtick block prefixed with
$compiles an isolated inner program and embeds it as a prefab, usable directly as aSpawnPrefabargument. The inner block canimportbut shares no wires with the outer file (it is a separate prefab); its diagnostics, highlighting, and completions all resolve against the inner program. on <expr>triggers - anonhandler can fire on an arbitrary boolean expression - a comparison (on hp <= 0), a method/index result (on a.Dot(b) > 0.0,on arr[i] > 0), a negation (on !flag), or a bare variable - not only a named event or input.- Chained method calls across lines - a call chain may continue with a leading
.on the next line (f(...)then.g(...)below it); the formatter indents the continuation. - Fixed a
varinitializer missing its=-var x: int 5silently dropped the value; it now reports amissing =error (and still recovers by taking the expression as the value). - Callable gate builtins - every variable/array/map wire gate is now callable as a function named after the in-game gate; this now includes the array-fill gates (
FillArrayFromPlayers(arr),FillArrayFromTeamMembers(arr, team),GetPlayersInZone(arr, zone),GetEntitiesInZone(arr, zone)), the function-call twins of thearr.fillFrom*methods. - Callable exec-flow gates -
Union(a, b)merges two exec signals into one (fires when either fires);Branch(cond, exec)routes an exec to.Aor.Bon a condition. - Fixed multi-output builtin field names - a gate returning several outputs (
Edge,Branch, …) now exposes them under their friendly names (Edge(b).Rising/.Falling,Branch(c, e).A/.B) as intended, instead of only the raw port names. on <call>()triggers - a builtin call that returns an exec (on ServerUptime(),on Change(v)) fires the handler on that exec, desugaring like any otheron <expr>trigger - distinct from the event-with-args form (on Clock(...)).- LSP: scope-aware completion & hover - the editor now resolves a name to its in-scope declaration, so a name reused across scopes (a
stringat file scope, acharacter[]inside a handler) is read as the one actually visible at the cursor rather than the first declaration found. This applies toreceiver.member completion (the correct method table), the bare-identifier list (a single entry with the in-scope type, no leak of a handler-local into file scope), and hover (the declaration under the cursor shows its own type). - LSP: default values in gate hovers - hovering a gate/builtin (e.g.
SpawnPrefab,Sweep,DisplayText) now shows a table of its parameter and settings-menu defaults (limit = 5,distance = 100,fontSize = 16, …), read from the same source the emitter uses; enum defaults show the member name (direction = X_Positive). Hovering a named argument (fontSize =) shows that field’s default too. DisplayTextlayout is per-axis - theVector2Dlayout ports (position/anchor/scale/pivot/shadowOffset) are set per axis:positionX/positionY,anchorX/anchorY,scaleX/scaleY,pivotX/pivotY,shadowOffsetX/shadowOffsetY. A constant axis bakes the property (an unset axis keeps its default); a runtime value wires itsX/Ysub-port.- LSP: gate hovers render composite defaults - composite/color parameter defaults show as their constructor or sRGB hex (
outlineColor = #181425), and aVector2Dsub-port axis shows that axis of its parent default (anchorY =→0.5). - Unknown call arguments now error - a named argument that matches no parameter and no settings-menu config field is a
WS041error instead of being silently dropped (a typo’d argument name that previously did nothing). The universalexec =override and a variadic call’s trailing options are exempt.
Migrating from 0.x
1.0.0 tightens several call signatures and reworks a few gates, so some .ws that compiled under 0.x needs edits.
- Teleporting to a position -
Teleport/RelativeTeleportdest/sourceare now theteleportreference type, soe.Teleport(Vec(x, y, z))ande.Teleport(other)no longer typecheck. Usee.SetLocation(Vec(x, y, z))to move to a raw position, or wire a teleport point into anin p: teleportport ande.Teleport(p). - Zone events take a
zonereference - the events’zoneinput is nowzone-typed (andtagFilterisstring), sozone = ewith an entity/brick value errors. Feed anin z: zoneport from a Zone brick:on ZoneEntered(character, zone = z) { ... }. zone/teleportare reference-only - like a var ref, they can’t be stored in avar/array/buffer(WS025) or picked with an if-then-else (WS031); pass them straight through ports and parameters.DisplayText.outlineSize/fontSizeare nowint- pass an integer. The per-axis layout args (positionX/positionY/anchorX/anchorY/scaleX/scaleY) are unchanged and keep working;pivotX/pivotY/shadowOffsetX/shadowOffsetYare new axes on the reworked gate.Swapresult fields renamed -Swap(cond, a, b)returns{ Output, OutputB }(was{ a, b }); readr.Output/r.OutputB(a barerstill auto-unwraps to the first value).InputReader().Jumpremoved - the movement record droppedJump; read the new axis/button fields instead (Up,Pitch/Yaw/Roll,MouseWheel,PressedC/E/Q/LeftMouse/RightMouse).BrickChanged/BrickRemovedlost their brick output - these events no longer carry abrickvalue, soon BrickChanged(brick) { ... }won’t bind; drop the parameter (on BrickChanged { ... }).RotToDirremoved - its gate no longer exists in the build; useq.ToDirection()(takes aquat) to turn a rotation into a forward direction.arraydeclaration keyword removed - declare container variables withvarinstead:var scores: int[](wasarray scores: int[]). Storage and behavior is identical - only the keyword changed - so the fix is a mechanical rename. Usingarrayas a declaration keyword is now a parse error that points at thevarform.
0.20.0
@layout("code")– source-shaped gate layout - a row per source line, expressions left to right, widely-read values down reusable gutter lanes, and own-line//comments rendered onto the plane.@layout("cube")– compact 3D packing - stacks gates into brick layers without analysing the wire graph. Minimal brick mass, no visible dataflow.@flat– inline every chip onto one grid - no microchip bricks or nested planes; chip-wall crossings become ordinary wires. Behavior unchanged.- Every chip-wall crossing now routes through a labeled boundary pin - crossings get their own
MicrochipInput/MicrochipOutputrerouter, even when not a declared parameter. Constant arguments still inline.
0.19.0
- Constant expressions in
var/arrayinitializers - an initializer may name a top-levelletconstant and compute with it:array mask: int[] = [1 << C_FLAG, WIDTH * HEIGHT]. - A named import now carries the constants an imported array is built from - the dependency closure skipped array initializers, so the import failed with “unknown identifier”.
- Fixed a spurious “unused import” on a constant used only by an array initializer - WS014 fired and Organize Imports would delete it, breaking the table it fed.
- Fixed a playground rename clobbering another file - the active-file pointer stayed on another file, so the next autosave overwrote it.
0.18.0
exec =on any exec call - an exec-gate call (array method, builtin, mod/chip) can run off an explicitexec = <trigger>, e.g. an array read in a pure binding:lut.get(i, exec = i + 1).- Fixed
emit output = valuefan-in - it wired both the value and the exec to the output’s input pin, failing to load; now just the value. - Certified constant folding - pure gates whose inputs are known constants are
evaluated at compile time against the in-game-certified semantics table,
constant-selector
Selects short-circuit, constant-condition branches truncate their dead side (including across chip boundaries), all before layout.Opaque(...)and@nofoldexempt code. The pass is opt-in while it stabilizes: enable it with a module-level@fold(or--fold);--no-fold(or a module-level@nofold) disables it and always wins over@fold. String concatenation, string methods, and${...}interpolation now fold too, byte-exact against the game’s own text rendering, alongside vector, rotation, color, and quaternion constructors and their certified math operations. - Fixed returning a record through another
mod-return f(x)orreturn rwith a record value wired the caller to a phantom node whose wires were silently dropped at emit. The record’s fields now forward to the caller. anytype - anin/let/mod-or-chip-param/-output can now be annotatedany, an operator-wildcard type that resolves real overloads (x & 1,x == "y", …) instead of erroring on an unknown type. It works anywhere, but the side effects of whichever overload gets picked are on you. Avar/static var/array/buffercan’t store one: a variable gate needs one concrete wire type to hold, so an explicitanythere is now a compile error.- String truthiness - a
stringnow coerces toboolwherever a bool is expected (anifcondition, a bool-typedlet/var, a bool port or chip param). The coercion compiles to an inserted!= ""compare gate, so the semantics are deterministic: empty is false, everything else — including"0"and"false"— is true. (Strings wired into bool ports manually viaanystill get the gates’ native content-aware truthiness, where"0"/"false"are also falsy.)
0.17.1
- Fixed a constant on a data-only port failing the build -
DisplayText.fontSizeand 13 other params name settable fields with no wire input; binding one emitted a wire emit rejects. Now written as data, with a test pinning the list. - Fixed destructuring a builtin multi-output call binding nothing -
let { Forward, Right } = c.InputReader()left every name an unwired placeholder. Fields now bind to the gate’s ports, and an unknown field errors with a suggestion instead of binding silently. - LSP reports lowering and emit errors on save - Live analysis stays typecheck-only, so lowering problems only surfaced on an explicit Compile. Saving now runs the full pipeline and publishes its diagnostics.
- New
arr.get(index)- A checked read giving{ Value, OutOfBounds }; used bare it is the element. Completion now offers those fields onarr[i].too. Blendis the math blend gate - An alias forlerp, accepting any math variant (float/int/vector/rotator/quat/color), as dolerp,Easing, andTween. The colour-space gate is nowColorBlend.Opaquehovers with its own docs - It showed the Rerouter gate’s blurb, which says nothing about the fold-hiding and type-erasing behaviour it exists for.- Fixed
.Valueon a multi-output result -a.pop().Valuetyped as the whole record, so every use of it mismatched. - Fixed a type alias not resolving through a namespace import -
import * as Twithmod f() -> MyTypefailed with “unknown type”. Aliases now inline as they do for a named import, andT.MyTypeparses as a qualified type. GetLeaderboardreturnsint- It was typedany, so arithmetic on its result had no operator overload.boolarithmetic with two bools -bool + bool(and- * / %) now promotes toint, matchingbool/intmixes and the bitwise ops;(a && b) + (c && d)compiles.
0.17.0
Opaque(x)builtin +@nofoldannotation -Opaquepasses a value through a rerouter and hides it from constant folding;@nofoldsuppresses folding for a declaration, or for the whole file when placed at the top separated by a blank line. No-op placements warn. Groundwork for gate-semantics verification circuits.- Gate semantics probe -
probes/gate_semantics.wsprints every probed gate interaction to the console on paste;scripts/gen_semantics.mjsturns the dump intodata/gate_semantics.json, andscripts/gen_verifier.mjsgeneratesprobes/verify_semantics.ws, which re-asserts every recorded case in-game. - Fixed a chip output named
x/y/z/r/g/b/areading garbage - Those names collide with vector/color component access, so reading one split the scalar and returned a component instead of the output. Field access now splits only when the value really is a vector or color. - Fixed a
chipcalled from inside a nested anon chip never firing - Its exec trigger stayed at the root, and an exec pulse cannot cross into an instance grid nested inside another anon chip. Partition now routes boundary-pin wires into the module that directly contains the instance. - A constant argument to a
chipno longer costs a gate per instance -F(1)materialized a_Varin the caller and wired it across the boundary. The constant now folds into the instance itself and its input pin is dropped, matching what the equivalentmodemits. - Fixed a tuple-destructured
modparameter binding nothing -mod f((a, b): (int, int))left every name unbound, so the body silently computed on zeros. - Fixed
let (a, b) = ton a tuple value - Both names bound nothing, and the shape was rejected as a non-tuple (WS010). out f(x)is now a parse error - The trailing call was dropped and re-parsed as a separate declaration, leaving a bare port.- Fixed a namespace import lost through a re-export -
Nsdidn’t travel with the imported declarations calling through it, so everyNs.f(...)silently did nothing at runtime. - Fixed a namespaced call losing its return type -
Ns.f(x)typed asany, soNs.f(x) + 1failed operator resolution (WS004) and dropped the expression. - New tree-sitter grammar -
editors/tree-sitter-wirescript/, with highlight/locals/indent queries. - Docs: dropped
matchexpressions - Reserved keyword, but the parser has no expression form for it. - New docs page: Best Practices - Gate count and scaling: why every call site is a copy (for
modandchipalike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state.
0.16.4 - 2026-07-17
- Fixed a constant shared across two chips reading 0 in one of them - A literal used as a wired operand (e.g.
x * 4) inside two separatechip { ... }blocks was merged by constant-deduplication into a single gate before anon-chip partitioning, leaving the second chip’s operand wired across the chip boundary — where emit’s per-module literal inlining can’t reach it, so the operand silently read its port default (0). Deduplication now groups by owning chip, keeping a shared constant once per chip. - Fixed
onhandlers bound toChange(x)-Change’sOnChangedoutput is now typedexec(wasany), solet c = Change(x)+on c { ... }fires on the change pulse. thenmay start its own line in an if expression -let x = if condfollowed by indentedthen .../else ...lines now parses; the formatter indents both keywords one level as expression continuations.- Fixed transitive imports resolving in the wrong order - When an imported file had imports of its own, its declarations were placed before the ones it imported, so any call into a deeper module was a use-before-declaration (WS021) or lowered against a missing declaration. This surfaced through a file that only re-exports another (
import "b"alone ina.ws). Nested imports now resolve ahead of the importing file’s own declarations, matching how the entry file already behaved.
0.16.3 - 2026-07-16
- Compiler is ~2x faster on large projects - mimalloc in the native binaries, thin LTO, a single-pass anon-chip partition, a quadratic wire-scan fix in inline chip calls, and Arc-shared ports/templates: lowering −69%, end-to-end −42%, lowering allocations −46%.
- Compiled output is deterministic - Anon-chip partitioning iterated a randomly-ordered set, so emitted gate/wire structure varied run to run; chips now partition in sorted order and repeated compiles produce identical graphs.
- New
fuzz_programsexample - Seeded grammar fuzzer that hunts silent miscompiles: programs with no error diagnostics whose output has_Unsupportedgates, duplicate/fan-in wires, or dangling endpoints. Findings write to a gitignoredfuzz_findings/.
0.16.2 - 2026-07-15
- Fixed LSP crash (stack overflow) on large programs - The cycle-analysis SCC walk is now iterative, and every
compile*entry point runs on a worker thread with a 256 MiB reserved stack. - Fixed LSP crash on multi-byte text - Hover/completion word scanners now step past characters by their real width, and member-receiver lookup converts the cursor column from chars to bytes.
- Stale compile-command diagnostics clear on edit - Editing or saving any
.wsfile clears the previous Compile command’s diagnostics; the next explicit compile repopulates them. - Rename applies to every reference find-references sees - Three
textDocument/renamefixes:- Open files match by canonical path and references are deduplicated, so edits are no longer doubled and rejected.
import { foo }rewrites toimport { bar }; shorthand expands to{ foo: bar }, and value-position names are untouched.- Rename works from any reference site (
u.foo, record fields); built-in event names refuse rename.
- Compiler is ~15% faster end-to-end - Internal tables use the Fx hasher (
crate::collections) instead of SipHash: lowering −15%/−26%, cycle analysis −45%/−55%, layout −36%, world building −21%/−33%. Map iteration is now deterministic, so output is more stable run-to-run. - ~30% fewer allocations during lowering - Chip declarations are shared via
Arcinstead of deep-cloned per call, and scope keys ride the interner; mod-heavy programs lower ~14% faster. Newcount_allocsexample reports per-stage allocation counts/bytes.
0.16.1 - 2026-07-15
chip letlabels the chip with its binding name -chip let x = ...now shows the binding name(s) as its display label; an explicit@label(...)still overrides.- Wider vertical gap between chip-pane rows - The wall layout’s row-to-row gutter was widened so stacked chip planes read as separated.
- Fixed repeated chip calls sharing wire endpoints - Later instances of the same
chip(foo(0),foo(1)) wired their boundaries to the first instance and failed to load (“Failed to connect wire”). Boundary wires now remap to each instance’s own nodes. - Hover on a namespace member shows its signature -
u.foo(viaimport * as u) now shows the full signature and exec-ness, matching a direct call’s hover. - Unresolved namespace/method call is now a hard error (WS002) -
ns.foo(...)whose base is not in scope errors at the dangling identifier instead of lowering to a silent_Unsupportedgate. - Organize Imports preserves namespace, bare, and multi-line imports - Alt+Shift+O now keeps every import form (namespace/bare imports are never pruned; unused named imports still are) and sorts a namespace import before a named import from the same module.
0.16.0 - 2026-07-14
- Fixed field triggers on a local in handlers -
on x.field(and negatedon !x.field) now fires the matching output port instead of the local’s default port. - Duplicate constant gates merged per chip - A repeated constant is emitted once and fanned out. Pure gates with no wired input only;
Random/stateful detectors are never merged; cut ~1200 gates on a large project. - LSP: member completion after
receiver.wins inside a call arg -Call(arg = recv.<here>completesrecv’s members (records complete their fields, including inonhandlers);Call(<here>still completes params. - LSP: more completion contexts -
import * as uthenu.<here>lists the module’s members.pos.<here>on avar pos: vectoroffers type methods + swizzle (x/y/z,r/g/b/a) alongside.Value/.prev;static vargets.Value/.prev.- Values typed by a
type Foo = { ... }alias completeFoo’s fields. - User
mod/chip/fncalls complete their param names instead of the global list. - All-required calls (
Vec(<here>)) offer their params; method calls drop the bound receiver param. @-annotation list adds@labeland@closed.- Native LSP and web playground share these paths.
- Doc comments on record-type fields - A
///on a field insidetype T = { ... }now parses (was a parse error) and shows on hover of that field. - Fixed hover on a namespace alias - Hovering
uinimport * as ushowsnamespace uand lists its members (wasnamespace u: unknown). - VS Code formatter (Prettier plugin) - Adds a space after commas; splits long braced imports (fill) and binary-op statements (one operator per line, lowest precedence first) at 100 cols; joins
} else {; honors// fmt-ignore(standalone guards the next line, trailing its own).///doc comments auto-continue on Enter. - Opened-plane headers space the doc off the title - A blank line now separates the size-96 title from the chip/module doc comment.
- Warn on asset/reference values in an array initializer (WS024) - Assets (
$Type/Name) and prefab refs can’t bake into a constantarray/varinitializer; build with.push(...)in an exec handler. All reference types (entity/character/controller/brick/prefab/assets) share the object wire and can’t be inlined. - Module doc comments stay separate from the first declaration - A top-of-file
///block followed by a blank line (or//comment) is the module doc (root plane header); a block directly above a declaration still documents it.
0.15.0 - 2026-07-13
- Color arithmetic -
+ - * / %operate RGBA channel-wise on twocoloroperands; a scalar broadcasts across channels (tint * 0.5). Same PrimMath gate as vectors/rotations. Randomis polymorphic -min/maxmay bevector,rotator,quat, orcolor; each component rolls independently and the same type is returned (Random(Vec(0,0,0), Vec(1,1,1))→ point in the unit cube). Scalarintform unchanged.- Fixed anonymous-record mod returns - A
modreturning a record literal (return { head: ..., rest: ... }) now destructures into per-field sources, so each field wires to its own value (was one_Unsupportedgate). - Non-root chips compile open by default - Opened planes stack as a wall above the compiled microchip (root at bottom, deeper nesting higher). New
@closedcollapses a chip but keeps its wall slot;open chipis now a no-op. - New
@label("text")annotation - Display-text override for chip labels/headers andin/outport labels (stacks with@sidein any order); the wiring-UI port name is unchanged. - Opened planes render a header - A size-96 title (the
@labeltext, else the chip name) plus the chip’s///doc comment, on an invisible brick at the plane’s top edge.
0.14.1 - 2026-07-13
- LSP: fixed a
return <expr>mod mislabeledexecon hover -returnalone no longer forces the exec label; only an exec op in the returned expression (e.g. an array read) does. - Pruned duplicate constants from dual imports - A module imported via both
import * as xand a named import no longer ships its top-levelletconstants twice; fully-disconnected pure gates and orphan literals are pruned.
0.14.0 - 2026-07-12
- Port-side rerouter pins -
@left/@right/@top/@bottomon a top-level port (same line or the line above) emits a pre-wired rerouter brick flush against that side of the microchip. Ports keep declaration order per side (ins/outs interleave), each pin is labeled with its port name; annotations insidechip {}/modbodies error (WS023).
0.13.1 - 2026-07-11
- Fixed
array.pop()returning0- Both gate outputs are now declared:.Valuereads the popped element and.IsEmptyreadsbIsEmpty(true once the array is empty after the pop). - Fixed
bufferinitializers inside chip/mod/handler bodies never wiring - The initializer expression was silently dropped, leaving the buffer’s input dangling. - Silently-dropped
varinitializers now warn (WSP001) - Warns on a non-constant init in pure position, any non-constantstatic varinit, and an exec-context array-var init that isn’t an array literal. Use aletfor a pure computed binding, or assign inside an exec handler.
0.13.0 - 2026-07-10
- ~4x faster compiles on large projects (5.9s → 1.5s):
- Each chip is laid out exactly once; the pre-emit layout pass no longer recurses into children.
- Layout: one toposort bucketed per connected component; prebuilt consumer map + O(1) occupancy checks.
- Emit: gate-data schema classification and interned names resolved once per gate class; no per-brick String clones.
- Lower: dead exec-union pruning is a single incremental worklist pass.
- brdb 0.8.0 - Unset component fields skip a defaults scan and two error-String allocations per field; brz index compression actually works (its size guard was dead code).
- Fixed field access on a call result dropping the call -
arr.find(x).Found/.Indexnow keep the call. - Fixed a standalone
chiplosing its exec output - An exec-bearing body ending inreturn <value>now ships the output. - Fixed
out X = Xemitting no wire - Applies when the output shares its name with a var/array. - LSP: hover on a call-result field resolves its type -
ids.find(x).Foundresolves from the call’s record. - LSP: goto-definition on a namespaced call resolves in the imported file -
u.foowithimport * as uno longer jumps to a same-named local decl. - Chip exec I/O gates are labeled - Exec gates say
exec; the anonymous-> typereturn output saysreturn(synthesized ports had no label). ControllerJoined/ControllerLeftexpose the player’s id -on ControllerLeft(controller, userId)(string); stable when the controller is torn down on disconnect.- Calling a chip/mod before it is declared is a hard error (WS021) - In both the compiler and the LSP (was a silent placeholder reading its default
0). in X: T[]array inputs are first-class - An array-typedinport supports array methods (X.length(),X.push(v), …) and passes to a mod/chip’sT[]parameter.- Namespaced module members resolve inside their own mods -
import * as nsonly; named imports were unaffected. - Chip/mod calls check their argument count (WS022) - Hard error in both the compiler and the LSP (a wrong count silently left a param unbound or dropped an arg). An
exec =trigger isn’t counted; a spread arg skips the check.
0.12.3 - 2026-07-10
- Anonymous-chip constants - Fixed literal constants not reaching anonymous chips.
0.12.2 - 2026-07-09
ReadBrickGrid()- New builtin.
0.12.1 - 2026-07-09
- Zone events bind their
Zoneinput -on ZoneEntered(character, zone = z)wireszinto the event gate’sZoneport, so a wiredinport selects the watched zone. CoversZoneEntered/ZoneLeft,EntityZoneEntered/Left,ProjectileZoneEntered/Left,BrickChanged/BrickRemoved. - Fixed a false recursion flag - An imported namespaced identifier no longer triggers it when conflicting with a local identifier.
0.12.0 - 2026-07-09
Language / Compiler
- Emitted saves label their elements with text decals - The top-level chip is titled with the entry file’s stem (or
--name); named chips, variables/arrays, and microchip I/O gates get diagonal floating name labels._-prefixed ports stay unlabeled. - Var/array exec gates tag their variable -
Var_Get/Var_Set/Var_Incrementand array-var gates carry a smaller tag naming the accessed variable, traced through the ref wire (works across chip boundaries for captured vars).
0.11.0 - 2026-07-08
Language / Compiler
- Gate data mappings derive from game data - Struct names and field lists come from the game-extracted pair table + schema, so new gates need no table edits. Stale entries for components the game lacks were dropped.
- Vector/Rotation literals embed into gate data -
e.SetLocation(Vec(0.0, 0.0, 100.0))bakes the vector into the gate instead of spawning a wiredMakeVector.Split*inputs still materialize. - Exhaustive gate-data write audit - A test serializes a literal into every representable field of every game component through the real writer; a failure names the gate and field.
- Record literals as call args bind their fields -
{ a: 1, b: 2 }passed to a destructured (f({ a, b }: P)) or whole-record (f(p: P)) param now lowers the fields. - String constants inline as wire variants - Ports that can’t hold an inline variant keep the real gate.
- Chips capture the whole enclosing scope -
let/in/event-param references now resolve; constants clone into the chip, solet K = 2used asarr.push(K)bakes2into the gate.
Bug Fixes
min/maxand 14 more expression gates embed literals -min,max,sign,round,exp,ln, the hyperbolics,Deg2Rad/Rad2Deg,BitCount, andScaleVecno longer drop literal args likemin(a, 3.0).ScaleVecwires to the real ports -Input/Scalarinstead of the nonexistentInputA/InputB.- Destructuring record literals - Now properly lowered to bindings.
0.10.2 - 2026-07-08
Language / Compiler
- emit/await loops with
buffer emit-buffer emit sig(1 tick),buffer(N),buffer(0.5s),buffer(myVar), orbuffer(delay, hold)inserts theBuffer(Ticks|Seconds)gate a wire-graph cycle needs. Constants bake into the gate; variables wire the duration port. - Payload ferrying -
emit sig = valuestores the value in hidden per-signal vars (one per record field);let x = await sig/let { a, b } = await sigreads it back. Cost: oneVar_Setper field per emit, oneVar_Getper field at the await. - Body-level
let x: execwires correctly -await xon a body-declared signal no longer lowers to a dead placeholder. - Signals are scoped per declaration - Two mods each declaring
let loop: execno longer share one signal; hubs are keyed per declaration and resolved through the scope. - Handler-local array vars re-init correctly -
var nums = [1,2,3]in a body rebuilds via clear + push instead of wiring a nonexistentVarRefport. - Layout no longer panics on multi-cycle SCCs - Feedback-edge removal iterates until acyclic, so two loops sharing a chain lay out.
Language / Compiler (types)
entitycoerces tocharacter/controller- Character/controller receiver methods and typed params accept entity values (e.g.Sweep’sHitEntity), wiring directly with no adapter gate.
Bug Fixes
CharacterDamagedattacker ischaracter-typed - Wasentity, which receiver methods and typed params rejected. The weapon binding staysentity.ShowStatusMessageand 12 more gates - Literal args now persist.- Recursive chip/mod calls error instead of crashing - Now a
WS020error.
Editor / IDE
- Named-arg hovers only fire on the arg name - In
delay = delay, hovering the value shows the symbol, not the param docs. - Method/call hovers only fire on the actual access - Array-method hovers require a
.methodaccess; builtin call/method hovers requirerecv.methodorname(. A bare identifier (e.g.var sum = 0) hovers as itself.
0.10.1 - 2026-07-07
Language / Compiler
- Asset references are
entity-typed and usable as values -$Type/Nameisentity(wasany), soweapon == $BRItemBase/Weapon_Pickaxetype-checks instead of erroring (WS004). As a value it materializes into the matching*Referencegate (ItemReference,AudioReference,EntityTypeReference, … by asset type), which outputs the asset as an entity wire. DisplayTextgained aneasingparam - The interpolation curve fortransition("Linear"/"EaseIn"/"EaseOut"/"EaseInOut"), a property-only enum likejustify.
0.10.0 - 2026-07-07
Bug Fixes
characterandcontrollerwire directly - No moreGetFromEntityadapter, an admin-only gate that got blocked on paste for non-admins.- Gate brick colours no longer double-darkened - Colours emit as the intended sRGB values instead of being pre-multiplied by γ=2.2.
- Multi-byte string chars survive emit - The lexer reads whole UTF-8 chars, so
█/éno longer mangle. - Long templates no longer drop values -
FormatTexthas only 7 substitution inputs; templates with more${...}values split across chained gates. on <local exec signal>fires across handlers -emit sigin one handler triggerson sigin another, regardless of source order.- Lexer no longer panics on stray multi-byte chars - A non-ASCII char outside a string (e.g.
▲) is now UTF-8-safe instead of crashing the LSP. FindPlayeris an exec gate returningcharacter- Has Exec/ExecOut ports and emits the found player’s character; was mis-declared pure returningentity.
Editor / IDE
$reference highlighting + hovers - Prefab ($./x.brz) and asset ($Type/Name) refs get TextMate scopes and hovers; prefab hovers show the resolved path and (in the LSP) whether the file exists.- Prefab refs are navigable - A resolvable
$./file.brzis a clickable link / go-to-definition target (Ctrl/Cmd-click or F12). - Missing prefab files warn - The LSP flags a
$./file.brzthat isn’t on disk or lacks the.brzextension. - Playground uploads
.brzprefabs - A Prefabs panel (upload + drag-drop) stores files as browser blobs (IndexedDB), offers them in$./completion, and embeds them at compile. - Named-arg completion + hover work in multi-line calls - The enclosing-call scan covers the whole call (skipping strings/comments), not just the current line.
- Enum-valued args complete their values - A named arg backed by a schema enum (e.g.
justify) completes its variants (Left/Center/Right), auto-quoted when no quote is open.
Language / Compiler
- Prefab references embed a
.brzintoSpawnPrefab-$./file.brz(relative) /$/abs.brz(absolute) embeds the archive content-addressed (brdb 0.7add_prefab) and sets the gate’sPrefabpath..brzrequired (WS019); resolution pluggable viaEmitOptions::prefab_resolver.
0.9.0 - 2026-07-07
New Builtins
- Split edge/change detectors -
Edge(bool) -> {Rising, Falling: bool},EdgeExec(float) -> {Rising, Falling: exec},Changed(any) -> bool,Change(any) -> any.
Bug Fixes
SpawnPrefabgained avelocityparam - The gate’sSpawnVelocityinput.SpawnPrefab()- Compiles again.
Gate Catalog / Data
- Gate inventory regenerated - 314 -> 316 entries (the two exec detectors).
- Edge Detector emit mapping key fixed - The class name was missing
Type, so its component data was never written.
Language / Compiler
exec =named arg on chip and mod calls - Pass a trigger when calling exec chips/mods outside an exec context. The call returns the completion exec as anexecresult field:await r.exec/on r.exec { }.- Import dependency pulling fixed - Imports pull same-file deps in record/array literals,
emitvalues,awaitexprs, and buffer inits; type aliases inline into importedlet/var/out/buffer/inannotations, not just chip/mod params. - WS013 understands
emit- The unassigned-output check countsemit x (= expr)and plain assigns anywhere in the body, per-output. - Named chip bodies capture top-level state - Free references to outer vars, arrays, buffers, and record bindings resolve against the caller’s scope.
Parser
- Multi-line array literals - Newlines allowed after
[, around commas, and before], with optional trailing comma - mirroring call-arg rules. Covers top-levelarrayinitializers and runtimefoo = [...]rebuilds.
Editor / IDE
- Formatter indents multi-line array literals - Both formatters (native, prettier plugin) track
[/]depth like(/); delimiter scanning stops at//so comments don’t skew indentation. - Formatter: one indent level per line - A line opening several groups (
f(x, {) indents its continuation once, not once per delimiter; the closing})returns to the opener’s level. - One “Wirescript” entry in the formatter picker - The extension keeps its prettier formatter and sends
provideFormatting: falseso the LSP doesn’t register a duplicate. - Prefab path completion -
$./(or$/) completes.brzrefs: the native LSP scans the document’s directory; the wasm playground offers dragged-in files via a new optionalprefabs_jsonregistry.
0.8.0 - 2026-07-06
New Builtins and Methods
- Chat / messaging -
ctrl.ShowChatMessage(msg)(per-player whisper),ctrl.ShowMessageBox(msg, title?)(modal popup), and globalBroadcastChatMessage(msg)/BroadcastStatusMessage(msg, flash?). - Audio -
entity.PlayAudioAt($BrickOneShotAudioDescriptor/..., volume?, pitch?, innerRadius?, maxDistance?, spatialized?)plays a one-shot at an entity (characters work);PlayGlobalAudio(audio, volume?, pitch?)plays for everyone. The descriptor is an inlined$asset reference. - Entity tags -
entity.SetTag("...")/entity.GetTag() -> stringattach an arbitrary string to any entity and read it back; zones can filter on tags. FindPlayer(name)- Pure value gate looking up a player entity by name.Change(input)- Any-typed companion toEdge: pulses the input value through when it changes.- Quaternion raw components -
Quat(x, y, z, w),q.SplitQuat() -> {X, Y, Z, W},a.QuatDot(b). - Inventory family -
char.AddInventoryItem(item)/SetInventoryItem(item, slot?),AddInventoryBrick(brick, size?)/SetInventoryBrick(...),AddInventoryEntity(entityType)/SetInventoryEntity(...), andAddInventoryItemAdv/SetInventoryItemAdvwith overrides (damage,speed,scale,itemName,projectile). Asset args are$Type/Namereferences.
New Events
CharacterDamaged(character, damage, attacker, attackerWeapon, attackerWeaponName)- A character took damage.EntityZoneEntered/EntityZoneLeft(entity) andProjectileZoneEntered/ProjectileZoneLeft(character,projectile,weapon,weaponName) - Zone events beyond characters; the projectile events’characteris the shooter.
Compiler / Output
- Generic asset-field emission - Gates with a
class/objectdata field (AudioDescriptor,Item,EntityType,BrickAsset,ProjectileOverride) register inlined$asset references in the world’s external-asset table automatically. Binary encoding needs in-game verification.
Gate Catalog / Data
- Gate inventory regenerated - 288 -> 314 entries (26 new classes); the messaging/tag/zone-event/quaternion/inventory gates are wired into the language.
- brdb data regenerated - Component
_maxschema (286 structs) andcomponent_db.rs(296 type mappings).assets/external.rskept the previous full catalog (the dump referenced only 14 assets). - Deliberately not exposed as builtins - The
*Referencegates ($Type/Namecovers them),Convert/ColorConvert(implicit coercions cover them), andAddInventoryEntry(opaque nested struct;GiveWeaponcovers it).
0.7.0 - 2026-07-05
Language Features
- Scalar var type inference -
var foo = ""is a string var,var n = 0an int var,var f = 1.5a float var (also bools, negatives, interpolated strings). A non-literal initializer refines from its expression (var v = Vec(1.0, 2.0, 3.0)isvector), same as buffers. - Everything casts to string - All variant-able primitives (numbers, floats, bools, vectors, rotators, colors, entities, characters, controllers, bricks, prefabs) coerce to
string:let s: string = 5is a cast, not a WS016 warning, and..accepts any of them ("hi " .. player). Unannotated array vars also infer constructor elements (var pts = [Vec(1.0, 1.0, 1.0)]isvector[]). Color()returnscolor- Wasany; matchesColorSRGB/ColorHex/Blend.
Constant Folding
Vec/Rotation/Coloron literal args fold to constants -var v = Vec(1.0, 2.0, 3.0)bakes into the Variable gate’s initial value, and constant constructors are legal top-level array initializer elements (loads pre-populated).- Folded constants inline into consumers - A constant
Vec(...)lands as a literal in the consuming gate’s data (Var_Set, math operands, select branches,arr.push); wire-only consumers (SetLocation/Teleport, component splits, chip inputs) get aMake*gate materialized, never silently zeroed. - Vars and arrays of every wire variant -
rotator(zero),quat(identity), andcolor(opaque white) vars get type-matched initial values;rotator[]/quat[]/color[]back onto the typed array variants (WireGraphRotatorArray/QuatArray/LinearColorArray) instead of doubles.
Dependencies
- Requires
brdb0.6.3 - The wire variant gainedRotator/Quat/LinearColormembers plus matching typed array variants; onlyWireGraphEnumWrapperremains unmapped.
0.6.0 - 2026-06-30
- Data regenerated - Gate inventory (285 -> 288: new
Convert/FindPlayergates), the brdb component_maxschema (258 structs), andcomponent_db.rs. - Sweep upgrade - The raycast
Sweep(...)gate gained optional per-channel flags:detectBricks,detectPlayers1–detectPlayers4,detectPhysics,detectMap, andignoreOwningGrid.
0.5.0 - 2026-06-29
Language Features
quattype + rotation/quaternion builtins - Aquatprimitive (distinct from the eulerrotator) plusdir.ToRotation(),q.ToDirection(),v.Rotate(q),q.Invert(),from.RotationTo(to),a.AngleTo(b),a.Slerp(b, alpha),axis.RotationByAngle(angle),q.ToAxisAngle(), andRotation(p, y, r)/r.ToEuler()for the euler rotator.- sRGB / hex color builtins -
ColorSRGB(r, g, b, a)andColorHex("#rrggbb")constructors;c.ToSRGB()/c.ToHex()/a.Blend(b, alpha)receivers. Cycle(count)/Toggle()- Stateful exec value gates (advance a counter / flip a bool each exec pulse).- User definitions shadow builtins - A
chip/mod/fnnamed like a builtin (e.g.chip Toggle) takes precedence at the call site. - Asset references -
$AssetType/AssetName(e.g.$BRItemBase/Weapon_Pistol) references an external asset embedded by name, encoded as an external-asset-table index on emit. Completion:$offers asset types,$Type/that type’s names (from the brdb catalog). HasRole/GiveWeapon-ctrl.HasRole("Admin") -> bool(role is a config string);char.GiveWeapon($BRItemBase/Weapon_Pistol, slot)sets an inventory slot to an item asset (builds the nestedEntryPlan). Binary encoding needs in-game verification.
Gate Catalog / Output
- Gate inventory refreshed - Adds 26 new gate classes; the rotation/quaternion, sRGB-color, and cycle/toggle ones are wired into the language, with component data structs registered for
.brdboutput.
0.4.0 - 2026-06-28
Language Features
- Pre-initialized arrays -
array foo: int[] = [1, 2, -3]writes literal contents (numbers incl. negatives, strings, bools) straight into the array gate, loading pre-populated. A non-literal top-level element is a clear error. - Inferred array-typed vars -
var foo = [1, 2, 3]infersint[]and lowers to the same array gate as anarraydeclaration; it indexes and iterates as a real array. - Runtime array assignment + spread - In an exec handler,
foo = [a, 1, ...other, 5]rebuilds an array var: clear -> push each item -> append each...spread. Elements can be any runtime value; a spread splices another array in place. - Array methods, one source of truth - Every method derives from a single
catalog::arraystable: completion offers the full set on any array-typed value, return types come from gate output ports.findreturns{ Index, Found, Value }(auto-unwraps to Index);pop/min/maxexpose.IsEmpty;insert/swap/sliceexpose.OutOfBounds. GetAimreplacesAimOrigin/AimDirection- A character’s camera/aim is one gate returningchar.GetAim().Origin/.Direction; reading both fields shares a single gate. The separate calls are removed.- Chat command config -
on ChatCommand("greet", "Greets the player", player, args): string literals fillCommandNamethenHelpTextin order (or namedDescription = "..."), and bare identifiers still bind the event outputs (controller,arguments).
Bug Fixes
- Vector components on stored values -
.x/.y/.z(and color.r/.g/.b/.a) work on a vector held in a variable orletbinding via the SplitVector/SplitColor gates, not just an inlineVec(...). Vec(...)literal arguments - Constant components are no longer dropped to0at emit;MakeVectorgained its component-data mapping.
Compiler / Output
- Gate defaults resolve from component_db - Unspecified data fields are omitted so the brdb writer fills them from
STRUCT_DEFAULTS; DisplayText’sFontSize/Lifetimenow resolve to the game defaults (16/5) instead of0.
0.3.0 - 2026-06-27
Language Features
- String variables -
var/static varof typestringstore in a Variable gate (the WireGraphVariant gained astrmember). TheWS018“strings can’t be stored in vars” diagnostic is gone. - Native string equality -
==/!=on strings lower directly to theCompareEqual/CompareNotEqualgates. Thecontains(a,b) && length(a) == length(b)workaround is removed. - Vector arithmetic -
+ - * / %operate component-wise on two vectors, and a scalar operand (v * 2.0,10.0 * v,v / 4) broadcasts - all on the sameMathAdd/Subtract/Multiply/Divide/Modulogates. TheScalehelper still works. - Any-variant variables - A
varcan hold any WireGraphVariant member (int,float,bool,string,vector, object types); typed vars get a type-matched initial value instead of a number default. - Typed arrays - The declared element type selects the backing
WireGraphArrayVariantmember (int-> Int64,float-> Double, plus Bool/String/Vector/Object), so elements keep their declared type.
Gate Catalog
- Regenerated inventory - Rebuilt from the in-game dump via a new checked-in generator (
scripts/gen_inventory.mjs): adds 76 gate classes (ArrayVar exec, Gamemode/Controller/Character, stringParseInt/ParseNumber, reference gates) and types 86 previously-anyports. 175 -> 260 entries. - Refreshed brdb component tables -
component_db.rsregenerated from the same dump so the new gates emit; the removedGamemode_EndRoundgate is gone.
New Builtins and Methods
- Array methods -
insert,find,sort(desc?),reverse,sum,min,max,average,swap,fill,resize,append,copyFrom,slice,fillFromPlayers,fillFromTeamjoinpush/pop/length/remove/clear/shuffle. Every ArrayVar gate is reachable. - Easing -
Easing(a, b, blend, fn?, dir?)andTween(target, duration, fn?, dir?); function/direction pass as an int or enum-name literal ("Quad","InOut", …) resolved against the engine’sEBREasingFunction/EBREasingDirectionenums. - Timer -
Timer(limit, restart?, pause?, resume?)returns{ Time: float, Expired: exec }; the controls are optional exec inputs andExpiredworks withon/await. - String parsing -
ParseInt(s) -> intandParseNumber(s) -> float(alsos.ParseInt()/s.ParseNumber()). - Controller -
GetUserName,GetUserId,GetDisplayName,IsTrusted,HasPermission,SetCanRespawn,SetTeamPinned. - Character -
GetDamage,SetDamage,IncDamage,SetTempPermission. - Entity -
SetFrozen. - Gamemode -
PlayerWins/TeamWins(replace the removed imperativeEndRoundgate and builtin),GetCurrentRound,SetTeam,GetTeamName,GetTeamLeaderboardValue/SetTeamLeaderboardValue/IncrementTeamLeaderboardValue. - Misc -
PrintToConsole,DeltaTime,ServerUptime,NearlyEqual,Dampen.
Compiler / Output
- Prefab output - Compiled programs emit a Brickadia prefab (
type: "Prefab"+Meta/Prefab.jsonwith brick bounds from the microchip shell) instead of a world, so the.brzpastes like a native copied selection (Ctrl+V) with a correct preview. - Loads on current builds - A bundle embeds only the component structs the program uses plus transitive schema deps, written dependency-first - matching game bundles. Replaces the full-catalog embed recent builds reject; real programs stay within the per-schema struct limit.
0.2.0
Language Features
emit target = expr- Set output value and fire exec in one statement. Works in both pure and exec contexts.await expr- Suspend exec chain and resume when expression fires. Armed-flag guard ensures one-shot execution (~7 gates per await).let name: exec- Local exec signals.emit namefires them from any handler;await nameoron namelistens.let x = await val on trigger- Capture a value when a trigger fires.await a || b- Race semantics via normal binary expressions._placeholder in await - Resolves to the armed flag (bool). Enablesawait Sleep(_, delay = 1.0).- Logical/comparison operator coercion -
&&,||,^^,!,==,!=,<,>,<=,>=now accept all wire variant types (bool, int, float, exec, string, entity, controller, character, brick, prefab).
Builtin Functions
Sleep(input, delay?, hold?)- BufferSeconds gate. Delays a value by seconds.SleepTicks(input, delay?, hold?)- BufferTicks gate. Delays a value by ticks.
Compiler
compile_to_world- New compile path returningbrdb::Worldfor.brdboutput.- CLI
.brdbsupport -just compile file.ws -o file.brdbemits SQLite saves. - Compile progress - LSP sends
wirescript/compileProgressnotifications; VS Code extension shows step counter in status bar. **(pow) fix - Now wires toInput/Exponentports instead ofInputA/InputB.- BRZ double-write fix - Fixed
to_brz_vecwriting the archive twice (exact 2x file size).
Editor / IDE
- Inlay type hints - Ctrl+Alt shows inferred types for
let/bufferbindings. Works in VS Code and the web playground. - Hover gate estimates - Hovering chips/mods/handlers/if-blocks shows estimated gate and microchip counts. Call-graph expansion sums callee costs recursively.
- Record field hover fix -
cpu.regs,cpu.cpsrand nested field access now show types correctly. onhandler hover - Shows gate estimate for the handler scope.ifhover estimates - Shows gate cost for the if/else scope.- Tuple display - Records with numeric keys show as
(bool, int)instead of{0: bool, 1: int}. awaitkeyword highlighting - Added to VS Code tmLanguage and Monaco monarch tokenizer.
Playground
- Inlay hints provider -
wirescript_inlay_hintsWASM binding + MonacoInlayHintsProvider. Hidden by default, shown on Ctrl+Alt. - New
async_signals.wsexample - Demonstrates emit-value, await, local exec signals, Sleep.
Documentation
- Updated
statements.mdwith emit-value, local exec signals, await, Sleep/SleepTicks. - Updated
exec-context.mdwith await section,_placeholder, Sleep examples. - Updated
builtins.mdwith Sleep/Delay section. - Updated
expressions.mdwith operator coercion for all wire variant types. - Updated
types.mdwith exec->bool coercion. - Removed
fnkeyword references from docs. just compile-brdbrecipe added.
Test Files
projects/tests/src/- New in-game test suite:test_await_emit.ws,test_variables.ws,test_operators.ws,test_control_flow.ws,test_chips_mods.ws,test_strings.ws.crates/wirescript/tests/- Integration tests:await_test.rs,emit_value.rs,local_exec.rs.
0.1.0
Language Features
- Records & tuples - User-defined record types (
type Point = { x: int, y: int }), record literals, destructuring (let { x, y } = p), spread operator ({ ...p, y: 99 }), tuple types and literals - Spread in call args - Pass record fields as named parameters:
foo({ ...defaults, x: 1 }) - Destructured params -
mod dist({ x, y }: Point) -> int { ... }in mods and chips on exprsyntax - Trigger handlers on arbitrary exec expressions, not just named events- Exportable vars/buffers/arrays -
var,buffer, andarraydeclarations are now importable across files - String
varerror (WS018) -var s: stringnow errors at typecheck time (Brickadia runtime doesn’t support string variables) - Ref/deref improvements - LSP completions on arrays and refs, output ref/deref fixes
Editor / IDE
- Record field hovers - Hover shows
State.counter: *intfor record fields and type declaration fields - Spread type validation - Extra fields from spread are caught with errors pointing at the
...exprspan - Chip/mod context hover - Hovering
chip/modkeywords shows whether the block is pure or exec - Event parameter hovers - Hover on event handlers shows parameter types
- Mod/chip return type hovers - Hover shows
-> (result: int)return types - Formatter fixes - Multi-line function call args indented correctly; operator continuation lines indented
typekeyword highlighting - VS Code extension highlights user-defined type names
Playground
- Docs panel refactor - Docs fetched from
docs/*.mdinstead of inline JS (~1900 lines removed fromdocs.js) - Examples loaded from files - Playground examples loaded from
sdk/examples/*.wsvia fetch instead of hardcoded JS - New
records.wsexample - Demonstrates records, destructuring, spread, and tuples
Bug Fixes
- Fix branch scoping - variables declared in
if/elsebranches no longer leak across branches - Fix string comparison gate using wrong variant
- Fix inline modules adding extra microchip outputs
- Fix
return exprin pure mods - Fix
on var.valuenot lowering handler body - Fix emit not chaining union gates for multiple
emitpaths - Fix import not pulling in same-file dependencies of imported declarations
- Fix array index access requiring exec context
- Fix array
.length()/.pop()returningAnytype - Fix string wire port emits with literal variant values
0.0.0
Language Features
- Standalone chip instantiation - Named chips with
-> (outputs)now compile to real child microchips, one per call site. Cross-chip wires resolve automatically. static var- Variables that persist across rounds:static var highScore: int = 0return expr- Return values from chips and mods- Single-output auto-unwrap -
chip Foo() -> (result: int)returnsintdirectly instead of{result: int} - Block expressions -
{ stmts; expr }as expressions - Compound assignment -
+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>= ^^logical XOR operator -a ^^ bis true when exactly one operand is truelettype annotations -let x: int = expr- Array params are always pass-by-reference -
mod init(arr: int[])passes the array by reference without needing* fndeprecation -fndeclarations emit a warning (WS015) suggestingletinstead
Builtin Functions (30 new)
- Select/Swap -
Select(cond, a, b),Swap(cond, a, b) -> {a, b} - String ops (all receiver on
string) -s.Length(),s.Contains(search),s.StartsWith(prefix),s.EndsWith(suffix),s.Find(search),s.Substring(start, len),s.Replace(search, repl),s.Split(delim) -> {Left, Right},s.ToLower(),s.ToUpper(),s.Trim() - Math -
tan,log(x, base),lerp(a, b, t),fmod(a, b) - Vector/Color -
v.SplitVec() -> {x, y, z},c.SplitColor() -> {r, g, b, a} - Edge detector -
Edge(input) -> {rising, falling} - Gamemode -
EndRound(winner?),GetTeamByName(name) - Character -
ShowHint(char, title, text) - Controller -
ShowStatusMessage(ctrl, message) - Bitwise -
BitNand(a, b),BitNor(a, b) - Renamed
MakeColor->Color - 93% gate coverage - 163 of 175 Brickadia gates supported
Events
- ChatCommand -
on ChatCommand(controller, arguments) { ... }
Compiler Optimizations
- NAND/NOR gate fusion -
!(a && b)compiles to a single NAND gate instead of NOT + AND. Same for!(a || b)-> NOR,~(a & b)-> BitwiseNAND,~(a | b)-> BitwiseNOR. - 7.2x faster chip compile - Schema parse caching + lower zstd level cuts chip program compile from 334ms to 46ms
- Receiver syntax on all vector ops -
v.Normalize(),a.Distance(b),v.Magnitude(), etc. now work as chained calls with correct type inference
Editor / IDE
- Cross-file go-to-definition - Clicking an imported symbol jumps to its declaration in the source file. Clicking an import path opens that file.
- Hover on
ifkeywords - Shows whether the block is in exec or pure context - Unused import/output warnings - Warnings for imported symbols and outputs that aren’t used
wirescript-checkCLI - Standalone type checker binary- VS Code extension auto-reload - Extension reloads when the LSP binary changes
Removals
ArrayReftype removed - arrays are always references, useint[]everywhereeventkeyword removed (was already deprecated)