Chips
Chips are Wirescript’s primary mechanism for organizing and reusing wire graph logic. They map to physical microchip bricks in Brickadia – a separate grid of gates contained within a single brick. (A module-level @flat compiles the same program onto one grid instead, with no microchip bricks at all.)
Contents
- Anonymous Chips
- Named Chips
constParameters andconst mod- Open and Closed Chips
- Compiling Without Microchips (
@flat) - Chip Labels and Headers
mod– Inline Chips- Boundary Pins
- Nested Chips
- Inline
modwith Mixed Parameters - Complete Example: Named Chip with Handlers
Anonymous Chips
An anonymous chip creates a physical microchip for visual organization while sharing the parent scope. Variables, buffers, and arrays declared inside an anonymous chip are accessible from outside it (and vice versa).
Basic Anonymous Chip
chip {
var x: int = 0
var y: int = 0
on trigger {
x = x + 1
}
}
// x and y are accessible here because anon chips share parent scope
out xValue = x.Value
Anonymous chips are purely organizational – they group related gates into a microchip brick without creating an isolated scope.
open Modifier
Chips compile open by default now (see Open and Closed Chips
below), so the open keyword is a redundant no-op kept for backward compatibility:
open chip {
var state: int = 0
// ...
}
chip let – Compact Let Bindings
chip let is shorthand for an anonymous chip containing one or more let bindings, separated by commas:
chip let score = c0 + c1 + c2 + c3
chip let doubled = score * 2, halved = score / 2
// Equivalent to:
chip {
let score = c0 + c1 + c2 + c3
}
chip {
let doubled = score * 2
let halved = score / 2
}
This is commonly used to wrap pure computations in their own microchip for layout clarity.
// From the 2048 example -- each chip let gets its own microchip
chip let moved = c0 != c0.prev || c1 != c1.prev || c2 != c2.prev
chip let score = c0 + c1 + c2 + c3 + c4 + c5
chip let emptyCount = (if c0 == 0 then 1 else 0) + (if c1 == 0 then 1 else 0)
chip on – Compact Handler
chip on is shorthand for an anonymous chip containing a single handler:
chip on trigger {
count = count + 1
}
// Equivalent to:
chip {
on trigger {
count = count + 1
}
}
This is the most common pattern for associating a handler with its own microchip:
chip on goLeft {
slide(c0, c1, c2, c3)
slide(c4, c5, c6, c7)
}
chip on goRight {
slide(c3, c2, c1, c0)
slide(c7, c6, c5, c4)
}
Negated and Combined Triggers in chip on
All trigger forms work with chip on:
// Negated trigger
chip on !running {
DisplayText(ctrl, "Game Over", fontSize = 48)
}
// Regular trigger
chip on moved {
// ...
}
Named Chips
Named chips define reusable components with explicit inputs and outputs. They create an isolated scope – the chip body cannot see the parent scope’s variables (except what is passed through parameters).
chip Name(params) -> outputs {
// body
}
Basic Named Chip
chip Counter(bump: exec, reset: exec) -> (value: int, overflow: bool) {
var n: int = 0
on bump {
n = n + 1
}
on reset {
n = 0
}
out value = n.Value
out overflow = n > 255
}
Parameters
Parameters are declared as name: type pairs in parentheses:
chip Adder(a: int, b: int) -> (sum: int) {
out sum = a + b
}
Destructured Parameters
Parameters can destructure record or tuple values directly in the signature, using the same { field, ... }: Type syntax as let destructuring. This is most useful for mod declarations that work with record types:
// Destructured parameters — fields are directly in scope in the body
mod distance({ x, y }: Point) -> int { return x + y }
mod process({ data, ...opts }: Config) { ... }
Each destructured name is bound directly, just as if written let { x, y } = param at the start of the body. The synthetic parameter name (_p0, _p1, …) is generated by the compiler and is not accessible in the body.
Tuple destructuring is also supported:
mod sum((a, b): (int, int)) -> int { return a + b }
Outputs
Outputs are declared after ->. A single output can omit the parentheses — it defaults to the name _:
// Single output (defaults to name "_")
chip Double(x: int) -> int {
out _ = x * 2
}
// As a mod with return
mod Double(x: int) -> int {
return x * 2
}
// Single-output chips auto-unwrap: f is directly an int
let f = Double(21) // f == 42, no .result needed
// Multiple named outputs (still use record access)
chip MinMax(a: int, b: int) -> (min: int, max: int) {
out min = if a < b then a else b
out max = if a > b then a else b
}
let mm = MinMax(3, 7) // mm.min == 3, mm.max == 7
Exec Outputs
Chips can have exec-typed outputs for signaling completion. Use out name: exec in the signature and emit name inside a handler:
chip Counter(bump: exec) -> (value: int, done: exec) {
var n: int = 0
on bump {
n = n + 1
emit done
}
out value = n.Value
}
Using Named Chips
Call named chips like functions. Each call creates a new physical microchip instance with independent internal state:
chip Double(x: int) -> int {
out _ = x * 2
}
let result = Double(21) // result = 42
// Multi-output chips return a record — access fields by name
chip MinMax(a: int, b: int) -> (min: int, max: int) {
out min = if a < b then a else b
out max = if a > b then a else b
}
let mm = MinMax(3, 7)
let lo = mm.min // 3
let hi = mm.max // 7
// Multiple calls create separate instances
let mm1 = MinMax(1, 10)
let mm2 = MinMax(5, 20) // independent from mm1
Note: The
-> (...)outputs declare the chip’s public interface. Useout name = exprin the body to wire values to those outputs. Do not redeclare the same name in both the signature and the body — this produces a warning.
Exec Chips
A chip whose body contains statement-level exec calls (array methods,
DisplayText, emit signal, …) is an exec chip — the body runs
as one exec chain. How it gets its trigger depends on the call site:
- Called inside a handler, the body joins the caller’s exec chain automatically and execution continues after it.
- Called outside an exec context (e.g. a top-level
let), pass the trigger as anexec = ...named argument — the same convention as builtin exec calls likeRandom(0, 10, exec = trigger).
in reset: exec
var vals: int[]
chip InitTables() -> (count: int) {
vals.clear()
vals.push(1)
out count = 1
}
let t = InitTables(exec = reset) // body runs whenever reset fires
A call with exec = also returns the chip’s completion exec as an exec
field on the result record (unless the chip declares its own exec output),
so callers can sequence on the body having run:
on start {
emit reset
await t.exec // resumes after InitTables finished
}
on t.exec { } // or treat it as an event
To bind body work to a specific exec parameter instead, declare the param
and use an on handler — the param does not implicitly invoke the body:
chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
Note: chip bodies reference top-level
vars,arrays, buffers, and record bindings freely — wire refs cross chip boundaries, so the body’s gates connect to the outer nodes directly (valsabove).
Reference Parameters (ref T / *T)
When a chip parameter has type ref T (or equivalently *T), the caller passes a variable by reference. The chip can both read and write the variable.
chip Increment(counter: ref int, amount: int) {
on trigger {
counter = counter + amount
}
}
When a chip has ref parameters, its body is automatically placed in exec context (because ref access implies mutable state operations).
The *T syntax is an alternative shorthand:
// These declarations are equivalent:
chip Swap(a: ref int, b: ref int) { ... }
chip Swap(a: *int, b: *int) { ... }
At call sites, pass variables directly – the compiler handles the reference wiring:
var x: int = 10
var y: int = 20
Swap(x, y) // x and y are passed by reference
const Parameters and const mod
A mod or chip parameter can be marked const, requiring the caller’s
argument to be a compile-time constant. Inside the body the parameter reads
as one – usable anywhere a const binding is (see
const – Compile-Time Binding):
mod ping(channel: const string, v: int) {
SendCustomEvent(channel, v)
}
in go: exec
var hp: int = 0
on go { ping("died", hp) } // "died" bakes as the event's channel name
A non-constant argument in a const slot is WS046:
in name: string
mod ping(channel: const string) { SendCustomEvent(channel) }
on go { ping(name) } // WS046 -- 'name' is a runtime value
v above stays an ordinary wired parameter – a mod/chip can freely mix
const and non-const parameters. A const parameter costs no pin: it
never becomes a MicrochipInput, so it doesn’t shift the pin index of any
parameter that follows it. For a chip (not a mod), each distinct
constant value passed to a const parameter gets its own compiled instance
– two call sites passing the same constant share one instance, since the
body is cached by a template key that includes the const value.
const mod
const mod marks every parameter of an inlined chip const in one step –
equivalent to writing const on each parameter individually:
const mod double(n: int) -> int { return n * 2 }
A call to a const mod is itself const-evaluable (see
What’s const-evaluable), so it can be
used to build a const value out of one or more calls – including calls to
other const mods and reads of module-level constants.
const chip is a parse error – a chip compiles to one shared physical
microchip template reused across call sites, so “every parameter is const”
isn’t a property the declaration can have the way it is for an inlined
mod. Mark the parameters you need constant individually instead:
const chip C(v: int) -> (r: int) { out r = v } // error -- use a mod, or const params
chip C(name: const string, v: int) -> (r: int) { out r = v } // fine
A const BINDING inside a named chip’s body is compile-time throughout that
chip – its handlers, its outs, and its constant-only config slots – the
same as inside a mod. Two places it is not: the body of an anonymous
chip { }, and a chip declared inside another chip, which does not inherit
the enclosing chip’s body constants (pass them in as const parameters). Both
are reported rather than silently dropped; see
Where const is allowed.
A const mod may declare multiple outputs, and its result destructures exactly
as an ordinary multi-output mod’s does. One rule is const-specific: the wire
graph keeps whichever assignment to an output comes FIRST in source order, so a
valued return placed after an out to the same output would disagree with
it, so it is rejected (WS046) rather than shipping a mismatch.
const mod pick(n: const int) -> (r: int) {
out r = 111
if n > 0 { return 222 } // WS046 -- disagrees with the wire graph
}
The mixed-parameter macro pattern
Because const and ordinary parameters mix freely, a mod can behave like a
small macro that both configures a gate at compile time and wires a runtime
value through it in the same call:
mod tagged(label: const string, amount: int) {
SendCustomEvent("evt_" .. label, amount)
}
in go: exec
var score: int = 0
on go {
tagged("score", score) // "evt_score" bakes; score stays a live wire
}
const if and tree-shaking
An if whose condition is const-evaluable is resolved at compile time: the
taken branch is checked and lowered normally, and the untaken branch is
dropped before type-checking ever looks at it.
const MODE = 1
var x: int = 0
in go: exec
on go {
if MODE == 1 {
x = 1
} else {
x = someModeTwoOnlyFunction() // never type-checked -- MODE is always 1
}
}
This is what lets one mod/chip serve call sites that share no common API:
a branch that wouldn’t even compile for a given constant value is simply
never checked for it. The editor’s hover on a dropped block explains why it
was dropped (e.g. `MODE == 1` is true here).
This does not work for a const parameter. A mod/chip body is
type-checked exactly once, before any call site exists – there is no real
argument yet to decide the branch with. A const parameter is seeded with a
type-shaped placeholder during that one-time check, and the placeholder is
deliberately never allowed to decide a branch: letting it choose would ship a
branch the placeholder happened to select while the OTHER branch – the one a
real call actually takes – goes unchecked. So both branches of an if on
a const parameter (or a const derived from one) are type-checked, and
each must be independently valid code, even though only one of them lowers
per call site:
var x: int = 0
mod f(mode: const int) {
if mode == 1 {
x = 1
} else {
x = someOtherModesFunction() // IS type-checked, even if every call site passes mode = 1
}
}
Both arms must compile for every possible constant value, not just the ones
any call site actually passes. A top-level const – or one read from inside
a mod body that is not itself a parameter – does not have this restriction;
only the parameter itself, and anything derived from it, carries the
placeholder.
Open and Closed Chips
Non-root chips compile open by default: each chip’s inner grid renders
as an upright plane facing the same side of the chip as its @bottom
rerouter pins, stacked in a wall above the placed microchip brick. The
root module’s plane sits at the bottom, its bottom edge just above the
brick; directly-nested chips occupy a row above it; deeper nesting stacks
higher still. Rows are centred and packed side by side, ordered first by
parent position, then by source order within a parent. Within a plane the
dataflow axis runs bottom-to-top: input gates sit at the bottom edge,
outputs at the top.
Annotate a chip with @closed to collapse it instead. A closed chip still
reserves its slot in the wall, so opening it later in-game reveals it in
place:
@closed chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
@closed chip {
var scratch: int = 0
}
@closed works on every chip form – chip Name(...), chip { ... },
chip on, and chip let. open chip { ... } still parses, but is now a
redundant no-op; combining @closed with open on the same chip is an
error.
Compiling Without Microchips (@flat)
A module-level @flat at the top of the entry file inlines every chip body
into the module that instantiates it. The program then emits no microchip
bricks and no nested grids – every gate lands on one grid, and a wire that
would have crossed a chip wall becomes an ordinary same-grid wire instead of a
pair of wires through a boundary pin.
Nothing about the program’s behavior changes. A chip is not a scoping or timing boundary, so a flattened program computes exactly what the nested one did; what goes away is the tree of planes to open and the per-crossing pin.
Because the microchip brick and its plane no longer exist, @closed and
@label have nothing to describe under @flat. They are inert rather than an
error, so a file can be compiled both ways without editing its chips.
See Statements for @flat’s placement rules and how it
composes with @layout(...).
Chip Labels and Headers
@label("text") overrides the display text on a chip’s shell-brick label
and its plane header:
@label("Score Tracker") chip {
var score: int = 0
}
@label also applies to in/out declarations at any nesting level, and
stacks with a @left/@right/@top/@bottom side annotation in either
order – see Outer Rerouter Pins.
There it overrides the port’s floating display label (and its rerouter pin
label); the port’s wiring-UI name is unaffected either way.
The @label argument may also be an expression: a constant folds to baked
text, and on a top-level var a runtime expression becomes a dynamic
label that shows the value live – see
Expression labels. A chip
label must be a constant (its shell brick has no wired text component).
Each opened plane with a title or doc comment gets a header at its top
edge: the title rendered as <size="96">...</>, with the chip’s /// doc
comment on the line below. The title is the chip’s @label text if
present, otherwise its name (the root chip’s title is the module name); an
anonymous, undocumented chip gets no header at all. Header text is passed
through raw, so rich-text tags inside a name or doc comment render as rich
text:
/// Counts ticks forever.
chip Counter(bump: exec) -> (value: int) {
var n: int = 0
on bump { n = n + 1 }
out value = n.Value
}
mod declarations always inline into their callers and never become a
physical chip, so any annotation – @closed, @label, or a side pin –
on a mod is an error.
mod – Inline Chips
The mod keyword declares a chip that is always inlined at call sites. Instead of creating a separate microchip brick, the mod’s gates are expanded directly into the caller’s grid.
mod Name(params) {
// body -- no output
}
mod Name(params) -> (result: type) {
// body -- single output, use `return expr` to set it
}
Mod Outputs and return value
Mods can declare a single output with -> (name: type). Use return expr to set the output and exit:
mod clamp(v: int, lo: int, hi: int) -> (result: int) {
if v < lo { return lo }
if v > hi { return hi }
return v
}
let clamped = clamp(x, 0, 255) // clamped is directly an int
Note: A single
return exprwires the value directly (pure, zero-tick). Multiplereturn exprstatements cause the compiler to insert a hidden variable — each branch does aVar_Set, and aVar_Getafter the return union reads the result. This adds exec gates but is correct within the same tick’s exec chain.
When to Use mod
Use mod when:
- You want to reuse logic without the overhead of a physical microchip
- The logic is small and called many times
- You need the expanded gates visible in the parent grid
mod slide(a: *int, b: *int, c: *int, d: *int) {
if a == 0 { a = b; b = c; c = d; d = 0 }
if b == 0 { b = c; c = d; d = 0 }
if c == 0 { c = d; d = 0 }
if a == b { a = a + b; b = 0 }
if b == c { b = b + c; c = 0 }
if c == d { c = c + d; d = 0 }
}
Variadic Parameters (...rest)
A mod (only a mod, never a chip) can end its parameter list with a ...rest
variadic that captures every argument past the fixed parameters. Because a mod is
inlined at each call site, rest is a compile-time tuple of the actual extra
arguments – there is no runtime variadic. A ...rest in the body then splats that
tuple onward into another call, one element per positional slot:
mod broadcast(name: const string, ...rest) {
SendGlobalCustomEvent(name, ...rest)
}
in amount: int
in kind: float
in go: exec
on go {
broadcast("hit", amount, kind) // -> SendGlobalCustomEvent("hit", amount, kind)
}
The fixed parameters are still required (passing fewer is an arity error); only the
trailing count is open. The same ...tuple splat works on any call – spread a bound
tuple, a tuple literal, or a multi-output result into consecutive arguments:
mod add3(a: int, b: int, c: int) -> (r: int) { return a + b + c }
in n: int
let pair = (2, 3)
out total = add3(n, ...pair) // -> add3(n, 2, 3)
Because the body is type-checked once (at the declaration, where the captured arity
isn’t known yet), a ...rest forward is not statically arity-checked against a
fixed-arity target – it is validated when the mod inlines at each call. Forwarding
into SendCustomEvent/SendGlobalCustomEvent (whose data slots accept any type) is
the common case and always sound.
mod vs chip
| Feature | chip | mod |
|---|---|---|
| Physical microchip | Yes | No (inlined) |
| Isolated scope | Yes | Expanded into caller |
Outputs with -> | Yes (multi) | Yes (single, with return expr) |
ref/* params | Yes | Yes |
| Reusable | Yes (separate instance per call) | Yes (expanded per call) |
mod declarations do not support output declarations with ->. Instead, their effect is through mutating ref parameters.
Boundary Pins
Every wire that crosses a chip wall – a value, an exec trigger, or a
variable reference alike – routes through a labeled MicrochipInput or
MicrochipOutput rerouter at that wall, one pin per external source, even
if the source isn’t a declared parameter. Multiple consumers inside the
chip fan out from the same pin rather than each getting their own. A
constant argument is the one exception – it’s inlined directly into the
gate that uses it rather than wired in, so it never costs a pin.
Nested Chips
Chips can be nested inside handler bodies and other chip bodies:
on trigger {
chip {
// This anonymous chip is inside the handler
let localCalc = a + b
// ...
}
chip {
// Another chip in the same handler
if condition { x = localCalc }
}
}
Nested anonymous chips still share their enclosing scope. Named chip declarations inside blocks create locally-scoped chip types.
// From the 2048 example -- deeply nested chips
if running && moved {
chip {
chip {
let b0 = 0
let b1 = b0 + if c0 == 0 then 1 else 0
// ...
}
let r = Random(0, emptyCount - 1)
chip {
let p0 = c0 == 0 && r == b0
// ...
}
chip {
if p0 { c0 = 2 }
// ...
}
}
}
Inline mod with Mixed Parameters
A mod can take both reference and value parameters. Value parameters are pure inputs; reference parameters allow mutation:
mod renderCell(c: *int, tid: *int, p: bool, px: float, py: float) {
let t = if p || c == 0 || c == c.prev then 0.0 else 0.15
let bucket = if c <= 4 then 0
else if c <= 16 then 1
else if c <= 64 then 2
else 3
let col = Fmt('{' .. bucket .. '}', 'eee4da', 'f2b179', 'f65e3b', 'edcf72')
let txt = '<color="${col}">${c}</>'
DisplayText(ctrl, txt, positionX = px, positionY = py, fontSize = 40, transition = t, textId = tid)
}
Complete Example: Named Chip with Handlers
chip Timer(tick: exec, reset: exec, limit: int) -> (value: int, expired: bool) {
var elapsed: int = 0
on tick {
if elapsed < limit {
elapsed = elapsed + 1
}
}
on reset {
elapsed = 0
}
out value = elapsed.Value
out expired = elapsed >= limit
}