Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Syntax – Language syntax reference: declarations, statements, blocks, statement terminators, comments, and doc comments.
  2. 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.
  3. Expressions – Operators (arithmetic, comparison, logical, bitwise, string concatenation), operator precedence, string interpolation, conditional expressions, field access, index access, and function calls.
  4. Statementsvar, 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).
  5. Builtin Functions – All built-in functions grouped by category: math/trig, vector, entity, controller/character, display, gamemode, raycasting, random, string formatting, and color.
  6. Chips – Anonymous chips (chip {}), chip let, chip on, named chips with parameters, mod (inline expansion), ref/* params, nested chips, the open modifier, and compiling without microchips (@flat).
  7. Execution Context – Pure vs exec context, what requires exec, handler exec chains, exec unions after handlers, and explicit exec parameters.
  8. Enums – Tagged unions: declaring enum variants (unit, positional, named payload), construction, .Discriminant, match, if let / let else, generic enums, and the built-in Option/Result.
  9. Best Practices – Gate count and scaling: why every call site is a copy (for mod and chip alike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state.
  10. 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.
  11. Testing – Writing a program that checks itself in game: the ReadBrickGrid() trigger, a check mod 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.
  12. 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.
  13. Diagnostics – Every WSxxx diagnostic code the compiler emits, grouped by category (context, names, types, calls, generics, config, …), with a one-line meaning and trigger for each.
  14. Upgrading – Breaking changes and how to migrate existing .ws code across versions; links the full CHANGELOG.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

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:

KeywordPurpose
varMutable variable declaration
letImmutable binding
bufferBuffered value (delayed one tick)
arrayArray declaration
chipChip declaration (anonymous or named)
modInline chip (expanded at call sites)
onEvent handler
inInput port declaration
outOutput port binding
emitEmit a user-defined event
ifConditional (statement or expression)
elseElse branch
thenUsed in if-then-else expressions
matchReserved; no expression form is implemented
returnEarly return from handler
importImport declarations from another file
fromUsed with import { } from "path"
asAlias in imports or namespace
trueBoolean literal
falseBoolean literal
refReference type or ref-of expression
openModifier for anonymous chips (start expanded)
typeRecord type declaration
staticPersistent-variable modifier (inside handlers/mods)
awaitSuspend 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

EscapeCharacter
\\Backslash
\"Double quote (in double-quoted strings)
\'Single quote (in single-quoted strings)
\nNewline
\tTab
\rCarriage return
\$Literal dollar sign (prevents interpolation)
\0Null 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 variable
  • let name = expr – Immutable binding
  • buffer name = expr – Buffered value
  • array name: type[] – Array
  • in name: type – Input port
  • out name = expr – Output port
  • chip name(params) -> outputs { body } – Named chip
  • chip { body } – Anonymous chip
  • chip let name = expr – Anonymous chip with let bindings
  • chip on trigger { body } – Anonymous chip with handler
  • mod name(params) { body } – Inline chip (macro-like)
  • on trigger { body } – Event handler
  • let name = on trigger { body } – Captured event with handler
  • import "path" – Import all declarations from file
  • import { names } from "path" – Selective import
  • import * as ns from "path" – Namespace import
  • if cond { body } – Conditional (in exec context)
  • return – Early return from handler
  • target = 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

TypeDescriptionDefault Value
boolBoolean (true / false)false
int64-bit signed integer0
float64-bit floating point0.0
stringText string""
vector3D vector (x, y, z floats)(0, 0, 0)
rotatorEuler rotation (pitch, yaw, roll floats)(0, 0, 0)
quatQuaternion (x, y, z, w); produced by the rotation conversion gates (dir.ToRotation(), …)identity
colorRGBA color (r, g, b, a floats)(0, 0, 0, 0)
entityReference to a game entitynull
characterReference to a player characternull
controllerReference to a player controllernull
execExecution 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

TypeDescription
anyUniversal type – compatible with everything, but can’t back a variable gate’s storage. See any Type below.
neverBottom 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

TypeDescriptionProduced byConsumed by
zoneReference to a Zone bricka Zone brick’s output (wire it into an in z: zone port)zone = … on the zone events; fillFromZone*
teleportReference 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>:

ClassMembers
Scalarint, float
Numericint, float, vector, rotator, quat, color
VariantNumeric + bool, string, entity, character, controller (all value variants)

Scalar ⊆ Numeric ⊆ Variant. Note bool is only a member of VariantScalar 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: int widens to float, so pick(flag, 1, 2.0) infers T = float (the int argument casts up).
  • Object types widen toward entity: character and controller both widen to entity, so pick(flag, aCharacter, aController) infers T = entity.
  • Incompatible operands – e.g. int and vector – 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 valueBool
""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

FromToRule
intfloatCoerce
floatintCoerce
boolintCoerce
boolfloatCoerce
intboolCoerce
floatboolCoerce
characterentityCoerce (subtype)
controllerentityCoerce
entitycharacterCoerce (wired directly — an entity wire can carry a player, e.g. a sweep hit)
entitycontrollerCoerce (wired directly)
charactercontrollerCoerce (wired directly)
controllercharacterCoerce (wired directly)
rotatorquatCoerce (interchangeable rotation values)
quatrotatorCoerce
any primitivestringVia format gate
stringboolCoerce (compiler inserts != "" – only empty is false)
execboolCoerce (true for one frame)
bool/int/float/vector/entity/character/controllerexecPulsing coerce
ref Tref U (T != U)Mismatch
anyanythingSame
anythinganySame

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

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 job is Idle, unsafe job.Running.ticks = 5 fills Running’s slot while job stays Idle, and a later match still takes the Idle arm.

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

Operators are listed from lowest (loosest binding) to highest (tightest binding):

PrecedenceOperatorsAssociativityDescription
2|| ^^LeftLogical OR, Logical XOR
3&&LeftLogical AND
4|LeftBitwise OR
5^LeftBitwise XOR
6&LeftBitwise AND
7== != isLeftEquality, enum variant test
8< <= > >=LeftComparison
9<< >>LeftBitwise shift
10+ - ..LeftAddition, subtraction, string concat
11* / %LeftMultiplication, division, modulo
12**RightExponentiation
- ! ~ * refUnary prefix operators
.field [i] (args)LeftPostfix: 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

OperatorOperationOperand TypesResult Type
+Additionint, intint
+Additionfloat, floatfloat
+Additionint, float or float, intfloat
+Additionint, bool or bool, intint
-Subtraction(same as +)(same as +)
*Multiplication(same as +)(same as +)
/Division(same as +)(same as +)
%Modulo(same as +)(same as +)
+ - * / %Vector mathvector, vector (or vector + scalar)vector
+ - * / %Color mathcolor, color (or color + scalar)color
+ - * / %Rotation mathquat, quat / rotator, rotator (or a mix)quat/rotator
+ - * / %Object operandint/float + an object (player, entity, …)numeric
**Exponentiationint/float (not vectors)(same as +)
-xNegation (unary)intint
-xNegation (unary)floatfloat

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.

OperatorOperationOperand Types
==Equalany 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

OperatorOperationOperand TypesResult
&&Logical ANDany wire variant pairbool
||Logical ORany wire variant pairbool
^^Logical XORany wire variant pairbool
!Logical NOT (unary)any wire variantbool

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.

OperatorOperation
&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.

LeftRightResult
stringstringstring
stringintstring
intstringstring
stringfloatstring
floatstringstring
intintstring
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 if expression compiles to a Select fed 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:

TypeComponents
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. a Change detector’s exec port is OnChanged). .exec denotes 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 .exec field 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 .exec names 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

ExpressionContextGateMeaning
x (bare)ExecVar_GetCurrent tick’s value
*xExecVar_GetCurrent tick’s value (explicit)
*xPureError WS006 — use .Value
x.ValuePure or Exec.Value portPrevious tick’s value (delayed)
x.prevPure or Exec.Value portPrevious tick’s value (same as .Value)
x (bare)PureVariable 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, before init lands, 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 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 on handlers): x auto-dereferences to type T when used in expressions. When passed to a *T parameter, it remains a reference.
  • In pure context (outside handlers): x refers to the variable reference itself (ref T). Use x.Value or x.prev to 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 as 0, and a non-finite float as 0.

  • 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, clamp fold 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/Color constructors.

  • 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 (see const Parameters and const mod), including one nested inside an operator, a unary operator, or a Vec/Rotation/Color constructor 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 argument
    

    A 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), then scaleUp(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

CodeMeaning
WS046Not 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.
WS047The 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.
WS048Const 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.
WS028Reused 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).
WS044Reused 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.
WS007Reused 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/out of the compiled file may be annotated. Inside chip {} or mod bodies 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 in and out freely 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 events let 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, in declarations) 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 @nofold applies only to the file compiled as the entry — an imported library’s own module-level @nofold does not carry into the importer (annotate the individual declarations instead).
  • A module-level @nofold disables the entire constant-fold pass for that compile — the same effect as --no-fold on the CLI.
  • @nofold also preserves literal-condition if branches. Normally an if whose condition is a literal true/false has 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 @fold is 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: @fold at 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 / @fold pair registers as neither and produces a module-level-only error.
  • If both a module-level @fold and @nofold are present, @nofold wins and the parser warns that the two conflict.
  • --fold on the CLI likewise just re-affirms the default; --no-fold disables 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 – @fold and @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) – @layout is 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 eventsSetFrozen(true) does not suppress EntityZoneEntered. 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 s suffix 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 await of that signal is sequenced through a Var_Set(armed = true) before entering the signal’s union — so the awaiting Var_Get can 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 await inside an if branch keeps pure flag semantics (its arm only fires when the branch is taken).

  • A back-edge loop whose await sits inside an if runs 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 await at its handler’s top level, 1 of 5 with it inside a branch.

    This bites hardest when a loop lives in a mod called from a step machine, because the mod inlines 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 if guarding the loop’s own continuation (if idx < 25) is fine and required - it is the exit test. What must not be branched is the await itself.

  • 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 var declared 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 it static, and reset it upstream of the emit:

    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

ConstructGates added
emit sig (bare)0 — joins the signal’s union
buffer(...) emit sig1 Buffer (Ticks/Seconds)
emit sig = scalar1 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 signal1 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.

FunctionGateDelay unitParams
SleepBufferSecondsseconds (float)input, delay, hold
SleepTicksBufferTicksticks (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:

OperatorEquivalentGate Used
+=x = x + exprIncVar
-=x = x - exprVar_Get + Sub + Var_Set
*=x = x * exprVar_Get + Mul + Var_Set
/=x = x / exprVar_Get + Div + Var_Set
%=x = x % exprVar_Get + Mod + Var_Set
&=x = x & exprVar_Get + AND + Var_Set
|=x = x | exprVar_Get + OR + Var_Set
^=x = x ^ exprVar_Get + XOR + Var_Set
<<=x = x << exprVar_Get + Shl + Var_Set
>>=x = x >> exprVar_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.

EventParametersDescription
RoundStartroundNumber: intGame round started
RoundEndroundNumber: intGame round ended
CharacterSpawnedcharacter: characterA character spawned
CharacterDiedcharacter: character, killer: character, killerWeapon: entity, killerWeaponName: stringA character died (killer / killerWeapon are who/what killed it)
ControllerJoinedcontroller: controller, userId: string, userName: stringA player joined
ControllerLeftcontroller: controller, userId: string, userName: stringA player left (userId / userName stay valid even as the controller is torn down on disconnect)
ControllerJoinedTeamcontroller: entity, team: entity, userId: string, userName: stringA player joined a team (team is the team they joined)
ControllerLeftTeamcontroller: entity, team: entity, userId: string, userName: stringA player left a team
ZoneEnteredcharacter: characterA character entered a zone
ZoneLeftcharacter: characterA character left a zone
EntityZoneEnteredentity: entityAn entity entered a zone
EntityZoneLeftentity: entityAn entity left a zone
ProjectileZoneEnteredcharacter: character, projectile: entity, weapon: entity, weaponName: stringA projectile entered a zone (character is the shooter)
ProjectileZoneLeftcharacter: character, projectile: entity, weapon: entity, weaponName: stringA projectile left a zone
CharacterDamagedcharacter: character, damage: float, attacker: character, attackerWeapon: entity, attackerWeaponName: stringA character took damage
CharacterFiredWeaponcharacter: character, direction: vector, start: vector, weapon: entity, weaponName: stringA 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
ChatCommandcontroller: controller, arguments: stringA 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:

  • on handlers
  • chip on handlers
  • if blocks within handlers
  • mod bodies (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

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. Use out name = expr in 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 an exec = ... named argument — the same convention as builtin exec calls like Random(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 (vals above).

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 expr wires the value directly (pure, zero-tick). Multiple return expr statements cause the compiler to insert a hidden variable — each branch does a Var_Set, and a Var_Get after 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

Featurechipmod
Physical microchipYesNo (inlined)
Isolated scopeYesExpanded into caller
Outputs with ->Yes (multi)Yes (single, with return expr)
ref/* paramsYesYes
ReusableYes (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

  • Pure functions are expressions – they produce a value and can be used anywhere.
  • Exec functions require an active exec context (inside an on handler). 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.

FunctionSignatureDescription
sin(x)(x: float) -> floatSine
cos(x)(x: float) -> floatCosine
tan(x)(x: float) -> floatTangent
asin(x)(x: float) -> floatArc sine
acos(x)(x: float) -> floatArc cosine
atan(x)(x: float) -> floatArc tangent
atan2(y, x)(y: float, x: float) -> floatTwo-argument arc tangent
sinh(x)(x: float) -> floatHyperbolic sine
cosh(x)(x: float) -> floatHyperbolic cosine
tanh(x)(x: float) -> floatHyperbolic tangent
asinh(x)(x: float) -> floatInverse hyperbolic sine
acosh(x)(x: float) -> floatInverse hyperbolic cosine
atanh(x)(x: float) -> floatInverse hyperbolic tangent
exp(x)(x: float) -> floate^x
ln(x)(x: float) -> floatNatural logarithm
sign(x)(x: float) -> floatSign (-1, 0, or 1)
abs(x)(x: float) -> floatAbsolute value
sqrt(x)(x: float) -> floatSquare root
pow(x, exponent)(x: float, exponent: float) -> floatPower
clamp(x, min, max)(x: float, min: float, max: float) -> floatClamp to range
round(x)(x: float) -> floatRound to nearest integer
floor(x)(x: float) -> floatRound down
ceil(x)(x: float) -> floatRound up
min(a, b)(a: float, b: float) -> floatMinimum of two values
max(a, b)(a: float, b: float) -> floatMaximum of two values
log(x, base)(x: float, base: float) -> floatLogarithm with arbitrary base
lerp(a, b, t)(a: T, b: T, t: float) -> TLinear interpolation; T is any math variant (see Easing/Tween)
fmod(a, b)(a: float, b: float) -> floatFloored modulo
Deg2Rad(x)(x: float) -> floatDegrees to radians
Rad2Deg(x)(x: float) -> floatRadians 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)

FunctionSignatureDescription
BitCount(x)(x: int) -> intCount set bits (popcount)
BitNand(a, b)(a: int, b: int) -> intBitwise NAND (same as ~(a & b))
BitNor(a, b)(a: int, b: int) -> intBitwise 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)

FunctionSignatureDescription
Vec(x, y, z)(x: float, y: float, z: float) -> vectorConstruct a vector
Dot(a, b)(a: vector, b: vector) -> floatDot product
Cross(a, b)(a: vector, b: vector) -> vectorCross product
Normalize(v)(v: vector) -> vectorNormalize to unit length
Magnitude(v)(v: vector) -> floatLength of vector
MagnitudeSq(v)(v: vector) -> floatSquared length (avoids sqrt)
Distance(a, b)(a: vector, b: vector) -> floatDistance between two points
DistanceSq(a, b)(a: vector, b: vector) -> floatSquared distance (avoids sqrt)
ScaleVec(v, s)(v: vector, scalar: float) -> vectorScale vector by scalar
RotToDir(rot)(rot: vector) -> vectorConvert 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.

FunctionSignatureDescription
Rotation(pitch, yaw, roll)(float, float, float) -> rotatorConstruct an euler rotator
r.ToEuler()(rotator) -> {Pitch, Yaw, Roll: float}Split a rotator into components
dir.ToRotation()(vector) -> quatQuaternion that points along dir
q.ToDirection()(quat) -> vectorForward direction of q
v.Rotate(q)(vector, quat) -> vectorRotate a vector by a quaternion
q.Invert()(quat) -> quatInverse rotation
from.RotationTo(to)(vector, vector) -> quatQuaternion rotating from onto to
a.AngleTo(b)(quat, quat) -> floatAngle between two quaternions
a.Slerp(b, alpha)(quat, quat, float) -> quatSpherical interpolation
axis.RotationByAngle(angle)(vector, float) -> quatQuaternion from axis + angle (radians)
q.ToAxisAngle()(quat) -> {Axis: vector, Angle: float}Decompose into axis + angle
Quat(x, y, z, w)(float, float, float, float) -> quatConstruct a quaternion from raw components
q.SplitQuat()(quat) -> {X, Y, Z, W: float}Decompose into raw components
a.QuatDot(b)(quat, quat) -> floatQuaternion 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)

FunctionSignatureDescription
Color(r, g, b, a?)(r: float, g: float, b: float, a?: float) -> colorConstruct a color (linear RGBA, 0-1 range)
ColorSRGB(r, g, b, a)(int, int, int, int) -> colorConstruct from sRGB bytes (0-255)
ColorHex(hex)(string) -> colorConstruct 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) -> stringHex string
a.ColorBlend(b, alpha)(color, color, float) -> colorBlend 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

FunctionSignatureDescription
Cycle(count)(count: int) -> int execReturns 0,1,…,count-1 advancing each exec pulse
Toggle()() -> bool execFlips between false/true each exec pulse

Select / Swap (Pure)

FunctionSignatureDescription
Select(cond, a, b)(cond: bool, a: any, b: any) -> anyReturns 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

FunctionSignatureDescription
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) -> boolBool pulse when the input changes
Change(input)(input: any) -> anyPulse 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)

FunctionSignatureDescription
All string functions support receiver syntax on string:
FunctionSignatureDescription
s.Length()(s: string) -> intString length
s.Contains(search, caseSensitive?)(s: string, search: string, caseSensitive?: bool) -> boolCheck if string contains substring
s.StartsWith(prefix, caseSensitive?)(s: string, prefix: string, caseSensitive?: bool) -> boolCheck prefix
s.EndsWith(suffix, caseSensitive?)(s: string, suffix: string, caseSensitive?: bool) -> boolCheck suffix
s.Find(search, caseSensitive?, start?)(s: string, search: string, caseSensitive?: bool, start?: int) -> intFind substring index (-1 if not found)
s.Substring(start, length)(s: string, start: int, length: int) -> stringExtract substring
s.Replace(search, replacement, caseSensitive?, maxReplacements?, start?)(s: string, search: string, replacement: string, caseSensitive?: bool, maxReplacements?: int, start?: int) -> stringReplace 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) -> stringConvert to lowercase
s.ToUpper()(s: string) -> stringConvert to uppercase
s.Trim()(s: string) -> stringRemove leading/trailing whitespace
s.ParseInt() / ParseInt(s)(s: string) -> intParse an integer from text
s.ParseNumber() / ParseNumber(s)(s: string) -> floatParse 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)

FunctionSignatureDescription
Fmt(format, a?, b?, c?, d?, e?, f?, g?)(format: any, a-g?: any) -> stringFormat 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.

MethodSignatureDescription
arr.push(value)(value: T)Append an element
arr.pop()() -> TRemove 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()() -> intNumber 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()() -> TSum of elements
arr.min() / arr.max()() -> TSmallest / largest element
arr.average()() -> floatMean 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 axes
  • MouseWheel: float – mouse wheel delta
  • PressedC / 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 position
  • Direction: 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

ParameterTypeRequiredDescription
targetcontrollerYesPlayer to display to
textanyYesText content (auto-converted to string)
positionX / positionYfloatNo2D screen position, per axis
anchorX / anchorYfloatNo2D anchor point, per axis
scaleX / scaleYfloatNo2D scale, per axis
pivotX / pivotYfloatNo2D pivot point, per axis
shadowOffsetX / shadowOffsetYfloatNo2D drop-shadow offset, per axis
anglefloatNoRotation angle
outlineSizeintNoText outline size
outlineColorcolorNoOutline color
fontColorcolorNoFont color
shadowColorcolorNoDrop-shadow color
miteredOutlineboolNoSharp (mitered) outline corners
letterSpacingfloatNoExtra spacing between letters
lineHeightfloatNoLine-height multiplier
wrapWidthfloatNoWrap width (0 = no wrap)
skewfloatNoItalic-style skew
zOrderintNoDraw order
lifetimefloatNoDisplay duration (seconds)
transitionfloatNoSeconds to interpolate to the new state when re-emitted with the same textId
textIdintNoUnique ID for updating text in-place
fontSizeintNoFont size (constant only)
justifyintNoJustification: DisplayTextJustification.Left / .Center / .Right, or bare Left / Center / Right (constant only)
easingintNoTransition curve: DisplayTextEasing.Linear / .EaseIn / .EaseOut / .EaseInOut, or bare Linear / EaseIn / EaseOut / EaseInOut (constant only)
typefaceintNoTypeface: TextTypeface.Regular / .Bold / .Italic / .BoldItalic, or bare Regular / Bold / Italic / BoldItalic (constant only)
fontasset refNoFont 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)

FunctionSignatureDescription
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()() -> floatSeconds elapsed since the previous tick
ServerUptime()() -> floatSeconds the server has been running
ReadBrickGrid()() -> entityThe brick grid this gate’s microchip is on, as an entity
NearlyEqual(a, b, tolerance)(a: float, b: float, tolerance: float) -> boolApproximate float equality
Dampen(target, smoothTime)(target: float, smoothTime: float) -> floatCritically-damped smoothing toward a target
Easing(a, b, blend, fn?, dir?)(a: T, b: T, blend: float, fn?: any, dir?: any) -> TEase from a to b by blend
Tween(target, duration, fn?, dir?)(target: T, duration: float, fn?: any, dir?: any) -> TStateful 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:

GateConfig attributes
SweepbodyPartsOnly
SweepSimpledirection (EBrickDirection), spreadTowardCenter, detectBricks, detectPlayers14, bodyPartsOnly, detectPhysics, detectMap
BlendclampAlpha
ColorBlendblendSpace (EBRColorSpace), clampAlpha
SlerpshortestPath, clampAlpha
Easingfunction (EasingFunction, schema EBREasingFunction), direction (EasingDirection, schema EBREasingDirection)
ConvertColorfromSpace, toSpace (EBRColorSpace)
DisplayTextfontSize, justify (DisplayTextJustification, schema EBRDisplayTextJustification), easing (DisplayTextEasing, schema EBRDisplayTextEasing), typeface (TextTypeface, schema EBRTextTypeface), font (a $Font/... asset ref)
GetAimlocalAim
AddInventoryItemAdv / SetInventoryItemAdvoverrideColors, 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 enumEBREasingFunction 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, then HelpText. The description can also be given by name as Description = "...".
  • The -> capture binds the event’s data outputs, in order: controller (the player who typed it), then arguments (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 = true on the receiver. Giving a target (including the receiver spelling, ent.SendCustomEvent(...)) makes it an object event, and only an object-scoped receiver matches one. A plain on 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 SpawnPrefab returned and target it, with isObject = true on 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 CustomEvent receiver fires on the tick after the SendCustomEvent runs — 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 a float where the receiver declared int. 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 same Object variant). 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 float on 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

ParameterTypeRequiredDescription
prefabprefab refNoThe 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.
offsetvectorNoSpawn position offset
rotationrotatorNoSpawn rotation offset
velocityvectorNoInitial velocity of the spawned entity
lifetimefloatNoLifetime in seconds (0 = permanent)
limitintNoMax concurrent instances
destroyAllexecNoWire 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.

ParameterTypeRequiredDescription
projectileTypeclass refYesThe explosion/projectile class — a $… asset reference (or a wired value)
instigatorentityNoThe character/entity that caused it (kill credit, etc.)
offsetvectorNoSpawn position offset
scalefloatNoExplosion scale multiplier
damagefloatNoDamage 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.

ParameterTypeRequiredDescription
worldPositionvectorYesAbsolute world position to spawn the explosion at
projectileTypeclass refYesThe explosion/projectile class — a $… asset reference (or a wired value)
instigatorentityNoThe character/entity that caused it (kill credit, etc.)
scalefloatNoExplosion scale multiplier
damagefloatNoDamage multiplier
on hit {
  SpawnExplosionAt(Vec(0.0, 0.0, 200.0), $BRWeaponProjectile/Grenade, instigator = attacker)
}

Raycasting (Exec)

Sweep

ParameterTypeRequiredDescription
originvectorYesRay start position
directionvectorYesRay direction
DistancefloatYesMaximum ray distance
radiusfloatNoSphere radius (0 = line trace)
relativeboolNoInterpret origin/direction in the owning grid’s local frame
ignoreentityNoA single entity to exclude from hits
ignoreListentity[]NoAn array var of additional entities to exclude (on top of ignore)
ignoreOwningGridboolNoExclude the grid this gate sits on (prevents self-hits)
collisionChannelintNoCollision channel to sweep on (EBRSweepCollisionChannel: 0 Physics, 1 Weapon, 2 Interaction, 3 Tool, 4–7 Player1–4, 8 NoAdditionalRestriction)
detectBricksboolNoDetect brick grids, including spawned prefabs — default false
detectMapboolNoDetect the static world / environment — default false
detectPhysicsboolNoDetect physics-simulating objects — default false
detectPlayers1detectPlayers4boolNoDetect players on collision channels 1–4 — default false

Detection is opt-in. Every detect* flag defaults to false, so a Sweep with none set detects nothing and always fires Miss. Enable the channel you want: detectBricks for brick grids / spawned prefabs, detectPlayers1 for players, detectPhysics for loose physics objects.

Returns a record with fields:

  • HitDistance: float – Distance to hit point
  • HitEntity: entity – Entity that was hit
  • HitLocation: vector – World position of hit
  • HitNormal: vector – Surface normal at hit
  • Hit: exec – Fires if something was hit
  • Miss: 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)

FunctionSignatureDescription
Random(min, max)(min: int, max: int) -> intRandom integer in [min, max]
Random(min, max)(min: T, max: T) -> T, Tvector/rotator/quat/colorPer-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.

FunctionSignatureDescription
Sleep(input, delay?, hold?)(input: any, delay?: float, hold?: float) -> anyDelay by seconds (BufferSeconds gate)
SleepTicks(input, delay?, hold?)(input: any, delay?: int, hold?: int) -> anyDelay by ticks (BufferTicks gate)
  • input – the value to delay. Use _ inside await to 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 .A or .B depending on cond at the instant it fires (a runtime if for 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

BuiltinSame 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:

BuiltinSame asBuiltinSame as
GetArrayElement(a, i)a.get(i)SortArray(a, desc?)a.sort(desc?)
SetArrayElement(a, i, x)a[i] = xReverseArray(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):

BuiltinSame 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:

BuiltinSame asBuiltinSame 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.

TagEffect
<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 / anchorY are fractions of the screen. 0 is left / top, 0.5 is center, 1 is right / bottom. This is the only pair that takes a 0..1 value.
  • positionX / positionY are 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.98 is 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:

FieldDefaultNote
Scale(1, 1)not zero, so unset scale never hides text
FontColoropaque whitenot transparent
Anchor(0.5, 0.5)screen center
Position(0, 0)no offset from the anchor
Pivot(-1, 0.5)
FontSize16
OutlineSize2text has an outline unless you pass 0
Lifetime5.0seconds; 0 means infinite, not “vanish now”
TextId00 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

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 let bindings
  • var initializers
  • out bindings
  • buffer expressions
  • 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:

  • on handler bodies
  • Code after on handlers at the same scope level (exec union)
  • Named chip bodies when the chip has ref parameters
  • 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:

OperationWhy
var = expr (assignment)Writing to a variable gate requires an exec chain
if { ... } (statement)Conditional execution requires an exec branch gate
emit eventFiring an event requires an exec signal
Exec function callsFunctions 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 * 2 use a Var_Get gate (exec) to read x when inside an exec context. This ensures the read is sequenced correctly in the exec chain — the Var_Get fires at the right point and its value output feeds the pure * gate. In pure context, x reads directly from the PseudoVar’s Value port (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

AccessContextGateMeaning
x (bare)ExecVar_GetCurrent tick’s value (auto-deref)
*xExecVar_GetCurrent tick’s value (explicit deref, same as bare)
*xPureError WS006 — use .Value
x (bare)PureVariable reference (ref T)
x.ValueEither.Value portPrevious tick’s value (delayed read)
x.prevEither.Value portPrevious 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

ConstructContextNotes
var x = expr initializerPureInitializer is pure even though var is mutable
let x = exprPure (at top level)Inside handlers: shares handler’s exec context
out x = exprPureAlways pure – outputs are continuous signals
buffer x = exprPureAlways pure
on trigger { ... } bodyExecPrimary way to enter exec
After on at same levelExecExec union from all preceding handlers
chip { ... } body (anon)Inherits parentShares parent’s context
chip Name(ref params) { ... }ExecAuto-exec from ref params
chip Name(value params) { ... }Pure (by default)Unless handlers inside create exec
mod Name(params) { ... }Inherits call siteInlined, takes caller’s context
await exprExecRewires exec continuation to expr
emit nameExecBare emit requires exec
emit name = exprEitherValue emit works in pure or exec
buffer(...) emit nameExecBuffered emit — delays delivery a tick+, legalises loops
let name: execEitherDeclares 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

  • await only works in exec context – it modifies the exec chain.
  • await works inside if blocks naturally.
  • Each await creates an armed flag (static bool) that guards the continuation. The continuation fires exactly once per arming.
  • _ inside await is typed as bool (the armed flag). It is only valid inside await expressions.
  • let foo = await 1 is 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

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.
modchip
Compiles toInline gates in the caller’s gridThe same gates, in a microchip instance
N call sitesN copies of the whole subtreeN copies of the whole subtree
Extra gatesNoneOne rerouter per boundary port, plus the container
Pure (no exec)YesYes
Named multi-outputsVia a returned recordYes (-> (a: int, b: int))
ref/* paramsYesYes

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 votedMask and yesMask rather than an array of -1/0/1; “voted no” is votedMask & ~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 check IsEmpty when the array can be empty). .sum() / .average() return a bare scalar.
  • If the slots you want to exclude already hold the identity value (0 for 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:

OperationCost per op
Building a string ("a" .. pad(x) .. "b")~1us
Reading a varfree
Map.get + Found checkfree
Reading a pre-built string from a mapfree
DisplayText callfree

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:

ConstructionBodies (gates)Ticks for N items
Unrolled callsN1
Back-edge loop (await)1N
Self-event fan-out1N
One send to N distinct receiversN1
N sends on N DISTINCT channelsN1

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 mod inlines per call site, so its cost is roughly body 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 LOCAL var becomes 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 early return returns 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 var declarations. 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 .brz has 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 SpawnPrefab reference 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-if is a Select gate, 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) % 4 where x / 16 is already proven <= 2 is just x / 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 let once and reuse it – each call is its own gate subtree, so f(x) + f(x) builds f twice.
  • A mod-local static var is per-copy, not shared – each inlined instance gets its own. Hoist shared state to a root var.
  • Hover a mod, chip, on, or if for its gate count. The estimate covers the whole construct (following its calls), and a mod reports 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:

  1. Profile with --dump-ir first – refactor the dominant gate kind / source line, not a guess.
  2. How many call sites reach the biggest mod? Multiply – that’s your bill.
  3. Can many producers be funneled through one queued dispatch site?
  4. Can near-identical entry points collapse into one parameterized mod?
  5. Can a hot shared routine be deferred behind a dirty flag to one call per tick?
  6. Is any per-slot boolean state an array that should be a bitmask?
  7. Is anything being re-derived inside a callee that could be passed in?
  8. Is an unrolled max/sum/count really a one-gate .max() / .sum() / .average()?
  9. Would a derived parallel array turn a per-element decode-and-add into a slice + sum?
  10. Is display/derived logic running on the exec chain when it could be a pure output binding?
  11. Is a per-tick roster sweep really one chip instance per entity?

See also

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

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

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-fold on the CLI’s compile command 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 @fold and @nofold are present, @nofold wins and the parser warns that the annotations conflict.
  • A @nofold in 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) stores 0.25 in the Clock’s IntervalSeconds data, and a constant Sweep distance, DisplayText fontSize, etc. do the same. A variable value still wires (so the setting stays dynamic).
  • A matching-type constant operand inlines too: the 0 in x | 0, a false operand of ||, and string literals into str fields (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 i64 field) does not inline — it keeps a converting carrier gate. Wire-variant math inputs (+/-/*) and Vector/Rotator/Quat/Color fields 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 1e15 in 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. 1e15 itself 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-argument Color(...) 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 @nofold at the top of a file, which disables the whole fold pass for that compile.
  • Rerouters, var/Var_Get reads, 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 string operand (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-fold on the CLI’s compile command 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

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 WS0xx range are not used: WS009 and WS018 have no emit site. WS034 was once a generic-chip cross-wiring guard and has since been removed.

Contents

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.

CodeMeaningTrigger
WS005Wire-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
WS007Exec-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

CodeMeaningTrigger
WS001Unknown event or trigger — the name after on isn’t a known event, input, let, buffer, var, or param.on Nope { }
WS002Unknown 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() }
WS012Import error — a circular import, an unresolvable file, or a named binding not found in the target module.import { nope } from "utils"
WS013Duplicate 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
WS021Use before declaration — a chip/mod is called above the point where it’s declared (declarations register in source order).helper() above mod helper() { }
WS043An 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
WS060Unknown variant on an enum path (Enum.Variant) - the enum has no variant with that name.enum Shape { Empty } then Shape.Nope
WS064Duplicate discriminant value in an enum declaration - each variant’s tag must be unique.enum E { A = 1, B = 1 }
WS065Wrong 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)
WS054Non-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 }
WS062A 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 }
WS063A 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.

CodeMeaningTrigger
WS003Type mismatch — a value doesn’t coerce to the expected type (assignment, argument, output, array element, if-branch join, event input).var n: int = "hi"
WS004No operator overload for the operand type(s) (arithmetic / comparison / logical)."a" + 1
WS008Taking &/ref of a non-reference — only a variable, ref parameter, or array/map element can be referenced, not a temporary.&(a + b)
WS011No 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
WS025A non-storable type used as storage — any, zone, teleport, or prefab in a var / buffer / array / map.var x: any = 0
WS031A 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
WS067A 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 => .. }
WS068A 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) { }
WS069An 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[] } }
WS070An 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
WS071A 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
WS072An 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

CodeMeaningTrigger
WS020Recursive chip/mod call — chips and mods inline at compile time and can’t call themselves, directly or mutually.mod f() { f() }
WS022Wrong argument count in a user mod/chip call.mod f(a: int) {} then f(1, 2)
WS035A 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 { ... }
WS036A 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()
WS038The 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]
WS041Unknown 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.

CodeMeaningTrigger
WS033Generic 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

CodeMeaningTrigger
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
WS019A prefab reference must end in .brz.$./level
WS023A 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 }
WS040A @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

CodeMeaningTrigger
WS010Destructure / 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]
WS026A map literal used somewhere other than initializing or assigning a Map variable.foo({ "a": 1 })
WS027Assigning a whole map from a non-literal is unsupported — use m.copyFrom(src).m = otherMap
WS039Invalid map key type — a Map<K, V> key must be int, string, or an object (entity/character/controller).var m: Map<float, int>
WS044An 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.

CodeMeaningTrigger
WS046Not 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
WS047The 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()
WS048Const 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
WS049A 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
WS050A 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()
WS051null 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
WS052A ...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)
WS053A 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")
WS056let 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
WS057emit 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()
WS059Change/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.

CodeMeaningTrigger
WS028Invalid 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

CodeMeaningTrigger
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 = valueawait 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 match expression whose arms are records or enum values compiles, choosing per leaf field. The record spelling dropped the statement silently; the enum spelling was a WS071 per 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/match picks each port separately. One Select over port 0 meant c.Found read the value and bFound was wired nowhere.
  • A mod/chip with 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 { .. }).field reads that field, distributing over the arms like if does.
  • A mod record argument accepts a container element or a record-valued if/match, as its chip twin already did.
  • An expression with no value read as one is a WS072 error. A no-output mod/chip typed as any, so o = 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 let initialized through such an alias carries its type, so reading it is no longer a WS002.
  • An import * as alias 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 as any and loaded empty.
  • Two import * as in one file under the same alias is a WS012; unreported, the second shadowed the first and every reference read whichever module came last.

Editor

  • The LSP watches the workspace’s .ws files, 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; only let x: T = { resolved before.

1.10.0

  • value is Enum.Variant tests 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 Clock takes only interval and enabled. pulseOn, onTime and offTime are inert fields of the gate’s data struct, so passing them baked config the gate never reads; naming one is now a WS041 error.

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.ws repeatedly 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 (Contains is now String Contains, Format Text is now Format String, Length is now Get 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 by scripts/gen_assets.mjs from the in-game dump. Weapon one-shot audio is renamed BOSA_* to OSA_*, 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.a on a *T parameter reported expected float, got *float, and an if-then-else over two such fields reported a branch mismatch.
  • rec.field.Value reads the field’s backing variable. Only x.Value on a plain identifier resolved, so the field spelling lowered to a placeholder and left its consumer unwired.
  • A record literal passed to a *T parameter is a WS008 error when a reference field has no variable behind it. An in port 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 chip with a *T parameter 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.
  • &x and ref x bind a chip’s reference, array, record, or map parameter to the caller’s storage. Only the mod form stripped the sigil, so the chip form left the parameter’s pin unfed and silently dropped every write through it.

1.8.0

  • *T on 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 = v writes. A tuple var collapsed 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-step let r = Chip(x) / r.field spelling resolved it; the inline form compiled to a placeholder wired to nothing.
  • A record used where one value is expected is a WS071 error instead of a silent placeholder. A record is several wires, so "x=" .. rec had 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 else capture 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 WS007 error instead of compiling to nothing.
  • An enum payload field that needs container storage is a WS069 error, 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/else selects per leaf, in assignment, out and 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/out port 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; &p on a record variable passed to a *P parameter 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 match or if let accepts a container element (match m[k], match arr[i]) or a .Value projection off a map get as its scrutinee. Only a named binding of the same read resolved before, so the inline form hit a placeholder.
    • Referencing one: &p.a on a record field is accepted, since each field is its own storage gate. It lowered correctly and was rejected by WS008 anyway.
    • A record or enum as a custom-event data param is now WS068, since a data slot is one wire and cannot carry one.
  • A scalar initializer is baked in the declared type, so var x: float = 0 builds 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 exec signal consumed inside a chip compiles. 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 / .prev opens a variable that spans several storage gates (a record, an enum); it used to emit a placeholder.
  • A var initializer naming a constant (var x: float = K) bakes that constant.

1.7.1

  • enum is 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/.roll and a quat’s .x/.y/.z/.w now read their components, lowering to a Split gate the way a vector’s .x already 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-toc and gated by just 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

  • enum types (tagged unions): enum Shape { Empty, Circle(float), Box { w: float, h: float } } supports C-like members, positional and named payloads, explicit = N discriminants (later members auto-number from there; a collision is WS064), and generics (enum Box<T> { Value(T), Empty }). Enums are nominal, so two identically-shaped ones are different types. .Discriminant reads a value’s tag as an int, and on a variant path (Shape.Circle.Discriminant) it folds to a compile-time constant.
  • match branches on a variant and binds its payload, both as an expression (comma-separated value arms, lowering to a Select tree) and as a statement (block arms, lowering to Branch/Union). Coverage is checked: an uncovered variant is WS054 and names the missing patterns, an arm that can never run is WS061, and patterns nest into payloads including other enums. A wrong bracket form for a variant’s shape is WS065, and .Discriminant or match on a non-enum is WS066.
  • if let and let else are single-variant refutable binds: if let Some(x) = o { ... } else { ... } runs the block only for that variant, and let Some(x) = o else { return } binds into the surrounding scope with a required diverging else (WS062).
  • Built-in Option<T> and Result<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, .Discriminant gives their real integer value, and a variant such as EasingFunction.Bounce passes directly as the matching gate config argument alongside the older bare-name form.
  • Enum and int conversion: value.ToInt() is an alias for .Discriminant, and Enum.FromInt(n) builds a value from a (possibly runtime) int tag with payloads defaulted to zero. The EnumToInt / IntToEnum builtins (renamed from EnumToInteger / IntegerToEnum) are the gate-backed twins: they now require an enum-typed value instead of accepting any, 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 (WS063 when 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 mod that reads or writes a container in its body (m.get(k), arr[i], m.set(...)) from a pure position now reports WS007, instead of silently wiring the container reference into the caller. This now also covers a container reached through the mod’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 mod whose 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, or buffer statement 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 out or in member (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 typing any and dropping to a placeholder; it works in expressions (a comparison, arithmetic), not just a bare let.

1.6.1

  • .exec names an event’s exec output, so a data-carrying event composes into Union(...); Union also takes an exec receiver, so a.Union(b) chains left-associatively.

Fixes

  • An inline record-returning mod call read by field (f(x).sum) now projects the field instead of an _Unsupported placeholder.
  • 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 ns namespace (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 mod call (f(n, ...g()), or a bound let t = g() then f(n, ...t)) now expands its elements instead of dropping them.
  • A field or index access on an aggregate-typed if expression ((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 on handler in an import * as module 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 via import { g } plus import { 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 ...tuple spread now expands into a call’s positional arguments. SendGlobalCustomEvent(name, ...t) splats a tuple across the event’s data slots, and f(...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 a WS003.
  • A mod can take a trailing ...rest variadic parameter that captures every argument past its fixed params. Each call site binds rest to a compile-time tuple of the extra args, and a ...rest in the body splats them onward, so mod broadcast(name: const string, ...rest) { SendGlobalCustomEvent(name, ...rest) } forwards any number of values. A call must still supply the fixed params (fewer is WS022). Only mods may be variadic; a ...rest on a chip is WS052.
  • 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 tuple let (p, t) = ... captures data outputs positionally. Annotate the type or the wire defaults to a float (WS055 warns when it can’t be determined). Bare await CustomEvent("c") (no binding) just waits.
  • A tuple return’s parentheses are optional: return a, b, c returns the same tuple as return (a, b, c), and .0/.1 access the elements.

Fixes

  • Compile progress now accounts for embedded prefabs: the reported step total grows by one per $./file reference 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 local let sig: exec signal wires the signal into the spawner’s destroy pin, so those prefabs are destroyed (a local exec let folds to a placeholder 0, so it needs this dedicated wiring).
  • wirescript-check runs through lowering, so it surfaces the same _Unsupported-placeholder warnings compile emits.
  • New WS053: a plain emit X in the same chain as a following await X warns that the kick fires before the await arms (parking the chain forever); use buffer emit X.
  • A record passed to a chip wires its fields: a record literal argument (Foo({ x: a, y: 2 })), a field whose value is a var (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 = 5 where p is not a var) reports WS007. A var-backed record field stays assignable.
  • == and != between two whole record values report WS004. Comparing a multi-output result to a scalar (arr.pop() == 5) still works.
  • A ...rest parameter in a destructured mod parameter 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.a read and write a single leaf, arr[i].inner / m[k].inner read and write the whole sub-record, and let 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 namespaced out port) and a nested path (A.B.bar()) report WS002; a named-argument call through a namespace reports WS022 when 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 if expression 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/ref parameter reports WS008.
  • A statement written after a nested on handler inside a handler body stays on the outer exec chain rather than binding to the nested handler’s trigger.
  • New WS057: emit X is an error when X is not an out port or a let ...: exec signal (an input port, a var, or an out/signal declared outside an enclosing named chip).
  • New WS056: let v = await sig on a signal that carries no payload is an error. Emit a value (emit sig = ...) or capture one with await <expr> on sig.
  • New WS058 (warning): an exec statement that never runs is flagged
  • A tuple return (a, b) to a mod with named multi-outputs wires each element to the matching output in declaration order, so let (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.r on a vector, color.x) reports WS010 rather than emitting a wrong-typed SplitColor/SplitVector; a negated-union or double-negation handler trigger (on !(a | b), on !!x) reports WS001 rather than dropping the whole handler; and assigning to a non-lvalue (f() = 5) reports WS007 rather 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-mod monomorphization, so a generic -x emits a negate gate with the right numeric type.
  • A constant Substring with a very large length clamps to the string end instead of overflowing into a panic.
  • New WS059: Change/Changed (and the Edge detectors) on a reference or container (a Map, an array, a *T ref, a zone, or a teleport) 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 stored var is tagged.

1.5.0

  • Added a null literal. 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: a var/out initializer, an assignment, a call argument, or a record field. null for a container, record, or reference-only type has no value and reports WS051; a bare let x = null with no target types as any.
  • 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 get push/pop/insert/remove/fill/resize/swap/reverse/clear/length plus element access (pts[i], pts[i].x, pts[i] = rec, p = pts[i]); maps get set/get/has/remove/clear/length/keys plus m[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, or exec) now reports WS049 instead of silently backing it with an unusable gate.
  • A record-container method with no per-field meaning (sort/shuffle, the aggregates sum/min/max/average, find, and the dual-container append/copyFrom/slice/values) now reports WS050 instead 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 as pts.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 on handler (import * as L from "lib" where lib has on 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-ir node 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-level on handlers.
  • Two import * as namespaces (or a local declaration plus an imported one) that share a state name now get distinct storage gates.
  • A var/array/map/buffer declared inside a handler, if, or block now gets its own storage gate instead of silently reusing an outer same-named one.
  • &x passed 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" or import { x }, now report WS013 instead of silently collapsing both onto one storage gate.
  • A container mutation (arr.push(x), m.set(k, v)) outside an exec context now reports WS007 instead 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 an if is now gated by the branch instead of wired unconditionally to the output; a guarded write no longer becomes permanent.
  • A let that shadows an in/out port of the same name now reports WS013 instead of silently hijacking it (let go before on go, with in go: exec, left the exec input dead). Ordinary value shadowing (let a = 1; let a = 2) is unaffected.
  • An output reached by an emit plus a default initializer (out r = 0 then emit r = …), or by emits split across a handler and an anonymous chip { … }, is now var-backed instead of driven by two wires - a load-breaking fan-in on the output rerouter.
  • A let aliasing an input-port array or map (in a: int[] then let x = a) now resolves for x[i] and container methods, like a var alias; 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 helper no longer make one module’s public functions run the other’s code, and a namespaced mod mutating g now 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 reports WS007 instead of silently lowering to a placeholder, matching the pure index-read rule. A const receiver or an explicit exec = <trigger> arg is exempt.
  • A captured handler with a non-Event trigger (let e = on go { … } where go is an exec input, var, or let) now runs its body and captures its exit as e; the body used to be dropped entirely unless the trigger was a built-in event.

1.4.3

  • An imported let no longer clobbers a same-named declaration in the importing file. An imported let start overwrote the file’s own in start: exec, so on start bound the imported value instead of the input and silently dropped the handler body.
  • Void container operations (push/clear/set, the keys/values fills) now type as never, so using their result as a value (let r = a.push(x)) is a type error instead of silently accepted.
  • Top-level on handlers in an imported file now run; they were silently dropped, and an on <expr> handler additionally left a dangling trigger gate with no body.
  • A let aliasing 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 as ns.name, so ns.tuple.0 resolves 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.brz prefab 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 warned WS014 while 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 in and out ports are now declared and reachable as ns.name. They were dropped entirely, so on ns.trigger { ... } matched nothing and silently discarded the whole handler body while both stages reported the file clean. Root-level var, array, map and buffer members 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 warned WS014, and Organize Imports would then delete it.
  • Two import * as namespaces that export the same member name now stay distinct. A.foo and B.foo had 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

  • const compile-time evaluation: const bindings, const parameters (f(name: const string, v: int)) and const mod declarations 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. An if on a const condition drops its untaken branch, and a const that 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 of InputReader. Same field names, so char.GetInputs().PressedQ reads 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, so GetLocation(e) was an error while e.GetLocation() compiled to a placeholder that read a default.
  • A local binding that shadows an import * as ns alias now reports WS002 at the call. A parameter or let named ns made every ns.f(...) in that scope resolve against the local value, which has no such member, so the call typed as any and compiled to a placeholder that did nothing while type-checking reported the file clean. The failure then surfaced wherever the any was 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 of any, 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, so ns.rect.a.x keeps its type instead of reading as any.
  • Namespace members (import * as ns) written without an annotation, or bound by a destructuring let, now carry their record type across the import too.
  • Compiler source reorganised

1.2.0

  • map[key] subscript syntax - m[k] and m[k] = v now work on a Map<K, V>, desugaring to the same get/set the m.get(k) / m.set(k, v) methods use (m[k] reads the value, auto-unwrapping the found flag; m[k] = v writes it). The read types as the value type V and, 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-level out 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 to any.
  • let t: (A, B) = … and let 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") is WS003).
  • A heterogeneous array literal in a handler (xs = [1, "hello", 2]) reports WS003 on the odd element.
  • An any[] / Map<any, …> parameter now accepts a concrete array/map argument.
  • == / != on two vector/rotator/quat/color values is now accepted (ordering stays scalar-only).
  • Assigning to a scalar let reports WS007 instead of silently emitting no gate.
  • An unknown named argument on a mod/chip call reports WS041 instead of being dropped.
  • A typo’d event config/input name (on Clock(intreval = …)) reports WS041 instead 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 var read after await re-reads fresh, so a value changed during the wait is visible.
  • ns.myValue on an import * as ns now reads the real value and type — it typed as any and compiled to a placeholder reading 0, while only ns.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 does emit sig now 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-case atom literal at its hyphen.
  • A field access on a scalar (x.whatever on an int) reports WS010 instead 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 + 1 in three outputs, or call the same mod twice, and you get one gate instead of a copy per use. (State-holding and @nofold gates are left alone.)
  • A variable read is reused across an if when 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/references now 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 type aliases 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 (a character capture) or a builtin function (a value named round) 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.
  • .ws source prefabs - a prefab reference may now point at a .ws source 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.ws reference itself, so a broken prefab underlines where it’s used. .brz archives work as before; any other extension is WS019.
  • 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 clear WS036 error instead of silently type-checking as any and lowering to a do-nothing placeholder.
  • Fixed: @fold dropping a runtime @label - a runtime @label(<expr>) (on a var or, 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 the on form (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. The on 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) or on Foo() -> { field: local } (record, by field name - named events, for subset/rename). on <call> -> ... also triggers on any exec-producing call - a mod/chip call, or a gate driven by on Foo(exec = x) -> (...) - auto-extracting the call’s exec output; a general call that exposes no exec (or an exec = with nowhere to attach) is a WS043 error. A single untyped output may drop the parens: -> who is shorthand for -> (who).
  • Custom-event data-type inference - an unannotated custom-event receiver slot takes its type from the matching in-unit SendCustomEvent on that channel; when none is inferable the slot defaults to float and warns WS042 (which replaces the old WS029 “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 a mod/chip parameter, an in/out port, or a record field (not just a file-scope var). A container method whose receiver isn’t a container is WS044, not a silent no-op.
  • Generic value builtins - Select, Swap, Sleep, SleepTicks, and Tween carry their argument’s type instead of any, so Select(c, 1, 2) is an int.
  • 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 *T field and a plain T are interchangeable at a call boundary, as parameters already are).
  • Tighter builtin argument types - DisplayText/PrintToConsole/Fmt/SetTag take string, SetLeaderboard/IncLeaderboard take int, and SpawnPrefab takes a prefab reference.
  • Fixed: exec after an emitting mod - a statement following a mod whose body ends in emit no 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) { ... } becomes on 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) { ... } becomes on CustomEvent("dmg") -> (amount: int) { ... }; an omitted type is inferred from the sender, or warns WS042.
  • Event triggers are calls - an event trigger is written with (): on RoundStart(), not on 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 the on <call> -> (...) model.
  • WS029 is removed - the “annotate your custom-event param” lint is replaced by inference plus WS042.

1.0.0

  • Maps (Map<K, V>) - a keyed variable collection paralleling arrays: var scores: Map<string, int>, keyed by int/string/object reference and holding any wire-storable value, with exec-context methods set/get/has/remove/clear/copyFrom/length/keys/values (get gives { Value, Found }, auto-unwrapping to Value). A non-int/string/object key type is a WS039 error.
  • Map literals - { k => v } keys by any expression, "s": v / :atom: v by a string/atom/int literal, and [expr] => v by a computed key; a fully-constant literal bakes the map pre-populated at rest, {} is an empty map, and m = { ... } in a handler desugars to clear() plus one set per entry in source order.
  • Atom literals (:name) - a compile-time int constant (the deterministic xxHash64 hash of the name), a readable stand-in for a magic number as an int-map key or enum-like tag; it only ever resolves at compile time, never from a runtime string.
  • Generic type syntax - Array<V> and Ref<V> are exact aliases of V[] 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 a WS028 error.
  • Custom events - SendCustomEvent(name, data...) pulses every on 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 SendCustomEvent on 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 & teleport reference 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’ zone input is now zone-typed, and Teleport/RelativeTeleport dest/source are now teleport-typed - teleporting to a raw position uses SetLocation.
  • Clock reads as an event - on Clock(interval = 2.0, enabled = running) { ... } runs its body on each pulse; interval and enabled are wire inputs (constant or dynamic, so the clock toggles at runtime) and pulseOn/onTime/offTime are 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, and ForceRespawn(player) (also player.ForceRespawn()).
  • New date/time and conversion builtins - GetUnixTime, FormatDate, Remap, LogicalShiftRight, EnumToInteger/IntegerToEnum, ItemToPickup, ConvertColor, and ToCharCode/FromCharCode; ParseInt/ParseNumber gained a .Success flag and auto-unwrap to their parsed value.
  • Zone array fills - arr.fillFromZoneEntities(zone, tagFilter?) / fillFromZonePlayers(...) populate an array from a zone, and arr.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 (also PlayClientAudio(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) alongside Forward/Right.
  • DisplayText returns a textId - capture the returned int to update or clear the same on-screen text later, with new color/outline/shadow/spacing/wrap styling params; position/anchor/scale/pivot/shadowOffset are Vector2D layout properties on the reworked gate.
  • GetDamage gives { Damage, DamageLimit } - auto-unwraps to Damage where a float is expected, and .DamageLimit reads the death threshold.
  • Richer event outputs - the fired-weapon event exposes the weapon and its name, CharacterDied the killer’s weapon and name, and ControllerJoined/ControllerLeft the player’s user name; Sweep/SweepSimple results carry a HitColor.
  • 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; existing controller-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/Name config refs.
  • @label(<expr>) labels - @label accepts an expression, not just a string literal. A constant folds to baked text; on a top-level var a 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 @label on a port/chip (which has no wireable text) is a WS040 error.
  • @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 a SpawnPrefab argument. The inner block can import but 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 - an on handler 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 var initializer missing its = - var x: int 5 silently dropped the value; it now reports a missing = 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 the arr.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 .A or .B on 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 other on <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 string at file scope, a character[] inside a handler) is read as the one actually visible at the cursor rather than the first declaration found. This applies to receiver. 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.
  • DisplayText layout is per-axis - the Vector2D layout 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 its X/Y sub-port.
  • LSP: gate hovers render composite defaults - composite/color parameter defaults show as their constructor or sRGB hex (outlineColor = #181425), and a Vector2D sub-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 WS041 error instead of being silently dropped (a typo’d argument name that previously did nothing). The universal exec = 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/RelativeTeleport dest/source are now the teleport reference type, so e.Teleport(Vec(x, y, z)) and e.Teleport(other) no longer typecheck. Use e.SetLocation(Vec(x, y, z)) to move to a raw position, or wire a teleport point into an in p: teleport port and e.Teleport(p).
  • Zone events take a zone reference - the events’ zone input is now zone-typed (and tagFilter is string), so zone = e with an entity/brick value errors. Feed an in z: zone port from a Zone brick: on ZoneEntered(character, zone = z) { ... }.
  • zone / teleport are reference-only - like a var ref, they can’t be stored in a var/array/buffer (WS025) or picked with an if-then-else (WS031); pass them straight through ports and parameters.
  • DisplayText.outlineSize/fontSize are now int - pass an integer. The per-axis layout args (positionX/positionY/anchorX/anchorY/scaleX/scaleY) are unchanged and keep working; pivotX/pivotY/shadowOffsetX/shadowOffsetY are new axes on the reworked gate.
  • Swap result fields renamed - Swap(cond, a, b) returns { Output, OutputB } (was { a, b }); read r.Output / r.OutputB (a bare r still auto-unwraps to the first value).
  • InputReader().Jump removed - the movement record dropped Jump; read the new axis/button fields instead (Up, Pitch/Yaw/Roll, MouseWheel, PressedC/E/Q/LeftMouse/RightMouse).
  • BrickChanged / BrickRemoved lost their brick output - these events no longer carry a brick value, so on BrickChanged(brick) { ... } won’t bind; drop the parameter (on BrickChanged { ... }).
  • RotToDir removed - its gate no longer exists in the build; use q.ToDirection() (takes a quat) to turn a rotation into a forward direction.
  • array declaration keyword removed - declare container variables with var instead: var scores: int[] (was array scores: int[]). Storage and behavior is identical - only the keyword changed - so the fix is a mechanical rename. Using array as a declaration keyword is now a parse error that points at the var form.

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/MicrochipOutput rerouter, even when not a declared parameter. Constant arguments still inline.

0.19.0

  • Constant expressions in var / array initializers - an initializer may name a top-level let constant 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 explicit exec = <trigger>, e.g. an array read in a pure binding: lut.get(i, exec = i + 1).
  • Fixed emit output = value fan-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 @nofold exempt 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) or return r with 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.
  • any type - an in/let/mod-or-chip-param/-output can now be annotated any, 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. A var/static var/array/buffer can’t store one: a variable gate needs one concrete wire type to hold, so an explicit any there is now a compile error.
  • String truthiness - a string now coerces to bool wherever a bool is expected (an if condition, a bool-typed let/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 via any still 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.fontSize and 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 on arr[i]. too.
  • Blend is the math blend gate - An alias for lerp, accepting any math variant (float/int/vector/rotator/quat/color), as do lerp, Easing, and Tween. The colour-space gate is now ColorBlend.
  • Opaque hovers 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 .Value on a multi-output result - a.pop().Value typed as the whole record, so every use of it mismatched.
  • Fixed a type alias not resolving through a namespace import - import * as T with mod f() -> MyType failed with “unknown type”. Aliases now inline as they do for a named import, and T.MyType parses as a qualified type.
  • GetLeaderboard returns int - It was typed any, so arithmetic on its result had no operator overload.
  • bool arithmetic with two bools - bool + bool (and - * / %) now promotes to int, matching bool/int mixes and the bitwise ops; (a && b) + (c && d) compiles.

0.17.0

  • Opaque(x) builtin + @nofold annotation - Opaque passes a value through a rerouter and hides it from constant folding; @nofold suppresses 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.ws prints every probed gate interaction to the console on paste; scripts/gen_semantics.mjs turns the dump into data/gate_semantics.json, and scripts/gen_verifier.mjs generates probes/verify_semantics.ws, which re-asserts every recorded case in-game.
  • Fixed a chip output named x/y/z/r/g/b/a reading 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 chip called 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 chip no longer costs a gate per instance - F(1) materialized a _Var in 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 equivalent mod emits.
  • Fixed a tuple-destructured mod parameter binding nothing - mod f((a, b): (int, int)) left every name unbound, so the body silently computed on zeros.
  • Fixed let (a, b) = t on 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 - Ns didn’t travel with the imported declarations calling through it, so every Ns.f(...) silently did nothing at runtime.
  • Fixed a namespaced call losing its return type - Ns.f(x) typed as any, so Ns.f(x) + 1 failed operator resolution (WS004) and dropped the expression.
  • New tree-sitter grammar - editors/tree-sitter-wirescript/, with highlight/locals/indent queries.
  • Docs: dropped match expressions - 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 mod and chip alike), 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 separate chip { ... } 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 on handlers bound to Change(x) - Change’s OnChanged output is now typed exec (was any), so let c = Change(x) + on c { ... } fires on the change pulse.
  • then may start its own line in an if expression - let x = if cond followed by indented then ... / 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 in a.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_programs example - Seeded grammar fuzzer that hunts silent miscompiles: programs with no error diagnostics whose output has _Unsupported gates, duplicate/fan-in wires, or dangling endpoints. Findings write to a gitignored fuzz_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 .ws file clears the previous Compile command’s diagnostics; the next explicit compile repopulates them.
  • Rename applies to every reference find-references sees - Three textDocument/rename fixes:
    • Open files match by canonical path and references are deduplicated, so edits are no longer doubled and rejected.
    • import { foo } rewrites to import { 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 Arc instead of deep-cloned per call, and scope keys ride the interner; mod-heavy programs lower ~14% faster. New count_allocs example reports per-stage allocation counts/bytes.

0.16.1 - 2026-07-15

  • chip let labels 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 (via import * 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 _Unsupported gate.
  • 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 negated on !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> completes recv’s members (records complete their fields, including in on handlers); Call(<here> still completes params.
  • LSP: more completion contexts -
    • import * as u then u.<here> lists the module’s members.
    • pos.<here> on a var pos: vector offers type methods + swizzle (x/y/z, r/g/b/a) alongside .Value/.prev; static var gets .Value/.prev.
    • Values typed by a type Foo = { ... } alias complete Foo’s fields.
    • User mod/chip/fn calls 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 @label and @closed.
    • Native LSP and web playground share these paths.
  • Doc comments on record-type fields - A /// on a field inside type T = { ... } now parses (was a parse error) and shows on hover of that field.
  • Fixed hover on a namespace alias - Hovering u in import * as u shows namespace u and lists its members (was namespace 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 constant array/var initializer; 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 two color operands; a scalar broadcasts across channels (tint * 0.5). Same PrimMath gate as vectors/rotations.
  • Random is polymorphic - min/max may be vector, rotator, quat, or color; each component rolls independently and the same type is returned (Random(Vec(0,0,0), Vec(1,1,1)) → point in the unit cube). Scalar int form unchanged.
  • Fixed anonymous-record mod returns - A mod returning a record literal (return { head: ..., rest: ... }) now destructures into per-field sources, so each field wires to its own value (was one _Unsupported gate).
  • Non-root chips compile open by default - Opened planes stack as a wall above the compiled microchip (root at bottom, deeper nesting higher). New @closed collapses a chip but keeps its wall slot; open chip is now a no-op.
  • New @label("text") annotation - Display-text override for chip labels/headers and in/out port labels (stacks with @side in any order); the wiring-UI port name is unchanged.
  • Opened planes render a header - A size-96 title (the @label text, 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 mislabeled exec on hover - return alone 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 x and a named import no longer ships its top-level let constants twice; fully-disconnected pure gates and orphan literals are pruned.

0.14.0 - 2026-07-12

  • Port-side rerouter pins - @left/@right/@top/@bottom on 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 inside chip {}/mod bodies error (WS023).

0.13.1 - 2026-07-11

  • Fixed array.pop() returning 0 - Both gate outputs are now declared: .Value reads the popped element and .IsEmpty reads bIsEmpty (true once the array is empty after the pop).
  • Fixed buffer initializers inside chip/mod/handler bodies never wiring - The initializer expression was silently dropped, leaving the buffer’s input dangling.
  • Silently-dropped var initializers now warn (WSP001) - Warns on a non-constant init in pure position, any non-constant static var init, and an exec-context array-var init that isn’t an array literal. Use a let for 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 / .Index now keep the call.
  • Fixed a standalone chip losing its exec output - An exec-bearing body ending in return <value> now ships the output.
  • Fixed out X = X emitting 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).Found resolves from the call’s record.
  • LSP: goto-definition on a namespaced call resolves in the imported file - u.foo with import * as u no longer jumps to a same-named local decl.
  • Chip exec I/O gates are labeled - Exec gates say exec; the anonymous -> type return output says return (synthesized ports had no label).
  • ControllerJoined/ControllerLeft expose 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-typed in port supports array methods (X.length(), X.push(v), …) and passes to a mod/chip’s T[] parameter.
  • Namespaced module members resolve inside their own mods - import * as ns only; 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 Zone input - on ZoneEntered(character, zone = z) wires z into the event gate’s Zone port, so a wired in port selects the watched zone. Covers ZoneEntered/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_Increment and 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 wired MakeVector. 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, so let K = 2 used as arr.push(K) bakes 2 into the gate.

Bug Fixes

  • min/max and 14 more expression gates embed literals - min, max, sign, round, exp, ln, the hyperbolics, Deg2Rad/Rad2Deg, BitCount, and ScaleVec no longer drop literal args like min(a, 3.0).
  • ScaleVec wires to the real ports - Input/Scalar instead of the nonexistent InputA/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), or buffer(delay, hold) inserts the Buffer(Ticks|Seconds) gate a wire-graph cycle needs. Constants bake into the gate; variables wire the duration port.
  • Payload ferrying - emit sig = value stores the value in hidden per-signal vars (one per record field); let x = await sig / let { a, b } = await sig reads it back. Cost: one Var_Set per field per emit, one Var_Get per field at the await.
  • Body-level let x: exec wires correctly - await x on a body-declared signal no longer lowers to a dead placeholder.
  • Signals are scoped per declaration - Two mods each declaring let loop: exec no 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 nonexistent VarRef port.
  • 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)

  • entity coerces to character/controller - Character/controller receiver methods and typed params accept entity values (e.g. Sweep’s HitEntity), wiring directly with no adapter gate.

Bug Fixes

  • CharacterDamaged attacker is character-typed - Was entity, which receiver methods and typed params rejected. The weapon binding stays entity.
  • ShowStatusMessage and 12 more gates - Literal args now persist.
  • Recursive chip/mod calls error instead of crashing - Now a WS020 error.

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 .method access; builtin call/method hovers require recv.method or name(. 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/Name is entity (was any), so weapon == $BRItemBase/Weapon_Pickaxe type-checks instead of erroring (WS004). As a value it materializes into the matching *Reference gate (ItemReference, AudioReference, EntityTypeReference, … by asset type), which outputs the asset as an entity wire.
  • DisplayText gained an easing param - The interpolation curve for transition ("Linear" / "EaseIn" / "EaseOut" / "EaseInOut"), a property-only enum like justify.

0.10.0 - 2026-07-07

Bug Fixes

  • character and controller wire directly - No more GetFromEntity adapter, 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 - FormatText has only 7 substitution inputs; templates with more ${...} values split across chained gates.
  • on <local exec signal> fires across handlers - emit sig in one handler triggers on sig in 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.
  • FindPlayer is an exec gate returning character - Has Exec/ExecOut ports and emits the found player’s character; was mis-declared pure returning entity.

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.brz is a clickable link / go-to-definition target (Ctrl/Cmd-click or F12).
  • Missing prefab files warn - The LSP flags a $./file.brz that isn’t on disk or lacks the .brz extension.
  • Playground uploads .brz prefabs - 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 .brz into SpawnPrefab - $./file.brz (relative) / $/abs.brz (absolute) embeds the archive content-addressed (brdb 0.7 add_prefab) and sets the gate’s Prefab path. .brz required (WS019); resolution pluggable via EmitOptions::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

  • SpawnPrefab gained a velocity param - The gate’s SpawnVelocity input.
  • 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 an exec result field: await r.exec / on r.exec { }.
  • Import dependency pulling fixed - Imports pull same-file deps in record/array literals, emit values, await exprs, and buffer inits; type aliases inline into imported let/var/out/buffer/in annotations, not just chip/mod params.
  • WS013 understands emit - The unassigned-output check counts emit 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-level array initializers and runtime foo = [...] 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: false so the LSP doesn’t register a duplicate.
  • Prefab path completion - $./ (or $/) completes .brz refs: the native LSP scans the document’s directory; the wasm playground offers dragged-in files via a new optional prefabs_json registry.

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 global BroadcastChatMessage(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() -> string attach 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 to Edge: 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(...), and AddInventoryItemAdv / SetInventoryItemAdv with overrides (damage, speed, scale, itemName, projectile). Asset args are $Type/Name references.

New Events

  • CharacterDamaged(character, damage, attacker, attackerWeapon, attackerWeaponName) - A character took damage.
  • EntityZoneEntered / EntityZoneLeft (entity) and ProjectileZoneEntered / ProjectileZoneLeft (character, projectile, weapon, weaponName) - Zone events beyond characters; the projectile events’ character is the shooter.

Compiler / Output

  • Generic asset-field emission - Gates with a class/object data 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 _max schema (286 structs) and component_db.rs (296 type mappings). assets/external.rs kept the previous full catalog (the dump referenced only 14 assets).
  • Deliberately not exposed as builtins - The *Reference gates ($Type/Name covers them), Convert/ColorConvert (implicit coercions cover them), and AddInventoryEntry (opaque nested struct; GiveWeapon covers it).

0.7.0 - 2026-07-05

Language Features

  • Scalar var type inference - var foo = "" is a string var, var n = 0 an int var, var f = 1.5 a float var (also bools, negatives, interpolated strings). A non-literal initializer refines from its expression (var v = Vec(1.0, 2.0, 3.0) is vector), 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 = 5 is 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)] is vector[]).
  • Color() returns color - Was any; matches ColorSRGB/ColorHex/Blend.

Constant Folding

  • Vec/Rotation/Color on 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 a Make* gate materialized, never silently zeroed.
  • Vars and arrays of every wire variant - rotator (zero), quat (identity), and color (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 brdb 0.6.3 - The wire variant gained Rotator/Quat/LinearColor members plus matching typed array variants; only WireGraphEnumWrapper remains unmapped.

0.6.0 - 2026-06-30

  • Data regenerated - Gate inventory (285 -> 288: new Convert / FindPlayer gates), the brdb component _max schema (258 structs), and component_db.rs.
  • Sweep upgrade - The raycast Sweep(...) gate gained optional per-channel flags: detectBricks, detectPlayers1detectPlayers4, detectPhysics, detectMap, and ignoreOwningGrid.

0.5.0 - 2026-06-29

Language Features

  • quat type + rotation/quaternion builtins - A quat primitive (distinct from the euler rotator) plus dir.ToRotation(), q.ToDirection(), v.Rotate(q), q.Invert(), from.RotationTo(to), a.AngleTo(b), a.Slerp(b, alpha), axis.RotationByAngle(angle), q.ToAxisAngle(), and Rotation(p, y, r) / r.ToEuler() for the euler rotator.
  • sRGB / hex color builtins - ColorSRGB(r, g, b, a) and ColorHex("#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/fn named 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 nested EntryPlan). 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 .brdb output.

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] infers int[] and lowers to the same array gate as an array declaration; 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::arrays table: completion offers the full set on any array-typed value, return types come from gate output ports. find returns { Index, Found, Value } (auto-unwraps to Index); pop/min/max expose .IsEmpty; insert/swap/slice expose .OutOfBounds.
  • GetAim replaces AimOrigin/AimDirection - A character’s camera/aim is one gate returning char.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 fill CommandName then HelpText in order (or named Description = "..."), 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 or let binding via the SplitVector/SplitColor gates, not just an inline Vec(...).
  • Vec(...) literal arguments - Constant components are no longer dropped to 0 at emit; MakeVector gained 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’s FontSize/Lifetime now resolve to the game defaults (16 / 5) instead of 0.

0.3.0 - 2026-06-27

Language Features

  • String variables - var/static var of type string store in a Variable gate (the WireGraphVariant gained a str member). The WS018 “strings can’t be stored in vars” diagnostic is gone.
  • Native string equality - == / != on strings lower directly to the CompareEqual / CompareNotEqual gates. The contains(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 same MathAdd/Subtract/Multiply/Divide/Modulo gates. The Scale helper still works.
  • Any-variant variables - A var can 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 WireGraphArrayVariant member (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, string ParseInt/ParseNumber, reference gates) and types 86 previously-any ports. 175 -> 260 entries.
  • Refreshed brdb component tables - component_db.rs regenerated from the same dump so the new gates emit; the removed Gamemode_EndRound gate is gone.

New Builtins and Methods

  • Array methods - insert, find, sort(desc?), reverse, sum, min, max, average, swap, fill, resize, append, copyFrom, slice, fillFromPlayers, fillFromTeam join push/pop/length/remove/clear/shuffle. Every ArrayVar gate is reachable.
  • Easing - Easing(a, b, blend, fn?, dir?) and Tween(target, duration, fn?, dir?); function/direction pass as an int or enum-name literal ("Quad", "InOut", …) resolved against the engine’s EBREasingFunction/EBREasingDirection enums.
  • Timer - Timer(limit, restart?, pause?, resume?) returns { Time: float, Expired: exec }; the controls are optional exec inputs and Expired works with on/await.
  • String parsing - ParseInt(s) -> int and ParseNumber(s) -> float (also s.ParseInt() / s.ParseNumber()).
  • Controller - GetUserName, GetUserId, GetDisplayName, IsTrusted, HasPermission, SetCanRespawn, SetTeamPinned.
  • Character - GetDamage, SetDamage, IncDamage, SetTempPermission.
  • Entity - SetFrozen.
  • Gamemode - PlayerWins / TeamWins (replace the removed imperative EndRound gate 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.json with brick bounds from the microchip shell) instead of a world, so the .brz pastes 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 name fires them from any handler; await name or on name listens.
  • 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). Enables await 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 returning brdb::World for .brdb output.
  • CLI .brdb support - just compile file.ws -o file.brdb emits SQLite saves.
  • Compile progress - LSP sends wirescript/compileProgress notifications; VS Code extension shows step counter in status bar.
  • ** (pow) fix - Now wires to Input/Exponent ports instead of InputA/InputB.
  • BRZ double-write fix - Fixed to_brz_vec writing the archive twice (exact 2x file size).

Editor / IDE

  • Inlay type hints - Ctrl+Alt shows inferred types for let/buffer bindings. 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.cpsr and nested field access now show types correctly.
  • on handler hover - Shows gate estimate for the handler scope.
  • if hover 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}.
  • await keyword highlighting - Added to VS Code tmLanguage and Monaco monarch tokenizer.

Playground

  • Inlay hints provider - wirescript_inlay_hints WASM binding + Monaco InlayHintsProvider. Hidden by default, shown on Ctrl+Alt.
  • New async_signals.ws example - Demonstrates emit-value, await, local exec signals, Sleep.

Documentation

  • Updated statements.md with emit-value, local exec signals, await, Sleep/SleepTicks.
  • Updated exec-context.md with await section, _ placeholder, Sleep examples.
  • Updated builtins.md with Sleep/Delay section.
  • Updated expressions.md with operator coercion for all wire variant types.
  • Updated types.md with exec->bool coercion.
  • Removed fn keyword references from docs.
  • just compile-brdb recipe 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 expr syntax - Trigger handlers on arbitrary exec expressions, not just named events
  • Exportable vars/buffers/arrays - var, buffer, and array declarations are now importable across files
  • String var error (WS018) - var s: string now 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: *int for record fields and type declaration fields
  • Spread type validation - Extra fields from spread are caught with errors pointing at the ...expr span
  • Chip/mod context hover - Hovering chip/mod keywords 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
  • type keyword highlighting - VS Code extension highlights user-defined type names

Playground

  • Docs panel refactor - Docs fetched from docs/*.md instead of inline JS (~1900 lines removed from docs.js)
  • Examples loaded from files - Playground examples loaded from sdk/examples/*.ws via fetch instead of hardcoded JS
  • New records.ws example - Demonstrates records, destructuring, spread, and tuples

Bug Fixes

  • Fix branch scoping - variables declared in if/else branches no longer leak across branches
  • Fix string comparison gate using wrong variant
  • Fix inline modules adding extra microchip outputs
  • Fix return expr in pure mods
  • Fix on var.value not lowering handler body
  • Fix emit not chaining union gates for multiple emit paths
  • Fix import not pulling in same-file dependencies of imported declarations
  • Fix array index access requiring exec context
  • Fix array .length() / .pop() returning Any type
  • 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 = 0
  • return expr - Return values from chips and mods
  • Single-output auto-unwrap - chip Foo() -> (result: int) returns int directly instead of {result: int}
  • Block expressions - { stmts; expr } as expressions
  • Compound assignment - +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=
  • ^^ logical XOR operator - a ^^ b is true when exactly one operand is true
  • let type annotations - let x: int = expr
  • Array params are always pass-by-reference - mod init(arr: int[]) passes the array by reference without needing *
  • fn deprecation - fn declarations emit a warning (WS015) suggesting let instead

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 if keywords - 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-check CLI - Standalone type checker binary
  • VS Code extension auto-reload - Extension reloads when the LSP binary changes

Removals

  • ArrayRef type removed - arrays are always references, use int[] everywhere
  • event keyword removed (was already deprecated)