Wirescript Language Reference
Wirescript is a high-level language that compiles to Brickadia’s logic gates. It replaces manual gate-by-gate wiring with a readable, imperative syntax while keeping the gates’ own execution model.
Table of Contents
- Syntax: Language syntax reference: declarations, statements, blocks, statement terminators, comments, and doc comments.
- Types: The type system: primitives (
int,float,bool,string,entity,controller,character,vector,rotator,color,exec), compound types (ref T,T[],Map<K, V>, tuples, unions, records, enums), and type coercion rules. - Expressions: Operators (arithmetic, comparison, logical, bitwise, string concatenation), operator precedence, string interpolation, conditional expressions, field access, index access, and function calls.
- Statements:
var,let,buffer, arrays, maps,in,out,if,match/if let/let else,on(handlers),event,emit, assignment, and expression statements; plus the module-level annotations a file opens with (@fold/@nofold,@layout("code")/@layout("cube"),@flat). - Builtin Functions: All built-in functions grouped by category: math/trig, vector, entity, controller/character, display, gamemode, raycasting, random, string formatting, and color.
- Chips: Anonymous chips (
chip {}),chip let,chip on, named chips with parameters,mod(inline expansion),ref/*params, nested chips, open and closed chips, and compiling without microchips (@flat). - Execution Context: Pure vs exec context, what requires exec, handler exec chains, exec unions after handlers, and explicit exec parameters.
- Enums: Tagged unions: declaring
enumvariants (unit, positional, named payload), construction,.Discriminant,match,if let/let else, generic enums, and the built-inOption/Result. - Best Practices: Gate count and scaling: why every call site is a copy (for
modandchipalike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state. - Constant Folding: Compile-time evaluation of pure gates with constant inputs, guarded by an in-game-certified semantics table; fold barriers; the certification story and reproducibility guarantees.
- Testing: Writing a program that checks itself in game: the
on Loaded()trigger, acheckmod over reference counters, staying silent unless something fails, making the failure line diagnostic, comparing two paths rather than one path against a constant, and what an in-game run cannot prove. - Game Knowledge: Brickadia behavior the language does not define but programs depend on: rich text markup, the fonts the game ships, input action and axis glyphs, and every action name.
- Diagnostics: Every
WSxxxdiagnostic code the compiler emits, grouped by category (context, names, types, calls, generics, config, …), with a one-line meaning and trigger for each. - Upgrading: Breaking changes and how to migrate existing
.wscode across versions; links the fullCHANGELOG.md.
Quick Example
// A simple counter that increments on each round start
var count: int = 0
on RoundStart() {
count = count + 1
}
out total = count
How It Works
Wirescript compiles down to Brickadia’s logic gates and the wires between them. 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.
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.
Writing correct Wirescript depends on this distinction. See Execution Context for details.
Learn
Learn about programming and logic in Brickadia at your own speed.
Already program? Start with Wirescript for Programmers. Want ready-made circuits instead? See Parts.
Basics
- Tools
- First Circuit
- Gates and Wires
- Pure and Exec
- Remembering Values
- Reacting to the Game
- Is It Working?
- Wirescript for Programmers (after Tools)
State
Values
- Numbers (after Remembering Values)
- True and False
- Vectors and Colors
- Converting Values
- Lists (after Reacting to the Game)
- Maps
Logic
- Truth Tables (after Gates and Wires)
- Choosing a Value
- Toggles and Latches (after Remembering Values)
- Bits (after Choosing a Value)
- Code Door
Strings
- Text Values (after Remembering Values)
- Taking Text Apart (after Reacting to the Game)
- Rich Text (after Text Values)
- Safe Text (after Taking Text Apart)
- Matching Text (after Taking Text Apart, Choosing a Value)
Templates
- Interpolation (after Remembering Values)
- Fmt
- Number Options
- Word Forms
- Templates Chosen at Run Time (after Reacting to the Game)
- Score Card (after Rich Text)
Events
Abstraction
- Mods (after Reacting to the Game)
- Chips
- Functions
- Imports
- Counting the Cost
- Presentation (after Chips)
Data Shapes
- Records (after Reacting to the Game, Lists)
- Records in Storage
- Tuples (after Mods)
- Destructuring
Enums
- Enums (after Records)
- Payloads
- Option and Result (after Mods)
- Enums from Pins
- Game Enums
Loops
- Counting Loops (after Reacting to the Game, Lists)
- Looping Over a List
- Looping Over a Map (after Maps)
- Index and Value
- Loops Across Ticks (after Waiting)
Random
- Dice (after Reacting to the Game)
- Random Colors (after Vectors and Colors)
- Shuffling (after Index and Value)
- Weighted Loot
Input
- Seat Controls (after Reacting to the Game)
- Remote Control (after Choosing a Value)
- Ready Check (after Looping Over a List, Maps, Choosing a Value, Waiting)
References
- Object References (after Reacting to the Game, True and False)
- Ref Parameters (after Mods, Lists)
- Ref Values
- Per-Player State (after Maps)
Prefabs
- Spawning a Prefab (after Reacting to the Game, Custom Events)
- Saved Builds
- Prefab Files
- Talking to a Copy
- Lifecycle and Storage
- Nested Prefabs
- One Chip per Player (after Looping Over a Map, Seat Controls)
Admin
- Admin Gates (after Is It Working?) (needs admin to load)
- World Control (after Forking and Merging) (needs admin to load)
- Teams (after Shuffling) (needs admin to load)
- Persistent Players (after Admin Gates, Object References, Looping Over a List) (needs admin to load)
- Player Control (needs admin to load)
- Chat Commands (after Admin Gates, Taking Text Apart, Safe Text) (needs admin to load)
- Every Player (after Looping Over a Map, Persistent Players, Mods) (needs admin to load)
- Moderation (after Player Control) (needs admin to load)
Mastery
- Testing (after Mods, Looping Over a List)
- Const and Folding (after Counting the Cost)
- Performance (after One Chip per Player)
- Multiplayer Architecture (after One Chip per Player, Per-Player State, Custom Events)
- Writing Libraries (after Imports)
Space
- Distance and Direction (after Vectors and Colors, Reacting to the Game)
- Dot and Cross
- Angles
- Rotations (after Looping Over a List)
Motion
- Blending Values (after Numbers, Vectors and Colors)
- Easing (after Reacting to the Game)
- Over Time (after Seat Controls)
Screen
- Messages to One Player (after Reacting to the Game)
- Text on Screen (after Rich Text)
- Keeping It Current (after Object References)
- A HUD for Everyone (after Per-Player State, Looping Over a Map, Index and Value)
Tools
Next steps: First Circuit, Wirescript for Programmers
Write Wirescript in the playground or in VS Code, compile it to a .brz
save, and paste the save in-game.
Contents
Writing and compiling
The playground runs in your browser with nothing to install. Write a
file, press Compile (or Ctrl+Shift+B), and it downloads a .brz
save. Errors show as red underlines as you type.
VS Code needs the
Brickadia Wirescript extension:
install it from that page, or search for “Brickadia Wirescript” in VS Code’s
Extensions view. Then open a .ws file. You get errors as you type,
completions, hover, go to definition, rename, formatting, and these docs
inside the editor. Ctrl+Shift+B compiles the open file and puts the
.brz on the clipboard, ready to paste into Brickadia.
The command line comes with the
Wirescript SDK, also the SDK button in the
playground’s toolbar. Its tools need Node.js 18 or later:
node check.mjs door.ws checks a file, and node compile.mjs door.ws writes
door.brz.
Every example in these lessons has a Compile button too, next to the copy button. It downloads that example as a save, so you can try it without writing anything.
The docs editor is the Editor tab at the bottom right of every docs page. It opens the playground’s editor in a drawer, with the same errors, completions and hover, plus Compile and Playground buttons. The pencil button on an example loads that example into it, so you can change it and compile the result. It does not save your work: it holds one piece of text, and loading another example replaces it. Write anything you want to keep in the playground or VS Code.
Pasting a circuit in-game
A compiled .brz is an ordinary Brickadia save holding one microchip brick.
Paste it in-game, either through Brickadia’s load dialog by the file’s path,
or straight from the clipboard after VS Code’s Ctrl+Shift+B, and place it
where you want it.
The microchip is the circuit. Its pins are small bricks on its sides, each labeled with its name. You wire them to the rest of your build with the wire tool, like any other component.
The starter library
Many examples begin with import { escape } from "sanitize". That line
brings in helpers from sanitize.ws, a small library that comes with these
lessons. An example’s Compile button and the docs editor fetch it for you.
To build those examples in the playground or VS Code, save
sanitize.ws in the same folder as your own file. This is
the whole file:
// Escape markup in a display name before showing it. A `mod`, so each use is a single gate.
mod escape(text: string) -> string {
return text.Replace("<", "<")
}
// Clean free-form player input: trim, cap the length, then escape.
// Capping first keeps the cut from landing inside an `<`.
fn sanitize(raw: string, maxLen: int = 24) -> string {
return raw.Trim().Substring(0, maxLen).Replace("<", "<")
}
escape makes a name safe to show, and sanitize also trims it and cuts it
to a length. Safe Text explains both, and
Imports explains import.
Code layout
The compiler normally arranges a chip’s gates to keep its wires short, with
no link to where the code sits in the file. To see each line of code as a row
of gates when you open the chip, put @layout("code") at the top of the file,
followed by a blank line:
@layout("code")
@left in a: bool
@left in b: bool
let both = a && b
@right out lit = both
Earlier lines sit higher, and indentation shows in the gates. This helps while
you learn to read compiled chips. See
@layout("code")
for the details.
Reading an error
When a file has a mistake, the compiler stops and says where. This pin uses a name the language keeps for itself:
@left in on: bool
Read the first line of the error:
Error: [WSP001] expected Ident, got 'on' (Kw)
It names the problem (on is a keyword, Kw) and gives the line and column.
More errors usually follow, all caused by the first one. Fix the first and
compile again.
Some reserved words make tempting pin names: in, out, on, from,
type, match, buffer, emit, fn, mod, const, static, await
and chip. Use another word (trigger, door, go).
Every error code is listed in Diagnostics.
Next: First Circuit.
First Circuit
Before this: Tools
Next steps: Gates and Wires
Your first circuit is a chip with one pin in and one pin out, and nothing between them but a wire. You will wire a switch to one side and a light to the other.
Contents
switch Enabled -> chip a
chip b -> light Enabled
Code
@left in a: bool
@right out b = a
@left in a: bool puts a pin named a on the chip’s left side. It takes a
bool: on or off. @right out b = a puts a pin named b on the right side
and gives it whatever arrives at a.
What does the light do when you flip the switch?
Run
- Compile the example and paste it in-game. The chip has a pin
labeled
aon its left and one labeledbon its right. - Place a Switch to the left of the chip and a Point Light to the right.
- Wire the Switch’s Enabled output to pin
a. - Wire pin
bto the Point Light’s Enabled input. - Flip the switch. The light turns on and off with it.
Under the Hood
Open the chip. Inside there is only a wire from a to b. The pins on the
chip’s sides are rerouters: @left and @right each add one. Leave the side
annotations off and the chip has no gates at all. The source decides which
pins a compiled chip has and what connects them.
The rerouters are why the side annotations exist. Your wires end on them, not on the chip, so they work as a socket: to replace the chip, break it and paste the new version over the same rerouters, and the wires stay as they were.
Modify
-
Replace the Switch with a Button, wired from its Held output. Now the light is on only while you hold the button.
-
Give the output a label of its own:
@label("Light") @right out b = a. The pin’s floating label now reads “Light”.@left in a: bool @label("Light") @right out b = a -
Flip the light: change the output to
b = !a.!is NOT, so the light is now on while the switch is off, and off while it is on.@left in a: bool @right out b = !aCompile it, break the old chip and paste the new one over its rerouters. The Switch and the light stay wired. Open the chip again: the wire now passes through one NOT gate.
Make
Make a chip with two inputs and two outputs that passes each input straight across to its own output, then wire two switches to two lights through it.
switch1 Enabled -> chip a
switch2 Enabled -> chip b
chip outA -> light1 Enabled
chip outB -> light2 Enabled
Next: Gates and Wires.
Gates and Wires
Before this: First Circuit
Next steps: Pure and Exec, Truth Tables
A gate is a brick that computes something from its inputs, like AND or addition. Wirescript writes gates as expressions: every operator becomes one gate, and every name you use becomes a wire to the gate that made it.
Contents
switch1 Enabled -> chip a
switch2 Enabled -> chip b
chip both -> light1 Enabled
chip either -> light2 Enabled
Code
@left in a: bool
@left in b: bool
@right out both = a && b
@right out either = a || b
&& is AND: true when both sides are. || is OR: true when either is. With
two switches wired to a and b, and two lights on both and either,
which lights are on for each of the four switch positions?
Run
- Compile the chip and paste it in-game. Place two Switches and two Point Lights.
- Wire each Switch’s Enabled to
aandb, andbothandeitherto each light’s Enabled. - Try all four positions.
bothlights only with both switches on.eitherlights with any switch on.
Under the Hood
Open the chip. It holds one AND gate and one OR gate, each wired from both pins, and nothing else.
The same circuit built by hand is two gate bricks and six wires. Wirescript writes the bricks and wires for you, and the source stays readable as the circuit grows.
let gives a value a name without adding a gate. The name is a wire you can
use again:
@left in a: bool
@left in b: bool
let both = a && b
@right out lit = both
@right out dark = !both
! is NOT. This chip has two gates (AND and NOT): both names the AND
gate’s output, and both outputs read it.
Use it: a two-way hall light
A hallway light with a switch at each end should change whenever either
switch is flipped. That is exclusive OR, written ^^: true when exactly one
side is true.
@left in near: bool
@left in far: bool
@right out light = near ^^ far
Wire a Switch at each end to near and far, and light to a Point
Light’s Enabled. Flipping either switch changes the light.
Make
Make a chip with three switch inputs whose light turns on when at least two of them are on.
switch1 Enabled -> chip a
switch2 Enabled -> chip b
switch3 Enabled -> chip c
chip lit -> light Enabled
Next: Pure and Exec.
Pure and Exec
Before this: Gates and Wires
Next steps: Remembering Values
Wires carry two kinds of signal. Telling them apart is the most important idea in this series.
- A pure wire carries a value that is always there, like the switch’s on/off. A gate on a pure wire recomputes whenever its inputs change. Every circuit so far has been pure.
- An exec wire carries a pulse: a moment when something happens, like a button being pressed. An exec pulse runs a chain of steps, one after another, and each step can change something.
Think of a light switch and a doorbell. The switch is always either up or down (pure). The doorbell rings at a moment, and the ring makes something happen (exec).
Contents
button Held -> chip pressed
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Code
@left in pressed: exec
var shut: bool = true
@right out solid = shut.Value
on pressed {
shut = !shut
}
pressed is an exec pin: an input whose job is to start something.
var shut is a variable: a value the circuit remembers and can change.
on pressed { ... } is a handler. It runs its steps once each time pressed
fires, and only then. shut = !shut flips the variable. shut.Value
reads the variable as a pure value, so solid follows it.
Wire a Button to pressed. What happens on the first press? The second?
What happens while you hold the button down?
Run
- Compile the chip and paste it in-game. Build a door from a few bricks and give one of them a Brick Property Changer.
- Wire a Button’s Held output to
pressed. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility inputs. One output can feed both. - Press the button: the door vanishes. Press again: it is back. Holding the
button does nothing more, because the handler runs once per press, when
pressedturns on.
Under the Hood
Open the chip. The variable is a Variable gate. The handler is a short chain:
a Get Variable reads shut, a NOT flips it, and a Set Variable stores it.
The chain’s exec input is wired from pressed. Held is an on/off value,
not a pulse, but wiring it into an exec pin works: the chain runs when Held
turns on, not while it stays on. solid is wired from the Variable gate’s
value, a pure wire.
on fires when its trigger turns on. on !pressed { ... } fires when it
turns off. That reads pressed as an on/off value, so declare the pin bool
for it: an exec pin stands for the moment something happens, not for a value
that stays on or off.
Modify: a door that closes by itself
@left in pressed: exec
var shut: bool = true
@right out solid = shut.Value
on pressed {
shut = false
await Sleep(_, delay = 3.0)
shut = true
}
await Sleep(_, delay = 3.0) pauses this chain for three seconds, then
carries on. Treat the line as a “wait” block for now: the _ and the other
forms of await come in a later lesson.
Press the button: the door opens, and three seconds later it shuts. Change the delay. What happens if you press again while it is open?
Make
Make a door that opens while the button is held and closes when it is
released, using on pressed and on !pressed. Declare the pin
@left in pressed: bool for this, since !pressed reads its value. Then
compare it with
@right out solid = !pressed, which does the same with one pure gate and no
handler. Reach for a handler when something has to be remembered or waited
for.
button Held -> chip pressed
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Good to Know
- Declare a pin
execwhen it only starts handlers, andboolwhen the circuit also reads its value (!pressed,if pressed,out x = pressed). - A handler on a pin runs once when the wired value turns on, not continuously while it stays on.
- A Button’s Held is on only while someone holds it. A Switch’s Enabled stays on until it is flipped back.
Next: Remembering Values.
Remembering Values
Before this: Pure and Exec
Next steps: Reacting to the Game, Variables, Numbers, Toggles and Latches, Text Values, Interpolation
A var keeps its value until a handler changes it. The value stays through
other presses and other players’ actions, and when the world is saved and
loaded again.
Contents
button Held -> chip pressed
chip text -> decal Text
Code
@left in pressed: exec
var count: int = 0
@right out text = "Pressed ${count.Value} times"
on pressed {
count += 1
}
var count: int = 0 is a whole number that starts at 0. count += 1 adds one
to it. "Pressed ${count.Value} times" builds text with the count in the
middle. String Interpolation has
the details.
What does the sign say after three presses?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
pressed, andtextto the Text Decal’s Text input. - Press the button a few times. The sign counts.
Under the Hood: let and var
A let is a name for a wire. It holds nothing. Its value is whatever its
gates compute right now. A var is a Variable gate that holds a value until a
handler writes a new one.
There are two ways to read a var:
count.Value, outside a handler, is a pure wire from the Variable gate. After a handler writes it, this wire shows the new value on the next tick, the game’s next update.count, inside a handler, reads the variable at that step of the chain. A step after a write sees the new value.
@left in pressed: exec
var count: int = 0
var last: int = 0
@right out shown = last.Value
on pressed {
count += 1
last = count
}
Inside the handler, last = count runs after count += 1, so it copies the
new count.
A var can hold other kinds of value too. var shown: string = "" holds
text, and a handler can write a whole message into it: shown = "Welcome".
Use it: a press-count sign
The first example is already a useful part: a visitor counter for a door, or a score for a button game.
Make
Make a sign that counts presses of one button and resets to 0 when a second button is pressed.
button1 Held -> chip pressed
button2 Held -> chip reset
chip text -> decal Text
Next: Reacting to the Game.
Reacting to the Game
Before this: Remembering Values
Next steps: Is It Working?, Lists, Taking Text Apart, Templates Chosen at Run Time, Exec Pins, Mods, Records, Counting Loops, Dice, Seat Controls, Object References, Spawning a Prefab, Distance and Direction, Easing, Messages to One Player
Parts that use this: Toggle Light, Zone Chime, Drawbridge, Clock Sign, Pressure Pad Door, Launch Pad
Handlers so far ran when a pin turned on. The game itself also starts
handlers: a clock that ticks, the chip loading, a player walking into a zone. This lesson also
adds if, constants and the arithmetic shortcuts.
Contents
A clock
const BLINK_SECONDS = 0.5
var lit: bool = false
@right out light = lit.Value
on Clock(interval = BLINK_SECONDS) {
lit = !lit
}
on Clock(interval = ...) runs its handler every interval seconds, forever.
const BLINK_SECONDS = 0.5 names a fixed value. Change it in one place to
change the speed.
Wire light to a Point Light’s Enabled. It is on for half a second,
then off for half a second.
When it loads
var loads: int = 0
@right out sign = "Loads: ${loads.Value}"
on Loaded() {
loads += 1
}
on Loaded() runs its handler once when the chip is pasted, and once more
each time the world loads with it in. Wire sign to the Text of a
brick with a Text Decal: it reads “Loads: 1” as soon as you paste it. A
variable keeps its value when the world is saved, so after the next load it
reads “Loads: 2”.
Use on Loaded() to set a circuit up: fill a list, send a first message,
start a sign at the right text.
Someone walks in
import { escape } from "sanitize"
@left in room: zone
on ZoneEntered(zone = room) -> (character) {
character.ShowChatMessage("Hello, ${escape(character.GetDisplayName())}!")
}
@left in room: zone is a pin for a zone. Give a brick an Attached Zone
and wire its Zone Ref output to room. on ZoneEntered(zone = room) runs
when a player walks into it, and -> (character) names the player who did.
ShowChatMessage sends a message only that player sees.
A display name is chosen by the player and can contain <, which text
displays read as the start of formatting. import { escape } from "sanitize"
brings in one helper from the lessons’ starter library, sanitize.ws, which
makes a name safe to show. The Compile button fetches the library with the
example; to build it in the playground or VS Code, save
the starter library beside your file. Use it
whenever a circuit shows text a player chose.
Deciding with if
@left in pressed: exec
var count: int = 0
@right out text = "Count: ${count.Value}"
on pressed {
if count >= 9 {
count = 0
} else {
count += 1
}
}
if runs one block or the other. This counter goes up to 9, then back to 0.
>= compares, as do ==, <, >, <= and !=.
Shortcuts
count += 1 is count = count + 1. The same works for -=, *= and /=.
Good to Know
Clockstarts when the chip loads and keeps going. Give itenabled = someBoolto turn it on and off. Its first pulse comes oneintervalafter it starts, not at once.ZoneEnteredandZoneLeftare right for reacting to one player, like this greeting. Over a long game a few can be missed, so do not keep a list or a count of who is inside from them. To know who is in a zone, ask the zone itself withfillFromZonePlayers(Ready Check), on a Clock when it must stay current.- Some events and actions only an admin may use: a chip containing them is blocked when someone without admin rights pastes it. Chat commands, the list of every player, broadcasts to everyone, and the world’s weather and time of day are among them. Lessons that need them are marked “needs admin to load” in the tree.
Make
Make a light that blinks only while a switch is on (on Clock(interval = 0.5, enabled = ...)), and a door that opens for three seconds whenever someone
walks into a zone in front of it.
switch Enabled -> chip blinking
chip light -> light Enabled
zone "Zone Ref" -> chip room
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Next: Is It Working?.
Is It Working?
Before this: Reacting to the Game
Next steps: Admin Gates
When a circuit does nothing, the compiler usually cannot tell you why. The wires are valid but do not do what you meant. This lesson is about seeing inside a running circuit.
Contents
Show the values
Add a debug output that turns the circuit’s state into text, and wire it to a Text Decal. To see each time a handler runs, send a chat line from it:
const NEEDED = 3
@left in pressed: bool
var count: int = 0
@right out lit = count.Value >= NEEDED
@right out debug = "pressed: ${pressed} count: ${count.Value} lit: ${lit}"
on pressed {
count += 1
BroadcastChatMessage("button pressed, count is now ${count}")
}
The sign shows what the circuit sees, live, and the chat line appears each time the handler runs. If no line appears when you press, the handler’s trigger is the problem, not its steps. Remove both once the circuit works.
BroadcastChatMessage sends to everyone in the world, and only an admin can
load a chip that uses it. Use it on your own server while you debug, and take
it out before you share the circuit.
Silent failures to check first
- A pin is not wired. An unwired input reads as zero, false or empty. If a handler never runs, check its trigger pin’s wire first.
- A wire goes to the wrong port. Components have many inputs. The light’s Enabled and Cast Shadows are both bools.
- Reading a list past its end gives the last value read, not zero, so a wrong index can look almost right.
- A handler that waits forever. A chain paused at an
awaitthat never resumes runs the steps before it and never the ones after. The circuit looks half working.
Testing alone
Most circuits can be tested by one player: buttons, switches, zones and clocks all work with only you in the world. When a circuit is for several players, test each piece alone first, then together.
Make
Take the circuit from Remembering Values and add a debug pin that shows the count and whether the last press was the button’s first.
button Held -> chip pressed
chip text -> decal1 Text
chip debug -> decal2 Text
Next: pick a lesson from the tree on the Learn page.
Wirescript for Programmers
Before this: Tools
If you already program, most of Wirescript reads like a small typed language. This page lists what works differently, then gives a short test for every lesson so you can skip the ones you already understand.
Contents
What is different
- The program is a circuit. A compiled file is one microchip of gates
and wires, and every gate evaluates every tick, 60 times a second. There is
no
mainand no instruction pointer:@right out lit = a && bis an AND gate that is always computing. - Two contexts, pure and exec. Pure expressions are wiring: they follow
their inputs. Exec code lives in handlers and runs as a chain of steps, in
order. Writes, array reads,
Randomandforare steps, so they only exist in a handler. See Pure and Exec. letis a wire,varis storage. Aletnames a gate’s output and costs nothing. Avaris a Var gate that only handlers write. Everyvarsurvives a world save and load. Nothing resets unless you build a reset.- Two ways to read a
var.count.Valueis a pure wire holding the value as of the start of this tick. Barecountinside a handler reads it at that step, so it sees a write made earlier in the same chain. - Handlers fire on edges. A pin that only starts handlers is declared
exec(@left in pressed: exec). Wire a bool output such as a Button’s Held into it andon pressed { ... }runs once when Held turns on. Holding a button is one press.on !pressedruns when it turns off. It reads the value, so declarepressed: boolfor that. The game starts handlers too:on Clock(interval = 0.5),on ZoneEntered(zone = room) -> (character). - Handlers have no order between them. Two
onroots that fire in the same tick, a Clock included, run in no fixed order. Order exists only inside one chain. - No short-circuiting. Both sides of
&&and||are wires into one gate, so both are always computed. In a handler, a call with side effects on the right still runs when the left already decides: guard it with a nestedif. - Time is explicit.
await Sleep(_, delay = 1.0)pauses a chain, andawait hitwaits for a pulse (one that came earlier is dropped).buffer before = xisxone tick ago, and a loop of pure wires needs one. - No loops in pure code.
for i in 0..n { }is a step. It compiles to a loop plane, a chip of its own that runs every iteration within the current tick, about 100,000 iterations per tick at most. Work spread over many ticks is abuffer emitand anawait. modinlines,fncalls. A mod’s body is copied into every call: 50 gates called from 10 places is 500 gates. Afnis one callable plane with a Function Call gate per call. It reads a pin or aletonly as a parameter (WS113) and cannot return a record or an enum (WS114).- Failures are silent numbers. Divide or remainder by zero gives 0. An
unwired input reads 0, false or empty. A read past the end of an array
gives that gate’s previous value, not 0: check
.OutOfBounds, and.Foundfor a map key. - Players are keyed by user id. A death makes a new character, so
per-player state lives in a
Map<string, T>keyed byGetUserId(). - Some gates need admin. Broadcasts, chat commands, player join, leave, spawn and death events, and environment writes only load for an admin. The rest of the chip loads without them.
This chip uses most of the list:
@left in pressed: exec
@left in reset: exec
var count: int = 0
var squares: int[]
var total: int = 0
var big: int = 0
buffer before: int = count.Value
@right out sign = "count ${count.Value}, a tick ago ${before}, sum ${total.Value}, ${big.Value} over 10"
fn square(n: int) -> int {
return n * n
}
on pressed {
count += 1
squares.push(square(count))
total = squares.sum()
var n: int = 0
for s in squares {
if s > 10 {
n += 1
}
}
big = n
await Sleep(_, delay = 1.0)
count += 1
}
on reset {
count = 0
total = 0
big = 0
squares.clear()
}
squares.sum() is one gate. A for runs its body once per element, so keep
it for work no single array method does, like the count here. An array read
in pure code is an error, not a wire:
var squares: int[]
@left in i: int
@right out pick = squares[i]
Test out
Answer the questions for a lesson without looking. If you get them right, skip the lesson. Open the answers to check.
Basics
- Tools: why does
@left in on: boolfail to compile? - First Circuit: how many gates does
@right out b = amake? - Gates and Wires:
let both = a && b, then two outputsbothand!both. How many gates? - Pure and Exec: what does
on pressedfire on, level or edge? How many times while the button is held? - Remembering Values: in
on pressed { count += 1; last = count }, doeslastget the old count or the new one? - Reacting to the Game: run a handler every half second, and greet the player who walks into a zone.
- Is It Working?: name three reasons a circuit can do nothing with no error.
Answers
onis a keyword (WSP001). Pick another name.- One: the rerouter that puts
bon the chip’s right side. Without@rightit makes none, just a wire fromatob. - Two, an AND and a NOT. A
letadds no gate. - The rising edge: once per press, however long it is held.
- The new one. A bare name in a handler reads the variable at that step.
on Clock(interval = 0.5) { ... }, andon ZoneEntered(zone = room) -> (character) { ... }with azonepin.- An unwired pin, a wire to the wrong port, a stale array read, a chain
parked at an
awaitthat never resumes.
Branches
- State. Reading a Variable: in a handler,
right after
count += 1, what doescount.Valueread? Buffers: build a set/reset latch from pure wires. Variables, Resetting: what does avarhold after the world is saved and loaded? - Values. Numbers: what is
7 / 0? Lists: what doesxs[5]give on a three-element array? Maps: how do you tell a missing key from one that holds 0? Converting Values: what type doesround(2.6)return? - Logic. Choosing a Value: what does a pure
if c then x else ycompile to, and does a divide by zero in the arm not taken do harm? Toggles and Latches: a button that turns a light on and off. - Strings. Text Values: is
"0"true or false? Safe Text: why escape a display name? - Templates. Interpolation: what does
"${true}"print? Word Forms: print a bool as “Yes” or “No”. - Events. Custom Events: what does the
receiver get for a constant argument? Waiting: what
happens to a pulse that arrives before its
await? - Abstraction. Mods and
Functions: when is a
fnsmaller than amod? Imports: what happens to a library’sonhandlers underimport { name } from "lib"? - Data Shapes. Records in Storage: how many
gates does a
varholding{ x: int, y: int }cost? - Enums. Enums from Pins: what does a
matchgive for an int that names no variant? Option and Result: read the value out of anOption. - Loops. Counting Loops: how long does a
1,000-iteration
fortake? Loops Across Ticks: a back-edge loop sits inside anif. Where must itslet x: execsignal be declared? - Random. Dice: can
Random(1, 6)give 6? How do you use one roll in two places? - Input. Seat Controls: does
on input.Forward > 0.5run every tick the key is held? Remote Control: how does a circuit read the keys of a player who is not sitting anywhere? - References. Per-Player State: why not key a score map by character? Ref Values: what happens when a handler reads through an empty ref?
- Prefabs. Lifecycle and Storage: do a spawned copy and its spawner share variables? Talking to a Copy: can a copy’s handlers run before the spawner’s first event arrives?
- Admin. Admin Gates: what happens when a player without admin rights pastes a chip with one admin gate in a handler?
Answers
- State. The old value:
.Valueis as of the start of the tick, so read barecount. A latch:let state = set || (held && !reset)withbuffer held: bool = state. After a load, the value it had: build a reset. - Values. 0. The value that read gate gave last time, so check
.OutOfBounds. Afloat. Addas int. - Logic. A Select gate. Both arms compute, and the divide gives 0, so no
harm.
on pressed { lit = !lit }. - Strings. True: only
""is false. A name can contain<, which starts a tag and restyles your text. - Templates.
1.Fmt("{A:Yes=No}", flag). - Events. It is not wired, so it can arrive as 0 or empty: send a
var. It is dropped, not saved. - Abstraction. When it is called from several places and its body costs
more than calling it. A one-line body is cheaper as a
mod. They are left out (WS014): useimport "lib"to keep them. - Data Shapes. Two: one Var gate per field.
- Enums. The default: 0, or empty text. Keep the number in range with
%.if let Some(v) = r { ... }, orlet Some(v) = r else { return }. - Loops. One tick. Inside that same
if. Declared outside it, the loop runs once and stops, and the compiler warnsWS118at theawait. - Random. Yes, both ends are included. Roll once into a
letor avar. - Input. No, once when it turns on. Wire a Switch’s Character into
InputReader: the Switch keeps the last player who flipped it, and the splitter follows their keys live.GetInputsreads keys only at the step that runs it, so keep it for one-time reads, not for watching keys. - References. A respawn makes a new character, so the score starts over. The chain stops there, silently.
- Prefabs. No: each copy has its own storage, and values travel by events. Yes: guard on a value the first event sets.
- Admin. The rest of the chip loads. That handler’s chain stops at the missing gate.
Where to go next
- Syntax, Types and Statements: the language in full.
- Execution Context: pure and exec in detail.
- Best Practices: gate counts and how programs scale.
- Parts: ready-made circuits to paste and read.
- The Learn tree, for any lesson you did not test out of.
Variables
Before this: Remembering Values
Next steps: Reading a Variable
The Remembering Values lesson used one var to count presses. This lesson
covers what a variable can hold, how it differs from a let, and how long it
remembers.
Contents
button Held -> chip toggle
chip text -> decal Text
chip lamp -> light Enabled
Code
@left in toggle: exec
var isOpen: bool = false
var opened: int = 0
let word = if isOpen.Value then "OPEN" else "CLOSED"
@right out text = "${word} (opened ${opened.Value} times)"
@right out lamp = isOpen.Value
on toggle {
isOpen = !isOpen
if isOpen {
opened += 1
}
}
A shop sign with a button behind the counter. There are two variables, each
with a type and a starting value: isOpen is a bool that starts false,
and opened is an int (a whole number) that starts at 0.
word is a let. if isOpen.Value then "OPEN" else "CLOSED" picks one of
two values. Unlike the if block in a handler, it runs no steps: it is a gate
that always shows one value or the other, and it changes the moment isOpen
does. A let holds nothing of its own.
Press the button three times, then save the world and load it again. What does the sign say?
Run
- Compile the chip and paste it in-game. Place a Button, a brick with a Text Decal, and a Point Light.
- Wire the Button’s Held to
toggle,textto the Text Decal’s Text, andlampto the Point Light’s Enabled. - Press the button. The sign reads “OPEN (opened 1 times)” and the light comes on. Press again: “CLOSED”, light off, and the count stays at 1.
- Save the world and load it. The sign and the light come back exactly as you left them.
Under the Hood
Open the chip. Each var is a Variable gate (the game names it Var), with
its starting value written into the gate. The let is a Select gate wired
from isOpen’s Variable gate. It picks a value and stores nothing.
The handler is a chain: a Get Variable reads isOpen, a NOT flips it, a Set
Variable stores it, a second Get Variable reads it for the if, and a Branch
picks the path. opened += 1 is a single Increment Variable gate.
The Variable gates are part of the chip’s bricks, so their values are saved with the world and come back when it loads.
A variable can hold any one kind of value: bool, int, float (a number
with a fraction), string (text), and more that later lessons cover. Leave
out the starting value and it starts at that type’s zero: var count: int
starts at 0, var label: string starts empty.
Modify
-
Change
var isOpen: bool = falsetotrue. A freshly pasted copy now starts open, with the light on. -
Add a variable declared inside the handler:
@left in pressed: exec var kept: int = 0 var lastLocal: int = 0 @right out text = "kept ${kept.Value}, local ${lastLocal.Value}" on pressed { var local: int = 0 local += 1 kept += 1 lastLocal = local }Press a few times.
keptclimbs, butlocalis always 1. Avarinside a handler is set back to its starting value every time the handler runs. In the chip, that is an extra Set Variable at the start of the chain. Avarat the top of the file keeps its value between runs. -
Count closings too: add
var closed: int = 0, add one to it in anelsebranch of theif, and show both counts on the sign.
Make
Make a lamp with two buttons, On and Off. A variable remembers whether the lamp is on, the light follows it, and a sign shows how many times it has been switched on. Pressing On while it is already on should not add to the count. Save and load the world to check that it remembers.
button1 Held -> chip turnOn
button2 Held -> chip turnOff
chip lamp -> light Enabled
chip text -> decal Text
Good to Know
- Every
varat the top of a file is stored in the chip’s bricks, so a world save and load brings each value back as it was. Nothing starts over by itself. For a fresh start, the circuit needs a way to set its variables back. - You may meet
static varin the reference. It keeps its value inside amod. At the top of a file a plainvaralready does, so these lessons never need it.
Next: Reading a Variable.
Reading a Variable
There are two ways to read a variable, and they can give different answers in the same moment. This lesson shows when each one sees a new value.
Contents
button Held -> chip pressed
chip text -> decal Text
Code
@left in pressed: exec
var count: int = 0
var before: int = 0
var after: int = 0
@right out text = "before ${before.Value}, after ${after.Value}, now ${count.Value}"
on pressed {
before = count
count += 1
after = count
}
Inside the handler, the bare name count reads the variable at that step of
the chain. before = count runs before the add, and after = count runs
after it. Outside the handler, count.Value is a pure wire from the Variable
gate.
What does the sign say after the first press? After the second?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
pressed, andtextto the Text Decal’s Text. - Press once. The sign reads “before 0, after 1, now 1”. Each press moves all three along by one.
Under the Hood
Open the chip. The handler’s chain has two Get Variable gates for count: one
before the Increment Variable and one after it. Each Get Variable reads the
variable when the chain reaches it, so the second one sees the new value.
The text has no Get Variable at all. Its Format String gate is wired straight from the Variable gates’ value outputs.
The game updates every wire about 60 times a second. Each update is a tick. A
handler’s steps all run inside one tick, one after another. Pure gates, and
the .Value wires they read, settle once per tick, so while a handler runs
they still show what the variable held when the tick began. The sign shows the
new count one tick after the press, far too soon to notice.
Modify
-
Swap the first two lines of the handler so
count += 1comes first. Nowbeforeandafteralways match. -
Build some text from
count.Valuein alet, and copy it in the handler:@left in pressed: exec var count: int = 0 var copied: string = "" let label = "count is ${count.Value}" @right out text = copied.Value on pressed { count += 1 copied = label }Press once: the sign says “count is 0”.
labelis a pure wire, and at the moment the handler copies it, it has not caught up with the add yet. Each press shows the count from before that press. -
Fix it by building the text inside the handler, where the bare name reads the new value:
@left in pressed: exec var count: int = 0 var copied: string = "" @right out text = copied.Value on pressed { count += 1 copied = "count is ${count}" }When the text only shows the count, it needs no copy at all:
@right out text = "count is ${count.Value}"builds it outside the handler, with no extra variable, and follows each press a tick later.
Make
Make a dice sign. Each press of a button rolls a new number with
Random(1, 6), which gives a whole number from 1 to 6 and, like a variable
write, runs as a step inside a handler. The sign shows this roll and the one
before it: “now 4, before 2”. Think about which line has to come first.
button Held -> chip roll
chip text -> decal Text
Good to Know
- Outside a handler,
count.Valueis the value as of the start of this tick. You will also see a barecountused outside a handler: it is the same read. These lessons write.Valueso the delay is visible in the code. - Inside a handler,
count.Valueis still that same pure wire, so after a write it shows the old value. Read the bare name,count, instead. - Text built in a
letand used by a handler that has just written one of its variables shows the value from before the write. It looks like a display that lags one press behind.
Next: Buffers.
Buffers
Before this: Reading a Variable
Next steps: Resetting
A buffer passes a value on one tick late. With that delay a circuit can feed
its own output back into itself, so memory can be built from gates alone, with
no variable and no handler.
Contents
button1 Held -> chip set
button2 Held -> chip reset
chip alarm -> light Enabled
Code
@left in set: bool
@left in reset: bool
let state = set || (held && !reset)
buffer held: bool = state
@right out alarm = state
This is a latch: set turns it on, reset turns it off, and it remembers in
between.
buffer held: bool = state makes held whatever state was one tick ago.
The line above already uses held, before it is declared: that is the loop.
state is on while set is pressed, or while it was on a tick ago and
reset is not pressed.
Walk through it. Press set: state turns on. A tick later held is on, so
state stays on after you let go. Press reset: state turns off, and a tick
later so does held.
What happens if you hold both buttons at once?
Run
- Compile the chip and paste it in-game. Place two Buttons and a Point Light.
- Wire one Button’s Held to
set, the other’s Held toreset, andalarmto the Point Light’s Enabled. - Press
set: the light comes on and stays on. Pressreset: it goes off and stays off. Hold both:setwins, and the light is on.
Under the Hood
Open the chip. There is an OR, an AND, a NOT and one Buffer (Ticks) gate set to wait one tick. Follow the wires: OR, into the buffer, into the AND, and back into the OR. The wires make a loop, and the buffer sits in it.
Every loop of wires needs a buffer somewhere in it. The game works out all pure gates together each tick, and a loop without a delay would need its own answer before it could work out its answer. The compiler refuses one. Here an output pin feeds itself:
@left in set: bool
@left in reset: bool
@right out alarm: bool = set || (alarm && !reset)
Error: [WS005] cycle in the wire graph has no barrier (a Buffer, Queue or EdgeDetector must break it); 3 gate(s) in the loop:
The error goes on to list each gate in the loop. Put a buffer anywhere in it
and the loop is legal.
The : bool on the buffer matters. state uses held before the compiler
reaches the buffer’s line, so without a type it does not yet know what held
holds:
@left in set: bool
@left in reset: bool
let state = set || (held && !reset)
buffer held = state
@right out alarm = state
Error: [WS004] no overload for '&&' on any, bool
The message is about &&, but the fix is on the next line: give the buffer
its type.
Modify
-
Make
resetwin when both are held:let state = !reset && (set || held). -
A buffer can count ticks. This one adds one to its own value every tick:
buffer tick: int = tick + 1 @right out text = "Ticks: ${tick}"Wire
textto a Text Decal’s Text. It climbs by about 60 every second. -
Turn the latch into a burglar alarm. Give a brick an Attached Zone and wire its Occupied output to
setinstead of the first button. The light comes on when someone walks in and stays on after they leave, until the reset button is pressed.
Make
Make a quiz buzzer for two players. Each player has a button and a light. The
first to press lights their own light, and the other player’s button then does
nothing until a third button resets both. Use one latch per player, and let
each latch’s set be blocked by the other latch.
button1 Held -> chip buzz1
button2 Held -> chip buzz2
button3 Held -> chip reset
chip lit1 -> light1 Enabled
chip lit2 -> light2 Enabled
Good to Know
- A buffer delays by one tick, about a sixtieth of a second, so the delay itself is too short to see.
onalready runs once when a button goes down, not while it is held. You do not need a buffer to catch the moment of a press.- A buffer read before its declaration needs a type. Without one the compiler
reports
WS004, pointing at whatever operator first uses the buffer.
Next: Resetting.
Resetting
Before this: Buffers
Variables keep their values through presses, players and world loads. Nothing starts over by itself, so most circuits with state need a reset: one input that puts every variable back where it began.
Contents
button1 Held -> chip red
button2 Held -> chip blue
button3 Held -> chip reset
chip text -> decal Text
Code
@left in red: exec
@left in blue: exec
@left in reset: exec
var redScore: int = 0
var blueScore: int = 0
var lastPoint: string = "none"
@right out text = "Red ${redScore.Value}, Blue ${blueScore.Value} (last: ${lastPoint.Value})"
on red {
redScore += 1
lastPoint = "red"
}
on blue {
blueScore += 1
lastPoint = "blue"
}
on reset {
redScore = 0
blueScore = 0
lastPoint = "none"
}
A scoreboard for two teams. Each team’s button adds a point and records who
scored last. The reset handler writes every variable back to the value it
was declared with.
After a few points, what does the sign say right after a reset?
Run
- Compile the chip and paste it in-game. Place three Buttons and a brick with a Text Decal.
- Wire the Buttons’ Held outputs to
red,blueandreset, andtextto the Text Decal’s Text. - Score a few points for each team. Press reset: the sign reads “Red 0, Blue 0 (last: none)”, the same as when it was pasted.
Under the Hood
Open the chip. There are three chains, one per handler. The reset chain is
three Set Variable gates in a row, each writing a constant, wired from the
reset pin.
When you add a variable to a circuit, add it to the reset handler too. A variable left out keeps its old value through every reset, which can look like a bug somewhere else.
Use it: a reset that passes on
A bigger build has several chips, each with its own state. Instead of running a wire from one reset button to every chip, each chip can pass the reset on to the next once it has finished its own. This is a daisy chain.
@left in point: exec
@left in reset: bool
var score: int = 0
var passing: bool = false
@right out text = "Score: ${score.Value}"
@right out resetNext = passing.Value
on point {
score += 1
}
on reset {
score = 0
passing = true
}
on !reset {
passing = false
}
resetNext turns on after this chip’s reset steps have run, and turns off
when the reset button is let go. on !reset runs when reset turns off.
It reads the pin’s value, so reset is a bool in this chip.
Wired into the next chip’s reset, that rising resetNext starts the next
chip’s reset, and so on down the line.
- Paste the chip three times. Give each copy its own Button on
pointand its own Text Decal ontext. - Wire a Button’s Held to the first copy’s
reset. Wire the first copy’sresetNextto the second copy’sreset, and the second’sresetNextto the third’sreset. - Score a few points on each, then press the reset button. All three signs go back to 0, each one just after the one before it.
Modify
-
Wire an Attached Zone’s Occupied output to
resetinstead of the button. The scores clear whenever someone steps into the zone. -
Keep a best score that survives a reset. Add
var best: int = 0, set it whenever a team’s score passes it, show it on the sign, and leave it out of the reset handler. -
Flash a light for two seconds after each reset. Add
var flash: bool = falseand@right out light = flash.Value, then end the reset handler with:flash = true await Sleep(_, delay = 2.0) flash = false
Make
Make a round counter chip with @left in reset: exec that counts how many
times it has been reset, and shows “Round 3” on a sign. Put it at the end of a
chain of two scoreboards, so one button clears the scores and starts a new
round.
circuit1 "Scoreboard"
circuit2 "Scoreboard"
chip "Round"
button1 Held -> circuit1 point
button2 Held -> circuit2 point
button3 Held -> circuit1 reset
circuit1 resetNext -> circuit2 reset
circuit2 resetNext -> chip reset
circuit1 text -> decal1 Text
circuit2 text -> decal2 Text
chip text -> decal3 Text
Good to Know
- A world save and load keeps every variable, so a circuit starts over only when its reset runs.
- A Button’s Held stays on for as long as it is held, but
on resetruns once per press. Holding the reset button does not keep resetting.
Next: pick a lesson from the tree on the Learn page.
Numbers
Before this: Remembering Values
Next steps: True and False, Blending Values
Wirescript has two kinds of number. An int is a whole number. A float is a
number that can have a fraction, like 2.5. Both are 64-bit, which means an
int can go up to 9,223,372,036,854,775,807, and a float can hold very large
and very small numbers. This lesson is about what happens when you divide them.
Contents
button1 Held -> chip addPizza
button2 Held -> chip addPerson
chip text -> decal Text
Code
@left in addPizza: exec
@left in addPerson: exec
var pizzas: int = 0
var people: int = 0
let slices = pizzas.Value * 8
let each = slices / people.Value
let spare = slices % people.Value
@right out text = "${slices} slices, ${people.Value} people: ${each} each, ${spare} spare"
on addPizza {
pizzas += 1
}
on addPerson {
people += 1
}
Each pizza has 8 slices, shared between everyone. * multiplies. /
divides, and an int divided by an int gives an int: the remainder is
dropped, so 7 / 2 is 3. % gives the remainder that / dropped, so 7 % 2
is 1.
With 2 pizzas and 3 people, how many slices does each person get, and how many are spare? What does the sign say with 1 pizza and nobody there yet?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire the Buttons’ Held outputs to
addPizzaandaddPerson, andtextto the Text Decal’s Text. - Add one pizza before anyone arrives. The sign reads “8 slices, 0 people: 0 each, 0 spare”. Dividing by zero gives 0, not an error.
- Add 3 people and another pizza: “16 slices, 3 people: 5 each, 1 spare”.
Under the Hood
Open the chip. There is one Multiply, one Divide and one Modulo gate, all
pure, wired from the two Variable gates. Divide and Modulo both take slices
and people, so they always agree with each other.
A number written with a point, like 8.0, is a float. Without one, like
8, it is an int. When one side of +, -, *, / or % is a float,
the result is a float and keeps its fraction: 7.0 / 2 is 3.5.
Modify
-
Show the exact share in pizzas.
as floatturns a value into afloatbefore the divide:@left in addPizza: exec @left in addPerson: exec var pizzas: int = 0 var people: int = 0 let share = pizzas.Value as float / people.Value @right out text = "Each person gets ${share} pizzas" on addPizza { pizzas += 1 } on addPerson { people += 1 }asapplies before the/, so this is(pizzas as float) / people. With 2 pizzas and 4 people, each person now gets half a pizza instead of 0. -
Order enough pizza. Everyone wants 3 slices, and pizzas come whole. Show how many to buy:
(people.Value * 3 + 7) / 8. Adding 7 before dividing by 8 rounds up instead of down. Try it with 1, 3 and 8 people. -
Take a square root of a number that goes negative:
@left in down: exec var level: float = 4.0 @right out text = "sqrt(${level.Value}) = ${sqrt(level.Value)}" on down { level -= 2.0 }The first press shows the root of 2. The second shows the root of 0. The third asks for the root of -2, which has no answer as a real number: the sign shows 0.
Make
Make a stopwatch. While a Switch is on, a Clock adds one to an int
every tenth of a second (on Clock(interval = 0.1, enabled = running)). The
sign shows the time as seconds and tenths, like “12.3 s”, using / and %
on the int. A Button sets it back to 0.
switch Enabled -> chip running
button Held -> chip reset
chip text -> decal Text
Good to Know
- Dividing by zero, or taking the remainder by zero, gives 0.
- A result that is not a real number, such as the square root of a negative
number, or one too large for a
float, is read as 0 by the gate it goes into.
Next: True and False.
True and False
Before this: Numbers
Next steps: Vectors and Colors, Object References
A bool is the simplest value: true or false. Switches and buttons give
bools, lights take them, and every comparison makes one. This lesson also
shows what happens when a bool meets a number.
Contents
switch1 Enabled -> chip a
switch2 Enabled -> chip b
switch3 Enabled -> chip c
chip text -> decal Text
chip passed -> light Enabled
Code
@left in a: bool
@left in b: bool
@left in c: bool
let votes = a + b + c
@right out text = "${votes} of 3 say yes"
@right out passed = votes >= 2
Each of three judges has a switch. When a bool is used in arithmetic, true
counts as 1 and false as 0, so a + b + c is the number of switches that
are on.
votes >= 2 compares two numbers and gives a bool: true when there are at
least two yes votes. The other comparisons work the same way: == (equal),
!= (not equal), <, >, <= and >=.
With two switches on, what does the sign say, and is the light on?
Run
- Compile the chip and paste it in-game. Place three Switches, a brick with a Text Decal, and a Point Light.
- Wire each Switch’s Enabled to
a,bandc. Wiretextto the Text Decal’s Text andpassedto the Point Light’s Enabled. - Flip the switches. The sign counts the yes votes, and the light is on with two or three of them.
Under the Hood
Open the chip. Two Add gates sum the three pins, and a Greater/Equal gate compares the total with 2. No gate turns the bools into numbers first: the Add gate takes them as they are.
A comparison is a gate like any other, and its bool output can go anywhere a
bool can: a light, an if, a variable, or another operator such as &&.
Modify
-
Show each vote on the sign:
"${a} ${b} ${c}: ${votes} of 3". A bool in text shows astrueorfalse. -
Give judge
atwo votes:let votes = a * 2 + b + c. Nowaand one other judge are enough, butbandcalone are not. -
Combine comparisons on a stored number. This chip counts presses and its light is on only for even counts above 5:
@left in pressed: exec var count: int = 0 let isEven = count.Value % 2 == 0 let big = count.Value > 5 @right out text = "${count.Value}: even ${isEven}, big ${big}" @right out lamp = isEven && big on pressed { count += 1 }count.Value % 2 == 0reads as “the remainder after dividing by 2 is 0”.%comes before==, as*comes before+.
Make
Make a “two-key” vault door: two switches far apart must both be on, and the door opens only if the number of presses on a third button is exactly 3. Wire the result to a Brick Property Changer’s Collision Enabled and Visibility so the door vanishes when it opens.
switch1 Enabled -> chip key1
switch2 Enabled -> chip key2
button Held -> chip pressed
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Good to Know
- A number used where a bool is wanted is
truewhen it is not zero, so-1as a condition is true. - But comparing a bool with a number compares it as exactly 1 or 0:
1 == trueis true, and-1 == trueis false. a == bon two bools is true when they match, both on or both off: the opposite of^^.
Next: Vectors and Colors.
Vectors and Colors
Before this: True and False
Next steps: Converting Values, Random Colors, Distance and Direction, Blending Values
Parts that use this: Launch Pad
Some values are made of several numbers. A color is four: red, green, blue
and alpha. A vector is three, a point or a direction: x, y and z. A
rotator is three angles: pitch, yaw and roll. This lesson builds them, takes
them apart, and puts a color on a light.
Contents
switch1 Enabled -> chip red
switch2 Enabled -> chip green
switch3 Enabled -> chip blue
chip lamp -> light Color
chip lit -> light Enabled
Code
@left in red: bool
@left in green: bool
@left in blue: bool
@right out lamp = Color(red, green, blue)
@right out lit = red || green || blue
Color(r, g, b) makes a color from three numbers, each from 0 (none) to 1
(full). A fourth number, alpha, is optional. Here each channel comes from a
switch: a bool is 1 when on and 0 when off, so each switch turns one channel
fully on or off.
With the red and green switches on and blue off, what color is the light?
Run
- Compile the chip and paste it in-game. Place three Switches and a Point Light. Turn off the light’s Use Brick Color setting: while it is on, the light takes the brick’s color and ignores the wire.
- Wire each Switch’s Enabled to
red,greenandblue. Wirelampto the Point Light’s Color andlitto its Enabled. - Try the switches. Red and green together make yellow, all three make white, and with all three off the light is off.
Under the Hood
Open the chip. Color(...) is one Make Color gate with the three pins
wired into its R, G and B inputs. lit is two OR gates.
Taking values apart
Each part of a value has a name: .r .g .b .a on a color, .x .y .z
on a vector, and .pitch .yaw .roll on a rotator. Each reads one float.
@left in pressed: exec
var spot: vector = Vec(0.0, 0.0, 0.0)
let step = Vec(1.0, 2.0, 0.5)
@right out text = "${spot.Value as string}, height ${spot.Value.z}"
on pressed {
spot = spot + step
}
Vec(x, y, z) makes a vector. + on two vectors adds them part by part, so
each press moves spot by 1 along x, 2 along y and 0.5 along z.
spot.Value as string writes the whole vector as text, X=1.000 Y=2.000 Z=0.500 after the first press, and spot.Value.z reads just the height.
Reading a part is a Split Vector gate (or Split Color, Split Rotation).
Reading several parts of the same value the same way (.x, .y, .z)
shares one split gate.
Modify
-
Add a
@left in dim: boolpin and dim the light while it is on:@right out lamp = if dim then Color(red, green, blue) * 0.25 else Color(red, green, blue). A number times a color multiplies every channel. -
Pick a random color on each press.
Randomon two colors picks each channel at random between them:@left in pressed: exec var shade: color = Color(1.0, 1.0, 1.0) @right out lamp = shade.Value @right out text = shade.Value.ToHex() on pressed { shade = Random(Color(0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0)) }Like a variable write,
Randomruns as a step in a handler..ToHex()turns a color into text, so a sign can show which color was picked. -
Turn a rotator one eighth of a circle per press.
Rotation(pitch, yaw, roll)takes angles in degrees:@left in pressed: exec var steps: int = 0 let facing = Rotation(0.0, steps.Value * 45.0, 0.0) @right out aim = facing @right out text = "yaw ${facing.yaw}" on pressed { steps = (steps + 1) % 8 }A Spot Light has a Rotation input that takes a rotator. Wire
aimto it, andtextto a Text Decal to watch the yaw count up by 45.
Make
Make a light that steps from blue to red. Each press moves it one step of
five, and the sixth press starts again at blue. Keep a step count with %,
and mix the two colors with arithmetic:
Color(1.0, 0.0, 0.0) * t + Color(0.0, 0.0, 1.0) * (1.0 - t), where t is a
float that goes from 0.0 (blue) to 1.0 (red).
button Held -> chip pressed
chip lamp -> light Color
Good to Know
- Color channels run from 0 to 1, not 0 to 255.
ColorSRGB(255, 128, 0, 255)builds a color from 0 to 255 values instead. Randomon two colors picks all four channels at random, alpha included.- Asking a value for a part it does not have, such as
.zon a color, is a compile error, never a silent zero.
Next: Converting Values.
Converting Values
Before this: Vectors and Colors
Next steps: Lists
Values often need to change type: a whole number into a float so a divide
keeps its fraction, a float back into a whole number, a number into text.
Some conversions happen by themselves. For the rest, as asks for one.
Contents
- Code
- Run
- Under the Hood
- Conversions that happen by themselves
- What
asconverts any- Modify
- Make
- Good to Know
button Held -> chip roll
chip text -> decal Text
Code
@left in roll: exec
var total: int = 0
var rolls: int = 0
var last: int = 0
let average = total.Value as float / rolls.Value
let nearest = round(average) as int
@right out text = "Rolled ${last.Value}. Average ${average}, about ${nearest}"
on roll {
last = Random(1, 6)
total += last
rolls += 1
}
Each press rolls a die. Random(1, 6) gives a whole number from 1 to 6, and
runs as a step in the handler like a variable write. The handler adds the
roll to total and counts it in rolls.
value as T converts a value to type T. total.Value as float turns the
total into a float before the divide, so the average keeps its fraction.
round(average) rounds to the nearest whole number, but its answer is still a
float. as int makes it an int.
After rolling a 3 and then a 4, the average shows its fraction. What would it
show without as float?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
roll, andtextto the Text Decal’s Text. - Press a few times. The average moves toward 3.5 as the rolls add up, and
aboutshows the nearest whole number.
Under the Hood
Open the chip. Each as is one Convert gate: one before the Divide, one
after the Round. The text needs no Convert of its own: ${} builds the text
with a Format String gate, which writes any value it is given.
Conversions that happen by themselves
boolto a number.trueis 1 andfalseis 0, as in True and False.intandfloat. Mixing them in arithmetic gives afloat, as in Numbers.- Anything into text inside
${}. - Text to
bool, where a bool is wanted: text istruewhen it is not empty. The compiler checks!= ""for you.
Each press shows or clears a message, and the light is on while there is one.
The chip has a Not Equal gate between the message and lamp, comparing
it with empty text.
What as converts
bool,int,floatandstringinto one another.- A
floatto anintdrops the fraction toward zero:2.7 as intis 2, and-2.7 as intis -2. Useroundfirst to get the nearest instead. - A number to a
boolistruewhen it is not zero. - Text to a number reads the number at its start:
"42abc" as intis 42, and"abc" as intis 0. - A
vector,rotatororcolorinto text:X=1.000 Y=2.000 Z=3.000for a vector.
A conversion the game cannot make, such as a vector into a number, would only
give the target type’s empty value, so the compiler warns (WS109) instead.
The full list is in Type Conversion.
any
any is a type that stands for every type. A pin declared @left in probe: any accepts whatever is wired to it, and probe as string shows it as text:
@left in probe: any
@right out text = probe as string
any throws the type away, so the compiler can no longer check what you do
with the value. A variable can hold any too, and keeps whatever kind of value
was stored last. To use it as a number again, ask for its type with
getType(). Inside the if, the compiler knows held is an int:
@left in probe: any
@left in grab: exec
var held: any = 0
var total: int = 0
on grab {
held = probe
if held.getType() is Type.Int {
total = total + held
}
}
@right out sum: int = total
Use a real type whenever you know it. The reference covers the rest in
any Type.
Modify
- Remove
as floatfromaverage. After a 3 and a 4 it shows 3: the divide is now between twoints and drops the remainder. - Count sixes. Add
var sixes: int = 0, add one whenlast == 6, and show the share of sixes as a whole percent: `(sixes.Value as float / rolls.Value- 100.0) as int`.
- Light a lamp while the average is above 3.5:
@right out lucky = average > 3.5.
Make
Make a fuel gauge. Each press of a button adds 7 liters to a 40-liter tank, but the tank never goes past 40. A sign shows “21 of 40 liters (52%)”, with the percent as a whole number, and a light is on while the tank is at least three quarters full. A second button empties it.
button1 Held -> chip fill
button2 Held -> chip empty
chip text -> decal Text
chip full -> light Enabled
Good to Know
round,floor,ceilandsqrtgive back afloat, even when the answer is whole. Addas intwhen you need anint.min,max,absandclampgive back the type they are given: anintstays anint.- Text that does not start with a number converts to 0, with no error.
Next: Lists.
Lists
Before this: Converting Values, Reacting to the Game
Next steps: Maps, Records, Counting Loops, Ref Parameters
Parts that use this: Combination Lock, Color-Cycling Light
A variable holds one value. A list holds many: one value per slot, in order.
The slots are numbered from 0, so the first value is in slot 0, the second in
slot 1, and so on. In code a list is called an array. It is declared with
var and keeps its contents between presses.
Contents
button1 Held -> chip roll
button2 Held -> chip step
chip text -> decal Text
Code
@left in roll: exec
@left in step: exec
var rolls: int[]
var count: int = 0
var index: int = 0
var shown: int = 0
@right out text = "${count.Value} rolls. Roll ${index.Value + 1} was ${shown.Value}"
on roll {
rolls.push(Random(1, 6))
count = rolls.length()
}
on step {
index = (index + 1) % count
shown = rolls[index]
}
var rolls: int[] is an array of ints. The [] after the type makes it a
list. It starts empty. rolls.push(...) adds a value to the end, and
rolls.length() says how many there are.
rolls[index] reads one element. The first is at index 0, the second at 1,
and so on. Each press of step moves index on by one, and % count wraps it
back to 0 after the last roll.
Array reads, pushes and lengths are steps, so they all happen inside handlers. The sign reads from ordinary variables that the handlers fill in.
Roll three times, then press step four times. Which roll does the sign show?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire the Buttons’ Held outputs to
rollandstep, andtextto the Text Decal’s Text. - Roll a few times, then step through them. The sign walks through the rolls in order and wraps around.
Under the Hood
Open the chip. The array is one Array Var gate. The roll chain is a
Random, a Push To Array and a Get Array Length. The step chain ends
in a Get Array Element. Every one of them has an exec input, because the
game has no pure gate that reads an array.
So the compiler refuses an array read outside a handler:
var rolls: int[] = [4, 2, 6]
@right out first = rolls[0]
Error: [WS007] array index read 'rolls[...]' outside an exec context
= [4, 2, 6] makes the array start with those values. It works only for
values known when you compile.
Reading past the end
Press step before any roll. There is no element at index 0 yet, but the
read does not fail. A read past the end of an array gives back the
previous value that gate read, not 0, so a wrong index can look like real
data.
Each read also reports .OutOfBounds, which is true when the index was not
in the list.
@left in roll: exec
@left in step: exec
var rolls: int[]
var index: int = 0
var shown: int = 0
var missing: bool = false
@right out text = "slot ${index.Value}: ${shown.Value} (missing: ${missing.Value})"
on roll {
rolls.push(Random(1, 6))
}
on step {
index += 1
let item = rolls[index]
shown = item
missing = item.OutOfBounds
}
Roll twice, then step. Slot 1 is real. Step again: slot 2 says missing, and still shows slot 1’s roll.
Modify
-
Show the biggest roll so far. In the
rollhandler, addbest = rolls.max().Valuewith a newvar best: int = 0, and show it on the sign.max()is one gate however long the list is. Like an element read, it gives.Valueand a second part,.IsEmpty. -
Add a third button that clears the list:
rolls.clear(), then setcountandindexback to 0. -
Keep only the last five rolls. After the push, drop the oldest one when there are too many:
if rolls.length() > 5 { rolls.remove(0) }remove(0)takes out slot 0, and every later value moves down a slot.
Make
Make a waiting line. One button hands out tickets numbered 1, 2, 3 and so on, and pushes each number onto the end of a list. A second button serves the ticket at the front: it shows that number on a sign and removes it from the list. The sign also shows how many tickets are waiting. Pressing serve with nobody waiting should not show an old ticket again.
button1 Held -> chip ticket
button2 Held -> chip serve
chip text -> decal Text
Good to Know
- An array starts empty unless its declaration lists constant values.
- A read past the end of an array gives the gate’s previous value, not 0.
Check
.OutOfBoundswhenever the index might be wrong. - Every
rolls[i]is its own gate and its own read. To use one read twice, bind it to alet, asitemis above. - To find a value by a name instead of a slot number, such as a score per player, use a map: the next lesson.
Next: Maps.
Maps
Before this: Lists
Next steps: Looping Over a Map, Ready Check, Per-Player State
Parts that use this: Welcome Zone, Teleporter Pad, Item Dispenser, Keycard Door, Checkpoints, Shop
A list finds a value by its slot number. Often what you know is something else: which player walked in, or which team scored. A map files each value under a key you choose, like a phone number filed under a name in an address book. Give it the key and it gives back the value.
Contents
zone "Zone Ref" -> chip room
Code
import { escape } from "sanitize"
@left in room: zone
var visits: Map<string, int>
on ZoneEntered(zone = room) -> (character) {
let id = character.GetUserId()
let before = visits.get(id)
let count = if before.Found then before.Value + 1 else 1
visits.set(id, count)
character.ShowChatMessage("Visit number ${count}, ${escape(character.GetDisplayName())}")
}
Map<string, int>is a map withstringkeys andintvalues. The first type in the<>is the key, the second is the value.character.GetUserId()is the player’s user id, a string that stays the same for that player on every visit. It is the key.visits.get(id)looks the key up and gives two things:.Found, whether the key was there at all, and.Value, the value filed under it.if before.Found then before.Value + 1 else 1picks one of two values: one more than the old count if the player has been here before, 1 if not.visits.set(id, count)files the new count under the id, replacing any old one.escape(...)makes a name the player chose safe to show in chat.
You walk into the zone three times, then a friend walks in for the first time. What does the friend’s message say?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone.
- Wire the Attached Zone’s Zone Ref to
room. - Walk in and out a few times. Each visit, you get a chat message with your count. A second player gets their own count, starting at 1.
Under the Hood
Open the chip. visits is one Map Var gate, and it stays one gate however
many players it holds. Its Map Var Ref output is wired into each gate that
reads or writes the map.
The handler is a chain. Get User Id turns the character into an id. Get Map Element looks the id up, and Set Map Element files the new count. Both take their Key from the same Get User Id. Between them, an Add works out one more than the old count, and a Select picks it when the Found output is true, or 1 when it is false. The message is a Get User Display Name, a Replace in String for the escape, a Format String and a Show Chat Message. That is eleven gates, plus the zone pin.
Like an array, a map has no pure gate that reads it. Every map gate has an exec input, so a read outside a handler is an error:
var visits: Map<string, int>
@right out first = visits.get("a").Value
Error: [WS007] map read 'visits.get(...)' outside an exec context
To show something from a map on a sign, copy it into a var inside the
handler, and wire the sign from that.
Keys can be int, string, a reference to a thing in the world, or a
brick. Anything else is an error:
var heights: Map<float, int>
Error: [WS039] map key type must be int, string, an object (entity/character/controller) or a brick, got float
Modify
-
Show how many different players have visited. Add
var players: int = 0, and at the end of the handler setplayers = visits.length().length()gives the number of keys. Wire@right out sign = "${players.Value} players have visited"to a Text Decal. -
Add a second zone that makes the chip forget you.
hasasks only whether a key is there, andremovetakes a key and its value out:@left in room: zone @left in forget: zone var visits: Map<string, int> on ZoneEntered(zone = room) -> (character) { let id = character.GetUserId() let before = visits.get(id) let count = if before.Found then before.Value + 1 else 1 visits.set(id, count) character.ShowChatMessage("Visit number ${count}") } on ZoneEntered(zone = forget) -> (character) { let id = character.GetUserId() if visits.has(id) { visits.remove(id) character.ShowChatMessage("Forgotten.") } else { character.ShowChatMessage("I don't know you yet.") } }After the forget zone, your next visit is number 1 again.
-
Give prizes on some visits. The keys of a map do not have to be players: this one is keyed by the visit number, and only three numbers have a prize. A list would need a slot for every number in between.
@left in room: zone var visits: Map<string, int> var prizes: Map<int, string> = { 1 => "a sticker", 5 => "a hat", 10 => "a crown" } on ZoneEntered(zone = room) -> (character) { let id = character.GetUserId() let before = visits.get(id) let count = if before.Found then before.Value + 1 else 1 visits.set(id, count) let prize = prizes.get(count) if prize.Found { character.ShowChatMessage("Visit ${count}: you win ${prize.Value}!") } }{ 1 => "a sticker", ... }fills the map when you compile. Eachkey => valuepair is one entry, and the entries are stored in the Map Var gate itself, so they cost no extra gates.
Make
Make a lap counter. Walking into a finish zone adds a lap for that player and tells them their lap count. On their third lap they get “You finished!” and their entry is removed, so they can race again from lap 1. A sign shows how many players are racing right now.
zone "Zone Ref" -> chip finish
chip text -> decal Text
Good to Know
- A map starts empty unless its declaration lists constant keys and values.
- A map holds one value per key.
seton a key that is already there replaces its value. - For a key that might be missing, check
.Foundorhas()rather than counting on what.Valueholds. - Every
getis its own gate and its own read. To use one read twice, bind it to alet, asbeforeis above. - Key players by
GetUserId(), not by their character: a player who dies comes back as a new character. Per-Player State compares the keys you could pick and when each one starts over.
See Maps and Map Types in the reference.
Next: pick a lesson from the tree on the Learn page.
Truth Tables
Before this: Gates and Wires
Next steps: Choosing a Value
A truth table lists every way a circuit’s inputs can be set, and what its output is for each one. It is the plan for a logic circuit: fill in the table first, then write the expression that matches it.
Contents
switch1 Enabled -> chip a
switch2 Enabled -> chip b
switch3 Enabled -> chip c
chip lit -> light Enabled
Code
Here are the four logic operators from Gates and Wires
as one table. 1 means on (true) and 0 means off (false).
a | b | a && b | a || b | a ^^ b | !a |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 |
| 0 | 1 | 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 1 | 1 | 0 | 0 |
&&(AND) is on only in the last row, where both inputs are on.||(OR) is off only in the first row, where both are off.^^(XOR, “exclusive or”) is on when the inputs differ.!(NOT) has one input and gives the opposite.
Two inputs make four rows. Each new input doubles the rows, so three inputs make eight. Here is a vote with three switches: the light turns on when at least two of them are on.
a | b | c | lit |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 1 |
| 1 | 1 | 1 | 1 |
To turn the table into code, read it another way: the light is on when a
and b are both on, or a and c are, or b and c are. Each pair is an
&&, and the pairs join with ||:
@left in a: bool
@left in b: bool
@left in c: bool
@right out lit = (a && b) || (a && c) || (b && c)
The parentheses make the grouping easy to read. && binds tighter than ||
already, so the circuit is the same without them.
Check the code against the table: in the row 1 1 1, every pair is on. Is the
light on? In how many of the eight rows is it on?
Run
- Compile the chip and paste it in-game. Place three Switches and a Point Light.
- Wire each Switch’s Enabled to
a,bandc. - Wire
litto the Point Light’s Enabled. - Walk through the table: try all eight positions. The light is on in four of them, the four rows with two or three switches on.
Under the Hood
Open the chip. There are five gates: three ANDs, one for each pair, and two
ORs that join them. Each && and || in the source is one gate.
Two expressions that give the same truth table are the same circuit, as far as anyone flipping the switches can tell. De Morgan’s laws are the two most useful pairs:
!(a && b)is the same as!a || !b. “Not both” means “at least one is off”.!(a || b)is the same as!a && !b. “Neither” means “both are off”.
This chip computes both sides of the first law. Fill in the truth table for
x and y, then flip the switches to check that two lights wired to them
always agree:
@left in a: bool
@left in b: bool
@right out x = !(a && b)
@right out y = !a || !b
Open it. x is a single NAND gate (“not and”): the compiler joins a !
written around an && into one gate. y is three gates, two NOTs and an OR.
The same happens for !(a || b), which becomes one NOR gate. When you
have a choice, write the form with the ! outside.
Modify
-
Count the votes instead. A
boolcounts as1when on and0when off wherever a number is expected, so you can add switches:@left in a: bool @left in b: bool @left in c: bool @right out lit = a + b + c >= 2Its truth table is the same as the first chip’s. Open it: two Add gates and a Greater/Equal gate.
-
Change the rule to “every switch is on”, then to “exactly one switch is on”. Write the truth table first, then the expression.
-
Chain XOR:
@right out lit = a ^^ b ^^ c. Fill in the eight rows. The light is on when an odd number of switches are on: one or three. This is a parity check, and it gives you a light that any of three switches can change, like the hall light with a third switch.
Make
Make a vote for four switches where the light turns on when at least three
are on. Write out the sixteen-row table, then write it two ways: with && and
|| only, and by counting. Check both against the table in-game.
switch1 Enabled -> chip a
switch2 Enabled -> chip b
switch3 Enabled -> chip c
switch4 Enabled -> chip d
chip logic -> light1 Enabled
chip counted -> light2 Enabled
Good to Know
a && balways computes both sides, even whenaalone already decides: wires do not skip anything. In a handler, an action called on either side still runs, so to do it only whenais on, put anifinside anif.- An input pin with no wire reads as off. A three-switch chip with only two
switches wired treats the third as
0. - A Switch’s Enabled stays on until it is flipped back, so a truth table is easy to walk through with switches. Button Held is on only while it is held.
Next: Choosing a Value.
Choosing a Value
Before this: Truth Tables
Next steps: Toggles and Latches, Bits, Matching Text, Remote Control, Ready Check
Parts that use this: Drawbridge, Moving Platform
The logic operators give on or off. Often you want to pick between two other
values instead: a red light or a green one, one message or another. The if
you met in Reacting to the Game chooses which
steps run. This lesson’s if chooses a value.
Contents
chip2 "Counter"
chip "Color"
button Held -> chip2 pressed
chip2 presses -> chip presses
chip color -> light Color
switch Enabled -> light Enabled
Code
@left in presses: int
let pick = presses % 3
@right out color = if pick == 0 then Color(1.0, 0.0, 0.0)
else if pick == 1 then Color(0.0, 1.0, 0.0)
else Color(0.0, 0.0, 1.0)
@left in presses: intis a number pin. A small counter chip, given in Run, counts a button’s presses and feeds the number in here.%is the remainder after dividing.presses % 3counts 0, 1, 2, then back to 0: 4 presses leave a remainder of 1, and 6 leave 0.Color(r, g, b)makes a color from its red, green and blue parts, each from0.0to1.0.Color(1.0, 0.0, 0.0)is red.if pick == 0 then A else Bis a value:Awhen the condition is true,Bwhen it is false. TheBhere is anotherif, so the choice has three answers.thenandelsecan start new lines, as they do here.
With no presses yet the light is red. What color is it after four presses? After nine?
Run
-
Compile the chip and paste it in-game. Place a Button, a Switch and a Point Light.
-
Paste this counter chip as well. It adds one each time
pressedturns on. Remembering Values explains how.@left in pressed: exec var count: int = 0 @right out presses = count.Value on pressed { count += 1 } -
Wire the Button’s Held to the counter’s
pressed, the counter’spressesto the first chip’spresses, andcolorto the Point Light’s Color. Turn off the light’s Use Brick Color setting: while it is on, the light takes the brick’s color and ignores the wire. -
Wire the Switch’s Enabled straight to the Point Light’s Enabled, and flip it on.
-
Press the button. The light goes red, green, blue, red again. Four presses leave it green, and nine leave it red.
Under the Hood
Open the color chip. There is one Modulo gate for presses % 3. Both
comparisons read it, because let pick names one wire. Then there are two
Select gates, one for each if.
A Select gate has two value inputs, Input A and Input B, and a bool
input, Select B. Its output is Input B while Select B is on, and Input A
while it is off. An if puts the condition on Select B, the then value
on Input B, and the else value on Input A. The second Select’s
output is the first one’s Input A: that is the chain.
A Select gate does not choose which input to compute. Both are computed all
the time, and the gate passes one of them along. So an if value always does
the work of both arms, and the condition only picks which answer is used:
@left in a: int
@left in b: int
@right out q = if b != 0 then a / b else 0
This chip has a Divide gate wired straight to its pins, and it divides even
while b is 0. That is harmless here, because the game’s Divide gives 0 when
you divide by zero. But the if did not protect the division: the arm that is
not chosen still runs. The “Both arms evaluate” note on the reference’s
Expressions page
has the details, including the exception for an arm that writes a variable
inside a handler.
Modify
-
Add a fourth color. Change
% 3to% 4and add one moreelse if pick == 2 then ...before the lastelse. TryColor(1.0, 1.0, 0.0), which is yellow. -
Choose text the same way. Add an output for a Text Decal’s Text that names the color:
@left in presses: int let pick = presses % 3 @right out color = if pick == 0 then Color(1.0, 0.0, 0.0) else if pick == 1 then Color(0.0, 1.0, 0.0) else Color(0.0, 0.0, 1.0) @right out name = if pick == 0 then "red" else if pick == 1 then "green" else "blue"Open it: the two outputs share the one Modulo and both Equal gates, and each has its own pair of Selects.
-
Add a
@left in dim: boolpin from a Switch, and an output for the Point Light’s Brightness that is one number whiledimis on and a larger one while it is off. Pick the numbers by trying them in-game.
Make
Make a three-light signal: a red, a yellow and a green Point Light, each
with its own @right out pin wired to its Enabled, so that one press of a
button moves which one is on. Use presses % 3, and an == for each light
in place of an if.
chip2 "Counter"
chip "Signal"
button Held -> chip2 pressed
chip2 presses -> chip presses
chip red -> light1 Enabled
chip yellow -> light2 Enabled
chip green -> light3 Enabled
Good to Know
- An unwired number pin reads as 0. Before the counter is wired,
pressesis 0 and the light is red. - Divide and Modulo give 0 when the second input is 0, rather than failing. That is why an untaken arm that divides by zero does no harm, and also why it can hide a mistake: a wrong 0 looks like a real answer.
Other ways to choose
A chain of ifs is not the only way to pick one of several values. The same
color picker can be written two more ways.
With match over an enum, each choice gets a name instead of a number.
Hue.FromInt(n) turns the number into the enum’s value with that position
(0 is Red, 1 Green, 2 Blue), and match gives the value for each one.
It compiles to the same Selects as the if chain:
enum Hue { Red, Green, Blue }
@left in presses: int
@right out color = match Hue.FromInt(presses % 3) {
Red => Color(1.0, 0.0, 0.0),
Green => Color(0.0, 1.0, 0.0),
Blue => Color(0.0, 0.0, 1.0),
}
With a list, the choices sit in order and the number reads one by its
position. A list can only be read inside a handler, so the chip keeps the
pick in a var and reads the list each time presses changes
(on Change(presses)):
const COLORS = [Color(1.0, 0.0, 0.0), Color(0.0, 1.0, 0.0), Color(0.0, 0.0, 1.0)]
@left in presses: int
var shown: color = Color(1.0, 0.0, 0.0)
@right out color = shown.Value
on Change(presses) {
shown = COLORS[presses % 3]
}
The list costs the same few gates however many choices it holds, while an
if chain or a match grows by a gate or two per choice, so a list wins once
there are many. Enums and
Lists cover both in full.
Next: Toggles and Latches.
Toggles and Latches
Before this: Choosing a Value, Remembering Values
The circuits in Truth Tables forget: their output depends only on what the switches say right now. A button is different. Its Held is on only for a moment, so a light that should stay on after the press needs a circuit that remembers. This lesson builds two: a toggle, which flips on each press, and a latch, which one button turns on and another turns off.
Contents
button Held -> chip pressed
chip light -> light Enabled
Code
@left in pressed: exec
var lit: bool = false
@right out light = lit.Value
on pressed {
lit = Toggle()
}
Toggle() is a gate with a memory of its own: one on/off value. Each time a
handler’s chain runs through it, it flips that value and gives the new one.
lit = Toggle() stores the answer in the variable, and light follows the
variable.
pressed is an exec pin. Wired from a Button’s Held, on pressed runs
its chain when Held turns on: the moment it goes from off to on, called the
rising edge. on !pressed runs when it turns off, the falling edge, but it
needs a bool pin, as the next example shows.
The first press turns the light on. What does the second press do? What happens while you hold the button down?
Run
- Compile the chip and paste it in-game. Place a Button and a Point Light.
- Wire the Button’s Held to
pressed, andlightto the Point Light’s Enabled. - Press the button: the light turns on. Press it again: off. Holding the button does nothing more, because the chain runs once, on the rising edge.
Under the Hood
Open the chip. The Toggle gate has an Exec input, and two outputs:
Exec Out, which carries the pulse on to the next step, and Output, its
on/off value. The next step is a Set Variable that stores Output in
lit.
pressed is wired straight into the Toggle’s Exec input. That is what
on pressed compiles to: the Button’s Held reaches an exec input, which
runs the chain when Held turns on. on !pressed puts a NOT gate in between,
so the chain runs when pressed turns off. The NOT reads the pin’s value,
so a pin used with on ! stays a bool.
This chip counts both edges. Wire text to a Text Decal’s Text, then
press, hold, and let go:
@left in pressed: bool
var downs: int = 0
var ups: int = 0
@right out text = "down ${downs.Value}, up ${ups.Value}"
on pressed {
downs += 1
}
on !pressed {
ups += 1
}
A long hold still counts one down, and the up comes only when you let go.
Use it: a set/reset latch
A latch has two inputs. set turns it on and reset turns it off, and
between presses it stays as it was. An alarm is one: one button arms it,
another disarms it, and a light shows which happened last.
The latch’s value depends on three things: set, reset, and its own value
a moment ago.
@left in set: bool
@left in reset: bool
let state: bool = set || (held && !reset)
buffer held: bool = state
@right out q = state
Read state as: on while set is held, or if it was on and reset is not
held. That “was on” is held: what the latch holds, not a Button’s
Held.
buffer held: bool = state makes held a copy of state delayed by one
tick, the game’s next update. The latch reads its own past value, so its wires
form a loop: state feeds held, and held feeds state. Every wire loop
must pass through a buffer. Without one, the compiler stops with WS005.
held is used on the line above the one that declares it. That is allowed
for a buffer, but the buffer then needs its type, : bool. Leave the type out
and the compiler cannot tell what held is when it reaches the &&:
@left in set: bool
@left in reset: bool
let state: bool = set || (held && !reset)
buffer held = state
@right out q = state
Error: [WS004] no overload for '&&' on any, bool
The message talks about &&, but the fix is the missing : bool on the
buffer.
To run the latch, wire two Buttons’ Held to set and reset, and q
to a Point Light’s Enabled. Press set: the light comes on and stays on
after you let go. Press reset: it goes off and stays off. Open the chip: an
OR, an AND, a NOT, and a Buffer (Ticks) gate whose output wires back into
the AND.
The toggle runs on exec pulses and stores into a variable. The latch is pure: nothing in it runs in steps, and it remembers only through its buffer.
Modify
-
Make the toggle flip when the button is let go instead: change
on pressedtoon !pressed, and the pin to@left in pressed: boolso!pressedcan read it. Press and hold: nothing happens until you release. -
The toggle does not need the variable. A handler can declare an output pin of its own, wired straight from the Toggle gate’s Output:
@left in pressed: exec on pressed { @right out light: bool = Toggle() }Open it: there is no Variable gate, just the Toggle.
-
In the latch, holding both buttons at once turns the light on:
setwins. Change it so thatresetwins:!reset && (set || held). Write the truth table forset,resetandheldboth ways and find the row where they differ.
Make
Make a “which was pressed last” sign: two buttons and two lights. Pressing
the first lights the first light and turns off the second. Pressing the
second does the opposite. One latch is enough: the second light is !q.
button1 Held -> chip set
button2 Held -> chip reset
chip q -> light1 Enabled
chip notQ -> light2 Enabled
Good to Know
onfires once when the wired value turns on, never again while it stays on. A button held down for a minute is one press.- A Button’s Held is on only while someone holds it, which is why the toggle and the latch need memory to keep the light on.
Next: Bits.
Bits
Before this: Choosing a Value
Next steps: Code Door
An int is a whole number. Inside the computer, it is stored as 64 on/off
values called bits. Think of it as a row of 64 switches. The bitwise
operators work on all of those switches at once, so one wire can carry the
state of many switches, and one gate can test them all.
Contents
switch1 Enabled -> chip s0
switch2 Enabled -> chip s1
switch3 Enabled -> chip s2
switch4 Enabled -> chip s3
chip text -> decal Text
Code
A number written in binary shows its bits. 0b starts a binary number, and
each digit is one bit, with the lowest on the right. Each bit is worth twice
the one to its right: 1, 2, 4, 8, and so on.
| Binary | Bits that are on | Value |
|---|---|---|
0b0001 | 1 | 1 |
0b0100 | 4 | 4 |
0b1011 | 8, 2, 1 | 11 |
0b1111 | 8, 4, 2, 1 | 15 |
This chip packs four switch pins into one number, one bit per switch:
@left in s0: bool
@left in s1: bool
@left in s2: bool
@left in s3: bool
let code = s0 | (s1 << 1) | (s2 << 2) | (s3 << 3)
@right out text = "code ${code}, ${BitCount(code)} on"
- A
boolcounts as1or0where a number is expected, sos1is the number 1 while its switch is on. <<shifts left:x << 2moves every bit ofxtwo places up.1 << 2is0b0100, which is 4. Sos2 << 2is 4 whiles2is on, and 0 while it is off.|is bitwise OR: each bit of the result is on if that bit is on in either side. Here each switch has its own bit, so OR puts them together.BitCount(x)counts how many bits ofxare on.
Switches s0 and s3 are on, and the others off. What does the sign say?
Run
- Compile the chip and paste it in-game. Place four Switches in a row and a brick with a Text Decal.
- Wire each Switch’s Enabled to
s0,s1,s2ands3, andtextto the Text Decal’s Text. - Flip the switches. With
s0ands3on, the sign readscode 9, 2 on: 1 + 8 is 9. All four on is 15, the largest a four-bit number can be.
Under the Hood
Open the chip. There are three Shift Left (Bitwise) gates, one for each
of s1, s2 and s3 (s0 is already in place), three OR (Bitwise)
gates joining them, and one Bit Count. The switch pins wire straight into
the shift gates: the game reads an on bool as 1.
Here are all the bitwise operators, on four-bit examples. Each works bit by bit:
| Operator | Name | Example | Result |
|---|---|---|---|
& | AND | 0b1100 & 0b1010 | 0b1000 |
| | OR | 0b1100 | 0b1010 | 0b1110 |
^ | XOR | 0b1100 ^ 0b1010 | 0b0110 |
~ | NOT | ~0b1100 | every bit flipped, all 64 of them |
<< | shift left | 0b0011 << 2 | 0b1100 |
>> | shift right | 0b1100 >> 2 | 0b0011 |
These are different from the logic operators &&, || and !, which treat
the whole value as a single on or off. One & does 64 ANDs, one per bit.
A mask is a number that picks out bits. code & 0b0100 keeps the bit worth
4 and clears every other, so it is 4 when s2 is on and 0 when it is off. Some
common uses:
@left in code: int
@right out s2On = (code & 0b0100) != 0
@right out third = (code >> 2) & 1
@right out withS0 = code | 0b0001
@right out withoutS0 = code & ~0b0001
@right out flipped = code ^ 0b1111
s2Ontests one bit: is it on?thirdreads the same bit as a number, 0 or 1: shift it down to the bottom, then mask it.withS0turns the bit worth 1 on, andwithoutS0turns it off.~0b0001is every bit except the lowest.flippedflips the low four bits.~codewould flip all 64, so to flip only some of them, XOR with a mask, or write~code & 0b1111.
Mind the parentheses in s2On. & binds more loosely than == and !=,
so without them code & 0b0100 == 4 would mean code & (0b0100 == 4): the
comparison first, then & with its true or false, which tests the wrong
switch. That is almost never what you want, so the compiler warns about it
(WS117) and asks for parentheses:
@left in code: int
@right out bad = code & 0b0100 == 4
(code & 0b0100) == 4 tests the bit, and code & (0b0100 == 4) says you
really meant the comparison’s result.
The compiler also joins a ~ around & or | into one gate: ~(a & b)
becomes a single NAND (Bitwise) gate, and ~(a | b) a single NOR
(Bitwise), as ! does for the logic operators.
Modify
-
Add a light for each switch that reads its bit back out of
code:@right out lamp3 = (code & 0b1000) != 0. Wire it to a Point Light’s Enabled and check it followss3. -
Show the bits in a row. Add an output for a second Text Decal that writes each bit as
1or0, highest first:@left in s0: bool @left in s1: bool @left in s2: bool @left in s3: bool let code = s0 | (s1 << 1) | (s2 << 2) | (s3 << 3) @right out row = "${(code >> 3) & 1}${(code >> 2) & 1}${(code >> 1) & 1}${code & 1}" -
Count in binary. Replace the four switches with one Button, the counter chip from Choosing a Value (Button Held to its
pressed), and an@left in presses: intpin wired from the counter’spresses. Give four Point Lights one bit each, from(presses & 1) != 0up to(presses & 0b1000) != 0. Press the button and watch the lights count to 15 and start over.
Make
Make a chip that takes two sets of four switches, packs each into a number,
and shows on a sign how many switches are set the same in both sets. XOR is
on wherever two bits differ, so BitCount(a ^ b) counts the differences.
switch1 Enabled -> chip a0
switch2 Enabled -> chip a1
switch3 Enabled -> chip a2
switch4 Enabled -> chip a3
switch5 Enabled -> chip b0
switch6 Enabled -> chip b1
switch7 Enabled -> chip b2
switch8 Enabled -> chip b3
chip text -> decal Text
Good to Know
- An
intis 64 bits, and comparing two ints compares all 64 of them. - A bool wired into a number input reads as exactly 1 or 0.
>>compiles to the game’s Shift Right (Arithmetic) gate. On the positive numbers in this lesson it moves bits down.
Next: Code Door.
Code Door
Before this: Bits
This project uses Bits for a door with four switches beside it. Set the switches to the secret code and the door opens. A light beside the switches glows brighter for each switch that is set right.
Contents
switch1 Enabled -> chip s0
switch2 Enabled -> chip s1
switch3 Enabled -> chip s2
switch4 Enabled -> chip s3
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
chip glow -> light Brightness
switch5 Enabled -> light Enabled
Code
const CODE = 0b1011
@left in s0: bool
@left in s1: bool
@left in s2: bool
@left in s3: bool
let code = s0 | (s1 << 1) | (s2 << 2) | (s3 << 3)
let right = 4 - BitCount(code ^ CODE)
@right out solid = code != CODE
@right out glow: float = right
const CODE = 0b1011is the secret code, written in binary so each digit is one switch:s3on,s2off,s1on,s0on, reading left to right.codepacks the four switches into one number, as in Bits.solidis on while the code is wrong. Wired to the door, it keeps the door there until the code matches.code ^ CODEis XOR: a bit of it is on wherever a switch differs from the code.BitCountcounts those wrong switches, so4 - BitCount(...)is the number of right ones.glow: floatmakes the output a decimal number, the kind a light’s Brightness takes.rightis a whole number, and the light reads it as a decimal.
All switches start off. How many are right before you touch anything? Which switches do you flip to open the door?
Run
- Compile the chip and paste it in-game. Build a door from a few bricks and give one of them a Brick Property Changer. Place four Switches in a row beside it, and a Point Light.
- Wire each Switch’s Enabled to
s0,s1,s2ands3. Puts3on the left, so the row of switches reads like the binary number. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility inputs. - Wire
glowto the Point Light’s Brightness, and give the light’s Enabled a Switch of its own, flipped on. - With every switch off, one switch is right:
s2, which the code wants off. Flips0,s1ands3on. The light grows brighter at each one, and on the last the door vanishes.
Under the Hood
Open the chip. The packing is the same three Shift Left and three OR
(Bitwise) gates as before. After it come an XOR (Bitwise), a Bit
Count, a Subtract for 4 - ..., and a Not Equal for solid.
There is no gate for CODE. A const is written into the gates that use
it: the number 11 is typed straight into an input of the XOR and of the Not
Equal. Anyone who opens the chip can read the code there.
The light gives the code away too. Flip any switch. If the light gets brighter, leave it, and if it gets dimmer, flip it back. Four tries find the code.
Modify
-
Choose your own code: change
CODEto another four-bit number, from0b0000to0b1111. Add a sign that shows the hint as text,@right out hint = "${right} of 4 right", wired to a Text Decal’s Text. -
Make the hint something you ask for. Add
@left in peek: boolfrom a Button’s Held, and make the light dark unless it is held:@right out glow: float = if peek then right else 0. -
Make the door wait for a button. Here it opens only when someone presses Try, and it stays as it is until the next press, whatever the switches do in between:
const CODE = 0b1011 @left in s0: bool @left in s1: bool @left in s2: bool @left in s3: bool @left in attempt: exec let code = s0 | (s1 << 1) | (s2 << 2) | (s3 << 3) var shut: bool = true @right out solid = shut.Value on attempt { shut = code != CODE }Wire a Button’s Held to
attempt. Now no light tells you which switches are right, and flipping a switch after the door opens does not close it.
Make
Make a door with eight switches and two codes: a guest code and an owner code, where either one opens it. Then add a light that shows which of the two was used.
switch1 Enabled -> chip s0
switch2 Enabled -> chip s1
switch3 Enabled -> chip s2
switch4 Enabled -> chip s3
switch5 Enabled -> chip s4
switch6 Enabled -> chip s5
switch7 Enabled -> chip s6
switch8 Enabled -> chip s7
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
chip owner -> light Enabled
Good to Know
- A door needs both Collision Enabled (players cannot walk through) and
Visibility (players can see it). Wire
solidto both. - A Switch’s Enabled stays on until it is flipped back, so in the first version the door closes again as soon as any switch moves.
- An unwired switch pin reads as off. With
s3unwired, the code0b1011can never match.
Next: pick a lesson from the tree on the Learn page.
Text Values
Before this: Remembering Values
Next steps: Taking Text Apart, Rich Text
Signs, chat lines and labels all carry text. In Wirescript a piece of text is
a string, and it moves along wires like any other value. This lesson builds
a shop sign that two buttons change, and a light that shows whether the sign
has anything on it.
Contents
button1 Held -> chip away
button2 Held -> chip back
chip text -> decal1 Text
chip count -> decal2 Text
chip lit -> light Enabled
Code
const AWAY = "Back in 5 minutes"
@left in away: exec
@left in back: exec
var note: string = ""
@right out text = "Shop: " .. note.Value
@right out lit: bool = note.Value
@right out count = "${note.Value.Length()} characters"
on away {
note = AWAY
}
on back {
note = ""
}
Text goes between quotes. "" is the empty string: text with nothing in it.
var note: string = "" is a variable that holds text and starts empty.
.. joins two pieces of text into one, so "Shop: " .. note.Value is the
words Shop: followed by whatever the note says.
note.Value.Length() counts the characters in the note.
lit is declared bool, but it is given a string. Where a bool is wanted,
a string reads as false when it is empty and true when it holds anything at
all.
What does each output show before any press, after pressing away, and after
pressing back?
Run
- Compile the chip and paste it in-game. Place two Buttons, two bricks with a Text Decal each, and a Point Light.
- Wire one Button’s Held to
awayand the other’s toback. - Wire
textto one Text Decal’s Text andcountto the other’s Text. Wirelitto the Point Light’s Enabled. - At first the sign reads
Shop:, the count reads0 characters, and the light is off. - Press
away. The sign readsShop: Back in 5 minutes, the count reads17 characters, and the light comes on. Pressbackand all three return to how they started.
Under the Hood
Open the chip. note is a Variable gate, and each handler is one Set
Variable that writes a fixed text into it.
textcomes from an Append String gate, the game’s join gate. Its inputs areShop:and the variable’s value.countcomes from a Get String Length gate feeding a Format String gate. That is the${...}from Remembering Values: one gate that fills values into a template.litcomes from a Not Equal gate that compares the note with"". You did not write it: the compiler adds it wherever a string is used as abool.
When every piece of a join is fixed text, the compiler joins it for you and no Append String is placed for it. Only a join with a value that can change needs a gate.
Quotes and escapes
A string can use double quotes or single quotes. They mean the same thing, so pick the one that saves you work. Single quotes let the text hold double quotes as they are:
'Ring the "bell"'
"Ring the \"bell\""
Both lines are the same text. A backslash in front of a character is an
escape: \" is a double quote inside double quotes, \' a single quote
inside single quotes, \\ a backslash, and \$ a dollar sign that does not
start a ${...}. Escape Sequences lists
them all. For a new line on a sign, use the <br> tag from the
Rich Text lesson.
Modify
-
Change
AWAYto'Ring the "bell"'. Compile and pressaway: the sign shows the quotes. How many characters does the count show now? -
==compares two strings and gives abool. Add a pin that is on only while the note says exactlyAWAY, and wire it to a second light:const AWAY = "Back in 5 minutes" @left in away: exec @left in back: exec var note: string = "" @right out text = "Shop: " .. note.Value @right out isAway = note.Value == AWAY on away { note = AWAY } on back { note = "" }!=is the opposite: true while the two texts differ. -
Use one button for both jobs. Inside a handler,
if noteis the same test aslit: true while the note is not empty.const AWAY = "Back in 5 minutes" @left in pressed: exec var note: string = "" @right out text = "Shop: " .. note.Value on pressed { if note { note = "" } else { note = AWAY } }Open the chip: the
ifis a Branch gate, and its condition is the same Not Equal against""that the compiler added forlit.
Make
Make a doorbell sign: pressing a Button shows Ding! on a Text Decal for
three seconds, then clears it, and a light stays on while the sign has text.
await Sleep(_, delay = 3.0) from Pure and Exec does
the waiting.
button Held -> chip ring
chip text -> decal Text
chip lit -> light Enabled
Good to Know
Length()counts characters as the game sees them (code points), so★is 1. Some emoji are several code points joined together: a figure with a skin tone and a gender sign counts 5, though it draws as one picture.- Only the empty string is false.
" ","0"and"false"all hold something, so they read as true. - An input pin that nothing is wired to reads as empty text.
Next: Taking Text Apart.
Taking Text Apart
Before this: Text Values, Reacting to the Game
Next steps: Safe Text, Matching Text, Chat Commands
Text often holds more than you need: a player’s full display name when you want only the first word, or a number written as digits. This lesson cuts text into pieces, changes its case, and reads numbers out of it. The text comes from players walking into a zone, so no admin rights are needed.
Contents
zone "Zone Ref" -> chip room
chip text -> decal Text
Code
import { escape } from "sanitize"
@left in room: zone
var visitor: string = "Nobody yet"
@right out text = "Last visitor: ${visitor.Value}"
on ZoneEntered(zone = room) -> (character) {
let name = character.GetDisplayName().Trim()
let parts = name.Split(" ")
let first = if parts.Found then parts.Left else name
character.ShowChatMessage("Hi ${escape(first)}!")
let initial = name.Substring(0, 1).ToUpper()
visitor = "${escape(initial)} (${name.Length()} characters)"
}
The zone handler is the one from Reacting to the Game. Each new piece is a method called on a string with a dot:
GetDisplayName()gives the name the player chose to show.Trim()removes spaces from both ends.Split(" ")cuts the text at the first space. It gives three values:Leftis the text before the space,Rightis the text after it, andFoundsays whether there was a space at all.Substring(0, 1)takes 1 character, starting at position 0. Positions count from 0, so position 0 is the first character.ToUpper()turns letters into capitals.ToLower()does the opposite.
if parts.Found then parts.Left else name picks the first word when there is
a space, and the whole name when there is not. escape(...) is the helper
from Reacting to the Game that makes player text safe to show.
Safe Text explains why it is needed.
A player named sam the builder walks in. What does the chat say to them,
and what does the sign say? What changes for a player named Sam?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone
and wire its Zone Ref to
room. - Wire
textto a Text Decal’s Text. - Walk into the zone. Chat greets you by the first word of your name, and the sign shows your initial in capitals and how long your name is.
- Ask a friend with a different name to walk in, or change your display name and walk in again. The sign shows whoever came last.
Under the Hood
Open the chip. The handler’s chain starts at the zone event, then passes
through a Get User Display Name gate. Reading a name is a step in the
chain, so GetDisplayName() only works inside a handler.
After it come gates that need no exec: Trim String, Split String
(with outputs named Left, Right and Found), a Select that
picks the first word or the whole name, Get Substring, String to
Upper and Get String Length. Two Replace in String gates are the
two escape calls. The chain ends with Show Chat Message and the Set
Variable that updates the sign.
Modify
-
Greet the rest of the name too.
parts.Rightis everything after the first space, so use it only whenparts.Foundsays there was one:import { escape } from "sanitize" @left in room: zone on ZoneEntered(zone = room) -> (character) { let name = character.GetDisplayName().Trim() let parts = name.Split(" ") if parts.Found { character.ShowChatMessage("Hi ${escape(parts.Left)}, also known as ${escape(parts.Right)}!") } else { character.ShowChatMessage("Hi ${escape(name)}!") } } -
Findgives the position where some text first appears, or -1 when it does not appear. Because positions count from 0, the position of the first space is also the length of the first word:@left in room: zone on ZoneEntered(zone = room) -> (character) { let name = character.GetDisplayName().Trim() let space = name.Find(" ") if space < 0 { character.ShowChatMessage("One word, ${name.Length()} characters") } else { character.ShowChatMessage("Your first word has ${space} characters") } } -
Text can hold a number.
ParseNumber()reads it, and gives two values:Value, the number, andSuccess, which is false when the text is not a number. This keypad builds text from button presses, then sets a light’s brightness from it:@left in one: exec @left in two: exec @left in enter: exec var typed: string = "" var level: float = 5.0 @right out shown = typed.Value @right out brightness = level.Value on one { typed = typed .. "1" } on two { typed = typed .. "2" } on enter { let n = typed.ParseNumber() if n.Success { level = n.Value } typed = "" }Wire three Buttons’ Held to
one,twoandenter,shownto a Text Decal’s Text, andbrightnessto a Point Light’s Brightness. Type12and press enter.ParseInt()works the same way for whole numbers, with anintfor itsValue.
Make
Make a keypad lock: three digit buttons, an enter button and a door. Pressing
enter reads the typed digits with ParseInt(), opens the door for three
seconds when the number equals a const CODE = 213, and clears the typed
text either way. Show the typed digits on a sign while the player types.
button1 Held -> chip one
button2 Held -> chip two
button3 Held -> chip three
button4 Held -> chip enter
chip text -> decal Text
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Good to Know
Length()andSubstringcount code points, so a character such as★is one character, not two.- A display name is chosen by its player. It can be one word or many, and it
can contain
<. Trim it before you split it, and escape any part of it you show.
Next: Rich Text.
Rich Text
Before this: Text Values
Next steps: Safe Text, Score Card, Text on Screen
Parts that use this: Shooting Gallery, Scoreboard HUD, Team Match
Text the game shows can carry formatting: bold, color, size, line breaks and small icons. The formatting is written as tags inside the string itself, so a circuit can change how a sign looks the same way it changes what it says. This lesson builds a shop sign driven by two switches.
Contents
switch1 Enabled -> chip shopOpen
switch2 Enabled -> chip busy
chip text -> decal Text
Code
@left in shopOpen: bool
@left in busy: bool
let word = if shopOpen then "OPEN" else "CLOSED"
let hex = if shopOpen then "44dd44" else "dd4444"
let note = if busy then "<i>Busy, please wait</>" else "Come on in"
@right out text = '<size="48"><b><color="${hex}">${word}</></></><br>${note}'
A tag is a word between < and >. The game reads it as an instruction and
does not show it:
<size="48">makes the text after it 48 points tall.<b>makes it bold, and<i>makes it italic.<color="44dd44">colors it. The color is six hex digits: two each for red, green and blue, so44dd44is green anddd4444is red.<br>starts a new line.</>closes the most recently opened tag. The three</>after${word}close the color, then the bold, then the size, so the second line is ordinary text.
The template uses single quotes, so the double quotes inside each tag need no escaping.
What does the sign show with shopOpen on and busy on? With both off?
Run
- Compile the chip and paste it in-game. Place two Switches and a brick with a Text Decal.
- Wire one Switch’s Enabled to
shopOpenand the other’s tobusy. - Wire
textto the Text Decal’s Text. - With both switches off, the sign shows a large red bold
CLOSEDover a smallerCome on in. FlipshopOpen: the word turns green and readsOPEN. Flipbusy: the second line changes toBusy, please waitin italics.
Under the Hood
Open the chip. There are three Select gates, one for each if, and one
Format String gate. The Format String’s template holds every tag, and
the Selects fill in the color, the word and the note. To the circuit, a tag
is only text. The sign is what reads it.
The if choices are bound to names with let before they go into the
template. That keeps the template short, and it is also the safe way to
write it: put a choice in a let, then put the name inside ${...}.
Modify
-
A color tag does not need a closing
</>. It lasts until another color tag replaces it, even across a<br>. Color both lines, with no</>at all:@left in shopOpen: bool let status = if shopOpen then '<color="44dd44">OPEN' else '<color="dd4444">CLOSED' @right out text = '${status}<br>Snacks and drinks<br><color="ffffff">Ask inside'The second line takes the status color, and the third line is white because a new color tag replaced it.
-
Use a character that is not on your keyboard. There is no escape code for it: copy the character and paste it into the string, for example
'<size="40">★ Open ★'. It counts as one character inLength().
Make
Make a sign for a door with two switches, locked and alarm. It shows a
green Unlocked or a red Locked on the first line, and while alarm is on,
a second line in bold yellow that reads ALARM.
switch1 Enabled -> chip locked
switch2 Enabled -> chip alarm
chip text -> decal Text
Good to Know
- Tags work anywhere the game shows text: Text Decals, chat, status messages. Rich Text Markup lists them all, including fonts.
<icon>Name</>draws one of the game’s own icons inline, at the size of the text around it. These docs do not list the icon names yet.- A wire carries at most 10,000 characters of text, and the rest is cut off. Every tag counts toward that, so a sign that colors every character can hit the limit long before it looks full.
- A player’s own name can contain tags too. Showing it unchanged lets it restyle your sign. Safe Text fixes that.
Next: Safe Text.
Safe Text
Before this: Taking Text Apart, Rich Text
Next steps: Chat Commands
Parts that use this: Obstacle Course, Whack-a-Light, Card Table
A player picks their own display name, and a name can contain anything, including tags. When a circuit shows that name, the game reads those tags as formatting. This lesson shows the problem, then fixes it with the two helpers in the lessons’ starter library.
Contents
switch Enabled -> chip safe
chip text -> decal Text
Code
import { escape } from "sanitize"
const TRICKY = '<size="96"><color="ff0000">Sam'
@left in safe: bool
let shown = if safe then escape(TRICKY) else TRICKY
@right out text = "Guest: ${shown}<br>Welcome!"
TRICKY stands in for a player whose display name is
<size="96"><color="ff0000">Sam. The switch picks whether the sign shows it
as it is, or after escape.
escape replaces every < with <. That is the standard way to write a
< that is not the start of a tag: the game shows it as a plain <, so the
tag appears as text instead of being obeyed.
With the switch off, what does the word Welcome! look like? With it on?
Run
- Compile the chip and paste it in-game. Place a Switch and a brick with a Text Decal.
- Wire the Switch’s Enabled to
safe, andtextto the Text Decal’s Text. - With the switch off, the name’s tags take over:
Samand everything after it,Welcome!included, is huge and red. - Flip the switch on. The sign shows the name exactly as it was typed, tags
and all, and
Welcome!looks normal.
Under the Hood
Open the chip. escape added one Replace in String gate, which searches
for < and puts < in its place. A Select chooses between the
escaped and the raw text, and a Format String builds the sign.
The library is a file named sanitize.ws, and
import { escape } from "sanitize" brings in one helper from it. This is the
whole file:
// Escape markup in a display name before showing it. A `mod`, so each use is a single gate.
mod escape(text: string) -> string {
return text.Replace("<", "<")
}
// Clean free-form player input: trim, cap the length, then escape.
// Capping first keeps the cut from landing inside an `<`.
fn sanitize(raw: string, maxLen: int = 24) -> string {
return raw.Trim().Substring(0, maxLen).Replace("<", "<")
}
escapeis amod. A mod is copied into every place that uses it, so eachescape(...)becomes one Replace in String gate right there.sanitizeis afn. A function is built once, on its own small board, and each place that uses it gets a Function Call gate that runs it.maxLen: int = 24gives the second input a default, sosanitize(name)meanssanitize(name, 24).
Use it: a guest sign
sanitize is for text that must also fit a space. It trims spaces from the
ends, keeps at most maxLen characters, then escapes what is left. This
sign keeps the last guest’s name short enough for a narrow sign, and greets
the guest by their full name in chat:
import { escape, sanitize } from "sanitize"
const SIGN_WIDTH = 12
@left in room: zone
var guest: string = "Nobody yet"
@right out text = '<size="32">Last guest:<br><b>${guest.Value}</>'
on ZoneEntered(zone = room) -> (character) {
let name = character.GetDisplayName()
guest = sanitize(name, SIGN_WIDTH)
character.ShowChatMessage("Welcome, ${escape(name)}!")
}
Wire an Attached Zone’s Zone Ref to room and text to a Text
Decal’s Text, then walk into the zone.
The cap comes before the escape because escaping turns one < into four
characters. If the cut came after, it could land in the middle of an <
and leave a broken piece such as &l at the end of the sign. Cutting first
means every < that survives the cut is escaped whole. The cost is that the
result can be longer than maxLen when the name contains <.
Which one to use:
escape(text)when the whole text should be shown, such as a name in a chat line.sanitize(text, maxLen)when the text must fit, such as a sign, a label or one row of a list.
Modify
- Change
SIGN_WIDTHto 4 and walk in again. Only the start of your name fits. - Run your sign through the switch test: replace
namewithTRICKYfrom the first example in thesanitizecall, and check that the sign shows the tags as text. - Greet the guest in chat by the first word of their name only, with
Split(" ")from Taking Text Apart. Split the raw name first, then escape the word you show: escaping is always the last step before text is displayed.
Make
Make a sign that lists the last three guests, one per line, newest first.
Keep three vars, move each one down a line when someone new walks in, and
sanitize every name before it is stored.
zone "Zone Ref" -> chip room
chip text -> decal Text
Good to Know
- Every place that shows text reads tags, chat included, so a name in a chat line can restyle that line too. Escape any text a player chose before you show it, wherever it goes.
escapeonly changes<. Text without a<comes out unchanged.
Next: Matching Text.
Matching Text
Before this: Taking Text Apart, Choosing a Value
A chain of if text == "..." ... else if text == "..." gets long fast, and
cutting the useful part out of text takes a Find and a Substring every
time. match does both in one place: it lists what the text might look like,
runs the first arm that fits, and hands you the pieces. This lesson greets
players by the shape of their name, so no admin rights are needed.
Contents
zone "Zone Ref" -> chip room
chip text -> decal Text
Code
import { escape } from "sanitize"
@left in room: zone
var said: string = "Nobody yet"
@right out text = said.Value
on ZoneEntered(zone = room) -> (character) {
let name = character.GetDisplayName().Trim()
let greeting = match name {
"" => "Hello, stranger!",
"Builder" | "Brickadian" => "Hello, fellow builder!",
"${first} ${_}" => "Hi ${escape(first)}! Love the long name.",
_ => "Hi ${escape(name)}!",
}
character.ShowChatMessage(greeting)
said = greeting
}
match name { ... } checks name against each arm in order, top to bottom,
and gives the value after the => of the first one that fits:
""fits only the empty text. A plain string in an arm means “exactly this”."Builder" | "Brickadian"fits either one.|reads as “or”."${first} ${_}"looks like text with${...}in it, but in an arm it works the other way round. Instead of putting a value into the text, it takes one out: it fits any text with a space in it, andfirstbecomes everything before the first space.${_}matches the rest without naming it._fits anything. Amatchon text must end with it (or a name), since no list of exact strings covers every possible name.
The arms are tried in order, so put specific ones above general ones. If the
_ arm came first, nothing below it would ever run, and the compiler would
warn you.
A player named Builder walks in. What does the chat say? What about sam the builder, and Sam?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone
and wire its Zone Ref to
room. - Wire
textto a Text Decal’s Text. - Walk into the zone. Chat greets you, and the sign shows the last greeting. A one-word name gets the last arm; a name with a space gets the third.
Under the Hood
Open the chip. A match that gives a value is a stack of Select gates,
one for each arm except the last, just like the if ... then ... else from
Choosing a Value:
""is one Compare Equal."Builder" | "Brickadian"is two Compare Equal gates joined by a Logical OR."${first} ${_}"is a Find in String looking for" ", a Compare Greater or Equal checking that it found one, and a Get Substring that cuts outfirst.${_}needs no gate, since nothing reads it.
These are the same gates you would build by hand with ==, Find and
Substring. match just writes them for you.
Modify
-
An arm can have a condition after
if, called a guard. The arm only runs when the pattern fits and the guard is true; when the guard is false,matchmoves on to the next arm. The guard can read the names the pattern took out:import { escape } from "sanitize" @left in room: zone on ZoneEntered(zone = room) -> (character) { let name = character.GetDisplayName().Trim() character.ShowChatMessage(match name { "${first} ${_}" if first.Length() > 8 => "Hi ${escape(first)}, what a first name!", "${first} ${_}" => "Hi ${escape(first)}!", _ => "Hi ${escape(name)}!", }) } -
The text around the holes decides where they go. A hole at the end takes everything after the text before it, and a hole at the start takes everything before the text after it:
import { escape } from "sanitize" @left in room: zone on ZoneEntered(zone = room) -> (character) { let name = character.GetDisplayName().Trim() character.ShowChatMessage(match name { "Sir ${rest}" => "Welcome, Sir ${escape(rest)}.", "${who}Bot" => "Beep boop, ${escape(who)}.", "[${tag}] ${who}" => "${escape(who)} from team ${escape(tag)}", _ => "Hi!", }) }"Sir ${rest}"can also be written"Sir " .. rest, joining the parts with..the way you join text. Both build the same gates. -
matchworks on numbers too. An arm can be an exact number, or a rangelow..high, which fits numbers fromlowup to, but not including,high:@left in room: zone var visits: int = 0 on ZoneEntered(zone = room) -> (character) { visits += 1 character.ShowChatMessage(match visits { 1 => "You are the first visitor!", 2..10 => "Visitor number ${visits}.", n if n % 100 == 0 => "Visitor ${n}, a round number!", _ => "Welcome back.", }) }
Make
Make a keypad lock: three digit buttons, an enter button and a door. The digit
buttons add 1, 2 or 3 to a typed text, and a sign shows it. On enter,
match the typed text with arms that run steps instead of giving a value
(each arm is a { ... } block):
- the code
"213"opens the door for three seconds; - a code starting with
3says “Codes never start with 3” in chat; - a code longer than three digits says “Too long” (a guard on
typed.Length()); - anything else says “Wrong code”.
Clear the typed text after every try.
button1 Held -> chip one
button2 Held -> chip two
button3 Held -> chip three
button4 Held -> chip enter
chip text -> decal Text
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Good to Know
- In a pattern,
${name}makes a new name for the text it matched. It never reads anamethat already exists, and the compiler warns when the two share a name. To compare against a value you already have, use a guard:"hi ${n}" if n == friend => .... - Matching is case-sensitive:
"builder"does not fit"Builder". Match onname.ToLower()and write the arms in lower case to ignore case. - A literal between two holes is found at its first appearance:
"${a}-${b}"splitsx-y-zintoxandy-z. matchalso works on enums, where each arm names a variant.
Next: pick a lesson from the tree on the Learn page.
Interpolation
Before this: Remembering Values
Next steps: Fmt
Most text a circuit shows mixes fixed words with live values: a score, a
count, a name. ${} puts a value into a piece of text. This lesson shows what
it compiles to, and why it is cheaper than joining the pieces yourself.
Contents
chip2 "Counter"
chip "Sign"
button1 Held -> chip2 addRed
button2 Held -> chip2 addBlue
chip2 red -> chip red
chip2 blue -> chip blue
chip joined -> decal1 Text
chip templated -> decal2 Text
Code
@left in red: int
@left in blue: int
@right out joined = "Red " .. red .. ", Blue " .. blue .. ", Total " .. (red + blue)
@right out templated = "Red ${red}, Blue ${blue}, Total ${red + blue}"
Text in quotes is a string. .. joins two strings into one, and turns a
number into text on the way. ${...} inside a string puts the value of what
is between the braces at that spot. The text around it stays as written. A
string with ${} in it is called a template.
Both outputs build the same words. Which one takes fewer gates? Guess a number for each before you open the chip.
Run
-
Compile the chip and paste it in-game. Place two Buttons and two bricks with a Text Decal.
-
Paste this counter chip as well. It counts each button’s presses, the way Remembering Values does, and puts the two counts on pins. The next lessons in this branch use it too.
@left in addRed: exec @left in addBlue: exec var redCount: int = 0 var blueCount: int = 0 @right out red = redCount.Value @right out blue = blueCount.Value on addRed { redCount += 1 } on addBlue { blueCount += 1 } -
Wire the first Button’s Held to the counter’s
addRedand the second’s toaddBlue. Wire the counter’sredandblueto the first chip’sredandblue. -
Wire
joinedto one Text Decal’s Text andtemplatedto the other’s. -
Press each button a few times. The two signs always read the same.
Under the Hood
Open the first chip and count the gates.
joinedis five Append String gates in a row. Each..is one gate, and each gate joins exactly two pieces. Three values with text in front of each make six pieces, and six pieces take five joins. In general, N values with text in front of each cost 2N-1 gates.templatedis one Format String gate. Its Format String isRed {0}, Blue {1}, Total {2}: each${}became a numbered slot, and the values are wired into the gate’s Input A, Input B and Input C.red + blueis one Add gate, and both outputs share it.
A Format String gate has seven inputs, Input A to Input G, so one gate holds up to seven values. Write more and the compiler splits the template across several gates and joins their outputs, so you never have to count:
@left in n: int
@right out row = "${n} ${n - 1} ${n - 2} ${n - 3} ${n - 4} ${n - 5} ${n - 6} ${n - 7} ${n - 8}"
This row has nine values. Open the chip: there are three Format String gates, one with seven slots, one with two, and one that joins the first two.
A brace in the text
The Format String gate reads {0} as a slot, so braces mean something to it.
In a ${} template you can still type { and } as text: the compiler
doubles each one in the gate’s text so the gate does not take it for a slot.
@left in red: int
@right out sign = "{red} = ${red}"
Open the chip: the gate’s Format String is {{red}} = {0}. Wire sign to
a Text Decal and check that it shows single braces. To show a $, write \$.
Modify
-
Add who is ahead.
if ... then ... elsepicks one of two values: here"Red"whenred > blueis true,"Blue"when it is false. Give it a name withlet, then put the name in the template.@left in red: int @left in blue: int let leader = if red > blue then "Red" else "Blue" @right out text = "Red ${red}, Blue ${blue}. ${leader} leads."What does the sign say when the scores are equal?
-
Fix the tie: an
elsecan hold anotherif.if red == blue then "Nobody" else if red > blue then "Red" else "Blue". -
Put the two scores on separate lines with
<br>, and color the names with<color="RRGGBB">(Rich Text Markup). Write the template in single quotes so the double quotes in the tag need no escaping:'<color="ff4040">Red ${red}<br><color="4080ff">Blue ${blue}'. It is still one gate: tags are just more text.
Make
Make a sign for three teams that shows each score and the total, written as
one template. How many gates does it take? How many would the .. version
take?
button1 Held -> chip red
button2 Held -> chip blue
button3 Held -> chip green
chip text -> decal Text
Good to Know
- A
boolin a slot prints as a number:trueshows1. Word Forms prints words instead.
See String Interpolation for the reference.
Next: Fmt.
Fmt
Before this: Interpolation
Next steps: Number Options
A ${} template holds its text and its values together. Fmt keeps them
apart: you write the template once, with lettered slots, and fill it with
values each time you use it. One template can then serve many signs.
Contents
chip2 "Counter"
chip "Sign"
button1 Held -> chip2 addRed
button2 Held -> chip2 addBlue
chip2 red -> chip red
chip2 blue -> chip blue
chip redText -> decal1 Text
chip blueText -> decal2 Text
Code
const LINE = "{A}: {B}"
@left in red: int
@left in blue: int
@right out redText = Fmt(LINE, "Red", red)
@right out blueText = Fmt(LINE, "Blue", blue)
const LINE = "{A}: {B}" names a fixed piece of text. Fmt(format, ...)
uses the Format String gate directly. The first argument is the format: the
template text. The rest are the values, up to seven of them. The first value
fills {A}, the second fills {B}, and so on up to {G}. These letters are
the names the gate’s own tooltip uses for its inputs.
Red is pressed twice and Blue once. What does each sign show? Now change
LINE to "{B} for {A}". What do they show?
Run
- Compile the chip and paste it in-game. Place two Buttons and two bricks with a Text Decal.
- Paste the counter chip from Interpolation. Wire
the Buttons’ Held to its
addRedandaddBlue, itsredandblueto this chip’sredandblue, andredTextandblueTextto each Text Decal’s Text. - Press the red button twice and the blue one once. The signs read
Red: 2andBlue: 1.
Under the Hood
Open the chip: two Format String gates, both with the Format String
{A}: {B}. In each, Input A holds its word, set inside the gate, and
Input B is wired from a pin. A const is not a gate: its text is copied
into every gate that uses it, so changing LINE changes both signs.
- A slot can be used more than once and in any order:
Fmt("{B} {A} {B}", x, y)printsy x y. - The numbered form
{0}to{6}also works:{0}is Input A. It is what${}compiles to, as you saw in Interpolation. Use the letters in your own templates: the options in the next lessons are written with them. - Passing more than seven values is a compile error (
WS011): the gate has no eighth input.${}splits long templates for you, andFmtdoes not.
Modify
-
Change
LINEto"{A} team: {B} points". Both signs change, and you edited one line. -
Add a third value: how many points each team still needs to win.
const GOAL = 10 const LINE = "{A}: {B} ({C} to go)" @left in red: int @left in blue: int @right out redText = Fmt(LINE, "Red", red, GOAL - red) @right out blueText = Fmt(LINE, "Blue", blue, GOAL - blue) -
The format does not have to be fixed. Pick one of two templates with a Switch: the format is then a wire into the gate’s Format String input.
const SHORT = "{A}: {B}" const LONG = "Team {A} has {B} points" @left in wide: bool @left in red: int let line = if wide then LONG else SHORT @right out redText = Fmt(line, "Red", red)Wire a Switch’s Enabled to
wideand flip it.
Make
Make a scoreboard for four teams. Use one const template for every row, and
join the four rows into one sign with a ${} template that puts <br>
between them. How many Format String gates does it take?
button1 Held -> chip red
button2 Held -> chip blue
button3 Held -> chip green
button4 Held -> chip yellow
chip board -> decal Text
Good to Know
- In a format string, a brace you type starts a slot, and a slot the gate
cannot fill prints
0:Fmt("literal{a}brace")showsliteral0brace.Fmtpasses its format to the gate as written, unlike${}, which doubles your braces. - A numbered slot with no value wired prints
0:Fmt("{0}{1}", 1)shows10.
See String Formatting for the reference.
Next: Number Options.
Number Options
Before this: Fmt
Next steps: Word Forms
Parts that use this: Clock Sign, Race Timer, Round Manager, Obstacle Course, Team Match
A slot can carry an option that changes how its number prints: commas between the thousands, or a fixed number of decimals. Leading zeros have no option, so this lesson builds them by hand, then prints real dates and times.
Contents
chip2 "Counter"
chip "Sign"
button Held -> chip2 addRed
chip2 red -> chip presses
chip big -> decal1 Text
chip share -> decal2 Text
chip both -> decal3 Text
Code
@left in presses: int
@right out big = Fmt("{A:,} points", presses * 125000)
@right out share = Fmt("{A:.2} each", presses / 4.0)
@right out both = Fmt("{A:,.2} coins", presses * 1234.5)
An option goes inside the slot, after a colon.
{A:,}groups the thousands with commas:1,234,567.{A:.2}prints exactly two digits after the point:12.50. Any count up to 6 works.{A:,.2}does both:1,234.50.
presses / 4.0 divides by a number with a point in it, so the result keeps
its fraction: 3 presses give 0.75, not 0.
After three presses, what does each sign show?
Run
- Compile the chip and paste it in-game. Place a Button and three bricks with a Text Decal.
- Paste the counter chip from Interpolation. Wire
the Button’s Held to its
addRedand itsredtopresses. Wirebig,shareandbothto the three Text Decals’ Text. - Press the button three times. The signs read
375,000 points,0.75 eachand3,703.50 coins. Press once more:shareshows1.00, two decimals even when they are zero.
Under the Hood
Open the chip. Each Format String gate still has its option in its
Format String ({A:,} points): the gate applies the option every time the
value changes.
Write options on the lettered slots, as here. Whether they also work on the
numbered form ({0:.2}) has not been checked, so the lettered form is the one
to use. Options need Fmt: a ${} template has nowhere to put them.
Modify
-
Leading zeros. No option pads
7to07, so write a helper that does.mod pad2(n: int) -> string { let s = "0${n}" return s.Substring(s.Length() - 2, 2) } @left in presses: int @right out padded = pad2(presses)moddefines a helper with a name.n: intis its input,-> stringsays it gives back text, andreturngives that text. Each use copies the helper’s gates into the chip (mod). The body puts a0in front, then keeps the last two characters:s.Length()counts the characters, ands.Substring(start, length)takeslengthof them from positionstart. So 7 becomes07, and 42 becomes042and then42. It only works below 100: 100 would come out as00. -
Minutes and seconds. A lap timer: the first press starts it, and every press after that shows the time since the first, as
mm:ss.mod pad2(n: int) -> string { let s = "0${n}" return s.Substring(s.Length() - 2, 2) } @left in pressed: exec var started: int = 0 var elapsed: int = 0 @right out lap = "${pad2(elapsed.Value / 60)}:${pad2(elapsed.Value % 60)}" on pressed { let now = GetUnixTime() if started == 0 { started = now } elapsed = now - started }GetUnixTime()reads the server’s clock as a whole number of seconds (counted from 1 January 1970).if started == 0 { ... }runs its block only on the first press, while nothing is stored yet. Dividing one whole number by another gives a whole number, soelapsed / 60is the full minutes.%gives the remainder: the seconds left over. Wire a Button’s Held topressedandlapto a sign. -
Dates.
FormatDateturns a Unix time into text.@left in pressed: exec var stamp: int = 0 @right out when = FormatDate(stamp.Value, "%Y-%m-%d %H:%M", useUTC = true).Output on pressed { stamp = GetUnixTime() }The sign shows when the button was last pressed. In the format,
%Yis the year,%mthe month,%dthe day,%Hthe hour and%Mthe minute.useUTC = truegives the time in UTC, andfalsegives the server’s local time. The gate also has a Success output,.Success, which is false when it could not format the date.
Make
Make a sign for a parkour course: the first press of a start button stores
the time, and a finish button shows the run’s time as mm:ss, plus the date
and time of the run.
button1 Held -> chip start
button2 Held -> chip finish
chip text -> decal Text
Good to Know
- Store times from
GetUnixTime(), notServerUptime(). Uptime starts again from zero when the server restarts, but avarkeeps its value, so a stored uptime can point into the future.
See String Formatting and Date / time for the reference.
Next: Word Forms.
Word Forms
Before this: Number Options
Next steps: Templates Chosen at Run Time
Numbers need the right words around them: 1 apple but 2 apples, 1st but 2nd.
A bool in a slot prints 1 when true, which is rarely what a sign should say.
The Format String gate can pick these words itself, in the template.
Contents
chip2 "Counter"
chip "Sign"
button Held -> chip2 addRed
chip2 red -> chip presses
switch Enabled -> chip isOpen
chip doorText -> decal1 Text
chip appleText -> decal2 Text
chip placeText -> decal3 Text
Code
@left in isOpen: bool
@left in presses: int
@right out doorText = Fmt("Door: {A:Open=Closed}", isOpen)
@right out appleText = Fmt("{A} {A}|plural(one=apple,other=apples)", presses)
@right out placeText = Fmt("You came {A}{A}|ordinal(one=st,two=nd,few=rd,other=th)", presses)
{A:Open=Closed}is an option for a bool: the word before=when the value is true, the word after it when false.{A}|plural(...)is a word form. A slot followed by a bar and a form prints a word picked by the value, instead of the value itself. The form lists words by category:oneis the word for a count of one,otherthe word for larger counts. That is why the template has{A}twice: the first prints the number, the second its word.{A}|ordinal(...)picks an ending for a place:one,two,fewandotherare the categories the gate sorts places into, giving1st,2nd,3rdand4th.
After one press, what do the signs say? After two?
Run
- Compile the chip and paste it in-game. Place a Switch, a Button and three bricks with a Text Decal.
- Paste the counter chip from Interpolation. Wire
the Button’s Held to its
addRed, itsredtopresses, and the Switch’s Enabled toisOpen. Wire the three outputs to the three Text Decals’ Text. - Flip the switch:
Door: Open,Door: Closed. Press the button and watch the words change with the number. - Find out two things the lesson does not tell you. What does
appleTextsay before the first press? What doesplaceTextsay at 11, 12 and 13?
Under the Hood
Open the chip: three Format String gates and nothing else. The words are inside each gate’s Format String, and the gate picks them as the value changes. No compare or select gates do the choosing.
By hand, the plural alone takes a compare and a select:
let word = if presses == 1 then "apple" else "apples". The ordinal would
take a chain of them.
These templates stay real gates even when every value is a constant: the compiler leaves options and word forms for the game to apply.
Modify
-
A form can insert the value itself. This prints
one applefor 1 and3 applesfor 3:@left in presses: int @right out basket = Fmt("{A}|plural(one=one apple,other={A} apples) in the basket", presses)Here the number appears only inside the
otherform, so there is one{A}before the bar. -
Two values with their own words. Add a second Button, counted on the counter’s
blue, oncoins:Fmt("{A} {A}|plural(one=player,other=players) found {B} {B}|plural(one=coin,other=coins)", presses, coins). -
Mix an ordinal and a bool option.
place <= 3is a bool, so{B}can turn it into words:@left in place: int @right out result = Fmt("{A}{A}|ordinal(one=st,two=nd,few=rd,other=th) place. Medal: {B:Yes=No}", place, place <= 3)
Make
Make a door sign that reads The door is open. It has been opened 1 time.,
with the right word for open or closed and for time or times. Count the
openings in a var that a handler adds to each time the switch turns on, as
in Remembering Values.
switch Enabled -> chip isOpen
chip text -> decal Text
Good to Know
- A
boolin a plain slot prints1when true. Use{A:Yes=No}, with your own words, to show it on a sign.
See String Formatting for the reference.
Next: Templates Chosen at Run Time.
Templates Chosen at Run Time
Before this: Word Forms, Reacting to the Game
Next steps: Score Card
The format of an Fmt is an input like any other. It can come from a list,
or be built while the circuit runs. This lesson uses that for message lists
and lookup tables, and gives one rule for keeping them safe.
Contents
zone "Zone Ref" -> chip room
Code
import { escape } from "sanitize"
const GREETINGS = [
"Welcome, {A}!",
"Good to see you, {A}.",
"{A} is visitor number {B}.",
]
@left in room: zone
var visitors: int = 0
on ZoneEntered(zone = room) -> (character) {
visitors += 1
let template = GREETINGS[(visitors - 1) % 3]
character.ShowChatMessage(Fmt(template, escape(character.GetDisplayName()), visitors))
}
[ ... ] is a list of values, an array. GREETINGS[i] reads the item at
position i, counting from 0. % is the remainder after dividing, so
(visitors - 1) % 3 goes 0, 1, 2, 0, 1, 2 as visitors arrive. The handler
picks a template from the list, and Fmt fills it with the visitor’s name and
number. The first two templates have no {B}, so they leave the number out.
escape makes the name safe to show, as in
Reacting to the Game.
Which greeting does the fourth visitor get? The sixth?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone
and wire its Zone Ref output to
room. - Walk into the zone, out, and in again a few times. Each entry shows the next greeting in your chat. Only the player who walked in sees it.
Under the Hood
Open the chip. The list is one Array Var gate holding the three templates. In the handler, a Get Array Element step reads one of them, and its output is wired into the Format String gate’s Format String input. The format is no longer text set inside the gate: it changes from one run of the handler to the next.
Reading an item at a position that changes is a step in a handler. Outside a
handler, GREETINGS[i] is an error (WS007).
Fmt as a lookup table
A format can also be built from a number, so that it picks one of the values:
@left in presses: int
let level = presses % 3
let hex = Fmt('{' .. level .. '}', 'ff4040', 'ffc040', '40ff40')
@right out sign = '<color="${hex}">Level ${level}'
'{' .. level .. '}' builds the format {0}, {1} or {2} while the
circuit runs, and the gate prints that one input. One Format String gate is
a table of up to seven values, picked by number, with no handler needed.
Here it picks a color for a <color> tag
(Rich Text Markup). Wire the
red count of the counter chip from Interpolation
to presses, a Button’s Held to the counter’s addRed, and sign to a
Text Decal’s Text.
Build this format with .., not ${}. "{${level}}" would have its braces
doubled, as in Interpolation, and they would never make a
slot.
Player text goes in a slot
Only text you wrote belongs in a format string. Text a player chose, such as a display name or something typed, goes into a slot:
// Wrong: the name becomes part of the format.
character.ShowChatMessage(Fmt("Welcome, " .. name .. "!"))
// Right: the name is a value in slot A.
character.ShowChatMessage(Fmt("Welcome, {A}!", escape(name)))
In the wrong version, a player named {A} or {0} is read as a slot, and
the gate prints something else in place of their name. In a slot, the name is
only a value. ${} is safe in the same way: "Hello, ${escape(name)}" puts
the name in a slot. Still escape it, because a < in a name starts a tag.
Modify
- Add a fourth greeting, and make the handler follow the list’s length so
you never have to change the
3:GREETINGS[(visitors - 1) % GREETINGS.length()]. - Use a word form from Word Forms in a greeting:
"{A} is our {B}{B}|ordinal(one=st,two=nd,few=rd,other=th) visitor.". - Give the lookup table a fourth color, and change level only every five
presses:
let level = clamp(presses / 5, 0, 3).clamp(x, low, high)keeps a value between two limits.
Make
Make a health sign. A pin hp: int from 0 to 100 picks a level with
clamp(hp / 34, 0, 2). Use one lookup Fmt for the color and a second for
the word (Healthy, Hurt, Danger), and show both in one template. Test it
with the counter chip’s red count standing in for hp.
chip2 "Counter"
chip "Health"
button Held -> chip2 addRed
chip2 red -> chip hp
chip text -> decal Text
Good to Know
- Reading a list past its end gives the last value read, not the first item
or an empty one. Keep the position in range, as
%does here. - A slot the gate cannot fill prints
0, so a mistake in a built format shows up as a0on the sign.
See String Formatting and
const containers at runtime
for the reference.
Next: Score Card.
Score Card
Before this: Templates Chosen at Run Time, Rich Text
This project uses the whole Templates branch in one template, filled by one gate: a sign with a styled title, numbers with options, the right words for each count, and a status line.
Contents
chip2 "Counter"
chip "Sign"
button1 Held -> chip2 addRed
button2 Held -> chip2 addBlue
chip2 red -> chip hits
chip2 blue -> chip misses
switch Enabled -> chip isOpen
chip card -> decal Text
Code
const CARD = '<size="32"><b><color="ffd040">TARGET RANGE</></></><br><color="ffffff">{A:,} {A}|plural(one=hit,other=hits), {B:,} {B}|plural(one=miss,other=misses)<br>Accuracy: {C:.1}%<br>{D:Open=Closed}'
@left in hits: int
@left in misses: int
@left in isOpen: bool
let accuracy = hits * 100.0 / (hits + misses)
@right out card = Fmt(CARD, hits, misses, accuracy, isOpen)
- The title line is rich text from Rich Text: a
size, bold and a color, each closed with
</>. The color tag before the body is never closed, so it lasts to the end. {A:,}and{C:.1}are number options from Number Options.{A}|plural(...)and{D:Open=Closed}are word forms from Word Forms.CARDis aconsttemplate filled byFmt, as in Fmt.
Before anything is pressed, hits + misses is 0. What does the accuracy line
say then?
Run
-
Compile the chip and paste it in-game. Place two Buttons, a Switch and a brick with a Text Decal.
-
Paste the counter chip from Interpolation. Wire the Buttons’ Held to its
addRedandaddBlue, itsredtohitsand itsbluetomisses, and the Switch’s Enabled toisOpen. Wirecardto the Text Decal’s Text. -
Press each button a few times and flip the switch. The card should read like this after three hits and one miss, with the switch on:
TARGET RANGE 3 hits, 1 miss Accuracy: 75.0% Open
Under the Hood
Open the chip. There is one add, one multiply and one divide for the accuracy, and one Format String gate for the whole card: four gates in all. Everything else (the title, the colors, the words, the options) is text inside the gate’s Format String.
The accuracy needs no check for zero shots. Dividing by zero gives 0, as in
Numbers, so a fresh card reads Accuracy: 0.0%.
The template must stay in one Fmt. Built with .. instead, each piece
would be its own gate, and the options and word forms would not work, because
only the Format String gate reads them.
Modify
- Add a best streak. Give the chip a third pin,
streak, add<br>Best streak: {E}to the template, and passstreakas the fifth value. - Color the accuracy by how good it is. Pick the color with a lookup
Fmtfrom Templates Chosen at Run Time, usingclamp(hits * 3 / (hits + misses), 0, 2)as the position, and pass it intoCARDas one more slot:<color="{F}">Accuracy: {C:.1}%. The position must be a whole number:hits * 3 / (hits + misses)divides twoints, which drops the fraction, whileaccuracyis a decimal and would build a format like{1.47}that picks nothing. - Give the card a place line:
You are {G}{G}|ordinal(one=st,two=nd,few=rd,other=th), withGfrom a second counter chip’sred. A template holds at most seven values,AtoG, so this one is now full.
Make
Make a shop sign for a vending machine. It shows the item name in a large
colored title, the price with thousands grouped (1,250 coins), how many are
left with the right word (1 can left, 3 cans left), and Sold out or
In stock from a bool. Use one const template and one Fmt.
chip2 "Counter"
chip "Shop"
button Held -> chip2 addRed
chip2 red -> chip remaining
switch Enabled -> chip inStock
chip card -> decal Text
Good to Know
- A template holds seven values at most,
{A}to{G}. A card that needs more must be split into twoFmts joined with... - A misspelled option is not an error. The compiler cannot check what is inside a template, so check the sign itself after every change.
- Text that comes from a player must never be part of the template itself: pass it in a slot, escaped, as in Safe Text.
Next: pick a lesson from the tree on the Learn page.
Exec Pins
Before this: Reacting to the Game
Next steps: Custom Events
Parts that use this: Countdown Sign, Doorbell, Round Manager, King of the Hill, Voting
So far a chip’s outputs have carried values. An output can also carry a pulse: a moment when something happened, which another part can react to. This lesson builds a doorbell that rings a real sound.
Contents
button Held -> chip pressed
chip sign -> decal Text
chip ring -> speaker Play
Code
@left in pressed: exec
var rings: int = 0
@right out ring: exec
@right out sign = "Rings: ${rings.Value}"
on pressed {
rings += 1
emit ring
}
@right out ring: exec is an exec output, a pin that carries pulses instead
of a value. emit ring sends a pulse out of it. It fires at the point the handler
has reached, so here it fires after rings has gone up.
sign is an ordinary value output, the same as in earlier lessons.
ring has no value. What can a wire from it go into? What happens if you
move emit ring above rings += 1?
Run
- Compile the chip and paste it in-game. Place a Button, a brick with a Text Decal, and a One Shot Audio brick.
- Open the One Shot Audio’s settings and pick a sound for it.
- Wire the Button’s Held to
pressed,signto the Text Decal’s Text, andringto the One Shot Audio’s Play. - Press the button. The sound plays once per press, and the sign counts the rings. Holding the button does not ring again.
Under the Hood
Open the chip. rings is a Var gate and sign comes from a
Format String gate. The handler is a single Increment Variable gate:
rings += 1 adds one in place. Its exec input is wired from pressed.
There is no gate for emit ring. The ring pin is wired straight from the
Increment Variable’s exec output, so the pulse leaves the chip the moment the
count has gone up. That is why the order of the lines matters. Move
emit ring first and the pin is wired from pressed itself, beside the
Increment Variable instead of after it.
emit can also set a value pin. This dice chip sets its number and then
fires a pulse:
@left in roll: exec
@right out face: int
@right out rolled: exec
on roll {
emit face = Random(1, 6)
emit rolled
}
emit face = ... makes the Random gate drive face, and emit rolled
fires after the roll. Whatever listens to rolled sees the new face. Set
the value first, then fire the pulse.
A pin has one driver. An out with a value already drives its pin all the
time, so a handler cannot also write it:
@left in pressed: exec
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
on pressed {
rings += 1
emit sign = "Ding!"
}
Error: [WS013] this `out sign = ...` drives `sign` continuously, and an `emit sign = ...` also writes it, so the two would fight over the output. Give this one a constant default (`out sign = 0`) and let the other write change it, or remove it
Drive each output one way, either with a value that is always computed or with writes from handlers.
Modify
-
Ring only on every other press. Wrap the emit:
if rings % 2 == 0 { emit ring }. -
Ring twice per press, half a second apart:
@left in pressed: exec @right out ring: exec on pressed { emit ring await Sleep(_, delay = 0.5) emit ring }Open the chip. Two places fire
ring, but a game input takes only one wire, so the compiler adds a Union gate that merges the two pulses into the pin. -
Combine the dice and the doorbell: wire
rolledto a One Shot Audio’s Play andfaceto a sign, so every roll makes a sound and shows its number.
Make
Make a doorbell that rings at most once every three seconds, however often
the button is pressed. The sign should count every press, but only show
“Ding!” when the bell really rang. A var can hold text too:
var shown: string = "".
button Held -> chip pressed
chip sign -> decal Text
chip ring -> speaker Play
Good to Know
- An exec output carries no value. Wire it only into an input that takes a
pulse, such as One Shot Audio Play. An exec input such as
pressedtakes a pulse or an on/off output like Held, and starts its handler when that output turns on. - A One Shot Audio with no sound picked plays nothing when pulsed, and nothing tells you why.
- A game input takes one wire. When two places in your code fire the same exec pin, the compiler merges them with a Union. Wiring two exec outputs into one input by hand is not allowed.
See Exec outputs and
emit in the reference.
Next: Custom Events.
Custom Events
Before this: Exec Pins
Next steps: Waiting, Spawning a Prefab, Multiplayer Architecture
Parts that use this: Obstacle Course
Two chips can talk without a wire between them. One sends a named event, and every chip listening for that name runs a handler. This lesson splits the doorbell in two: a button chip by the door and a light chip anywhere else in your build.
Contents
- Code
- Run
- Under the Hood
- Constants do not travel
- Sending to one grid
- Global events
- Modify
- Make
- Good to Know
chip "Button chip"
button Held -> chip pressed
chip "Light chip"
chip light -> light Enabled
chip sign -> decal Text
Code
The button chip:
@left in pressed: exec
var presses: int = 0
on pressed {
presses += 1
SendCustomEvent("doorbell", presses)
}
The light chip:
var rings: int = 0
var lit: bool = false
@right out light = lit.Value
@right out sign = "Rings: ${rings.Value}"
on CustomEvent("doorbell") -> (count: int) {
rings = count
lit = !lit
}
SendCustomEvent("doorbell", presses) sends an event on the channel
"doorbell", carrying one value. on CustomEvent("doorbell") runs whenever
an event on that channel arrives, and -> (count: int) names the value it
carried. A channel name is any text you choose. Both ends must spell it the
same way.
Each event can carry up to eight values, in order. The receiver lists them in
the same order, with the same types the sender sends: here one int.
The two chips share no wire. Paste a second copy of the light chip. What happens to it when you press the button?
Run
- Compile both chips and paste them in-game. Place a Button by the first, and a Point Light and a brick with a Text Decal by the second.
- Wire the Button’s Held to
pressed. Wirelightto the Point Light’s Enabled andsignto the Text Decal’s Text. - Press the button. The light toggles and the sign counts the presses.
- Paste the light chip again, with its own light. Both lights now toggle on
every press: every chip listening on
"doorbell"hears it.
Under the Hood
Open the button chip. After the Increment Variable comes a Get
Variable that reads presses, then a Send Personal Custom Event gate.
The channel name is typed into the gate, and the count is wired into its
Data In1.
Open the light chip. A Personal Custom Event gate, with doorbell typed
in, starts the handler. Its Data Out1 is wired into the Set Variable for
rings.
“Personal” means the event reaches only circuits pasted by the same player. A light chip someone else pasted does not hear your button.
The handler runs on the tick after the send, not in the same tick. An event is delivered on the next frame, so a reply to an event, and the reply to that, each cost a tick.
The light chip has no sender in its own code, so it cannot work out the
type of count by itself. Leave the type off and the compiler warns:
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
on CustomEvent("doorbell") -> (count) {
rings = count
}
WARN [WS042] custom event 'doorbell' data param 'count' (#1): no in-unit sender to infer its type from, so it defaults to float; annotate `count: <type>` to silence
The sender decides what each slot carries. The receiver’s types have to match it, and a mismatch is not always reported, so write the types out on every receiver.
Constants do not travel
Send a fixed number and the event arrives, but the number may not:
@left in pressed: exec
on pressed {
SendCustomEvent("doorbell", 1)
}
This compiles with no warning. Open the chip: Data In1 has no wire. The
1 is typed into the gate’s box instead, and the receiver can read 0 there.
Put the value in a variable (var door: int = 1) and send door. The chip
then reads it with a Get Variable and wires it into Data In1.
Sending to one grid
Every listener hears an ordinary event. To answer just one chip, send to its grid. Here the button chip sends its own grid along with the count, and the light chip answers on a second channel:
ReadBrickGrid() is the grid the chip sits on, as an entity.
door.SendCustomEvent(...) sends to that grid only. A sent-to event is an
object event, and only a receiver marked isObject = true hears one. Without
it, on CustomEvent("doorbell.heard") never runs, and nothing reports why.
Chips placed straight on the ground all sit on the same grid, so a reply sent
there reaches every isObject listener on the ground. Sending to a grid
pays off when chips sit on separate grids, such as a vehicle or a copy the
circuit spawned itself.
Global events
An ordinary custom event is personal: it reaches only circuits pasted by the
player who pasted the sender. A global event reaches every listening circuit,
whoever pasted it. Send one with SendGlobalCustomEvent and listen with
on GlobalCustomEvent:
@left in pressed: exec
var presses: int = 0
on pressed {
presses += 1
SendGlobalCustomEvent("doorbell", presses)
}
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
on GlobalCustomEvent("doorbell") -> (count: int) {
rings = count
}
How they differ from personal events:
- Who hears it. Every
on GlobalCustomEventwith the channel’s name runs, in every player’s circuits. - Separate channels. A global
"doorbell"and a personal"doorbell"never meet:SendGlobalCustomEventdoes not reachon CustomEvent, andSendCustomEventdoes not reachon GlobalCustomEvent. - Admin to send. Send Global Custom Event is an admin gate, so only an admin can load the button chip. Listening needs no admin: anyone’s chip can react to an admin’s global events.
The rest works the same way: up to 8 values, target with isObject = true
for one grid, and the receiver runs on the tick after the send. Use personal
events inside one player’s build, and global events when circuits pasted by
different players must talk, such as a server’s round system that everyone’s
chips follow.
Modify
- Send a second value: add
var door: int = 1to the button chip and sendSendCustomEvent("doorbell", presses, door). Receive it as-> (count: int, door: int)and show it on the sign. Paste a second button chip withdoor = 2, so the sign says which door rang. - Make the light chip’s sign show “Back door” or “Front door” instead of the number.
- In the reply version, wire the button chip’s
lampto a small Point Light beside the button, so the person ringing sees that someone heard.
Make
Make a call board with three buttons in different rooms and one board in a fourth room. Each button chip sends its room number. The board shows the last room that called and lights a lamp for it until a reset button on the board is pressed.
chip "Board"
chip2 "Call"
button1 Held -> chip2 pressed
button2 Held -> chip reset
chip sign -> decal Text
chip lamp1 -> light1 Enabled
chip lamp2 -> light2 Enabled
chip lamp3 -> light3 Enabled
Good to Know
- Events reach only circuits pasted by the same player. A chip a friend pasted does not hear yours, unless the event is global.
- The receiver runs on the tick after the send.
- A constant argument is not wired, and the receiver can read 0 or an empty
string. Send a
varinstead. - A send to a grid needs
isObject = trueon the receiver, and a receiver marked that way hears only sends to its grid. - One send gate fired many times in a single tick does not deliver every time. The game logs “Too many events at once!” for that channel. Separate send gates firing once each on the same channel all deliver.
- When several events arrive in one tick, their order is not fixed.
See Custom Events in the reference.
Next: Waiting.
Waiting
Before this: Custom Events
Next steps: Forking and Merging, Loops Across Ticks, Ready Check
Parts that use this: Proximity Door, Pressure Pad Door, Hold to Use, Moving Platform, Keycard Door, Whack-a-Light
A handler so far ran from top to bottom in one go. await lets it stop
partway, wait for something to happen, and carry on from there. This lesson
builds a reaction timer, then a guessing game that waits for one of two
buttons.
Contents
- Code
- Run
- Under the Hood
- Racing two waits
- Signals of your own
- Calling from outside a handler
- Modify
- Make
- Good to Know
button1 Held -> chip start
button2 Held -> chip hit
chip light -> light Enabled
chip sign -> decal Text
Code
@left in start: exec
@left in hit: exec
var lit: bool = false
var began: float = 0.0
var shown: string = "Press start"
@right out light = lit.Value
@right out sign = shown.Value
on start {
shown = "Wait for the light..."
await Sleep(_, delay = 2.0)
lit = true
began = ServerUptime()
await hit
lit = false
shown = "${ServerUptime() - began} seconds"
}
The handler is one sequence with two waits in it. await Sleep(...) waits
two seconds. await hit waits for the hit button to be pressed.
ServerUptime() is the number of seconds the server has been running, so
storing it when the light comes on and subtracting it after the press gives
the reaction time.
What happens if you press hit while the sign still says “Wait for the
light…”?
Run
- Compile the chip and paste it in-game. Place two Buttons, a Point Light and a brick with a Text Decal.
- Wire one Button’s Held to
startand the other’s tohit. Wirelightto the Point Light’s Enabled andsignto the Text Decal’s Text. - Press start. Two seconds later the light comes on. Press hit as fast as you can. The light goes off and the sign shows your time.
- Press hit early, during the wait. Nothing happens, and the timer still waits for a press after the light comes on.
Under the Hood
Open the chip. Each await became the same handful of gates:
- a Var that holds the await’s armed flag,
- a Set Variable that turns the flag on when the chain reaches the
await, - a Get Variable and a Branch that let the waited-for pulse through only while the flag is on,
- a Set Variable that turns the flag off again.
That is about five gates per await. The Sleep adds one Buffer
(Seconds). Its input is the armed flag (that is what _ stands for), and
its output turns on two seconds after the flag does.
The flag is why an early press does nothing. hit is wired into the second
await’s Get Variable all the time, but until the chain gets there, the flag
is off and the Branch drops the pulse.
Racing two waits
Nobody pressing hit leaves the timer waiting forever. || between two
waits resumes on whichever comes first:
@left in start: exec
@left in hit: bool
var shown: string = "Press start"
@right out sign = shown.Value
on start {
shown = "Press hit within 3 seconds"
await hit || Sleep(_, delay = 3.0)
if hit {
shown = "In time"
} else {
shown = "Too slow"
}
}
The two sides go into an OR gate, and the await resumes when the OR
turns on. The race does not say which side won. A press normally still holds
hit on when the chain resumes, so if hit tells the two apart. That
reads the pin’s value, so hit is a bool here, not an exec.
Signals of your own
let answer: exec makes a signal, an exec with a name, which any handler can
fire with emit and any handler can wait for with await. It can carry a
value along:
@left in ask: exec
@left in left: exec
@left in right: exec
var shown: string = "Press ask"
@right out sign = shown.Value
let answer: exec
on left {
emit answer = 1
}
on right {
emit answer = 2
}
on ask {
let secret = Random(1, 2)
shown = "Left or right?"
let picked = await answer
if picked == secret {
shown = "Right guess!"
} else {
shown = "Wrong guess"
}
}
emit answer = 1 fires the signal and stores 1 with it. let picked = await answer waits for the signal and reads the value that came with it. The
value is kept in a hidden variable, so it is still there when the waiting
handler picks it up.
Wire three Buttons to ask, left and right. Pressing left or right
before ask does nothing, for the same reason the early hit did nothing.
Calling from outside a handler
Some calls are steps, like Random, and need a pulse to run. Inside a
handler the pulse comes from the chain. Outside one, give it with exec =:
@left in roll: exec
let r = Random(1, 6, exec = roll)
@right out face = r
exec = roll wires roll into the Random gate’s exec input. There is no
handler and no Set Variable, just one gate. The same works for array and map
steps.
Modify
- Make the wait before the light random: store
Random(1, 4)in avar delay: floatfirst, thenawait Sleep(_, delay = delay). - Give the reaction timer the race: wait for
hit || Sleep(_, delay = 3.0)and show “Too slow” when the light was missed. The race readshitas a value, so make it@left in hit: bool, as in the race above. Then catch a cheater: checkhitright before the light comes on, and show “Too early!” if it is already held. - In the guessing game, add a third button and a third answer. Then add a
timeout: a second
on askhandler that waits five seconds and doesemit answer = 0. Show “Out of time” whenpickedis 0. (A race with||cannot carry a value, so the timeout sends one of its own.)
Make
Make a “Simon says” for one button: the light blinks one to three times
(chosen with Random), then the player presses the button that many times
within four seconds. Use a signal that the button handler fires, and count
the presses in a var.
button1 Held -> chip start
button2 Held -> chip press
chip light -> light Enabled
Good to Know
- A pulse that arrives before its
awaitis reached is dropped, not saved for later. awaitwaits for a pulse. With a Button’s Held wired to the pin, that is the moment Held turns on, so holding the button down while the await is reached does not count as a press.Sleepmeasures seconds andSleepTicksmeasures ticks.SleepTickswithdelay = 0resumes on the next tick, anddelay = Nafter N + 1 ticks.- A value stored with
ServerUptime()is only good while the server keeps running. A restart sets the uptime back to zero.
See Await, Payload Ferrying and Explicit Exec Argument in the reference.
Next: Forking and Merging.
Forking and Merging
An exec chain does not have to be a single line. It can split one way or the other, split into two paths that both run, and join back together. This lesson builds a shop counter with an open sign and a bell, then takes its pulses apart and puts them back together.
Contents
- Code
- Run
- Under the Hood
- Merging with Union
- Forking with
.exec - Naming a handler
- Modify
- Make
- Good to Know
switch Enabled -> chip staffed
button Held -> chip bell
chip sign -> decal Text
chip chime -> speaker Play
Code
@left in staffed: bool
@left in bell: exec
var shown: string = "Closed"
@right out sign = shown.Value
@right out chime: exec
on staffed {
shown = "Open"
}
on !staffed {
shown = "Closed"
}
let door = Branch(staffed, bell)
on door.A {
emit chime
}
on door.B {
shown = "Closed. Back soon"
}
on !staffed runs when staffed turns off, the opposite edge to
on staffed.
Branch(staffed, bell) is a gate that sends each bell pulse one of two
ways: out of .A while staffed is on, out of .B while it is off.
on door.A and on door.B are handlers on those two outputs.
The switch is off and someone rings. What does the sign say, and does the chime play? What happens when the switch is then flipped on and off again?
Run
- Compile the chip and paste it in-game. Place a Switch behind the counter, a Button in front of it, a brick with a Text Decal and a One Shot Audio brick with a sound picked in its settings.
- Wire the Switch’s Enabled to
staffedand the Button’s Held tobell. Wiresignto the Text Decal’s Text andchimeto the One Shot Audio’s Play. - Flip the switch on and the sign says “Open”. Ring, and the chime plays.
- Flip the switch off and ring: no chime, and the sign asks customers to come back.
Under the Hood
Open the chip. on staffed is a Set Variable wired straight from staffed.
on !staffed puts a NOT gate in front of its Set Variable, so the chain
starts when the NOT turns on, which is when staffed turns off.
There is one Branch gate. staffed goes into its Cond input and
bell into its Exec. Exec Out A is wired straight to the chime
pin, and Exec Out B to the Set Variable for the “Back soon” sign.
An if inside a handler compiles to this same Branch gate. Branch(...) is
the gate itself, for when there is no handler to put an if in.
door.A reads a field of a result, and on can trigger on a field like
that, the same as on a pin.
Merging with Union
Two exec wires cannot go into one input. To run one handler from two pulses, merge them with a Union gate:
@left in front: exec
@left in back: exec
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
let rang = Union(front, back)
on rang {
rings += 1
}
rang is a named exec, a Union of the two buttons. It fires when either
does. You can also write the call straight into the trigger:
on Union(front, back) { ... }.
|| is not a merge. It makes one value, true while either side is true, so
on front || back misses a press of one button while the other is held.
With an object on one side, it is true for as long as the object exists,
and the compiler warns:
@left in front: bool
@left in rider: character
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
on front || rider {
rings += 1
}
WARN [WS116] `||` here makes one value, it does not wait for either side: an object is true for as long as it exists, so this handler fires once, when the object appears, and never again. To fire on either, write `on Union(a, b)`
Forking with .exec
One exec output wired into two inputs runs both. That is how one pulse starts two things at once. A call normally joins the chain, so the next line waits until it has finished.
@left in bell: exec
var lit: bool = false
var rings: int = 0
@right out light = lit.Value
@right out sign = "Rings: ${rings.Value}"
chip Flash() {
lit = true
await Sleep(_, delay = 1.0)
lit = false
}
on bell {
let _ = Flash().exec
rings += 1
}
chip Flash() { ... } names a block of steps that runs wherever Flash() is
called, on a microchip brick of its own.
Chips covers chips in full. Flash() lights the
lamp for a second. .exec on the call names the pulse
it sends when it has finished, and let _ = lets that pulse go unused. The
call then runs beside the chain instead of in it, so the count goes up at
once. Write plain Flash() instead and the sign changes a second late,
because rings += 1 waits for the flash.
To run something after the flash, keep the result and trigger on its
.exec:
let f = Flash(exec = bell)
on f.exec {
flashes += 1
}
exec = bell starts the flash from bell with no handler around it, as
Random(..., exec = ...) did in Waiting. on f.exec runs
when it has finished.
Naming a handler
let ... = on gives a handler a name, and the name is a trigger that fires
after the handler’s body has run:
@left in bell: exec
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
@right out chime: exec
let rang = on bell {
rings += 1
}
on rang {
emit chime
}
Without a body, let ... = on names the trigger itself. An on that uses
the name runs as if the trigger, with its settings, were written there:
@left in room: zone
var visits: int = 0
@right out sign = "Visits: ${visits.Value}"
let entered = on ZoneEntered(zone = room)
on entered -> (character) {
visits += 1
character.ShowChatMessage("Welcome!")
}
Modify
-
A handler can declare an output of its own. Replace
shownandsignin the shop with these two handlers:@left in staffed: bool on staffed { @right out sign: string = "Open" } on !staffed { out sign = "Closed" }Open the chip. The two writes share a hidden Var that drives the pin. It starts empty, so the sign is blank until the switch first moves.
-
Give the shop a back door: merge a second bell Button into the first with
Union, and branch onstaffedafter the merge. -
Make the chime flash a light too, without delaying the sign: fork a
Flash()fromon door.A.
Make
Make a stage light. A Switch turns the show on and off. While the show is on, a Button flashes a spotlight for one second and plays a sound at the same moment, and a sign counts the flashes after each one ends. While the show is off, pressing the Button shows “No show tonight” on the sign.
switch Enabled -> chip show
button Held -> chip flash
chip spot -> light Enabled
chip sound -> speaker Play
chip sign -> decal Text
Good to Know
on xfires whenxturns on andon !xwhen it turns off. Neither fires again while the switch stays where it is.- A Branch decides by its Cond at the moment the pulse arrives. Changing the condition afterwards does not reroute a pulse already sent.
- Exec wires may split but not join. One output into many inputs is fine, but many outputs into one input is not. The compiler merges them with a Union when your code asks for it.
See Forking the Exec Chain,
Exec-flow gates,
Negated Triggers,
Union Triggers,
Handler-declared outputs and
let on in the reference.
Next: pick a lesson from the tree on the Learn page.
Mods
Before this: Reacting to the Game
Next steps: Chips, Tuples, Option and Result, Ref Parameters, Every Player, Testing
Parts that use this: Combination Lock, Checkpoints, Race Timer, Shop, Voting, Obstacle Course, Whack-a-Light, Card Table
A mod gives a piece of circuit a name, so you can write it once and use it
in many places. Every place that uses it gets its own copy of the gates.
Contents
zone1 Occupied -> chip busy1
zone2 Occupied -> chip busy2
chip sign1 -> decal1 Text
chip sign2 -> decal2 Text
Code
@left in busy1: bool
@left in busy2: bool
mod status(name: string, busy: bool) -> string {
return if busy then "${name}: busy" else "${name}: free"
}
@right out sign1 = status("Workshop", busy1)
@right out sign2 = status("Storage", busy2)
mod status(name: string, busy: bool)declares a mod with two inputs, like the pins of a small chip.-> stringsays it gives back one value, a piece of text.returnnames that value. Here it is oneif ... then ... elseexpression:if busy then A else BisAwhilebusyis true andBotherwise. Choices can chain:if a then X else if b then Y else Z.status("Workshop", busy1)is a call. It runs the mod’s body withnameset to"Workshop"andbusyset tobusy1.
The mod is written once and called twice. How many gates does the chip hold?
Which of them belongs to status?
Run
- Compile the chip and paste it in-game. Build two small rooms. Give a brick in each an Attached Zone that fills the room, and place a Text Decal by each door.
- Wire the first zone’s Occupied to
busy1and the second’s tobusy2. - Wire
sign1andsign2to the two Text Decals’ Text. - Walk into the workshop and its sign says
Workshop: busy. Walk out and it saysWorkshop: free. The storage sign does not change.
Under the Hood
Open the chip. There are two Select gates and nothing named status.
Each call became its own Select. Click one. Its two text inputs are typed
in, Workshop: free and Workshop: busy. The other Select holds the storage
texts. The name was a constant, so the compiler worked out both texts ahead
of time and typed them into that call’s copy.
The compiler pastes a mod’s body in at each call and fills in the inputs.
One call is one Select, and ten calls would be ten. In a code editor, hover over status to see “inlined per
call”, the compiler’s reminder of this.
A mod can also choose its value step by step, with if statements and an
early return:
@left in busy1: bool
mod status(name: string, busy: bool) -> string {
if busy { return "${name}: busy" }
return "${name}: free"
}
@right out sign1 = status("Workshop", busy1)
Error: [WS007] `status` returns from inside a branch, so choosing between its returns needs an exec context: call it from a handler or a `mod` body, or pass `exec = ...`
if statements run one step after another, so they need a pulse to run on.
An out pin has none. Write the choice as one expression, as in the first
version, or call the mod inside an on handler, where there is a pulse.
Modify
-
Add a third room, an office. Add
@left in busy3: booland@right out sign3 = status("Office", busy3). Open the chip again and there is a third Select. -
Give
statustwo outputs by naming them. A light by each door shows whether the room is free:@left in busy1: bool @left in busy2: bool mod status(name: string, busy: bool) -> (text: string, lit: bool) { out text = if busy then "${name}: busy" else "${name}: free" out lit = !busy } let workshop = status("Workshop", busy1) let storage = status("Storage", busy2) @right out sign1 = workshop.text @right out sign2 = storage.text @right out free1 = workshop.lit @right out free2 = storage.litWith named outputs, a call gives back a record, and
workshop.textandworkshop.litread its two values. Wirefree1andfree2to two Point Lights’ Enabled. -
Count how many times each room has been used. A mod can change a variable it is given, if the input is marked with
*:@left in busy1: bool @left in busy2: bool var visits1: int = 0 var visits2: int = 0 mod status(name: string, busy: bool) -> string { return if busy then "${name}: busy" else "${name}: free" } mod bump(count: *int, by: int = 1) { count += by } on busy1 { bump(visits1) } on busy2 { bump(visits2) } @right out sign1 = "${status("Workshop", busy1)}<br>${visits1.Value} visits" @right out sign2 = "${status("Storage", busy2)}<br>${visits2.Value} visits"count: *intmeans “a variable holding an int”, not a copy of its value, sobump(visits1)adds tovisits1itself. Ref Parameters covers*in full.by: int = 1is a default, so a call that leavesbyout uses 1.<br>in the sign’s text starts a new line. Change the storage room to count each visit twice, withbump(visits2, 2).on busy1fires when the room goes from empty to occupied, so a second person walking in while the first is still there is not counted.
Make
Make a sign for a parking area with three bays, each with its own zone. Write
a mod that takes a bay’s number and whether it is taken, and returns one line
of text such as Bay 2: taken. Show all three lines on one sign, and add a
fourth line that says Full when every bay is taken.
zone1 Occupied -> chip bay1
zone2 Occupied -> chip bay2
zone3 Occupied -> chip bay3
chip sign -> decal Text
Good to Know
- Every call is a full copy. A mod of 50 gates called from ten places is 500 gates, and nothing warns you. Counting the Cost shows how to check.
- A
static var, a variable declared inside a mod that keeps its value between runs, is copied too, so each call has its own. Two calls never share a count. - A zone’s Occupied stays on while anyone is inside, so the sign shows
busyuntil the last person leaves.
See also: mod: Inline Chips,
Default and Named Arguments and
return.
Next: Chips.
Chips
Before this: Mods
Next steps: Functions, Presentation
A chip is a mod that gets a microchip brick of its own. Its gates sit on
their own grid inside the brick, which you can open in-game. Chips keep a big
circuit tidy, and a named chip can be placed many times, each with its own
state.
Contents
button1 Held -> chip red
button2 Held -> chip blue
button3 Held -> chip reset
chip board -> decal Text
Code
@left in red: exec
@left in blue: exec
@left in reset: exec
chip Tally(press: exec, clear: exec) -> int {
var n: int = 0
on press { n += 1 }
on clear { n = 0 }
out _ = n.Value
}
let redScore = Tally(red, reset)
let blueScore = Tally(blue, reset)
@right out board = "Red ${redScore} Blue ${blueScore}"
chip Tally(...) is written like a mod: inputs in brackets, -> int for
the value it gives back. Inside, a chip can hold anything a file can, such as
a variable, handlers, and out _ = ... to set the value it gives back (_ is
the name of a single unnamed output).
Tally keeps a count in var n. Red and Blue both call it. When Red’s
button is pressed, does Blue’s score go up too?
Run
- Compile the chip and paste it in-game. Place three Buttons, for Red, Blue and Reset, and a Text Decal.
- Wire each Button’s Held to
red,blueandreset, andboardto the Text Decal’s Text. - Press Red three times and Blue once, and the sign says
Red 3 Blue 1. Press Reset and both go back to 0.
Under the Hood
Open the chip. Inside are two microchip bricks, both labeled Tally. Open
one. It holds a Var gate n, an Increment Variable for on press,
and a Set Variable for on clear.
Each call made a new chip with its own n, so Red and Blue keep separate
scores. That is the same rule as a mod (every call is a copy), with a brick
around each copy.
Look at the edge of an open Tally: three pins, press, clear and _.
Each is a Microchip Input or Microchip Output gate. Every wire that
crosses a chip’s wall goes through a pin. One pin serves every gate inside
that reads the same thing, and a constant needs no pin at all, because it is
typed into the gate that uses it. So a chip costs one pin for each different thing
that crosses its wall.
The wall is only visual. A chip’s body can read and change a top-level
var directly, and the wire crosses the wall through a pin. Nothing
about the circuit’s behavior changes when gates move into a chip.
The @left and @right pins belong to the outermost chip, the one the whole
file compiles to. A chip inside it cannot have side pins:
@left in red: bool
chip {
@right out shown = red
}
Error: [WS023] side annotations only apply to top-level ports of the compiled file
Declare the pin at the top level instead, and set it from inside the chip.
Modify
-
Add a third team, Green: one more input, one more
Tally(...)call, and one more microchip when you open the chip. -
Give
Tallya second output that pulses when a team reaches 5:@left in red: exec @left in blue: exec @left in reset: exec var winner: string = "nobody yet" chip Tally(press: exec, clear: exec) -> (score: int, won: exec) { var n: int = 0 on press { n += 1 if n == 5 { emit won } } on clear { n = 0 } out score = n.Value } let r = Tally(red, reset) let b = Tally(blue, reset) on r.won { winner = "Red" } on b.won { winner = "Blue" } on reset { winner = "nobody yet" } @right out board = "Red ${r.score} Blue ${b.score} Winner: ${winner.Value}"won: execis a pulse output.emit wonfires it, andon r.wonruns when Red’s chip fires it. -
Chips without names. This version keeps the scores at the top level and puts each piece of work in a chip of its own:
@left in red: exec @left in blue: exec @left in reset: exec var redScore: int = 0 var blueScore: int = 0 chip on red { redScore += 1 } chip on blue { blueScore += 1 } @closed chip ClearScores() { redScore = 0 blueScore = 0 } on reset { ClearScores() } chip let board = "Red ${redScore.Value} Blue ${blueScore.Value}" @right out text = boardchip on red { ... }is a handler in a chip of its own.chip let board = ...puts one calculation in a chip.ClearScoreshas steps but no handler. Called insideon reset, it runs as a step of that handler, and the handler carries on after it.@closedmakes a chip arrive shut. Chips arrive open by default.
Open the chip. The
boardchip has two pins in, one for each score, and one out.ClearScoreshas four: a pulse in, a pulse out, and one for each variable it sets.
Make
Make a row of three lights, each with its own Button. Write one named chip that takes a Button’s Held and gives back whether its light is on, where each press flips it. Call it three times. Then add a fourth Button that turns all three lights off at once.
button1 Held -> chip toggle1
button2 Held -> chip toggle2
button3 Held -> chip toggle3
button4 Held -> chip allOff
chip lit1 -> light1 Enabled
chip lit2 -> light2 Enabled
chip lit3 -> light3 Enabled
Good to Know
- A named chip called twice is two chips with two sets of state. If two
places must share a count, keep the count in a top-level
varand pass it in, or let the chip change it directly. - A chip’s pins are labeled with the names from your code, so good input names make the opened chip easy to follow.
See also: Chips, Boundary Pins and Open and Closed Chips.
Next: Functions.
Functions
A fn is written like a mod, but it is built only once. Every call runs that
one copy, so calling it from many places costs one small gate per call
instead of a whole copy of the body.
Contents
button1 Held -> chip a
button2 Held -> chip b
button3 Held -> chip c
chip board -> decal Text
Code
@left in a: exec
@left in b: exec
@left in c: exec
var hitsA: int = 0
var hitsB: int = 0
var hitsC: int = 0
var signA: string = ""
var signB: string = ""
var signC: string = ""
fn medal(hits: int) -> string {
if hits >= 10 { return "gold" }
if hits >= 5 { return "silver" }
return "bronze"
}
on a {
hitsA += 1
signA = "A: ${hitsA} hits, ${medal(hitsA)}"
}
on b {
hitsB += 1
signB = "B: ${hitsB} hits, ${medal(hitsB)}"
}
on c {
hitsC += 1
signC = "C: ${hitsC} hits, ${medal(hitsC)}"
}
@right out board = "${signA.Value}<br>${signB.Value}<br>${signC.Value}"
Three targets each count their hits, and the sign gives each one a medal.
medal is written with fn instead of mod, and it is called in three
places. If it were a mod, there would be three copies of its body. How many
are there now?
Run
- Compile the chip and paste it in-game. Place three Buttons as targets and a Text Decal.
- Wire the Buttons’ Held to
a,bandc, andboardto the Text Decal’s Text. - Press one target five times and its line changes from bronze to silver.
Under the Hood
Open the chip. Among the handlers’ gates are three Function Call gates,
one per call, and one extra board, a Function Definition microchip. Open
it. The body of medal is in there once:
- a Function Entry, where each call starts
- a Function Input for
hitsand a Function Output for the result - a Var that holds the answer, a Set Variable for each
returnand a Get Variable to read it back - the two Greater/Equal tests, two Branch gates, and two Unions where the paths join
That is 14 gates in all.
Each Function Call is wired to the definition. When a handler reaches its
call, the call hands hits to the board, the body runs to its end, and the
handler carries on with the answer.
Count it up. As a fn, medal costs its 14 gates once, plus one Function
Call per call, so 17 for three calls. As a mod it has no entry, input or
output, so it is 11 gates, but copied into every call, so 33 for three
calls. With one call the mod is cheaper. From two calls on, the fn is.
A function’s board has no wires coming in from outside. Everything it uses
arrives through the call. A top-level var still works inside a fn (the
compiler gives the board a handle to it), but a pin or a let does not:
@left in a: exec
@left in bonus: bool
var hitsA: int = 0
fn points(hits: int) -> int {
if bonus { return hits * 2 }
return hits
}
on a { hitsA = points(hitsA + 1) }
@right out board = hitsA.Value
Error: [WS113] `fn points` reads `bonus` from outside its plane. A function reaches outer storage (a `var`, array or map) and constants, not a computed value: pass it as a parameter
As the message says, make it an input,
fn points(hits: int, bonus: bool), and call points(hitsA + 1, bonus).
A function also cannot give back a record or an enum (WS114). Use a mod
for those.
Modify
-
Change
fn medaltomod medaland compile again. It still works. Open the chip. There is no Function Definition, and there are three copies of the tests and branches, one in each handler. -
A
fnneeds a pulse to run on. Inside a handler it uses the handler’s. Outside one, give it a pulse withexec =. Here a Check button asks for the medal:@left in hit: exec @left in check: exec var hits: int = 0 var sign: string = "Press check" fn medal(n: int) -> string { if n >= 10 { return "gold" } if n >= 5 { return "silver" } return "bronze" } on hit { hits += 1 } on medal(hits.Value, exec = check) -> (m) { sign = "${hits} hits: ${m}" } @right out board = sign.Valueon medal(..., exec = check) -> (m)runs the call each timecheckturns on, then runs the block with the answer inm. -
A function can change top-level variables. Keep the best score of all targets: add
var best: int = 0, putif hits > best { best = hits }as the first line ofmedal, and addBest: ${best.Value}to the sign. Open the Function Definition. A new Var gate labeled&bestis the handle that reachesbest.
Make
Make a sign that shows time as text such as 2 min 5 s. Write a fn that
turns a number of seconds into that text. Give it three Buttons that add 10,
30 and 60 seconds to a total, and one that clears it, and show the total
through your function. Between two ints, / divides and drops the
remainder, and % gives that remainder: 125 / 60 is 2 and 125 % 60 is 5.
button1 Held -> chip add10
button2 Held -> chip add30
button3 Held -> chip add60
button4 Held -> chip clear
chip sign -> decal Text
Good to Know
- A
fncall runs the whole body before the handler’s next step, within the same tick. - A variable declared inside a
fnis shared by every call and starts again from its first value at each call. One call never sees what another left there. - In a chip that is spawned as a prefab (a later branch), a
fncannot reach top-level variables. Pass values in and give them back.
See also: fn: Function and
When to use fn, and its limits.
Next: Imports.
Imports
Before this: Functions
Next steps: Counting the Cost, Writing Libraries
Mods, chips and functions can live in a file of their own, and other files
can bring them in with import. One file of helpers then serves every
circuit you build.
Contents
zone "Zone Ref" -> chip room
chip text -> decal Text
Code
import { escape, sanitize } from "sanitize"
@left in room: zone
var last: string = "Nobody yet"
var guests: int = 0
@right out text = "Last guest: ${last.Value}<br>Guests: ${guests.Value}"
on ZoneEntered(zone = room) -> (character) {
let name = character.GetDisplayName()
last = sanitize(name, 16)
guests += 1
character.ShowChatMessage("Welcome, ${escape(name)}!")
}
import { escape, sanitize } from "sanitize" brings two helpers in from a
file named sanitize.ws, the lessons’
starter library. escape is the mod
from Reacting to the Game that makes a name
safe to show. sanitize is a fn that also trims spaces from the ends and
cuts the text to a length, here 16 characters. After the import, both are
called as if they were written in this file.
When you paste this chip in-game, where is the code for escape and
sanitize? Does the game need the library file?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone, and place a Text Decal.
- Wire the zone’s Zone Ref to
room, andtextto the Text Decal’s Text. - Walk into the zone. The sign shows your name and a count of 1, and chat
says
Welcome, with your name.
Under the Hood
Open the chip. The library’s code is in it:
escapeis a mod, so it was copied in as one Replace in String gate beside the Show Chat Message.sanitizeis afn, so the chip has a Function Definition board holding its body (Trim String, Get Substring, Replace in String) and one Function Call in the handler.
The compiler reads the imported file and builds what you use into the chip. The save holds everything, and the game never sees the library file.
There are three ways to write an import:
import { escape, sanitize } from "sanitize" // just these names
import "sanitize" // everything in the file
import * as safe from "sanitize" // everything, as safe.escape(...)
The last form keeps the library’s names apart from yours. The calls become
safe.escape(name) and safe.sanitize(name, 16). Forget the import, and
the compiler stops at the first call:
@left in room: zone
var last: string = "Nobody yet"
@right out text = "Last guest: ${last.Value}"
on ZoneEntered(zone = room) -> (character) {
last = safe.sanitize(character.GetDisplayName(), 16)
}
Error: [WS002] unknown identifier 'safe' in call `safe.sanitize(...)`: no namespace, variable, or value named 'safe' is in scope (is an import missing?)
Modify
-
Change the import to
import * as safe from "sanitize"and putsafe.in front of both calls. Then tryimport "sanitize"with the plain names. All three compile to the same gates. -
Start a library of your own. In the playground, press + under Files to add a file, and rename it to
medals.ws. In VS Code, savemedals.wsin the same folder as your main file. Put a helper in it:mod medal(hits: int) -> string { return if hits >= 10 then "gold" else if hits >= 5 then "silver" else "bronze" }Then use it from your main file:
import { medal } from "medals" @left in hit: exec var hits: int = 0 @right out board = "${hits.Value} hits, ${medal(hits.Value)}" on hit { hits += 1 }The path is the file’s name without
.ws. In VS Code it is counted from the importing file’s folder, so a file in a subfolder isimport { medal } from "lib/medals". -
Move a variable into the library: add
var total: int = 0tomedals.ws, importtotaltoo, and add one to it in the handler. A variable from an import is shared. Every file in the program that imports it reads and writes the same Var gate.
The examples on these pages can import only the starter library. Their
Compile button knows sanitize but none of your files. Your own libraries
work in the playground and VS Code.
Make
Write a library with two helpers for signs: a mod that pads a number to two
digits (7 becomes 07) and a fn that turns seconds into m:ss. Use both
from two different chips, a lap timer and a countdown, each in its own main
file. As in Functions, / and % split seconds into minutes
and the seconds left over.
chip "Lap Timer"
chip2 "Countdown"
button1 Held -> chip lap
chip sign -> decal1 Text
button2 Held -> chip2 start
chip2 sign -> decal2 Text
Good to Know
- Each pasted chip carries its own copy of what it imported. Two chips built
from the same library share nothing in-game, not even a library
var. - A library can have its own
onhandlers.import { name } from "x"brings only the names it lists and leaves those handlers out. The compiler warns (WS014). Useimport "x"orimport * as x from "x"to keep them. - Change the library and the chips already pasted in-game stay as they were. Compile and paste each one again to update it.
See also: Imports.
Next: Counting the Cost.
Counting the Cost
Before this: Imports
Next steps: Const and Folding
Every gate is a brick that has to be pasted, loaded and run. This lesson counts them: how a mod, a chip and a function multiply, how to read the count from the compiler, and the second kind of cost that a gate count does not show.
Contents
button1 Held -> chip a
button2 Held -> chip b
button3 Held -> chip c
chip board -> decal Text
Code
@left in a: exec
@left in b: exec
@left in c: exec
var hitsA: int = 0
var hitsB: int = 0
var hitsC: int = 0
mod medal(hits: int) -> string {
return if hits >= 10 then "gold" else if hits >= 5 then "silver" else "bronze"
}
on a { hitsA += 1 }
on b { hitsB += 1 }
on c { hitsC += 1 }
@right out board = "A ${medal(hitsA.Value)}<br>B ${medal(hitsB.Value)}<br>C ${medal(hitsC.Value)}"
This is the medal board from Functions, with medal as a
one-line mod and the medals worked out straight into the sign. medal has
two tests and two choices. How many gates does it cost in all? How many
gates does each extra target add?
Run
- Compile the chip and paste it in-game. Place three Buttons and a Text Decal.
- Wire the Buttons’ Held to
a,bandc, andboardto the Text Decal’s Text. - Press a target five times and its medal turns to silver.
Under the Hood
Open the chip and count. Each call to medal is two Greater/Equal and
two Select gates, so 4 gates a call and 12 for three calls. Each target also has its
pin, its Var and its Increment Variable, so one more target adds 7
gates.
So the cost model is:
- A
modorchipcosts its body times the number of calls. A chip adds a microchip brick and its pins on top. - A
fncosts its body once, plus one Function Call per call.
Counting bricks in-game is slow. If you build the compiler from source,
just ir board.ws prints the compiled chip as text (the same as
wirescript compile board.ws --dump-ir). Each gate is one line:
%18 = Expr_CompareGreaterOrEqual(InputA: %hitsA.Value, InputB: 10)
%21 = Expr_CompareGreaterOrEqual(InputA: %hitsA.Value, InputB: 5)
%24 = Expr_Select(InputA: "bronze", InputB: "silver", bSelectB: %21)
%25 = Expr_Select(InputA: %24, InputB: "gold", bSelectB: %18)
(The real lines are longer. They also list the gate’s input and output
types, and the line and column of the code each gate came from.) Lines that
start %name = are gates. Among them, in lines are pins and chip lines
are microchips. This chip has 22. To count them:
just ir board.ws > board.wsir
grep -cE '^\s*%\S+ = ' board.wsir
In the playground or VS Code, hovering over a mod’s name shows a quick estimate. It is worked out before the compiler simplifies anything, so the real count can differ.
Steps in a row
A gate count says how much circuit there is. It does not say how much runs
when a pulse arrives. A handler is a chain of steps, each waiting for the
one before it, all within one tick. A fn call is one gate on that chain,
but the pulse goes through every step of the body before the chain carries
on. A for loop (in the Loops branch) is one body run many times over: few
gates, many steps. @sliced on a loop lets the game spread its passes over
several ticks.
Two handlers on the same pin are two chains. Neither waits for the other, and there is no saying which runs first, so split work this way only when the two halves do not depend on each other.
Modify
-
Write
medalwith early returns. A mod that returns from inside anifneeds a pulse, so the medals are now worked out in the handlers and kept in variables:@left in a: exec @left in b: exec @left in c: exec var hitsA: int = 0 var hitsB: int = 0 var hitsC: int = 0 var medalA: string = "" var medalB: string = "" var medalC: string = "" mod medal(hits: int) -> string { if hits >= 10 { return "gold" } if hits >= 5 { return "silver" } return "bronze" } on a { hitsA += 1 medalA = medal(hitsA) } on b { hitsB += 1 medalB = medal(hitsB) } on c { hitsC += 1 medalC = medal(hitsC) } @right out board = "A ${medalA.Value}<br>B ${medalB.Value}<br>C ${medalC.Value}"The chip grows from 22 gates to 52. Each early return sets a hidden variable, and the branches join again with Union gates.
medalis now 11 gates a call instead of 4. -
Change
mod medaltofn medal. The count drops to 37, because the body is built once, and each call is one Function Call. -
Keep the
fn, and write its body as the one-lineif ... then ... elseagain, and the count is 30. Then try ten targets. The four versions come to:How medalis written3 targets 10 targets mod, one expression, in the sign22 73 mod, early returns, in handlers52 173 fn, early returns, in handlers37 88 fn, one expression, in handlers30 81 (Ten targets is 2 more than 7 a target suggests, because a sign with ten values needs three Format String gates instead of one.)
The one-line mod wins at every size here. Its body is 4 gates, and a call costs nearly as much as that: the Function Call, plus a variable to keep each answer in and the gates that store and read it.
Make
Take a circuit you built in an earlier lesson that calls a mod more than
once. Count its gates, then try it with the mod written as one expression,
as an early-return mod, and as a fn. Which is smallest, and why?
Good to Know
- Nothing warns about size. A mod called from inside another mod is copied once per call of the outer one too, so the counts multiply.
- Every pasted chip is a separate copy. Ten chips pasted from one save cost ten times its gates.
- A
fnsaves gates only when its body is bigger than the cost of calling it. For a few gates of body, a one-linemodis smaller.
See also: Best Practices: Gate Count & Scaling, Profiling and Sliced loops.
Next: pick a lesson from the tree on the Learn page.
Presentation
Before this: Chips
A circuit that works can still be hard to use: which pin is which, which
chip does what. Annotations, the words that start with @, and /// doc
comments change how a chip looks in-game without changing what it does.
Contents
button1 Held -> chip red
button2 Held -> chip blue
button3 Held -> chip reset
chip board -> decal1 Text
chip leader -> decal2 Text
Code
/// One button per team, one to reset.
@label("Scoreboard")
@color(Color(0.1, 0.1, 0.4))
@left @label("Red +1") in red: exec
@left @label("Blue +1") in blue: exec
@bottom @label("Reset") in reset: exec
/// Counts presses until cleared.
@color(Color(0.6, 0.6, 0.6)) @material(Metal)
chip Tally(press: exec, clear: exec) -> int {
var n: int = 0
on press { n += 1 }
on clear { n = 0 }
out _ = n.Value
}
let redScore = Tally(red, reset)
let blueScore = Tally(blue, reset)
@label("Rounds: ${rounds}") @material(Glow)
var rounds: int = 0
on reset { rounds += 1 }
@right @label("Board") out board = "Red ${redScore} Blue ${blueScore}"
@top out leader = if redScore > blueScore then "Red" else if blueScore > redScore then "Blue" else "Tied"
This is the scoreboard from Chips, with annotations added:
- The lines at the top, each group followed by a blank line, are about the
whole chip:
///is its description,@labelits title,@colorthe color of its brick. @label("Red +1")on a pin changes the name shown beside it in-game.@bottomand@topput a pin on the chip’s bottom or top edge instead of a side.@colorand@materialonTallystyle its microchip brick.Metalis one of the brick materials.Glowlights theroundsvariable’s brick.@label("Rounds: ${rounds}")on a top-levelvaris a live label, which shows the value as it changes.
Only one of these annotations adds a gate. Which one?
Run
- Compile the chip and paste it in-game. The brick is blue and
labeled
Scoreboard.Red +1andBlue +1are on its left side,Reseton the bottom,Boardon the right, andleaderon top. - Wire three Buttons’ Held to the three input pins, and the
boardandleaderoutputs to two Text Decals’ Text. - Open the chip. Its plane has a header, with
Scoreboardin large letters and the description below it. The twoTallychips are gray metal. Open one and its header showsTallyandCounts presses until cleared. - Press Reset a few times. The glowing variable’s label counts the rounds.
Under the Hood
Compiled without any annotation, this chip has 22 gates. With them it has
23. The extra one is a Format String that builds the text
Rounds: 3 for the live label. Every other annotation only sets a label, a
color or a position on a brick that exists anyway.
A mod has no brick of its own (its gates are copied into the caller), so
it cannot take a label, a color or a pin side:
@label("Board")
mod medal(hits: int) -> string {
return if hits >= 10 then "gold" else "bronze"
}
Error: [WSP001] annotations are not allowed on 'mod' declarations
The blank lines at the top matter. An annotation with no blank line after
it belongs to the next declaration. @label("Scoreboard") directly above
@left in red: exec would rename that pin instead of the chip.
Modify
-
Lay the gates out like the code. Add
@layout("code")as the first annotation at the top, directly under the description’s blank line.@layoutand@flatmust come before@labeland@color:/// One button per team, one to reset. @layout("code") @label("Scoreboard") @color(Color(0.1, 0.1, 0.4))Open the chip. Each line of code is a row of gates, in the same order as the source, and indented the same way. A
//comment on a line of its own shows up as text between the rows. -
Make it small. Replace
@layout("code")with two lines,@flatand@layout("cube").@flatbuilds every chip’s gates onto one grid, so the twoTallychips are gone, along with their pins, their color and their headers.@layout("cube")packs the gates into a tight block. The circuit works as before. A file with afncannot be flat, because the function runs on its own board.@flatreportsWS080there. -
Add
@closedin front ofchip Tallyso the two chips arrive shut, and moveleaderto the bottom edge with@bottom. Pins on the same edge line up in the order they are declared.
Make
Take the largest circuit you have built so far and present it: a title and description for the chip, a label on every pin that says what to wire there, a color for each inner chip, and a live label on the one variable you most often want to check.
Good to Know
///directly above@labelor@colorat the top, with no blank line between them, keeps the description but hands the annotations to the first declaration below, not the chip. Leave a blank line after the description.@labelchanges only the name shown in the world. When you wire a pin, it is still listed by the name in the code,red, notRed +1.
See also: Outer Rerouter Pins,
@label,
@color / @material,
@layout("code"),
@flat and
Doc Comments.
Next: pick a lesson from the tree on the Learn page.
Records
Before this: Reacting to the Game, Lists
Next steps: Records in Storage, Enums
A record groups a few named values into one. type names the shape once,
and then a single var can hold a whole record, such as a round number, a
score and a best score.
Contents
button1 Held -> chip point
button2 Held -> chip nextRound
chip text -> decal Text
Code
type Tally = { round: int, points: int, best: int }
@left in point: exec
@left in nextRound: exec
var tally: Tally = { round: 1, points: 0, best: 0 }
@right out text = "Round ${tally.round.Value}: ${tally.points.Value} points, best ${tally.best.Value}"
on point {
tally = { ...tally, points: tally.points + 1 }
}
on nextRound {
if tally.points > tally.best {
tally = { ...tally, best: tally.points }
}
tally = { ...tally, round: tally.round + 1, points: 0 }
}
type Tally = { round: int, points: int, best: int }names a shape with three fields, each with its own type. On its own it builds nothing.{ round: 1, points: 0, best: 0 }is a record literal, with a value for every field, by name.tally.pointsreads one field. Outside a handler, add.Value, as with any variable.{ ...tally, points: tally.points + 1 }is a spread....tallycopies every field oftally, andpoints:after it replaces that one field.
Score three points, press nextRound, then score one more. What does the sign
say?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire one Button’s Held to
point, the other’s Held tonextRound, andtextto the Text Decal’s Text. - Press
pointthree times. The sign reads “Round 1: 3 points, best 0”. PressnextRoundand it reads “Round 2: 0 points, best 3”. One more point gives “Round 2: 1 points, best 3”.
Under the Hood
Open the chip. There is no gate called tally. There are three Var
gates, labeled tally.round, tally.points and tally.best. A record in a
var is one Var gate per field. The sign’s Format String is wired to all
three.
The point chain is a Get Variable for tally.points, an Add and a single
Set Variable. The spread names all three fields, but only points changes, so
the compiler writes only that one. tally.points += 1 would build the same
three gates.
The nextRound chain reads points and best, compares them with a Greater
and a Branch, and ends with two Set Variables, one for round and one for
points.
A literal must name every field. Leave out the spread and the compiler says which fields the value has and which it needed:
type Tally = { round: int, points: int, best: int }
@left in point: exec
var tally: Tally = { round: 1, points: 0, best: 0 }
on point {
tally = { points: tally.points + 1 }
}
Error: [WS003] expected {round: int, points: int, best: int}, got {points: int}
Modify
- Change the
pointhandler totally.points += 1. One field can be written on its own, without a spread. - Add a
missesfield. Add it to thetype, give it a starting value in the literal, show it on the sign, and add a third button that adds one to it. - Add a reset button. Name the starting record once with
const START: Tally = { round: 1, points: 0, best: 0 }, start withvar tally: Tally = START, and writetally = STARTin the reset handler. The reset chain is three Set Variables, each with a number typed in.
Make
Make a lap counter for a race. Keep a record with the number of laps, the
time the current lap started and the fastest lap so far. Use a zone at the
finish line and GetUnixTime() for the time. Each time a player walks in,
finish the lap, keep the fastest, and start the next one.
zone "Zone Ref" -> chip finish
chip text -> decal Text
Good to Know
- Each field is its own Var gate, so a record is saved with the world field by field, like any other variable.
- Two handlers that write the same record have no order between them. Press both buttons in the same tick, and either handler’s write can land last.
See Record Types for the full rules.
Next: Records in Storage.
Records in Storage
A record can live in an array or a map as well as in a var. This lesson
keeps a record for every player who hits a target, and counts what that
costs: one storage gate per field, however many players there are.
Contents
target "Just Hit" -> chip hit
target "Character That Just Hit" -> chip shooter
chip text -> decal Text
Code
import { escape } from "sanitize"
type Stats = { name: string, hits: int }
@left in hit: exec
@left in shooter: character
var stats: Map<string, Stats>
var shown: string = "No hits yet"
@right out text = shown.Value
on hit {
let id = shooter.GetUserId()
let before: Stats = if stats.has(id) then stats.get(id) else { name: "", hits: 0 }
let next: Stats = { name: shooter.GetDisplayName(), hits: before.hits + 1 }
stats.set(id, next)
shown = "${escape(next.name)}: ${next.hits} hits"
}
Map<string, Stats> files a whole Stats record under each player’s user
id. stats.has(id) checks whether this player has a record yet. If so,
stats.get(id) reads it. If not, the else starts them at zero. next is
the updated record, and stats.set(id, next) files it.
stats holds two fields for each player. How many map gates will the chip
need for it: one, two, or one per player?
Run
- Compile the chip and paste it in-game. Place a Target and a brick with a Text Decal.
- Wire the Target’s Just Hit to
hit, its Character That Just Hit toshooter, andtextto the Text Decal’s Text. - Shoot the target a few times. The sign shows your name and your count. A second player’s hits start their own count from 1.
Under the Hood
Open the chip. There are two Map Var gates, labeled stats.name and
stats.hits. A map of records is one map per field, and it stays two
however many players it holds.
Every operation fans out across the fields. stats.get(id) is two Get Map
Element gates, one per map, and stats.set(id, next) is two Set Map
Element gates. stats.has(id) is one Has Map Element, since every field
has the same keys. before and next are records too, but they are values,
not storage, and have no gates of their own.
The stats.name read is there although nothing uses before.name, because
getting a whole record reads every field.
The same rule holds for each kind of storage:
type Point = { x: int, y: int }
@left in step: exec
var here: Point = { x: 0, y: 0 }
var trail: Point[]
var marks: Map<string, Point>
@right out text = "At ${here.x.Value}, ${here.y.Value}"
on step {
let next: Point = { x: here.x + 1, y: here.y + 2 }
here = next
trail.push(next)
marks.set("last", next)
}
| Declaration | Storage gates |
|---|---|
var here: Point | 2 Var |
var trail: Point[] | 2 Array Var |
var marks: Map<string, Point> | 2 Map Var |
let next: Point | none |
trail.push(next) is two Push To Array gates, one per column. Some array
operations make no sense across columns, and the compiler refuses them:
type Point = { x: int, y: int }
@left in step: exec
var trail: Point[]
on step {
trail.push({ x: 1, y: 2 })
trail.sort()
}
Error: [WS050] `.sort()` is not supported on a record array - it has no per-field meaning (sorting/shuffling would desync the fields, and the aggregate/dual-array forms are not implemented). Use `pts[i]` for element access, or store a scalar array
Sort by one field instead: trail.x.sort() sorts the rows by x and moves
every y along with its row.
Modify
- Read only the field you need. Replace the
beforeline withlet hits = if stats.has(id) then stats.get(id).hits else 0, and usehits + 1innext. The chip now has one Get Map Element instead of two. - Add a
lastHit: intfield set toGetUnixTime()on every hit, and show how many seconds passed since that player’s previous hit. - Keep a leader. Add
var leader: Stats = { name: "nobody", hits: 0 }, and after each hit, ifnext.hits > leader.hits, setleader = next. Show the leader on a second sign. Count the gates the leader adds.
Make
Make a guest book for a zone. Keep each player’s name, number of visits and
the Unix time of their first visit in one Map<string, Visitor>. When a
player walks in, greet them with their visit count and how long ago they
first came.
zone "Zone Ref" -> chip room
chip text -> decal Text
Good to Know
- A Target’s Just Hit stays on for the Target’s On Time setting. The handler runs when it turns on, so a second hit that lands while it is still on is likely to be missed. Keep On Time short.
- A missing key is not a zero record you can count on. Check
has()before reading a record map. - Key players by
GetUserId(), not by the character. A player who dies comes back as a new character, and would start a new record.
See Records as storage for every operation a record array or map supports.
Next: Tuples.
Tuples
Before this: Records in Storage, Mods
Next steps: Destructuring
A mod can give back more than one value. Several values in a row, in
order, are a tuple. This lesson builds a mod with two outputs, takes them
apart with let (lo, hi) = ..., and passes a tuple on to another mod.
Contents
button1 Held -> chip pressA
button2 Held -> chip pressB
chip text -> decal Text
Code
mod minmax(a: int, b: int) -> (lo: int, hi: int) {
out lo = min(a, b)
out hi = max(a, b)
}
@left in pressA: exec
@left in pressB: exec
var a: int = 0
var b: int = 0
let (lo, hi) = minmax(a.Value, b.Value)
@right out text = "${lo} to ${hi}, a gap of ${hi - lo}"
on pressA { a += 1 }
on pressB { b += 1 }
aandbcount the presses of two buttons.-> (lo: int, hi: int)gives the mod two outputs, each with a name and a type.out lo = ...andout hi = ...set them, one line each.let (lo, hi) = minmax(a.Value, b.Value)takes the two values apart and names them, in order, so the first output becomesloand the secondhi. You choose the names, and they do not have to match the mod’s.
Press one button five times and the other twice. What does the sign say?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire the first Button’s Held to
pressA, the second’s Held topressB, andtextto the Text Decal’s Text. - Press the buttons. With five presses and two, the sign reads “2 to 5, a gap of 3”. It does not matter which button has more.
Under the Hood
Open the chip. Past the two counters (a Var and an Increment Variable
each), there are four gates: a Min, a Max, a Subtract for
hi - lo, and the Format String. The mod, the tuple and the let have no
gates of their own. A mod puts its body where it is called, and the tuple is
the two wires coming out of it.
The pattern on the left of = must have one name per value:
mod minmax(a: int, b: int) -> (lo: int, hi: int) {
out lo = min(a, b)
out hi = max(a, b)
}
@left in a: int
@left in b: int
let (lo, hi, mid) = minmax(a, b)
@right out gap = hi - lo
Error: [WS010] destructure shape: this pattern binds 3 position(s), but the value has 2 value(s)
Passing a tuple on
A tuple can also be kept whole and handed on:
mod minmax(a: int, b: int) -> (lo: int, hi: int) {
return min(a, b), max(a, b)
}
mod describe(lo: int, hi: int) -> string {
return "${lo} to ${hi}"
}
@left in pressA: exec
@left in pressB: exec
var a: int = 0
var b: int = 0
let range = minmax(a.Value, b.Value)
@right out text = describe(...range)
@right out gap = range.hi - range.lo
on pressA { a += 1 }
on pressB { b += 1 }
return min(a, b), max(a, b)sets both outputs on one line, in order.let range = minmax(a.Value, b.Value)keeps the tuple under one name.range.hiandrange.loread its values by the names the mod gave them.describe(...range)is a spread, which passes the tuple’s values as the call’s arguments,lofirst, thenhi.
Past the counters, the chip is again a Min, a Max, a Subtract and a Format String.
Modify
- Add a third button and a mod
order3(a: int, b: int, c: int) -> (lo: int, mid: int, hi: int). The middle value is the total minus the smallest and the largest. - Make the sign say “a tie” when the gap is 0. Use an
ifexpression onhi - lo. - Write your own tuple:
let pair = (a, b)makes one from two values. Spread it intominmax(...pair).
Make
Make a chip for four counted buttons that shows the most presses and which button has them. Write a mod that compares two buttons and returns both the bigger count and the button’s number, and call it three times.
button1 Held -> chip pressA
button2 Held -> chip pressB
button3 Held -> chip pressC
button4 Held -> chip pressD
chip text -> decal Text
Good to Know
- The sign is pure, so it follows the two counts by itself. Only the counting happens in handlers.
- Press only the first button five times, and the sign reads “0 to 5”.
See Tuple Destructuring and Spread in Call Arguments.
Next: Destructuring.
Destructuring
Before this: Tuples
Tuples took a mod’s outputs apart with let (lo, hi) = ....
Records come apart the same way, by field name instead of by position. This
works in a let, in a mod’s parameter list and in an event’s data.
Contents
zone "Zone Ref" -> chip room
chip text -> decal Text
Code
import { escape } from "sanitize"
type Visit = { name: string, seconds: int }
mod describe({ name, seconds }: Visit) -> string {
return "${escape(name)} stayed ${seconds} seconds"
}
@left in room: zone
var entered: Map<string, int>
var last: Visit = { name: "Nobody", seconds: 0 }
@right out text = describe(last)
on ZoneEntered(zone = room) -> { character: who } {
entered.set(who.GetUserId(), GetUnixTime())
}
on ZoneLeft(zone = room) -> { character: who } {
let { Value: since, Found } = entered.get(who.GetUserId())
if Found {
last = { name: who.GetDisplayName(), seconds: GetUnixTime() - since }
}
}
ZoneLeft is the opposite of ZoneEntered. It runs when a player walks out.
GetUnixTime() is the current time as a whole number of seconds, so the
difference between two readings is a time in seconds.
The code uses three kinds of destructuring:
-> { character: who }takes the event’s data by field name. The zone events have one field,character, andcharacter: whonames itwhohere.entered.get(...)gives back a record with two fields,ValueandFound.let { Value: since, Found } = ...takes both out, renamingValuetosinceand keepingFoundunder its own name.mod describe({ name, seconds }: Visit)takes aVisitand names its fields straight away, so the body usesnameandsecondswith novisit.in front.
Walk into the zone, wait ten seconds, and walk out. What does the sign say? What happens to a player whose entry time was never stored?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone, and place a brick with a Text Decal.
- Wire the Attached Zone’s Zone Ref to
room, andtextto the Text Decal’s Text. - The sign starts as “Nobody stayed 0 seconds”. When you walk in, wait and walk out, it shows your name and how long you stayed.
Under the Hood
Open the chip. Destructuring builds no gates. The Get Map Element in the
ZoneLeft chain has a Value output and a Found output, and the two
names are those wires. Found goes into a Branch, and since into the
Subtract that works out the stay.
describe(last) is a Replace in String (from escape) and a Format
String, wired from the last.name and last.seconds Var gates. Passing the
record to the mod and taking it apart again costs nothing.
A pattern can only name fields the value has:
type Visit = { name: string, seconds: int }
var last: Visit = { name: "Nobody", seconds: 0 }
let { name, time } = last
@right out text = name
Error: [WS002] no field `time` on this value; available fields: name, seconds
Modify
- Destructure a record in a
let. In theZoneLefthandler, after settinglast, addlet { seconds } = lastand send the player their own time withwho.ShowChatMessage("You stayed ${seconds} seconds"). - Keep the longest stay. Add
var longest: Visit = { name: "Nobody", seconds: 0 }and a second sign. Replace it whenever a stay beats it. - Write the events with tuple capture instead,
-> (who). Both forms name the same output. Record capture can also pick fields out by name when an event has several.
Make
Make a “hold the hill” zone. Keep each player’s total time inside across
visits, in a Map<string, Visit> keyed by user id, and show who has held it
longest. Write one mod that takes a Visit apart in its parameter list to
build the sign’s text.
zone "Zone Ref" -> chip hill
chip text -> decal Text
Good to Know
ZoneLeftmay fire for a player with no stored entry time, for example one who was already inside when the chip was pasted.Foundis then false, and the sign keeps the last real visit. Over a long game a zone event can also be missed, which loses that one visit the same way.GetUnixTime()counts whole seconds, so a short stay can show as 0 or 1.- A display name is chosen by the player.
describeescapes it before it reaches the sign.
See Record Destructuring, Destructured Parameters and Binding Event Data.
Next: pick a lesson from the tree on the Learn page.
Enums
A traffic light is always exactly one of red, yellow or green. An enum
names those choices, a variable holds one of them, and match picks what to
do for each.
Contents
chip lamp -> light Color
switch Enabled -> light Enabled
Code
enum Light { Red, Yellow, Green }
var state: Light = Light.Red
@right out lamp = match state {
Red => Color(1.0, 0.0, 0.0),
Yellow => Color(1.0, 0.8, 0.0),
Green => Color(0.0, 1.0, 0.0),
}
on Clock(interval = 2.0) {
match state {
Red => { state = Light.Green }
Green => { state = Light.Yellow }
Yellow => { state = Light.Red }
}
}
enum Light { Red, Yellow, Green }makes a new type with three variants. ALightis always one of them.Light.Rednames a variant.var state: Light = Light.Redholds one and starts red.- The first
matchis a value. Each arm,Red => ..., gives the value for that variant, and the arms are separated by commas. Inside the arms the variants are written bare, sincestateis already known to be aLight. - The second
matchis a statement. Each arm is a block of steps, and only the block for the current variant runs.
The light starts red. In what order do the colors come, and how long does each one last?
Run
- Compile the chip and paste it in-game. Place a Point Light and a Switch.
- Wire
lampto the Point Light’s Color, and the Switch’s Enabled to the light’s Enabled. Flip the switch on. - In the Point Light’s settings, turn off Use Brick Color. While it is on, the light keeps its brick’s color and ignores the wire.
- Watch the light go red, green, yellow, red, two seconds each.
Under the Hood
Open the chip. state is one Var gate, labeled state.__disc, holding
a number: 0 for Red, 1 for Yellow, 2 for Green, in the order the
variants are declared. An enum without data is that number, with names
the compiler checks for you.
The value match is three Equal gates, each comparing that number with
one variant’s, and three Selects in a row that pick the color. The
statement match in the handler is a Get Variable, then an Equal and a
Branch per arm, with a Set Variable on each arm’s path.
A match must cover every variant. Leave one out and the compiler names it:
enum Light { Red, Yellow, Green }
var state: Light = Light.Red
@right out lamp = match state {
Red => Color(1.0, 0.0, 0.0),
Green => Color(0.0, 1.0, 0.0),
}
Error: [WS054] non-exhaustive match: `Yellow` is not covered
When you add a variant to an enum later, every match that forgets it
fails to compile, so none are missed.
Modify
- Write the handler with a value
matchinstead:state = match state { Red => Light.Green, Green => Light.Yellow, Yellow => Light.Red }. The chain becomes one Get Variable and one Set Variable, with the choice made by Selects in between. - Add a crossing button. Add
@left in walk: execfrom a Button’s Held, and anon walkhandler with a statementmatchthat turns a green light yellow and leaves red and yellow alone (an arm can be an empty block,{}). - Add
@right out stop = match state { ... }, givingtruefor red and yellow andfalsefor green, and wire it to a Brick Property Changer’s Collision Enabled on a barrier brick.
Make
Make a sky light that runs through dawn, day, dusk and night, ten seconds
each. Give each part of the day its own color and its own Brightness,
both chosen with a match on one enum.
chip lamp -> light Color
chip brightness -> light Brightness
switch Enabled -> light Enabled
Good to Know
- A Point Light’s Use Brick Color overrides a wired Color. Turn it off whenever a circuit sets the color.
- The state is a Var gate, so the light comes back at the same color after the world is saved and loaded.
Next: Payloads.
Payloads
Before this: Enums
Next steps: Option and Result
Parts that use this: Round Manager, Whack-a-Light, Team Match
A variant can carry data of its own. A countdown carries how many seconds are left, and a finished game carries how long it took. This lesson builds a small game round as a state machine: one enum value, moved from variant to variant by buttons and a clock.
Contents
button1 Held -> chip start
button2 Held -> chip finish
chip sign -> decal Text
Code
enum Phase { Lobby, Countdown(int), Playing { started: int }, Over { seconds: int } }
@left in start: exec
@left in finish: exec
var phase: Phase = Phase.Lobby
@right out sign = match phase {
Lobby => "Press start",
Countdown(n) => "Starting in ${n}",
Playing { .. } => "Go!",
Over { seconds } => "Finished in ${seconds} seconds",
}
on start {
if phase is Phase.Lobby || phase is Phase.Over {
phase = Phase.Countdown(3)
}
}
on finish {
let Playing { started } = phase else { return }
phase = Phase.Over { seconds: GetUnixTime() - started }
}
on Clock(interval = 1.0) {
match phase {
Countdown(n) => {
if n <= 1 {
phase = Phase.Playing { started: GetUnixTime() }
} else {
phase = Phase.Countdown(n - 1)
}
}
_ => {}
}
}
Countdown(int)carries one value by position, built asPhase.Countdown(3).Playing { started: int }carries a named field, built asPhase.Playing { started: ... }.- In a
matcharm,Countdown(n)names the carried valuen, andOver { seconds }names the field.Playing { .. }matches without naming anything. phase is Phase.Lobbyistruewhilephaseis that variant.let Playing { started } = phase else { return }takes the data out ifphaseisPlaying, and otherwise runs theelse, which must leave the handler, here withreturn. After it,startedis ready to use.GetUnixTime()is the current time as a whole number of seconds._ => {}covers every variant not named above it.
Press start. What does the sign show over the next few seconds? What
happens if you press finish before the countdown ends?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire the Buttons’ Held outputs to
startandfinish, andsignto the Text Decal’s Text. - Press
start. The sign counts “Starting in 3”, 2, 1, then “Go!”. Wait a while and pressfinish. The sign shows “Finished in 7 seconds”, or however long it was. finishdoes nothing until the round is playing, andstartdoes nothing while one is running.
Under the Hood
Open the chip. phase is four Var gates: phase.__disc for the variant’s
number, and one for each piece of data, phase.__Countdown_0,
phase.__Playing_started and phase.__Over_seconds. Every variant has its
own slots, side by side. The sign’s match reads the number to choose an arm,
and each arm reads only its own variant’s slot.
Each phase = Phase.Countdown(3) writes the number and the new data with Set
Variables, and clears the slots of the other variants. The extra Var gates
named Phase.__... hold the zeros it clears them with.
is is a single Equal on the number, as is the test in let ... else. That
number is also readable as phase.Discriminant: 0 for Lobby up to 3 for
Over.
An enum has no ==, because comparing two values would mean comparing every
slot. Test the variant with is:
enum Phase { Lobby, Countdown(int) }
@left in start: exec
var phase: Phase = Phase.Lobby
on start {
if phase == Phase.Lobby {
phase = Phase.Countdown(3)
}
}
Error: [WS004] no overload for '==' on Phase, Phase
Modify
- Show the step number on a second sign:
@right out step = phase.Discriminant. It reads 0 in the lobby and 3 when the round is over. - Ignore a
finishin the first five seconds of play. After thelet ... else, addif GetUnixTime() - started < 5 { return }. - Add a
Paused { left: int }variant and a pause button that stops a countdown and keeps the seconds left. The sign’smatchstops compiling until it has aPausedarm. Pressingstartwhile paused carries on from where it stopped.
Make
Make a microwave. It is Idle, Cooking(int) with the seconds left, or
Done. A button adds ten seconds while it is idle or cooking, a Clock counts
down, and a light shows Done until a door button sends it back to Idle.
button1 Held -> chip add
button2 Held -> chip door
chip done -> light Enabled
Good to Know
GetUnixTime()counts whole seconds, so a round’s time is rounded to a second.- The Clock and the buttons start separate handlers with no order between
them. A
finishpressed in the same tick as the last countdown step may see either the countdown or the round.
See Enums, is and
if let / let else.
Next: Option and Result.
Option and Result
Next steps: Enums from Pins
Some questions have no answer. No number is 7 divided by 0.
Two enums come built in for this. Option is either Some(value) or
None, and Result is either Ok(value) or Err(reason). A mod that might
fail returns one, and the caller has to look before using the value.
Contents
button1 Held -> chip addA
button2 Held -> chip addB
button3 Held -> chip go
chip text -> decal1 Text
chip lastGood -> decal2 Text
Code
mod safeDiv(a: int, b: int) -> Option<int> {
if b == 0 {
return None
}
return Some(a / b)
}
@left in addA: exec
@left in addB: exec
@left in go: exec
var a: int = 0
var b: int = 0
var shown: string = "Press to divide"
var kept: int = 0
@right out text = shown.Value
@right out lastGood = "Last answer: ${kept.Value}"
on addA { a += 1 }
on addB { b += 1 }
on go {
let r = safeDiv(a, b)
shown = match r {
Some(q) => "${a} / ${b} = ${q}",
None => "${a} / 0 has no answer",
}
if let Some(q) = r {
kept = q
}
}
aandbcount the presses of two buttons, andgodivides them.-> Option<int>says the mod gives back anOptionthat holds anintwhen there is one.Some,None,OkandErrare written bare, with noOption.in front.- The
matchmust handle both, so the caller cannot reach the number without saying what happens when there is none. if let Some(q) = r { ... }runs its block only whenrisSome, with the number namedq. It is amatchwith one arm that matters.
Press the first button eight times and the second twice, then press go.
What do the two signs say? What do they say if you press go before ever
touching the second button?
Run
- Compile the chip and paste it in-game. Place three Buttons and two bricks with a Text Decal each.
- Wire the three Buttons’ Held to
addA,addBandgo, andtextandlastGoodto the two Text Decals’ Text. - Press
gobefore touching the second button. The sign says “0 / 0 has no answer”, and the last answer stays 0. Press the first button eight times, the second twice, andgo. The sign says “8 / 2 = 4”, and the last answer becomes 4.
Under the Hood
Open the chip. The mod’s answer is held in two Var gates, __ret.__disc
and __ret.__Some_0. An Option is an enum like any other, with a number
for the variant (Some is 0, None is 1) and a slot for the value. Each
return writes them, and the two paths meet at a Union before the
match reads them.
The Divide is a pure gate, so it works out a / b all the time, even
when b is 0. Only the Some path stores its answer.
Call a mod that returns an enum inside a handler, as here. Its returns
are steps that write those Var gates, and a handler is where steps run.
A fn, a function compiled once and shared by every caller, cannot return
an enum at all:
fn safeDiv(a: int, b: int) -> Option<int> {
if b == 0 {
return None
}
return Some(a / b)
}
Error: [WS114] `fn safeDiv` returns a record, tuple, enum or exec; return its values instead. A `fn` compiles once to one plane; use a `mod` for this
Result
Option says only that there is no answer. Result also says why:
mod safeDiv(a: int, b: int) -> Result<int, string> {
if b == 0 {
return Err("cannot divide by zero")
}
return Ok(a / b)
}
@left in addA: exec
@left in addB: exec
@left in go: exec
var a: int = 0
var b: int = 0
var shown: string = "Press to divide"
@right out text = shown.Value
on addA { a += 1 }
on addB { b += 1 }
on go {
shown = match safeDiv(a, b) {
Ok(q) => "${a} / ${b} = ${q}",
Err(why) => why,
}
}
Result<int, string> holds an int when it works and a string when it
does not. Here the match reads the call directly, with no let.
Modify
- Use
let ... elseinstead ofif let:let Some(q) = r else { return }leaves the handler when there is no answer, and the lines after it can useqfreely. - Add a third counted button
cand divide twice:a / b, then that answer byc. Show “no answer” if either division fails. - In the
Resultversion, also refuse ababove 10, with its ownErrmessage.
Make
Make a vending machine. Each press of one button puts in a coin, and a
second button buys. A mod buy(coins: int, price: int) -> Result<int, string> gives back the change, or the reason the sale failed. Show the
answer on a sign.
button1 Held -> chip coin
button2 Held -> chip buy
chip text -> decal Text
Good to Know
bstarts at 0, so it is 0 until its button is pressed. That is why the firstgohas no answer.keptis a Var gate, so the last good answer survives a failed division, and a save and load.
See Built-in Option and Result
and if let / let else.
Next: Enums from Pins.
Enums from Pins
Before this: Option and Result
Next steps: Game Enums
Pins carry numbers, and an enum without data is a number underneath. This
lesson turns a count of button presses into an enum with FromInt, and then
passes an enum from one chip to another over a wire.
Contents
chip2 "Counter"
chip "Dimmer"
button Held -> chip2 pressed
chip2 presses -> chip presses
chip brightness -> light Brightness
chip label -> decal Text
switch Enabled -> light Enabled
Code
enum Mode { Off, Dim, Bright }
@left in presses: int
let mode = Mode.FromInt(presses % 3)
@right out brightness = match mode {
Off => 0.0,
Dim => 20.0,
Bright => 100.0,
}
@right out label = match mode {
Off => "Off",
Dim => "Dim",
Bright => "Bright",
}
Mode.FromInt(n) gives the variant whose number is n: 0 is Off, 1 is
Dim, 2 is Bright, in the order they are declared. presses % 3 keeps the
number in that range, so each press moves to the next mode and the fourth
press comes back to Off.
After four presses, which mode is the light in? After nine?
Run
-
Compile the chip and paste it in-game. Place a Button, a Point Light, a Switch and a brick with a Text Decal.
-
Paste a counter chip too, the press counter from Remembering Values with its count on a pin:
@left in pressed: exec var count: int = 0 @right out presses = count.Value on pressed { count += 1 } -
Wire the Button’s Held to the counter’s
pressedand the counter’spressesto the dimmer’spresses. Wirebrightnessto the Point Light’s Brightness, andlabelto the Text Decal’s Text. Wire the Switch’s Enabled to the light’s Enabled and flip it on. -
Press the button. The light steps dim, bright, off, and the sign names each mode.
Under the Hood
Open the dimmer chip. FromInt has no gate. The Modulo for
presses % 3 is the mode. Its output is the variant’s number, and a Mode
is that number.
The two matches share three Equal gates, one per variant, and each
has three Selects of its own. Matching the same value twice costs only
the Selects.
Enum pins
An enum without data can be a pin. It travels as its number, and on the other side it is the enum again. Here one chip works out the mode, and a second chip shows it:
enum Mode { Off, Dim, Bright }
@left in presses: int
@right out mode: Mode = Mode.FromInt(presses % 3)
enum Mode { Off, Dim, Bright }
@left in mode: Mode
@right out label = match mode {
Off => "Off",
Dim => "Dim",
Bright => "Bright",
}
Paste both, wire the counter’s presses to the first chip’s presses, the
first chip’s mode to the second chip’s mode, and the second’s label to
a sign. The wire between them is an int.
A variant with data cannot cross a pin, since a pin carries one wire and the data needs more:
enum Mode { Off, Dim(float), Bright }
@left in mode: Mode
@right out label = match mode {
Off => "Off",
Dim(level) => "Dim",
Bright => "Bright",
}
Error: [WS049] `mode` holds an enum value (`Mode`), so it can't cross this port: an enum is a tag plus a slot per payload field and a pin carries a single wire. Only a top-level `in`/`out` port of an enum with no payloads (a built-in game enum included) carries one, as its tag. Pass it to a `mod`, pass a chip a `*Mode` (which wires each part), or pass the tag and payload as plain values (`.Discriminant`, a `match` result) and build the enum inside
Modify
- Replace the Switch with the mode itself: add
@right out lit = !(mode is Mode.Off)and wire it to the light’s Enabled. - Add a fourth mode,
Medium, betweenDimandBright. Bothmatches stop compiling until they have aMediumarm, and% 3must become% 4. - Take out the
% 3. After the third press, the number matches no variant. Watch what the light and the sign do then.
Make
Make a two-chip fan control. The first chip reads two Switches as a
number from 0 to 3 (the first switch counts 1 and the second counts 2, so
a + b * 2), and sends a Speed enum out of a pin. The second chip takes the Speed and sets a light’s brightness and
a sign.
chip "Switches"
chip2 "Fan"
switch1 Enabled -> chip a
switch2 Enabled -> chip b
chip speed -> chip2 speed
chip2 brightness -> light Brightness
chip2 label -> decal Text
switch3 Enabled -> light Enabled
Good to Know
- A number that matches no variant makes a
matchwith no_arm fall through to a default value: 0 for a number, empty text for a string. Keep the number in range, as% 3does, or end thematchwith a_arm, which catches a number no variant has. - The two chips are compiled separately, so nothing checks that they agree.
If one lists
DimbeforeOff, the wire still carries 0, 1 or 2, and the second chip reads the wrong mode. Declare the enum the same way in both.
See Enums at ports and Enum and int conversion.
Next: Game Enums.
Game Enums
Before this: Enums from Pins
Parts that use this: Paint Gun
Some enums come from the game itself, for easing curves, brick settings and
the typefaces of on-screen text. They need no enum declaration. This lesson eases a light on and off with them, and recolors
the chip’s own brick.
Contents
button Held -> chip held
chip brightness -> light Brightness
switch Enabled -> light Enabled
Code
@left in held: bool
let target = if held then 100.0 else 0.0
@right out brightness: float = Tween(target, 1.0, function = EasingFunction.Bounce, direction = EasingDirection.Out)
Tween(target, 1.0, ...) is a value that moves toward target, taking one
second to get there. function picks the shape of the move, and direction
picks which end of it the shape applies to.
EasingFunction and EasingDirection are game enums. The compiler reads
them from the game’s own lists, so they need no declaration. EasingFunction has
Linear, Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back,
Elastic and Bounce, and EasingDirection has In, Out and InOut.
Hold the button. How does the light come on? What does it do when you let go?
Run
- Compile the chip and paste it in-game. Place a Button, a Point Light and a Switch.
- Wire the Button’s Held to
held,brightnessto the Point Light’s Brightness, and the Switch’s Enabled to the light’s Enabled. Flip the switch on. - Hold the button. The light rises over a second and bounces as it arrives. Let go, and it falls back the same way.
Under the Hood
Open the chip. There are two gates: a Select for target, and a
Tween. The Tween’s Function input has 10 typed into it, and its
Direction input has 1.
Those are the game’s own numbers for Bounce and Out.
EasingFunction.Bounce.Discriminant is 10. An enum you declare numbers its
variants from 0 in your order. A game enum uses the numbers the game saves.
The Tween takes its function on a wire, so a variable can choose it while the circuit runs:
@left in held: bool
@left in next: exec
var style: EasingFunction = EasingFunction.Linear
@right out brightness: float = Tween(if held then 100.0 else 0.0, 1.0, function = style)
@right out name = match style {
Linear => "Linear",
Bounce => "Bounce",
Elastic => "Elastic",
_ => "Style ${style.Discriminant}",
}
on next {
style = EasingFunction.FromInt((style.Discriminant + 1) % 11)
}
Each press of next moves to the next of the eleven functions, and the
sign names it.
Not every gate takes its enum on a wire. The Easing gate has its function as a setting, chosen when the chip is built, so it needs a constant:
@left in t: float
var style: EasingFunction = EasingFunction.Bounce
@right out level = Easing(0.0, 100.0, t, function = style)
Error: [WS028] unknown enum member 'style' for EBREasingFunction; expected one of: Linear, Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce
The message reads style as a member name it does not know. The same error
goes on to say that Function is a constant-only setting.
Brick properties
BrickProperty names the settings of a brick: Visible, Color,
Material, the collision flags and more. This chip recolors its own brick
at each press:
@left in pressed: exec
var lit: bool = false
on pressed {
lit = !lit
let c = if lit then Color(1.0, 0.8, 0.0) else Color(0.2, 0.2, 0.2)
GetMicrochipBrick().SetBrickProperty(BrickProperty.Color, c)
}
GetMicrochipBrick() is the chip’s own brick. The Set Brick Property
gate has the property as a setting, so BrickProperty.Color must be written
out, not chosen by a variable.
Modify
- Try other curves in the first chip:
EasingFunction.Elastic,EasingFunction.Back, anddirection = EasingDirection.InOut. - Add a second button to the styles chip that steps the direction too:
var dir: EasingDirection = EasingDirection.In, passed asdirection = dir, and stepped with% 3. - In the brick chip, also set
BrickProperty.MaterialtoBrickMaterial.Glowwhen lit andBrickMaterial.Plasticwhen not.
Make
Make a light show. A Clock flips the light’s target every two seconds, and on every second flip it moves to the next easing function. A sign shows the name of the curve being shown.
chip brightness -> light Brightness
chip label -> decal Text
switch Enabled -> light Enabled
Good to Know
- A Tween that gets a new target part way through should head for it from where it is, so letting go early turns the light around mid-bounce.
GetMicrochipBrick()does not yet name the right brick inside a spawned prefab.
See Built-in game enums, Gate config properties and GetBrickProperty / SetBrickProperty.
Next: pick a lesson from the tree on the Learn page.
Counting Loops
Before this: Reacting to the Game, Lists
Next steps: Looping Over a List
A for loop runs the same steps many times in a row. This lesson draws a
progress bar on a sign, one cell per pass of the loop, and every pass finishes
within the same tick.
Contents
button Held -> chip add
chip bar -> decal Text
Code
const WIDTH = 10
@left in add: exec
var filled: int = 0
var drawn: string = "----------"
@right out bar = drawn.Value
on add {
filled = (filled + 1) % (WIDTH + 1)
var cells: string = ""
for i in 0..WIDTH {
if i < filled {
cells = "${cells}#"
} else {
cells = "${cells}-"
}
}
drawn = cells
}
for i in 0..WIDTH { ... }runs its block once for each number from 0 up toWIDTH, leavingWIDTHout: 0, 1, 2 and so on up to 9. Each pass is called an iteration, andiholds the number for that pass.cellsstarts empty, and each iteration adds one character to the end of it:#whileiis belowfilled,-after.- When the loop is done, the handler carries on with the next line, and
drawn = cellsputs the finished bar on the sign. % (WIDTH + 1)wrapsfilledback to 0 after a full bar.
Press the button three times. What does the sign show? What does the eleventh press show?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
add, andbarto the Text Decal’s Text. - Press the button. Each press adds one
#, and the press after a full bar starts again from all dashes.
Under the Hood
Open the chip. The loop is a microchip of its own, a For Loop, with
Count set to 10, First Index to 0 and Step to 1. Its Exec
input comes from the step before it in the handler, and its Completed
output carries on to drawn = cells.
Open the For Loop. Inside is a second plane with a For Loop Entry gate.
Its Body output starts each iteration and its Index output is i.
After it come a Get Variable for filled, a Less comparing i with
it, a Branch, and on each side a Get Variable, a Format String and
a Set Variable that add one character to cells.
The game runs this plane once per iteration, ten times in a row, all within
the tick the button was pressed. The gates are placed once. Change WIDTH
to 40 and the chip has as many gates as before. Only the Count changes.
filled and cells live outside the loop, and reach into it on wires
through the For Loop’s side, as wires reach into any chip. A variable outside
the loop keeps its value from one iteration to the next, which is how cells
grows. A var declared inside the loop’s block would start again from its
first value on every iteration.
Modify
-
Fill the bar from the right. Change the loop to
for i in (0..WIDTH).rev()..rev()runs the same numbers backward, 9 down to 0, so the#s are added last and end up on the right. -
Make a bar half as long.
(0..WIDTH).step(2)counts in twos: 0, 2, 4, 6 and 8, five iterations instead of ten. Each cell now stands for two presses. -
Count in any step size.
count(n, from = ..., step = ...)is another way to write a range:niterations, starting atfrom, going up bystep. Here each press picks a new step size, from 1 to 9:@left in add: exec var size: int = 0 var drawn: string = "" @right out text = drawn.Value on add { size = size % 9 + 1 var row: string = "" for i in count(5, from = size, step = size) { row = "${row} ${i}" } drawn = "Counting by ${size}:${row}" }The second press shows
Counting by 2: 2 4 6 8 10. The start and the step come from a variable here, so they reach the For Loop on wires instead of being typed in.
Make
Make a fuel gauge. One button adds fuel, another uses it, and a Clock burns a
little every second while a switch is on. The sign shows a bar of 20 cells
and the amount after it, like [########------------] 40%.
button1 Held -> chip add
button2 Held -> chip spend
switch Enabled -> chip running
chip bar -> decal Text
Good to Know
- A
forloop finishes within one tick, however many iterations it has. Sixty iterations take one tick, not one second. - A loop with a large Count does not cost more gates, but every iteration runs every gate in the plane again. Work that is the same each time belongs above the loop, not inside it.
- The game stops a loop that runs too many times in one tick (about 100,000 iterations across all loops). A loop that is cut off stops partway.
foris a step, so it lives in a handler. Written at the top of the file, it does not compile.
See for Loops in the reference.
Next: Looping Over a List.
Looping Over a List
Before this: Counting Loops
Next steps: Looping Over a Map, Ready Check, Persistent Players, Testing, Rotations
Parts that use this: Healing Zone
A for loop can walk through an array, one element per iteration, and
break can stop it as soon as it finds what it is looking for. This lesson
keeps a list of dice rolls and searches it.
Contents
button1 Held -> chip roll
button2 Held -> chip search
chip text -> decal Text
Code
@left in roll: exec
@left in search: exec
var rolls: int[]
var shown: string = "No rolls yet"
@right out text = shown.Value
on roll {
rolls.push(Random(1, 6))
shown = "${rolls.length()} rolls"
}
on search {
var place: int = 0
var found: int = 0
for r in rolls {
place += 1
if r == 6 {
found = place
break
}
}
if found > 0 {
shown = "First six: roll ${found}"
} else {
shown = "No six in ${rolls.length()} rolls"
}
}
for r in rolls { ... }runs its block once for each element ofrolls, first to last, withrholding that element. An empty array runs it zero times.placecounts the iterations, so it says which roll is being looked at.breakstops the loop at once. The handler carries on after the loop, withfoundholding the place of the first six.foundstays 0 when no roll was a six, and the sign says so.
Say the rolls so far are 4, 6, 2 and 6. Which roll does the search report? How many iterations run?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire the Buttons’ Held outputs to
rollandsearch, andtextto the Text Decal’s Text. - Roll a few times, then search. Also search before any six comes up. The sign then says how many rolls had no six.
Under the Hood
Open the chip. The loop is a For Each Array microchip. Its Array Var
Ref input is wired from the rolls Array Var, and it runs once per
element.
Inside, the For Each Array Entry gate has three outputs: Body starts
each iteration, Value is r, and Index is the element’s position
(Index and Value uses it). After them come an
Increment Variable for place, an Equal, a Branch, the write to
found, and a Break gate at the end of the chain. When an iteration reaches Break, the loop ends and the For Each
Array’s Completed output fires.
The loop reads the array straight from its gate, so there is no Get Array Element in the chip.
There is no continue to skip the rest of one iteration:
@left in search: exec
var rolls: int[]
var sixes: int = 0
on search {
for r in rolls {
if r != 6 { continue }
sixes += 1
}
}
Error: [WS078] `continue` does not exist in this language, and there is no gate a loop plane could lower it to. Restructure the rest of the body under an `if`.
Put the rest of the iteration under an if instead: if r == 6 { sixes += 1 }.
Modify
-
Show the total of all rolls. In the
rollhandler, after the push, add a loop with nobreak:@left in roll: exec var rolls: int[] var shown: string = "No rolls yet" @right out text = shown.Value on roll { rolls.push(Random(1, 6)) var total: int = 0 for r in rolls { total += r } shown = "${rolls.length()} rolls, total ${total}" } -
Count the sixes instead of finding the first one: drop the
break, and add one to asixesvariable each timer == 6. -
Search for any number. Add a third button that steps a
targetvariable through 1 to 6 (target = target % 6 + 1), show it on the sign, and search fortargetinstead of 6.
Make
Make a streak finder. After every roll, search the list for two sixes in a
row, and light a Point Light when there are. Keep the previous element in
a variable declared before the loop, and compare it with r.
button Held -> chip roll
chip streak -> light Enabled
Good to Know
- A loop over an empty array runs zero times and goes straight to the code after it. Plan for “nothing found”.
breakends the whole loop, not only the current iteration. Nothing after thebreakin the block runs.- A read like
rolls[i]past the end of the array gives a stale value, butfor r in rollsvisits only the elements that are there, so it never reads past the end. - When the test is plain equality, an array can answer in one gate.
rolls.find(6).Indexis the index of the first 6 (-1when there is none), androlls.sum()is the total. A loop is for tests one gate cannot do, such as the streak in Make.
See break in the reference.
Next: Looping Over a Map.
Looping Over a Map
Before this: Looping Over a List, Maps
Next steps: Index and Value, One Chip per Player, Every Player, A HUD for Everyone
Parts that use this: Shooting Gallery, Scoreboard HUD, King of the Hill, Voting, Obstacle Course, Team Match
A for loop over a map visits every key, with the value filed under it. This
lesson counts laps for every player who crosses a finish line, and lists them
all on a board.
Contents
zone "Zone Ref" -> chip finish
chip board -> decal Text
Code
import { escape } from "sanitize"
@left in finish: zone
var laps: Map<string, int>
var names: Map<string, string>
var shown: string = "No laps yet"
@right out board = shown.Value
on ZoneEntered(zone = finish) -> (character) {
let id = character.GetUserId()
let before = laps.get(id)
let count = if before.Found then before.Value + 1 else 1
laps.set(id, count)
names.set(id, escape(character.GetDisplayName()))
var text: string = ""
for key, n in laps {
let name = names.get(key)
text = "${text}${name}: ${n}<br>"
}
shown = text
}
- The first half of the handler is the visit counter from
Maps, with one count per
player filed under their user id. A second map,
names, files each player’s display name under the same id, escaped so it is safe to show. for key, n in laps { ... }runs once per key inlaps.keyis the user id andnthe lap count filed under it.- Inside,
names.get(key)looks up the name for that id. Every id inlapswas also given a name, so the lookup always finds one. <br>starts a new line on the sign, so each player gets a row.
Two players cross the line: you twice, a friend once. How many rows does the board have, and how many times does the loop run?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone across your finish line, and place a brick with a Text Decal.
- Wire the Attached Zone’s Zone Ref to
finish, andboardto the Text Decal’s Text. - Walk through the zone a few times. Your row counts up. A second player gets a row of their own.
Under the Hood
Open the chip. The loop is a For Each Map microchip, with its Map Var
Ref wired from the laps map. Inside, the For Each Map Entry gate has
Key and Value outputs for key and n, and an Index output too.
The names.get(key) inside the loop is a Get Map Element gate. The
names map lives outside the loop, and a wire carries its reference in
through the loop chip’s side, like any other wire into a chip.
Some languages write a map loop as for { key, value } in .... Here the
braces mean something else, and a map loop with them does not compile:
@left in finish: zone
var laps: Map<string, int>
var total: int = 0
on ZoneEntered(zone = finish) -> (character) {
laps.set(character.GetUserId(), 1)
for { key, value } in laps {
total += value
}
}
Error: [WS076] a `{ ... }` binding destructures the value in its slot, and this slot is `string`, not a record. Name the slot instead (`for v in ...`); a record-typed array or map cannot be iterated at all.
Name the slots instead: for key, value in laps.
Modify
- Add up everyone’s laps. Keep a
var total: int = 0before the loop, addnto it inside, and showTotal: ${total}under the rows. - Find the leader. Keep
var best: int = 0andvar leader: string = ""before the loop. Inside, whenn > best, set both:best = nandleader = key. After the loop, showLeader: ${names.get(leader)}. - Number the rows. A map loop has a third slot, the index:
for key, n, i in lapscounts 0, 1, 2 as it goes. Start each row with${i + 1}..
Make
Make a checkpoint race. A second zone is a checkpoint, and a lap only counts
if the player touched the checkpoint since their last lap. Keep a
Map<string, bool> of who has touched it, set it in the checkpoint’s
handler, and clear it for that player when their lap counts.
zone1 "Zone Ref" -> chip finish
zone2 "Zone Ref" -> chip checkpoint
chip board -> decal Text
Good to Know
- Do not count on the order of the rows. A map loop is not sorted by laps or by name. Sort the results yourself if the order matters.
- A respawn makes a new character for the same player, so a map keyed by the
character would start a new row after every death. Key by
GetUserId(), which stays the same. - A display name is chosen by the player. Escape it before it goes on a sign.
See Bindings in the reference for every binding a loop takes.
Next: Index and Value.
Index and Value
Before this: Looping Over a Map
Next steps: Loops Across Ticks, Shuffling, A HUD for Everyone
Parts that use this: Card Table
A loop over an array can name both the element’s position and the element itself. This lesson uses the position to split the players in a lobby into two teams, in the order they walked in.
Contents
zone "Zone Ref" -> chip lobby
button Held -> chip split
chip board -> decal Text
Code
import { escape } from "sanitize"
@left in lobby: zone
@left in split: exec
var ids: string[]
var names: string[]
var shown: string = "Walk in, then press split"
@right out board = shown.Value
on ZoneEntered(zone = lobby) -> (character) {
let id = character.GetUserId()
if !ids.find(id).Found {
ids.push(id)
names.push(escape(character.GetDisplayName()))
}
}
on split {
var red: string = ""
var blue: string = ""
for i, name in names {
if i % 2 == 0 {
red = "${red} ${name}"
} else {
blue = "${blue} ${name}"
}
}
shown = '<color="ff5050">Red:${red}<br><color="5090ff">Blue:${blue}'
}
- The zone handler signs players up.
ids.find(id).Foundis true when the id is in the list already, so each player is added once, however often they walk in.idsholds user ids, andnamesholds the matching display names in the same order. for i, name in namesnames two slots.iis the element’s index (0 for the first, 1 for the second) andnameis the element. With one name, as in Looping Over a List, you get only the element. An array loop puts the index first, and a map loop puts the key first and the index third.i % 2 == 0is true for even indexes, so the first, third and fifth players go to Red, and the others to Blue.- The sign is rich text.
<color="...">colors the text after it, and<br>starts a new line. The text is in single quotes, so the double quotes inside the tags need no escaping.
Four players walk in, in the order A, B, C, D. Who is on Blue?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone, and place a Button and a brick with a Text Decal.
- Wire the Attached Zone’s Zone Ref to
lobby, the Button’s Held tosplit, andboardto the Text Decal’s Text. - Walk into the zone and press the button. Alone, you are the only one on Red. With friends, each new player alternates between the teams.
Under the Hood
Open the chip and the For Each Array inside it. Its For Each Array
Entry gate has an Index output beside Value. i % 2 is a
Modulo gate wired from Index, then an Equal and a Branch.
Naming i costs nothing extra, because the output is always there.
Each player’s id and name are pushed together, so ids and names stay in
the same order. Index 2 of names is the name of the player whose id is
index 2 of ids.
.step(2) looks like a way to take every other player, but it only works on
a range:
@left in split: exec
var names: string[] = ["A", "B", "C", "D"]
var red: string = ""
@right out board = red.Value
on split {
red = ""
for name in names.step(2) {
red = "${red} ${name}"
}
}
Error: [WS083] `.step()` applies to a range, not to an array or map. Give the loop a range source (`for i in 0..n`), or drop the `.step()`.
Use the index to choose instead, as the lesson’s code does.
Modify
-
Test alone with made-up players. Start the list with names in it:
var names: string[] = ["A", "B", "C"]. Walk in, and you are the fourth. -
Make three teams. Use
i % 3, with a third color and a third line on the sign. -
Show a numbered sign-up list of the last five players instead of teams. Work out the length once, before the loop, and skip the earlier ones:
import { escape } from "sanitize" @left in lobby: zone var ids: string[] var names: string[] var shown: string = "Nobody yet" @right out board = shown.Value on ZoneEntered(zone = lobby) -> (character) { let id = character.GetUserId() if !ids.find(id).Found { ids.push(id) names.push(escape(character.GetDisplayName())) } let total = names.length() var text: string = "" for i, name in names { if i >= total - 5 { text = "${text}${i + 1}. ${name}<br>" } } shown = text }totalis read once, above the loop. Written inside theif, the length would be read again on every iteration.
Make
Make a relay order board. Players sign up at a zone, and the sign shows them
as legs of a relay: “Leg 1: A”, “Leg 2: B” and so on. Add a second button that
removes the first player in the list (names.remove(0), and the same for
ids), so the next player moves up to leg 1.
zone "Zone Ref" -> chip lobby
button1 Held -> chip show
button2 Held -> chip next
chip board -> decal Text
Good to Know
- The index starts at 0, not 1. Add 1 before showing it to people.
- Two arrays kept side by side stay matched only while every change is made to both. Remove from one and not the other, and every name after that point lines up with the wrong id.
- One player cannot test a two-team split. Seed the list with test names, or ask a friend to walk in.
See Bindings in the reference.
Next: Loops Across Ticks.
Loops Across Ticks
Before this: Index and Value, Waiting
A for loop finishes inside one tick, so it cannot wait. To repeat a step
with a pause between each, you build the loop from await and a buffered
emit instead. This lesson fades a lamp up like a sunrise, one small step
every quarter of a second.
Contents
button Held -> chip pressed
chip lamp -> light Color
chip lit -> light Enabled
chip text -> decal Text
Code
const STEPS = 20
@left in pressed: exec
var step: int = 0
var running: bool = false
let t = (step.Value as float) / (STEPS as float)
@right out lamp = Color(1.0, 0.8, 0.5) * t
@right out lit = step.Value > 0
@right out text = "${step.Value * 5}%"
on pressed {
if running {
return
}
running = true
step = 0
let ramp: exec
buffer emit ramp
await ramp
if step < STEPS {
step += 1
buffer(0.25s) emit ramp
} else {
running = false
}
}
let ramp: execis a local signal, andawait rampwaits for it, as in Waiting.buffer emit rampfiresrampone tick later. By then the handler has reachedawait rampand is waiting, so it wakes up.buffer(0.25s) emit rampfiresrampagain a quarter of a second later. The handler goes back to theawaitand runs theifagain. That jump back is the loop.- When
stepreachesSTEPS, theelseruns instead, nothing firesrampagain, and the loop ends. runningstops a second press from starting a second loop on top of the first.returnends the handler early, so a press during the sunrise does nothing.tgoes from 0 to 1 asstepgoes from 0 to 20, and the lamp’s color is the warm white timest.litturns the light on from the first step.
How long does the sunrise take? What happens if you press the button again halfway through?
Run
- Compile the chip and paste it in-game. Place a Button, a Point Light and a brick with a Text Decal. Turn off the light’s Use Brick Color. While it is on, the light ignores the wired color.
- Wire the Button’s Held to
pressed,lampto the Point Light’s Color,litto its Enabled, andtextto the Text Decal’s Text. - Press the button. The lamp warms up over five seconds and the sign counts to 100%. Presses during the sunrise are ignored.
Under the Hood
Open the chip. buffer emit ramp is a Buffer (Ticks) gate set to one
tick, and buffer(0.25s) emit ramp is a Buffer (Seconds) set to 0.25.
Both feed a Union, which merges the two into one exec. The await is a
hidden Var that remembers it is waiting, read by a Get Variable
and a Branch. Follow the exec from the Buffer (Seconds) through the Union
and the if, and you are back at the Buffer (Seconds). It is a loop of wires
with a buffer in it, like the latch in Buffers.
A for loop cannot do this. Its iterations all run in one tick, and the
compiler refuses an await inside one:
const STEPS = 20
@left in pressed: exec
var step: int = 0
on pressed {
for i in 0..STEPS {
step = i
await Sleep(_, delay = 0.25)
}
}
Error: [WS079] a loop body runs synchronously within one tick, so it cannot suspend. Move the `await` outside the loop, or use a back-edge loop (`emit` + `await`) if you need work spread across ticks.
The emit that starts the loop must be buffered as well. A plain emit ramp
fires before the await on the next line is waiting, so nothing hears it:
const STEPS = 20
@left in pressed: exec
var step: int = 0
@right out text = "${step.Value * 5}%"
on pressed {
step = 0
let ramp: exec
emit ramp
await ramp
if step < STEPS {
step += 1
buffer(0.25s) emit ramp
}
}
WARN [WS053] `emit ramp` fires in this tick, before the following `await ramp` arms - so the await never resumes from it and the chain parks forever. Use `buffer emit ramp`, which lands the next tick, after the await is armed
The loop’s result
A for loop has a result too. let run = for ... names it, and after the
loop run.iterations says how many iterations ran:
@left in roll: exec
var rolls: int[]
var shown: string = "No rolls yet"
@right out text = shown.Value
on roll {
rolls.push(Random(1, 6))
var total: int = 0
let run = for r in rolls {
total += r
}
if run.aborted {
shown = "Cut short after ${run.iterations} rolls"
} else {
shown = "${run.iterations} rolls, total ${total}"
}
}
run.aborted is true when the game cut the loop off, for running too many
iterations in one tick. A break does not count as cut off. These are the
loop chip’s Iterations Executed and Aborted outputs.
Modify
- Make it a sunset too. Add a second button and a second handler that runs
the same loop downward:
stepstarts atSTEPSand goes down by one each pass while it is above 0. Give it its own signal, and sharerunning, so neither starts while the other runs. - Change the pace.
buffer(0.1s)is faster.buffer(3) emit rampwaits three ticks instead of a time in seconds. - Make the steps visible. Instead of a color, show a bar on the sign, built
with a
forloop as in Counting Loops, redrawn on every pass of the slow loop. The fast loop runs inside the slow one.
Make
Make a light show. A const array holds six colors. A button plays them on a
Point Light, one every half second, then turns the light off. A second press
during the show does nothing.
button Held -> chip play
chip lamp -> light Color
chip lit -> light Enabled
Good to Know
- A loop can sit inside an
if(for exampleif !running { ... }around the whole loop) when its signal,let ramp: exec, is declared inside that sameif. If the signal is declared outside it, the loop would run once, and the compiler warnsWS118at theawait. The code usesreturninstead, which keeps the loop at the top of the handler. - A
buffer emitends its path through the handler. Code after theifthat holds it runs only on the path that did not loop, which here is once, when the loop ends. A line after abuffer emitin the same block never runs, and the compiler warnsWS058there. - Keep loop counters like
stepoutside the handler. They have to keep their value from one pass to the next. - Use a
forloop for work that must finish now, and a buffered loop for work that should take time.
See Loops under emit and
Loop result in the reference.
Next: Dice.
Dice
Random(1, 6) rolls a die and gives a whole number from 1 to 6.
This lesson rolls two dice, and shows why two dice are not the same as one
roll from 2 to 12.
Contents
button Held -> chip roll
chip text -> decal Text
Code
@left in roll: exec
var a: int = 1
var b: int = 1
@right out text = "${a.Value} + ${b.Value} = ${a.Value + b.Value}"
on roll {
a = Random(1, 6)
b = Random(1, 6)
}
Random(min, max)gives a whole number frommintomax, and both ends can come up:Random(1, 6)gives 1, 2, 3, 4, 5 or 6.- Each
Randomis a separate roll.aandbget two different rolls, so they can match or not. Randomis a step, like a variable write, so it runs in a handler. The sign readsaandbfrom their variables.
Press the button many times. Which total comes up most often? Would
Random(2, 12) give totals in the same way?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
roll, andtextto the Text Decal’s Text. - Press the button and watch the totals. 7 comes up most. 2 and 12 are rare.
Under the Hood
Open the chip. Each Random(1, 6) is one Random gate, with 1 typed into
Min and 6 into Max. Its Exec input runs it, and it puts out a new
number on Output each time. The two Random gates run one after the other
in the chain, each followed by a Set Variable.
There are 36 ways two dice can land, and six of them add to 7 (1 + 6, 2 + 5,
and so on), but only one adds to 2. Random(2, 12) has no such pattern.
Each of its 11 totals is as likely as the others.
A Random outside a handler has nothing to run it:
@left in roll: exec
@right out face = Random(1, 6)
Error: [WS007] exec call 'Random' outside an exec context (pass exec = ... to override)
Move the roll into on roll { ... } and keep the result in a variable, as
the lesson’s code does.
Modify
-
Flip a coin.
Random(0, 1)gives 0 or 1. StoreRandom(0, 1) == 1in aboolvariable and showHeadsorTailswithif heads.Value then "Heads" else "Tails", which gives the first text whenheadsis true and the second when it is false. -
Count the sevens. Roll both dice into one
let, and keep two counts:@left in roll: exec var rolls: int = 0 var sevens: int = 0 @right out text = "${sevens.Value} sevens in ${rolls.Value} rolls" on roll { let total = Random(1, 6) + Random(1, 6) rolls += 1 if total == 7 { sevens += 1 } }A
letis one roll, however often you read it, sototalis the same number in theifas on the line above. Over many rolls, about one in six is a 7. -
Roll a twenty-sided die,
Random(1, 20), and light a Point Light on a 20.
Make
Make a doubles game. Each press rolls two dice and shows them. Doubles (both dice the same) light a Point Light. Three doubles in a row light a second light, and any roll that is not a double resets the streak.
button Held -> chip roll
chip text -> decal Text
chip double -> light1 Enabled
chip streak -> light2 Enabled
Good to Know
Random(1, 6)includes both 1 and 6.Random(0, 6)would give seven different numbers.- Every
Randomcall rolls again. To use one roll in two places, roll it once into aletor a variable and read that. - Each roll is new. The dice do not remember earlier rolls, and a 6 is as likely right after a 6 as at any other time.
See Random in the reference.
Next: Random Colors.
Random Colors
Before this: Dice, Vectors and Colors
Next steps: Shuffling
Random on two colors picks each channel on its own, between the two colors’
values for it. Choose the two colors well and you get every shade of one
family and nothing else. This lesson makes a fire that flickers.
Contents
switch Enabled -> chip burning
chip lamp -> light Color
chip lit -> light Enabled
Code
@left in burning: bool
var flame: color = Color(1.0, 0.5, 0.1)
@right out lamp = flame.Value
@right out lit = burning
on Clock(interval = 0.1, enabled = burning) {
flame = Random(Color(0.8, 0.3, 0.0, 1.0), Color(1.0, 0.6, 0.1, 1.0))
}
- Ten times a second, while
burningis on, the Clock picks a newflame. Random(low, high)on two colors rolls red between the two reds (0.8 to 1.0), green between the two greens (0.3 to 0.6), blue between the two blues (0 to 0.1), and alpha between the two alphas.- Both alphas are 1.0, so alpha always comes out 1.0. Give the bounds different alphas and the alpha is random too.
litpasses the switch straight through, so the light is off when the fire is out.
Can this fire ever turn blue? Can it turn pure yellow (full red, full green)?
Run
- Compile the chip and paste it in-game. Place a Switch and a Point Light. Turn off the light’s Use Brick Color. While it is on, the light takes the brick’s color and ignores the wire.
- Wire the Switch’s Enabled to
burning. Wirelampto the Point Light’s Color andlitto its Enabled. - Flip the switch on. The light flickers through reds and oranges. Flip it off, and the light goes out.
Under the Hood
Open the chip. There is a Clock with Interval Seconds set to 0.1 and the switch wired to its Enabled, one Random gate with the two colors typed into Min and Max, and a Set Variable. The same Random gate that rolls dice rolls colors. The type of Min and Max decides what it picks.
The fire cannot turn blue, because blue never rises above 0.1 and red never falls below 0.8. It cannot turn pure yellow either, since green stops at 0.6. Each channel stays inside its own range, whatever the others roll.
Modify
-
Make a blue flame. Change the bounds to
Color(0.0, 0.2, 0.6, 1.0)andColor(0.3, 0.6, 1.0, 1.0)for flickering blues. -
Pick from a list of colors instead of mixing channels. A
constarray holds the colors, and a random index picks one:const PALETTE = [ Color(1.0, 0.0, 0.0), Color(1.0, 0.5, 0.0), Color(1.0, 1.0, 0.0), Color(0.0, 1.0, 0.0), Color(0.0, 0.3, 1.0), Color(0.6, 0.0, 1.0), ] @left in pressed: exec var shade: color = Color(1.0, 1.0, 1.0) @right out lamp = shade.Value on pressed { shade = PALETTE[Random(0, 5)] }Wire a Button’s Held to
pressed. The light only ever shows one of the six colors. Indexes run from 0, so six colors needRandom(0, 5). -
Flicker the brightness too.
Randomgives whole numbers, so roll a whole number and divide for a decimal:Random(70, 100) / 100.0gives 0.70 to 1.00. Keep it in afloatvariable, and wire it to the light’s Brightness.
Make
Make a party floor of four lights. On every beat (a Clock at half a second), each light picks a color from the palette. Then make sure no two lights next to each other show the same color on the same beat.
chip lamp1 -> light1 Color
chip lamp2 -> light2 Color
chip lamp3 -> light3 Color
chip lamp4 -> light4 Color
switch Enabled -> light1 Enabled
switch Enabled -> light2 Enabled
switch Enabled -> light3 Enabled
switch Enabled -> light4 Enabled
Good to Know
- A Point Light with Use Brick Color on ignores a wired Color. Turn it off.
Randomon two colors picks alpha too. Give both bounds the same alpha to keep it fixed.Randomrolls whole numbers, or colors, vectors and rotators. For a decimal, roll a whole number and divide.- A Clock this fast changes the light every tenth of a second for as long as
the switch is on. Turn it off with
enabledwhen nothing needs to change.
See Random and Color in the reference.
Next: Shuffling.
Shuffling
Before this: Random Colors, Index and Value
Next steps: Weighted Loot, Teams
Parts that use this: Card Table
shuffle() puts an array in a random order. Every element stays, and none is
repeated, which is what you want for a turn order, a team draw or a deck of
cards.
Contents
button Held -> chip deal
chip board -> decal Text
Code
@left in deal: exec
var names: string[] = ["Ann", "Bo", "Cy", "Dee", "Eve"]
var shown: string = "Press to pick the order"
@right out board = shown.Value
on deal {
names.shuffle()
var text: string = ""
for i, name in names {
text = "${text}${i + 1}. ${name}<br>"
}
shown = text
}
namesstarts with five names typed in, so you can try it alone.names.shuffle()puts the elements in a random order, in place, sonamesitself changes.- The loop from Index and Value writes the new order as a numbered list, one name per line.
Press the button a few times. Can a name ever appear twice? Can one be left out?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
deal, andboardto the Text Decal’s Text. - Press the button. The five names come out in a new order each time, all five, once each.
Under the Hood
Open the chip. names.shuffle() is one Shuffle Array gate, wired to the
names Array Var. Then comes the For Each Array loop that builds the
list.
Compare picking each place with Random instead:
@left in deal: exec
var names: string[] = ["Ann", "Bo", "Cy", "Dee", "Eve"]
var shown: string = "Press to pick the order"
@right out board = shown.Value
on deal {
var text: string = ""
for i in 0..5 {
text = "${text}${i + 1}. ${names[Random(0, 4)]}<br>"
}
shown = text
}
Each place rolls on its own here, so a name can come up twice and another not at all. A shuffle only moves the names around, so that cannot happen.
Modify
-
Split into teams after the shuffle. Use
i % 2as in Index and Value: even places on Red, odd places on Blue. The teams now change on every press. -
Shuffle real players. Take the lobby from Index and Value, where a zone pushes each player’s id into
idsand escaped name intonames. Do not shufflenamesitself, or it no longer lines up withids. Shuffle a copy:import { escape } from "sanitize" @left in lobby: zone @left in deal: exec var ids: string[] var names: string[] var order: string[] var shown: string = "Walk in, then press deal" @right out board = shown.Value on ZoneEntered(zone = lobby) -> (character) { let id = character.GetUserId() if !ids.find(id).Found { ids.push(id) names.push(escape(character.GetDisplayName())) } } on deal { order.copyFrom(names) order.shuffle() var text: string = "" for i, name in order { text = "${text}${i + 1}. ${name}<br>" } shown = text }copyFromreplaces everything inorderwith the elements ofnames, and the loop then walksorder. Wire an Attached Zone’s Zone Ref tolobby. -
Deal from a deck. Each press draws one card, and the deck is shuffled again only when it runs out:
const CARDS = ["Ace", "King", "Queen", "Jack", "Ten"] @left in draw: exec var deck: string[] var shown: string = "Press to draw" @right out card = shown.Value on draw { if deck.length() == 0 { deck.copyFrom(CARDS) deck.shuffle() } let top = deck.pop() shown = "${top} (${deck.length()} left)" }deck.pop()takes the last card off the deck. Five presses show all five cards, and the sixth starts a new shuffled deck.
Make
Make a raffle. Players walk into a zone to enter, once each. A button shuffles the entries and shows the first three as 1st, 2nd and 3rd prize. A second button clears the entries for the next raffle.
zone "Zone Ref" -> chip entry
button1 Held -> chip draw
button2 Held -> chip clear
chip board -> decal Text
Good to Know
shuffle()changes the array itself. If another array has to stay lined up with it, shuffle a copy.- A shuffle of an empty array, or of one element, leaves it as it was.
- With one player you cannot see a random team split. Test with names typed in, as this lesson’s code does.
See Array Methods in the reference.
Next: Weighted Loot.
Weighted Loot
Some prizes should be rarer than others. This lesson gives each prize a weight, rolls one number, and walks a running total until the roll falls inside a prize’s share.
Contents
button Held -> chip pull
chip text -> decal Text
Code
const NAMES = ["Common", "Rare", "Epic"]
const WEIGHTS = [60, 30, 10]
@left in pull: exec
var prize: string = "Pull the lever"
@right out text = prize.Value
on pull {
let roll = Random(1, 100)
var total: int = 0
var pick: int = 0
for i, w in WEIGHTS {
total += w
if roll <= total {
pick = i
break
}
}
prize = "${NAMES[pick]} (rolled ${roll})"
}
NAMESandWEIGHTSare twoconstarrays, one entry per prize, in the same order. The weights add up to 100, so each is the chance in a hundred.rollis a number from 1 to 100.- The loop adds each weight to
totalin turn: 60, then 90, then 100. The first timerollis at or undertotal, that prize wins andbreakstops the loop. - So 1 to 60 is Common, 61 to 90 is Rare, and 91 to 100 is Epic.
The roll is 75. Which prize wins? What about 91?
Run
- Compile the chip and paste it in-game. Place a Button (the lever) and a brick with a Text Decal.
- Wire the Button’s Held to
pull, andtextto the Text Decal’s Text. - Pull a few times. Most pulls are Common, about one in three is Rare, and Epic turns up about once in ten.
Under the Hood
Open the chip. A const array that the circuit reads while it runs becomes
an Array Var with its values typed in. WEIGHTS feeds the
For Each Array loop. NAMES is read once, after the loop, by a
Get Array Element with pick as its index.
Inside the loop, the roll comes in on a wire from the Random gate outside.
It is rolled once, before the loop, so every iteration compares against the
same number. A Random inside the loop would roll again on every iteration, and
the weights would no longer mean what they say.
The arrays are const, so they cannot change while the game runs, and
WEIGHTS.push(5) does not compile. Declare one with var to make it
changeable.
Modify
-
Add a Legendary prize. Add
"Legendary"toNAMESand a weight of 2 toWEIGHTS, and take 2 off Epic so the weights still add up to 100. Then check by hand which rolls give Legendary. -
Color the prize by its rarity. A map literal,
{ key: value, ... }, can be aconsttoo:const NAMES = ["Common", "Rare", "Epic"] const WEIGHTS = [60, 30, 10] const COLORS = { "Common": "aaaaaa", "Rare": "4080ff", "Epic": "c050ff" } @left in pull: exec var prize: string = "Pull the lever" @right out text = prize.Value on pull { let roll = Random(1, 100) var total: int = 0 var pick: int = 0 for i, w in WEIGHTS { total += w if roll <= total { pick = i break } } let won = NAMES[pick] prize = '<color="${COLORS[won]}">${won}' }COLORS[won]looks up the color for the prize’s name, the wayNAMES[pick]looks up a name by its index. -
Keep each prize and its weight together in one map, and loop over it:
const LOOT = { "Common": 60, "Rare": 30, "Epic": 10 } @left in pull: exec var prize: string = "Pull the lever" @right out text = prize.Value on pull { let roll = Random(1, 100) var total: int = 0 var won: string = "" for name, w in LOOT { total += w if roll <= total { won = name break } } prize = "${won} (rolled ${roll})" }The loop may visit the prizes in any order. That does not change the odds, because each prize still gets a stretch of the numbers as wide as its weight.
Make
Make a loot chest that never gives the same prize twice in a row. When the roll lands on the prize from last time, roll again, at most three times, and take whatever the third roll gives. Keep the last prize in a variable.
button Held -> chip pull
chip text -> decal Text
Good to Know
- The weights must add up to the top of the roll. If they add up to 90 and
the roll goes to 100, a roll of 91 or more matches nothing, and the sign
shows the first prize, since
pickwas never changed. - Roll once, outside the loop. A roll inside the loop changes on every iteration.
- Weights set only the chances. Ten pulls can bring two Epics, or none.
See const containers at runtime
in the reference.
Next: pick a lesson from the tree on the Learn page.
Seat Controls
Before this: Reacting to the Game
Next steps: Remote Control, One Chip per Player, Over Time
A seat knows who is sitting in it, and a circuit can read that player’s keys. This lesson turns a seat into a joystick that drives a light.
Contents
seat "Sitting Character" -> chip rider
chip brightness -> light Brightness
chip readout -> decal Text
switch Enabled -> light Enabled
Code
@left in rider: character
let input = rider.InputReader()
@right out brightness = (input.Forward + 1.0) * 2.0
@right out readout = "Forward ${input.Forward}, Right ${input.Right}"
rider: character is a pin for a player’s character, here the person sitting
in the seat. rider.InputReader() reads that player’s controls. It gives a
record of fields, among them:
Forward: the forward and back keys, from -1 (back) to 1 (forward),Right: the left and right keys, from -1 to 1,PressedQ,PressedE,PressedC:truewhile that key is held.
input.Forward + 1.0 runs from 0 to 2, so brightness runs from 0 (holding
back) to 4 (holding forward).
Someone sits down and presses nothing. How bright is the light? What does the readout say?
Run
- Compile the chip and paste it in-game. Place a Seat, a Point Light and a brick with a Text Decal.
- Wire the Seat’s Sitting Character to
rider. Wirebrightnessto the Point Light’s Brightness, andreadoutto the Text Decal’s Text. - Give the light’s Enabled a Switch of its own, flipped on.
- Sit in the seat. Hold forward and the light brightens. Hold back and it dims. Let go and it returns to the middle. The readout follows every key.
Under the Hood
Open the chip. InputReader is not a gate. It is a Seat Control
Splitter, the brick the game offers for splitting a seat’s controls into
separate wires. Its Character input is wired from rider, and its
outputs (Input Forward, Input Right, Pressed Q and the rest) feed
the gates after it: an Add and a Multiply for the brightness, and a
Format String for the readout.
Every gate here is pure. Nothing waits for a pulse. The splitter’s outputs change while the player presses keys, the same way a Switch’s Enabled does, and the light follows on the same tick.
InputReader reads one player, the one wired into it. A second seat needs a
second splitter, wired from its own Sitting Character.
Modify
-
Steer a second light with
input.Right, so left dims it and right brightens it. -
Make a horn. Add
@right out honk: execandon input.PressedQ { emit honk }, and wirehonkto a One Shot Audio’s Play with a sound picked. -
Count how many times the rider pushes forward. Holding a key is not a press, but a comparison turns it into one:
@left in rider: character let input = rider.InputReader() var pushes: int = 0 @right out readout = "Pushes: ${pushes.Value}" on input.Forward > 0.5 { pushes += 1 }input.Forward > 0.5turns on when the key goes down, so the handler runs once per push, however long it is held.
Make
Make a seat that works a drawbridge sign. Holding forward for a full second
shows “Bridge down” on a Text Decal, and holding back for a full second shows
“Bridge up”. A short tap does nothing. Use await Sleep(...) inside the
handler, then check the key again. Keep the sign’s text in a
var shown: string.
seat "Sitting Character" -> chip rider
chip sign -> decal Text
Good to Know
- A Seat’s Sitting Character is empty while nobody sits in it. Keep the
rest of your build sensible when
rideris empty. - A handler on a held key runs once per press, when the value turns on, not on every tick it stays held.
ForwardandRightare decimals between -1 and 1. Compare them with> 0.5rather than== 1.0.
See InputReader in the reference.
Next: Remote Control.
Remote Control
A Seat hands its rider to a circuit, but only while they sit. A Switch hands over a player too. Its Character output holds whoever flipped it last, wherever they go. This lesson builds a remote for a light. Flip the switch, walk away, and your keys still work the light.
Contents
switch Character -> chip holder
chip shining -> light Enabled
chip brightness -> light Brightness
Code
@left in holder: character
let keys = InputReader(holder)
var lit: bool = false
var level: float = 2.0
@right out shining = lit.Value
@right out brightness = level.Value
on keys.PressedQ {
lit = !lit
}
on keys.Forward > 0.5 {
level = min(level + 0.5, 4.0)
}
on keys.Forward < -0.5 {
level = max(level - 0.5, 0.0)
}
holder is wired from the Switch’s Character. InputReader(holder) is
the same as holder.InputReader() from Seat Controls.
It reads the holder’s keys as they press them.
on keys.PressedQruns once each time Q goes down, and flips the light.on keys.Forward > 0.5runs once per push forward, and raises the level by half a step. Back lowers it.min(..., 4.0)andmax(..., 0.0)keep the level between 0 and 4.
Nothing here runs on a timer. Every handler starts from a key.
One player flips the switch on and walks off. A second player flips it off. Whose Q turns the light on now?
Run
- Compile the chip and paste it in-game. Place a Switch and a Point Light.
- Wire the Switch’s Character to
holder. Wireshiningto the Point Light’s Enabled andbrightnessto its Brightness. - Flip the switch and walk away from it. Press Q and the light comes on. Push forward a few times and it gets brighter, one step per push. Pull back and it dims.
- With a second player: they flip the switch, and now their Q works the light and yours does nothing. Which way the switch points does not matter. Flipping it hands over the remote.
Under the Hood
Open the chip. InputReader is a Seat Control Splitter, the same
component as in Seat Controls. Its Character input is wired from
holder, so from the Switch. Its outputs are steady wires that change while
the holder presses keys. Nothing samples them.
The Pressed Q output is wired straight into the Exec input of a Get Variable. An exec input fires when a bool wired into it turns on, so the chain runs once per press: Get Variable, NOT, Set Variable. Input Forward goes into a Greater and a Less gate, and their outputs start the other two chains the same way, each with an Add or a Subtract, a Min or a Max, and a Set Variable.
A Seat’s Sitting Character empties when the rider stands up. A Switch’s
Character does not. It changes only when someone else flips the switch.
That makes it a steady character wire to feed into InputReader.
Reading the Keys Once
GetInputs reads the same fields as InputReader, but it is a step that
reads the keys at the moment a handler reaches it. Use it when a handler
already has a player from an event, and no character wire exists to feed a
splitter. This hallway greets anyone who walks in backwards:
@left in hall: zone
var walkers: int = 0
@right out count = walkers.Value
on ZoneEntered(zone = hall) -> (character) {
if character.GetInputs().Forward < -0.5 {
walkers += 1
character.ShowStatusMessage("Nice moonwalk!")
}
}
The zone event gives the player who entered, and one Get Inputs gate reads whoever that is. Since it is a step, it cannot drive a pin outside a handler:
@left in holder: character
@right out forward = holder.GetInputs().Forward
Error: [WS007] exec call 'GetInputs' outside an exec context (pass exec = ... to override)
Keep GetInputs for a one-time read like this. To follow someone’s keys,
wire their character into InputReader.
Modify
- Let the remote work only while the switch is on. Add
@left in active: bool, wire the Switch’s Enabled to it, and writeon keys.PressedQ && active. Do the same for the two forward handlers. - Make the light glide between levels instead of jumping. Wrap the output
in a Dampen gate, which follows its target smoothly:
@right out brightness = Dampen(level.Value, 0.3). The second value is the gate’s Smooth Time, and larger values catch up more slowly. - Show who holds the remote. Add
var shown: string = "",@right out sign = shown.Valuewired to a Text Decal’s Text, andon Change(holder) { shown = escape(holder.GetDisplayName()) }, withimport { escape } from "sanitize"at the top, as in Reacting to the Game. A name can contain<, andescapekeeps it from reading as formatting.
Make
Make a remote door. A Switch hands the remote to a player. Pushing forward
opens the door (wire a solid output to a Brick Property Changer’s
Collision Enabled and Visibility), and pulling back closes it. Q
locks the door: while it is locked, forward does nothing, until Q is
pressed again.
switch Character -> chip holder
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Good to Know
- A Switch’s Character keeps the last player who flipped it, wherever they walk. A respawn makes a new character, so after dying the holder flips the switch again to take the remote back.
- A handler on a key runs once per press, when the key goes down, not on every tick it stays held.
- E flips any switch and presses any button the player looks at. That is why the light toggles on Q.
- One
InputReaderreads one player. To read the keys of every player at once, give each player a chip of their own, as in One Chip per Player. GetInputsreads any player, wherever they are, but only at the moment it runs. A key pressed and released between two reads is never seen.
See InputReader and GetInputs in the reference.
Next: Ready Check.
Ready Check
Before this: Remote Control, Looping Over a List, Maps, Choosing a Value, Waiting
Parts that use this: King of the Hill, Card Table, Team Match
A game often waits until its players say they are ready. There are three
common ways to ask: one Button that everyone presses, seats that count as
ready while someone sits in them, and a /ready chat command. This lesson
builds the button version as a ready board for a room, then shows the other
two. The board changes only when something happens. There is no timer.
Contents
zone "Zone Ref" -> chip room
button "Press Count" -> chip holding
button Character -> chip presser
chip board -> decal Text
Code
import { escape } from "sanitize"
@left in room: zone
@left in holding: int
@left in presser: character
var players: character[]
var ready: Map<string, bool>
var last: int = 0
var shown: string = "Nobody here"
@right out board = shown.Value
let redraw: exec
on Change(holding) {
if holding > last {
let id = presser.GetUserId()
let r = ready.get(id)
ready.set(id, !(r.Found && r.Value))
emit redraw
}
last = holding
}
on ZoneEntered(zone = room) -> (character) {
emit redraw
}
on ZoneLeft(zone = room) -> (character) {
ready.remove(character.GetUserId())
buffer emit redraw
}
on redraw {
players.fillFromZonePlayers(room)
var text: string = ""
var count: int = 0
for p in players {
let r = ready.get(p.GetUserId())
let isReady = r.Found && r.Value
if isReady {
count += 1
}
let box = if isReady then "[x] " else "[ ] "
text = "${text}${box}${escape(p.GetDisplayName())}<br>"
}
shown = "${count}/${players.length()} ready<br>${text}"
}
Three things can change the board, and each has a handler:
- A press.
holdingis the Button’s Press Count, the number of players holding it right now. It goes up when someone presses and down when they let go, andon Change(holding)runs on both.holding > lastlets only a rise through, so each press counts once and a release does nothing.presser, the Button’s Character, is the player who pressed. Their mark flips. - Someone walks in. They join the board, not ready.
- Someone walks out. Their mark is removed, so they come back not ready.
Each handler ends by firing redraw, a signal of the chip’s own. The
redraw handler fills players with everyone standing in the zone, walks
the list with for p in players, and writes one line per player and the
total. <br> starts a new line on a sign.
A player holds the button down for three seconds and lets go. How many times does their mark flip?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone covering the room, put a Button inside the room, and place a brick with a Text Decal where the room can see it.
- Wire the Attached Zone’s Zone Ref to
room, the Button’s Press Count toholdingand its Character topresser. Wireboardto the Text Decal’s Text. - Walk into the zone. The board lists you as
[ ]. Press the button and you become[x], and the count goes up. Press it again and you are back to[ ]. - Walk out and you leave the board. Walk back in and you are
[ ]again. - With a second player, hold the button down while they press it. Their mark flips, and yours does not.
Under the Hood
Open the chip. on Change(holding) is a Change Detector (Exec) on the
count. A Get Variable reads last, a Greater compares, and a
Branch picks the path. Both paths meet at a Union before last is
set. The two zone handlers are a Player Entered Zone Event and a
Player Left Zone Event. Exec wires cannot join on one input, so
Union gates merge the three emit redraws into one chain. The leave
handler’s buffer emit goes through a Buffer (Ticks) first, so its
redraw runs a tick later, once the zone has let the player go.
The redraw chain starts with a Get Players In Zone gate, which fills the
players Array Var. Then a For Each Array chip runs its inside once
per player, all within the same tick.
Why the count, and not the Button’s Held? Held stays on while anyone holds the button, so a second player pressing during that time does not turn it on again. The count goes up on every press, and Character changes to the player who made it.
The map is keyed by GetUserId(), not by the character, because a player who
dies comes back as a new character with the same user id. var text and
var count are declared inside the handler, so they start over on every
redraw.
Ready by sitting
Seats need no button. A player is ready while they sit. Each Seat’s Is Occupied says whether someone sits there, and its Sitting Character says who:
import { escape } from "sanitize"
const NEEDED = 2 // seated players needed to start
@left in sat1: bool
@left in who1: character
@left in sat2: bool
@left in who2: character
@left in sat3: bool
@left in who3: character
@right out go: exec
var name1: string = "-"
var name2: string = "-"
var name3: string = "-"
let seated = sat1 + sat2 + sat3
@right out board = "${seated}/3 seated<br>${name1.Value}<br>${name2.Value}<br>${name3.Value}"
on sat1 { name1 = escape(who1.GetDisplayName()) }
on !sat1 { name1 = "-" }
on sat2 { name2 = escape(who2.GetDisplayName()) }
on !sat2 { name2 = "-" }
on sat3 { name3 = escape(who3.GetDisplayName()) }
on !sat3 { name3 = "-" }
on seated >= NEEDED {
emit go
}
Wire each Seat’s Is Occupied and Sitting Character to a sat and
who pair, board to a sign, and go to whatever starts the game. A bool
adds as 1 or 0, so seated counts the occupied seats. on seated >= NEEDED
fires once when the count reaches NEEDED, and again only after it drops
below and comes back. Set NEEDED = 3 to wait for every seat.
Ready by chat command
Players can also type /ready. Add this handler to the button chip, and
each /ready flips the mark of the player who typed it:
on ChatCommand("ready", "Mark yourself ready, or not") -> (who, arguments) {
let id = who.GetUserId()
let r = ready.get(id)
ready.set(id, !(r.Found && r.Value))
emit redraw
}
Chat Command is an admin gate, so a chip with this handler loads only for an admin. Chat Commands covers it.
Modify
- When everyone in the zone is ready, and there are at least two of them, show “All ready!” on the first line instead of the count.
- Ignore presses from outside the room. In the press handler, fill
playersfrom the room first, and flip the mark only ifplayers.find(presser).Found. - Use two buttons, Ready and Not Ready, instead of one that flips. Each
needs its own Press Count and character pins, its own
last, and its ownon Change(...)handler.
Make
Make a voting booth. Players in the room press a Yes button or a No button. A player can change their vote, but only their last one counts, and walking out takes the vote away. The board shows the yes and no totals, and nobody’s name.
zone "Zone Ref" -> chip room
button1 "Press Count" -> chip yes
button1 Character -> chip yesBy
button2 "Press Count" -> chip no
button2 Character -> chip noBy
chip board -> decal Text
Good to Know
Changedoes not fire when the chip loads, so pasting it marks nobody.- A display name is chosen by the player and can contain
<. Escape it before it reaches a sign, asescapedoes here. - The loop finishes within one tick, however many players there are, but its inside runs once per player on every redraw.
- Over a long session a zone event can be missed, and the board then
stays out of date until the next press or arrival. Each redraw asks the
zone who is inside, so firing
redrawfrom a slow Clock as well keeps the board right. - A button reads one press at a time. To follow the keys of every player in a room at once, see One Chip per Player.
See Edge / Change Detectors,
Zone array fills and
for Loops in the reference.
Next: pick a lesson from the tree on the Learn page.
Object References
Before this: Reacting to the Game, True and False
Next steps: Ref Parameters, Persistent Players, Keeping It Current
Some values are not numbers or text but things in the world: a player, a player’s body, a brick. A wire carries a reference to the thing, not a copy of it. This lesson keeps a player in a variable to play tag.
Contents
zone "Zone Ref" -> chip base
chip sign -> decal Text
Code
import { escape } from "sanitize"
@left in base: zone
var it: character = null
var shown: string = "Nobody is it"
@right out sign = shown.Value
on ZoneEntered(zone = base) -> (character) {
if character != it {
let old = it
it = character
shown = "${escape(character.GetDisplayName())} is it"
character.ShowChatMessage("You are it!")
old.ShowChatMessage("You are free.")
}
}
var it: characterholds a character: the body of the player who is “it”.= nullstarts it empty.nullis a reference to nothing.character != itasks whether the player who walked in is a different character from the one stored. It compares which character, not names.let old = itkeeps the previous holder, so they can be told they are free afterithas moved on.
The player who is it walks out and back in. What happens? Now they fall off the map, respawn, and walk in again. What happens then?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone for the base, and place a brick with a Text Decal.
- Wire the Attached Zone’s Zone Ref to
base, andsignto the Text Decal’s Text. - Walk into the base: you get “You are it!” and the sign shows your name. Walk out and in again: nothing happens, you are already it.
- A second player walks in: they are it, and you get “You are free.”
Under the Hood
Open the chip. it is one Var gate, and its value is a character. The
handler starts with one Get Variable that reads it. A Not Equal
compares that with the event’s Character, and a Branch decides.
old adds no gate. It is that same first read.
Both Show Chat Message gates have a Player State input, the game’s name for a player. The character wire goes straight into it with no gate in between, because a player input accepts a character.
The first time anyone walks in, old is null, so the last message has
nobody to go to. != null asks whether a reference holds anything:
@left in base: zone
var it: character = null
on ZoneEntered(zone = base) -> (character) {
if it != null {
it.ShowChatMessage("You are free.")
}
it = character
}
In a comparison, null takes the type of the other side, here character.
The chip compares it with an empty character in a Not Equal gate.
References come in a few kinds:
| Type | What it is | Where a circuit gets one |
|---|---|---|
character | a player’s body, while they are alive | zone events, a Seat’s Sitting Character, a Target’s Character That Just Hit |
controller | the player, the same through every death and respawn | ControllerOf (an admin gate); a character wired into a player input still works, but stays the body |
entity | any object in the world: a player, a brick grid | any character, or ReadBrickGrid(), the grid the chip sits on |
brick | one brick | GetMicrochipBrick(), the chip’s own brick |
Modify
-
Count the tags. Add
var tags: int = 0and show it on the sign. The very first walk-in is not a tag, since nobody was it: add 1 totagsonlyif old != null. -
Play tag with a gun instead. Replace the zone with a Target: shooting it makes you it.
import { escape } from "sanitize" @left in hit: exec @left in shooter: character var it: character = null var shown: string = "Nobody is it" @right out sign = shown.Value on hit { if shooter != it { let old = it it = shooter shown = "${escape(shooter.GetDisplayName())} is it" shooter.ShowChatMessage("You are it!") old.ShowChatMessage("You are free.") } }Wire the Target’s Just Hit to
hitand its Character That Just Hit toshooter. -
Show who is it on the chip itself. A brick is a reference too, and
GetMicrochipBrick()is the chip’s own brick. Add this line to the handler, afterit = character:GetMicrochipBrick().SetBrickProperty(BrickProperty.Color, Color(1.0, 0.2, 0.2))BrickProperty.Colorpicks which setting of the brick to change, andColor(1.0, 0.2, 0.2)is a color made of red, green and blue amounts from 0 to 1. The chip turns red at the first tag. Keep the brick in a variable first,var chipBrick: brick, set it withchipBrick = GetMicrochipBrick(), and recolorchipBrick. A brick can be stored like a character.
Make
Make a king of the hill. A zone sits on top of a hill. The last player to
walk in is king, and a sign shows the king’s name. A Point Light by the
hill is on while the king is standing in the zone and off when they leave.
Use on ZoneLeft(zone = ...) -> (character) and compare the character who
left with the king.
zone "Zone Ref" -> chip hill
chip sign -> decal Text
chip lit -> light Enabled
Good to Know
- A player who dies loses their character. Until they respawn they have no
character at all, and when they do, they get a new one.
!=treats the new one as a different character, so in the tag chip a respawned “it” who walks in is made it again. - Do not keep a player’s score under their character for the same reason. A later lesson, Per-Player State, keys it by the player’s user id instead.
- A Seat’s Sitting Character is empty while nobody sits in it.
- The message to the old holder goes last on purpose. By then the new player is already it, whatever happens to a message sent to a character that may be gone.
- A character goes straight into any player input, so the
ControllerOfandCharacterOfhelpers are not needed to message them or read their keys.ControllerOfalso uses a gate only an admin can load. - A character wired in where a controller goes is still the character. It is not equal to that player’s controller, and a stored one stops standing for the player at their next respawn.
See Object references & assets,
The null literal and
Bricks in the reference.
Next: Ref Parameters.
Ref Parameters
Before this: Object References, Mods, Lists
Next steps: Ref Values
A mod’s inputs are copies: changing one inside the mod changes nothing
outside it. Mark an input with * and the caller hands over the variable
itself, so the mod can change it. This lesson scores a two-team match with one
mod that changes three variables.
A *int is a reference, like the ones in
Object References. A character names a player’s
body out in the world, and a *int names a variable kept somewhere else.
Contents
target1 "Just Hit" -> chip redHit
target2 "Just Hit" -> chip blueHit
chip sign -> decal Text
Code
@left in redHit: exec
@left in blueHit: exec
var red: int = 0
var blue: int = 0
var redRounds: int = 0
var blueRounds: int = 0
@right out sign = "Red ${red.Value} - ${blue.Value} Blue<br>Rounds ${redRounds.Value} - ${blueRounds.Value}"
mod point(mine: *int, theirs: *int, rounds: *int) {
mine += 1
if mine >= 5 {
rounds += 1
mine = 0
theirs = 0
}
}
on redHit { point(red, blue, redRounds) }
on blueHit { point(blue, red, blueRounds) }
mine: *intreads “a variable holding an int”. The caller passes a variable, and every use ofmineinside the mod is that variable.point(red, blue, redRounds)scores for red:mineisred,theirsisblue. The blue call passes the same two variables the other way round.- The first to 5 points wins the round, and both scores go back to 0.
<br>in the sign’s text starts a new line.
Red has 4 points and blue has 3. Red scores. What does the sign show?
Run
- Compile the chip and paste it in-game. Place two Targets, one for each team, and a brick with a Text Decal.
- Wire the red Target’s Just Hit to
redHit, the blue Target’s Just Hit toblueHit, andsignto the Text Decal’s Text. - Shoot the targets. Each hit adds a point to its team. The fifth point wins the round and clears both scores.
Under the Hood
Open the chip. There is no gate named point. As with every mod, each call
pasted the body in: two copies of seven gates, one copy under each handler.
Follow the red copy. Its Increment Variable has a Var Ref input,
wired from the red Var. That wire is what mine became. The copy’s two
Set Variable gates are wired to red and blue. In the blue copy the
same gates are wired the other way round. A * parameter costs no gate. It
only decides where the mod’s gates are wired.
Without the *, mine is a copy of a number, and a number cannot be
changed:
@left in redHit: exec
var red: int = 0
mod point(mine: int) {
mine += 1
}
on redHit { point(red) }
Error: [WS007] 'mine' isn't a writable target
The other way round, a * parameter needs a variable to point at. A number
or a sum is not one:
@left in redHit: exec
var red: int = 0
mod point(mine: *int) {
mine += 1
}
on redHit { point(red + 1) }
Error: [WS008] cannot pass a non-lvalue to ref parameter 'mine': `*T`/`ref` needs a variable or ref parameter, not a literal, expression, `let`, `arr[i]`, or call
Modify
-
Make the target score a parameter. Add
to: int = 5topointand comparemine >= to. Play a short match withpoint(red, blue, redRounds, 3)andpoint(blue, red, blueRounds, 3). -
Add a button that makes the teams swap ends, and swaps their scores with them:
mod swap(a: *int, b: *int) { let before = a a = b b = before }Call
swap(red, blue)andswap(redRounds, blueRounds)fromon swapEnds { ... }, with@left in swapEnds: execwired from a Button’s Held.let before = areadsabefore the next line overwrites it. -
Keep a log of red’s score after every hit. Arrays are already passed as themselves, with no
*. A mod with a plainint[]parameter pushes into the caller’s array.@left in redHit: exec var red: int = 0 var log: int[] var logged: int = 0 @right out sign = "${logged.Value} hits logged" mod note(list: int[], score: int) { list.push(score) } on redHit { red += 1 note(log, red) logged = log.length() }
Make
Make a shop counter. Each player area has a Button that earns a coin and a
Button that buys a lamp for 3 coins. Write one
mod buy(coins: *int, lamps: *int, cost: int) that takes the cost from
coins and adds a lamp only if there are enough coins. Use it for two
players’ buttons, and show both players’ coins and lamps on a sign.
button1 Held -> chip earn1
button2 Held -> chip buy1
button3 Held -> chip earn2
button4 Held -> chip buy2
chip sign -> decal Text
Good to Know
- A
*parameter adds no gates, but the mod’s body is still copied at every call. Three calls ofpointare 21 gates. - The writes happen in order, one step after another, so a later line inside
the mod sees an earlier line’s change:
mine >= 5reads the score after the+= 1. - Two handlers that write the same variable on the same tick both run, in no set order. If both targets are hit at once, either team’s point can land first.
See Reference Parameters and Ref and Deref in the reference.
Next: Ref Values.
Ref Values
Before this: Ref Parameters
Next steps: Per-Player State
A *int parameter points a mod at a variable when you write the call. A ref
can also be a value: kept in a variable, and pointed somewhere else while the
game runs. This lesson keeps three counters and a button that chooses which
one the other button adds to.
Contents
button1 Held -> chip next
button2 Held -> chip add
chip sign -> decal Text
Code
@left in next: exec
@left in add: exec
var a: int = 0
var b: int = 0
var c: int = 0
var picked: *int
var name: string = "none"
@right out sign = "A ${a.Value} B ${b.Value} C ${c.Value}<br>Picked: ${name.Value}"
on next {
if name == "A" {
picked = &b
name = "B"
} else if name == "B" {
picked = &c
name = "C"
} else {
picked = &a
name = "A"
}
}
on add {
*picked += 1
}
var picked: *intis a variable that holds a ref to anintvariable. It starts empty.&bisb’s ref, as a value.picked = &bstores it.*pickedis the variablepickedpoints at.*picked += 1adds one to whichever ofa,borcthat is.nextstepspickedfrom A to B to C and round again.namekeeps the letter for the sign.
The add handler never mentions a, b or c. Press next twice, then
add three times. Which counter goes up? What happens if you press add
before you have ever pressed next?
Run
- Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
- Wire one Button’s Held to
next, the other’s Held toadd, andsignto the Text Decal’s Text. - Press
addfirst: nothing happens, since nothing is picked yet. Pressnext: A is picked. Pressadd: A goes up. Pressnextandadd: B goes up.
Under the Hood
Open the chip. picked is a Var like the others, but what it holds is a
ref to another Var. The three Set Variable gates that write picked each
take their Value from a different counter’s Var Ref output. That wire
is &a, &b or &c.
The add chain is two gates. A Get Variable reads picked, and its
Value output is wired into the Var Ref input of an Increment
Variable. In Ref Parameters the Var Ref input was wired
straight from one counter when you compiled. Here it comes from a variable,
so which counter it reaches is decided while the game runs.
A ref is not the number it points at. Leave out the * and the compiler
stops you:
@left in add: exec
var a: int = 0
var picked: *int
on add {
picked = &a
picked += 1
}
Error: [WS004] operator '+' cannot be applied to a reference (*int, int): refs and bricks only compare with `==` and `!=`
A ref can start out pointing somewhere. The = &a is baked into the save,
so picked already holds a’s ref when the program is pasted, with no gate
to set it:
var a: int = 0
var picked: *int = &a
The initializer has to name a var, array or map declared above it. A ref that
is only known while the game runs, such as one read from another var, is
still dropped with a WSP001 warning.
Modify
-
Add a third button that clears the picked counter:
*picked = 0. One handler clears whichever counter is picked. -
Keep the refs in a list instead of an
ifchain.(*int)[]is an array of refs, filled the first timenextruns:@left in next: exec @left in add: exec var a: int = 0 var b: int = 0 var c: int = 0 var lanes: (*int)[] var lane: int = -1 var picked: *int @right out sign = "A ${a.Value} B ${b.Value} C ${c.Value}<br>Picked: ${lane.Value + 1}" on next { if lanes.length() == 0 { lanes.push(&a) lanes.push(&b) lanes.push(&c) } lane = (lane + 1) % 3 picked = lanes[lane] } on add { *picked += 1 }Add a fourth counter
d. Only the list and the% 3change. -
A ref value can go to a
*intparameter. The mod then works on the variable the ref points at. Add this mod and call it withpicked:mod bump(count: *int, by: int) { count += by } on add { bump(picked, 5) }
Make
Make a switchboard. Three Point Lights are each driven by a bool
variable. One button picks the next light, and another flips the picked one
on or off (*sel = !*sel). A sign says which light is picked.
button1 Held -> chip next
button2 Held -> chip flip
chip lamp1 -> light1 Enabled
chip lamp2 -> light2 Enabled
chip lamp3 -> light3 Enabled
chip sign -> decal Text
Good to Know
- An empty ref stops the chain. Reading or writing through
pickedbefore anything was stored in it ends the handler at that line, with no message. - Refs compare only with refs.
picked == &aasks whetherpickedpoints ata. Comparing a ref with a number is an error (WS084), because the game treats1 == <ref>as true. - Shown as text, a ref prints as
Var, not as the value it points at. Read through it with*first. - Arrays and maps are already passed as themselves: an
int[]mod parameter needs no*, as in Ref Parameters.
See Reference values and Ref and Deref in the reference.
Next: Per-Player State.
Per-Player State
Before this: Ref Values, Maps
Next steps: Multiplayer Architecture, A HUD for Everyone
Parts that use this: Shooting Gallery, Checkpoints, Race Timer, Scoreboard HUD, King of the Hill, Shop, Voting, Obstacle Course, Team Match
A circuit that remembers something for each player stores it in a map, under a key that stands for the player. Which key you pick decides when the memory starts over. This lesson counts target hits per player for good, and per life beside it.
Contents
target "Just Hit" -> chip hit
target "Character That Just Hit" -> chip shooter
chip sign -> decal Text
Code
import { escape } from "sanitize"
@left in hit: exec
@left in shooter: character
var hits: Map<string, int>
var lifeHits: Map<string, int>
var bodies: Map<string, character>
var shown: string = "No hits yet"
@right out sign = shown.Value
on hit {
let id = shooter.GetUserId()
let before = hits.get(id)
let total = if before.Found then before.Value + 1 else 1
let body = bodies.get(id)
let sameLife = body.Found && body.Value == shooter
let lifeBefore = lifeHits.get(id)
let life = if sameLife then lifeBefore.Value + 1 else 1
hits.set(id, total)
lifeHits.set(id, life)
bodies.set(id, shooter)
shown = "${escape(shooter.GetDisplayName())}: ${total} hits, ${life} this life"
}
shooter.GetUserId()is the player’s user id, a string that belongs to their account. All three maps are keyed by it.hitscounts every hit the player has made.bodiesstores the character the player had at their last hit. On the next hit,body.Value == shooteris true only while they are still in that same body.lifeHitscounts on whilesameLifeholds, and starts again at 1 when it does not.
You hit the target three times, fall off the map, respawn, and hit it once more. What does the sign say?
Run
- Compile the chip and paste it in-game. Place a Target and a brick with a Text Decal.
- Wire the Target’s Just Hit to
hit, its Character That Just Hit toshooter, andsignto the Text Decal’s Text. - Shoot the target three times:
3 hits, 3 this life. Die and respawn, and shoot once more:4 hits, 1 this life. A second player’s hits start their own counts.
Under the Hood
Open the chip. There are three Map Var gates. One Get User Id gate
turns the shooter into an id, and every Get Map Element and Set Map
Element takes its Key from it. A map can hold references as well as
numbers. The bodies map’s values are characters, and an Equal compares
the stored one with the shooter.
Each key starts over at a different time:
| Key | Stays the same | Starts over |
|---|---|---|
GetUserId() | through deaths and respawns | never: it belongs to the account |
the controller | through deaths and respawns | it is the player, but getting it from a character needs the admin ControllerOf |
the character | while the player stays alive | at every respawn |
So hits is keyed by the user id, and the per-life count compares
characters. Keying hits by the character would quietly reset a player’s
score every time they died, and leave an entry behind for every old body.
The compiler does not catch every wrong key. This compiles with no warning:
@left in hit: exec
@left in shooter: character
var hits: Map<string, int>
on hit {
hits.set(shooter, 1)
}
The map takes a string key, so the compiler turns the character into text
with a Format String gate. The key is the character printed as text, not
its user id. Always call GetUserId() for a string key.
A Map<controller, int> accepts a character too, wired straight into its
Key with no gate between. The game then stores the hit under that one
body rather than the player. A character and its player’s controller are not
equal, so a respawn starts a new entry. Without admin, the user id is the
key that lasts.
A controller can also carry values itself. SetCustomProperty and
GetCustomProperty store values on an entity under a name, and on a
controller they follow the player through every respawn. They are lost when
the player reconnects, which a map keyed by user id is not. This needs the
controller, and only the admin ControllerOf turns a character into one
(Persistent Players). Without admin, keep
the maps keyed by user id.
Modify
-
Keep each player’s best life. Add
var best: Map<string, int>. After working outlife, readlet oldBest = best.get(id), andif !oldBest.Found || life > oldBest.Value { best.set(id, life) }. Show the best on the sign. -
Add a reset button.
on reset { ... }callsclear()on every map and setsshownback to"No hits yet". -
Greet players coming back from a respawn. Put a zone in the doorway out of the spawn area, and compare the character walking through with the one in
bodies:@left in hit: exec @left in shooter: character @left in doorway: zone var hits: Map<string, int> var bodies: Map<string, character> on hit { let id = shooter.GetUserId() let before = hits.get(id) hits.set(id, if before.Found then before.Value + 1 else 1) bodies.set(id, shooter) } on ZoneEntered(zone = doorway) -> (character) { let id = character.GetUserId() let body = bodies.get(id) if body.Found && body.Value != character { let total = hits.get(id) character.ShowChatMessage("Welcome back. ${total.Value} hits so far.") bodies.set(id, character) } }Storing the new body stops the greeting repeating every time they walk through.
Make
Make a checkpoint race. Walking through a checkpoint zone records the player
in a Map<string, bool> keyed by user id. Walking through the finish zone
says “Finished!” only to players who passed the checkpoint, and clears their
entry. Check that a player who passes the checkpoint, dies and respawns can
still finish.
zone1 "Zone Ref" -> chip checkpoint
zone2 "Zone Ref" -> chip finish
Good to Know
- A respawn makes a new character for the same player. Nothing removes the old one from a map keyed by characters: its entry stays forever.
- A player’s user id stays the same through every death and respawn. Their display name is chosen by the player, so escape it before showing it.
- Every
getis its own gate and its own read. Bind each one to alet, asbeforeandbodyare here, and read thelet. - A character is not equal to its player’s controller. A character used as
a
Map<controller, ...>key, or stored in acontrollervariable, is still the body, and a respawn leaves it behind. - Do not reach for
ControllerOfto turn a character into a player: it uses a gate only an admin can load. The user id needs no admin.
See Map Types and Object references & assets in the reference.
Next: pick a lesson from the tree on the Learn page.
Spawning a Prefab
Before this: Reacting to the Game, Custom Events
Next steps: Saved Builds
A circuit can build things while the game runs. It holds a prefab, a small build packed inside the chip, and the Spawn Prefab gate puts a copy of it into the world each time its exec fires. This lesson spawns a little chip above the spawner whenever someone steps on a pad.
Contents
zone "Zone Ref" -> chip pad
button Held -> chip clear
Code
@left in pad: zone
@left in clear: exec
on ZoneEntered(zone = pad) -> (character) {
SpawnPrefab(
prefab = $```
@label("Box")
var age: int = 0
on Clock(interval = 1.0) {
age += 1
}
```,
offset = Vec(0.0, 0.0, 150.0),
lifetime = 10.0,
limit = 5,
destroyAll = clear
)
}
SpawnPrefab(...)spawns one copy each time the handler runs.prefab = $followed by a block between three backticks is the prefab, written in place. It is a whole Wirescript program of its own: here a chip labeled “Box” that counts its age in seconds. The compiler builds it first and packs it into the spawner’s save.offsetis where the copy appears, measured from the Spawn Prefab gate itself.Vec(0.0, 0.0, 150.0)is avector(three numbers: x, y, z): here 150 units straight up from the spawner.lifetime = 10.0removes each copy 10 seconds after it appears.limit = 5caps how many copies of this gate’s prefab exist at once.destroyAll = clearremoves every copy this gate has made whenclearturns on.
You step on the pad seven times in a row, quickly. How many boxes are there afterwards, and how many are left 15 seconds later?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone for the pad, and place a Button beside it.
- Wire the Attached Zone’s Zone Ref to
pad, and the Button’s Held toclear. - Step on the pad. A Box chip appears above the spawner.
- Step off and on a few more times. Never more than five exist at once.
- Wait ten seconds: they vanish one by one, in the order they appeared. Spawn a few more and press the button: they all go at once.
Under the Hood
Open the chip. The handler is a Player Entered Zone Event, then a
Spawn Prefab gate. The offset, the lifetime and the limit are typed into
its Spawn Offset, Lifetime and Limit. The clear pin is wired
into Destroy All, a second exec input on the same gate, so one gate both
spawns and clears.
The Box is not in the chip as gates. It is a file inside the save, and the gate’s prefab setting points at it. Pasting the spawner brings the Box with it.
The offset is a distance from the Spawn Prefab gate, not a position in the
world. To put a copy somewhere else, such as above the player who stepped on
the pad, spawn it first and then move it. SpawnPrefab gives back the copy
it spawned, and SetLocation moves that copy to a position in the world:
@left in pad: zone
on ZoneEntered(zone = pad) -> (character) {
let box = SpawnPrefab(
prefab = $```
@label("Box")
var age: int = 0
```,
limit = 5
)
box.SetLocation(character.GetLocation() + Vec(0.0, 0.0, 150.0))
}
character.GetLocation() is the position of the player who stepped on the
pad, and adding Vec(0.0, 0.0, 150.0) raises it 150 units. The copy appears
at the spawner and is moved over the player’s head on the same exec chain.
Putting the player’s position into offset instead would add it to the
spawner’s own position, and the box would land far away.
Modify
- Set
lifetime = 0.0. A lifetime of 0 means the copies stay until something removes them. Now only the button clears them. - Spawn two boxes per step: one 150 units up and one 300 units up. Each
SpawnPrefabcall is its own gate with its own limit. What does that do to the most boxes you can have? - Remember where the last player stood in a
var spot: vector. From anon Clock(interval = 2.0, enabled = ...)handler, with a Switch’s Enabled turning the clock on, spawn a box and move it tospot.
Make
Make a tower pad: every step on the pad spawns a box 100 units higher than the last one, up to ten, and a button clears the tower and starts the height over.
zone "Zone Ref" -> chip pad
button Held -> chip clear
Good to Know
- The offset is measured from the Spawn Prefab gate, not from the middle of
the world. For a position in the world, spawn the copy and then
SetLocationit. SetLocationmoves a spawned copy. It does not move a pasted chip’s own grid.- A copy is a new object in the world. Each one costs the server something
to keep, so give a spawner a
limitand either alifetimeor a way to clear it. destroyAllremoves only what its own gate spawned. A second spawner’s copies stay.
See SpawnPrefab and Prefab References in the reference.
Next: Saved Builds.
Saved Builds
Before this: Spawning a Prefab
Next steps: Prefab Files
A prefab written in Wirescript compiles to a chip of gates, nothing else. To spawn real bricks, such as a crate, a lamp post or a target to shoot at, build the thing in-game, save it as a prefab, and point the spawner at that file.
Contents
zone "Zone Ref" -> chip pad
button Held -> chip clear
Code
@left in pad: zone
@left in clear: exec
on ZoneEntered(zone = pad) -> (character) {
SpawnPrefab(
prefab = $./crate.brz,
offset = Vec(0.0, 0.0, 150.0),
lifetime = 30.0,
limit = 3,
destroyAll = clear
)
}
This is the spawner from Spawning a Prefab, with one change:
prefab = $./crate.brz instead of a block. $./ starts a path to a file,
relative to the folder the .ws file is in. The file must end in .brz, the
format Brickadia saves prefabs in.
The compiler reads crate.brz once, when you compile, and packs a copy into
the spawner’s save. Later you rebuild the crate in-game and save it over
crate.brz. Which crate does the spawner already in your world make?
Run
This block needs a file beside it, so its Compile button is off. Compile it in one of these two ways.
In VS Code or with the command line:
- Build a crate in-game. Select it with the selector and save the selection
as a prefab. Brickadia writes it as a
.brzfile in itsSaved/Prefabsfolder (on Windows,%LOCALAPPDATA%\Brickadia\Saved\Prefabs). - Copy that file into the folder with your
.wsfile and name itcrate.brz. - Compile the
.wsfile (Ctrl+Shift+B in VS Code, orwirescript compile spawner.ws). The compiler findscrate.brznext to it.
In the playground:
- Save the crate as in step 1 above.
- In the sidebar’s Prefabs panel, press the upload button and pick the
file. It is listed as
./crate.brz. Click it to insert$./crate.brzat the cursor. - Compile. Uploaded prefabs stay in your browser for later visits.
Then paste the spawner in-game and wire it as in the last lesson: an
Attached Zone’s Zone Ref to pad, a Button’s Held to clear.
Step on the pad: a crate appears above the spawner.
Under the Hood
The save holds its own copy of the crate, so the spawner works in any world, with or without the file. That is also the answer to the question: the spawner keeps the crate it was compiled with. Rebuilding the crate changes nothing until you compile again and paste the new spawner.
A missing file stops the compile:
Error: emit: prefab reference `$./crate.brz`: cannot read crate.brz
A path without .brz is caught sooner, and this one needs no file to show:
@left in pad: zone
on ZoneEntered(zone = pad) -> (character) {
SpawnPrefab(prefab = $./crate, offset = Vec(0.0, 0.0, 150.0))
}
Error: [WS019] prefab reference `$./crate` must end in `.brz` (a prebuilt archive) or `.ws` (a source file compiled on reference)
The second ending, .ws, is the next lesson.
A build that does something
A saved build can carry its own circuit. To spawn targets that count hits:
- Build a target: a post with a Target brick on top.
- Write a small chip for it with a
@left in hit: execpin and whatever it should do when hit. Compile it and paste it onto the post. - Wire the Target’s Just Hit to
hit. - Select the post, the target and the chip together and save the selection
as
target.brz. - Spawn
$./target.brzfrom the spawner. Every copy brings its own chip, already wired.
A copy’s chip cannot see the spawner’s variables. How the two talk is Talking to a Copy, two lessons on.
Modify
- Save a second build (a lamp, a sign) and spawn it from a second pad with a
second
SpawnPrefabcall. - Make the crate drop onto the player who stepped on the pad: keep the
copy with
let crate = SpawnPrefab(...), thencrate.SetLocation(character.GetLocation() + Vec(0.0, 0.0, 500.0)). - Leave
prefabout entirely:SpawnPrefab(offset = ..., limit = 3)compiles to a Spawn Prefab gate with no prefab set, and you set one on the placed gate in-game instead. Try it, and compare which way is easier to change later.
Make
Make a supply drop. Stepping on a marker pad stores that player’s position
in a var spot: vector. A button then drops a crate 300 units above spot,
at most two at a time, each gone after a minute. The offset is measured from
the spawner, so spawn the crate and then move it with SetLocation.
zone "Zone Ref" -> chip marker
button Held -> chip drop
Good to Know
- The prefab is copied into the save when you compile. Editing the
.brzafterwards changes nothing until you recompile and paste again. - Every chip that spawns a prefab carries its own copy of it, so a large build makes every spawner that uses it large.
- A spawned build is a new object, separate from the build it came from.
See Prefab References in the reference.
Next: Prefab Files.
Prefab Files
Before this: Saved Builds
Next steps: Talking to a Copy
An inline prefab is handy while it is short. Once it grows, move it into a
.ws file of its own, next to the spawner, and reference the file. The
compiler builds it when it builds the spawner.
Contents
zone "Zone Ref" -> chip pad
button Held -> chip clear
chip count -> decal Text
Code
Two files in one folder. The spawner, spawner.ws:
@left in pad: zone
@left in clear: exec
var spawned: int = 0
@right out count = "Spawned: ${spawned.Value}"
on ZoneEntered(zone = pad) -> (character) {
spawned += 1
SpawnPrefab(
prefab = $./box.ws,
offset = Vec(0.0, 0.0, 150.0),
lifetime = 10.0,
limit = 5,
destroyAll = clear
)
}
The prefab, box.ws:
@label("Box")
var age: int = 0
on Clock(interval = 1.0) {
age += 1
}
$./box.ws works like $./crate.brz from Saved Builds,
except that the file is Wirescript source. The compiler compiles box.ws on
its own, then packs the result into the spawner’s save, the same way it packs
an inline block.
The spawner counts its spawns in spawned. Could box.ws show that number
on each box?
Run
These blocks need each other, so their Compile buttons are off.
In VS Code or with the command line: save both files in one folder,
open spawner.ws and press Ctrl+Shift+B (or run
wirescript compile spawner.ws). You compile only the spawner, and box.ws
is built with it. Paste it in-game and wire it as in
Spawning a Prefab, plus count to a Text Decal’s
Text.
In the playground: a $./...ws reference is not available there. Compile
box.ws by itself, which downloads box.brz. Upload that in the Prefabs
panel, and change the reference to $./box.brz. The spawner then works the
same way, but a change to box.ws needs both steps again.
Under the Hood
The compiled spawner is the same as the one from the inline version: a Spawn Prefab gate whose prefab setting points at a copy of the box inside the save.
The answer to the question is no. A prefab is a separate program. It has its own variables and sees none of the spawner’s, whether it lives in a file or inline. Inline makes this easy to forget, because the code sits inside the spawner’s handler:
@left in pad: zone
var spawned: int = 0
on ZoneEntered(zone = pad) -> (character) {
spawned += 1
SpawnPrefab(prefab = $```
var shown: int = 0
on Clock(interval = 1.0) {
shown = spawned
}
```, offset = Vec(0.0, 0.0, 150.0), limit = 5)
}
Error: [WS002] unknown identifier 'spawned'
A copy learns values from the spawner by events, which is Talking to a Copy.
A misspelled file name is reported at the reference:
Error: [WS019] cannot read prefab source `$./box.ws`
Modify
- Move the inline prefab from Spawning a Prefab into a file of its own, and check that the spawner behaves the same.
- Reference
$./box.wsfrom a second spawner in the same folder. Each spawner’s save gets its own copy of the box. - Put the prefab in a subfolder,
parts/box.ws, and reference$./parts/box.ws.
Make
Keep two prefab files, small.ws and big.ws, each labeled differently,
and a spawner with two pads that spawns one or the other. Then change the
label in small.ws, recompile, and paste the spawner again to see the
change.
zone1 "Zone Ref" -> chip smallPad
zone2 "Zone Ref" -> chip bigPad
Good to Know
- A prefab is copied into the save when you compile. Changing
box.wschanges nothing in-game until you recompile the spawner and paste it again. - A prefab and its spawner share no variables. A spawned copy is a separate program in the world, with storage of its own.
See Prefab References in the reference.
Next: Talking to a Copy.
Talking to a Copy
Before this: Prefab Files
Next steps: Lifecycle and Storage
A spawned copy sees none of the spawner’s variables. To tell it something, send it an event. This lesson spawns a marker for each player who walks through a doorway, and tells the marker whom to follow.
Contents
zone "Zone Ref" -> chip room
Code
@left in room: zone
var height: float = 120.0
on ZoneEntered(zone = room) -> (character) {
let marker = SpawnPrefab(
prefab = $```
@label("Marker")
var owner: character
var lift: float = 0.0
var ready: bool = false
on CustomEvent("marker.init", isObject = true) -> (who: character, up: float) {
owner = who
lift = up
ready = true
on Clock(interval = 0.1) {
if ready {
if owner == null {
ReadBrickGrid().DestroySpawnedPrefab()
} else {
ReadBrickGrid().SetLocation(owner.GetLocation() + Vec(0.0, 0.0, lift))
}
}
}
}
```,
limit = 8
)
marker.SendCustomEvent("marker.init", character, height)
}
SpawnPrefabgives back the copy it just spawned, as anentity.let marker = ...keeps it.marker.SendCustomEvent("marker.init", character, height)sends an event to that copy only, carrying the player and the height. The height is afloat, a number that can have a fraction. Sending to one grid is from Custom Events.- Inside the copy,
on CustomEvent("marker.init", isObject = true)hears it and stores both values in the copy’s own variables. - With no
offset, the copy appears at the spawner. ReadBrickGrid()inside a copy is the copy itself. Every tenth of a second,SetLocationmoves it to the player’s position, raised bylift. A spawned copy can move itself like this. A pasted chip cannot.- A player who leaves the server turns the stored
ownerintonull. The marker then removes itself withDestroySpawnedPrefab, so no marker is left floating for a player who has gone. A death does the same a little later: the dead player’s character turns intonullsome time after they die.
The Clock is written inside the init handler, next to the values it uses,
but it is a handler of its own and starts running the moment the copy
appears. What would it do on its first few pulses without the if ready
check?
Run
- Compile the chip and paste it in-game. Give a doorway an Attached
Zone and wire its Zone Ref to
room. - Walk through the doorway. A Marker chip appears at the spawner, jumps over your head, and from then on floats 120 units above it, following you around.
- Walk through again: a second marker follows you too. A friend who walks through gets one of their own.
Under the Hood
Open the spawner. After the Spawn Prefab gate comes a Get Variable
for height and a Send Personal Custom Event. The player and the height
are wired into Data In1 and Data In2, and the Spawn Prefab gate’s
Entity output is wired into the send’s Target.
height is a var on purpose. A constant is not wired into an event: send
120.0 directly and the copy can read 0 instead. A variable is read by a
Get Variable gate, whose output is wired in like the player is.
The copy is compiled on its own, so it cannot see the sender to work out its
event’s types. Write them out (who: character, up: float), and keep them
in the same order and types as the send.
Three things here fail silently, with no error from the compiler:
- Leave out
isObject = trueand the copy never hears the event: a send to one grid is heard only by receivers marked that way. - Leave out the
readycheck and the copy moves before the event arrives, with an emptyownerand aliftof 0. The event arrives a tick after the send, and the copy’s clock is already running by then. - Send the height as
120.0and the copy may read 0.
Modify
- Make the marker float in front of the player instead of above:
Vec(100.0, 0.0, 50.0). Is “in front” the same direction whichever way the player faces? - Remove
isObject = true, compile, and walk through the doorway. Where does the marker go? Put it back. - Give the spawner a second zone that raises the newest marker. Keep it in
a
var last: entityas you spawn it. From the second zone’s handler, sendlasta"marker.height"event carrying avar taller: float = 300.0, and give the copy a second receiver that setslift.
Make
Make a guide: when a player steps on a start pad, spawn a marker that sits
above a goal position (a var goal: vector the spawner sends along) until
that player gets within 200 units of it, then removes itself with
ReadBrickGrid().DestroySpawnedPrefab(). Distance(a, b) gives the distance
between two positions as a float.
zone "Zone Ref" -> chip start
Good to Know
- The copy’s handlers are live from the moment it spawns, before any event from the spawner arrives, even one written inside the init handler. Guard work on a value the first event sets.
- After a death the marker holds the old body, so it stops following the
player and removes itself once that body turns into
null. Walking through the doorway again gives the new body a marker of its own. - A send to one copy needs
isObject = trueon the copy’s receiver, and nothing reports it when it is missing. - A constant sent in an event can arrive as 0. Send a
var. - Events reach only circuits pasted by the same player, and a spawned copy counts as the spawner’s. Copies spawned by a friend’s chip do not hear yours.
See Custom Events and SpawnPrefab in the reference.
Next: Lifecycle and Storage.
Lifecycle and Storage
Before this: Talking to a Copy
Next steps: Nested Prefabs
Every spawned copy is its own program, with variables nobody else shares. This lesson spawns boxes that each know their own number, live for a time that depends on it, remove themselves, and tell the spawner when they go.
Contents
zone "Zone Ref" -> chip pad
button Held -> chip clear
chip sign -> decal Text
Code
@left in pad: zone
@left in clear: exec
var made: int = 0
var number: int = 0
var live: int = 0
var lastGone: int = 0
@right out sign = "Boxes: ${live.Value}, last gone: ${lastGone.Value}"
on ZoneEntered(zone = pad) -> (character) {
made += 1
number = made
live += 1
let box = SpawnPrefab(
prefab = $```
@label("Box")
var number: int = 0
var age: int = 0
on CustomEvent("box.init", isObject = true) -> (n: int) {
number = n
}
on Clock(interval = 1.0) {
if number > 0 {
age += 1
if age >= number * 2 {
SendCustomEvent("box.gone", number)
ReadBrickGrid().DestroySpawnedPrefab()
}
}
}
```,
offset = Vec(0.0, 0.0, 150.0),
limit = 10,
destroyAll = clear
)
box.SendCustomEvent("box.init", number)
}
on CustomEvent("box.gone") -> (n: int) {
live -= 1
lastGone = n
}
on clear {
live = 0
}
- The spawner numbers each box and sends the number to that box only.
- Each box keeps the number in its own
numberand counts its ownage. Box 1 lives 2 seconds, box 2 lives 4, box 3 lives 6. ReadBrickGrid().DestroySpawnedPrefab()removes the copy that runs it. Before it goes, the box sends"box.gone"with its number. This send has no target, so it reaches every listener pasted by the same player, the spawner included.- The spawner counts live boxes up on each spawn and down on each
"box.gone". The button clears them all withdestroyAll, and sets the count to 0 itself, since cleared boxes send nothing.
The spawner has a var number, and so does every box. When box 3 sets its
number to 3, what happens to the spawner’s number and to box 1’s?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone for the pad, and place a Button and a brick with a Text Decal.
- Wire the Zone Ref to
pad, the Button’s Held toclear, andsignto the Text Decal’s Text. - Step on the pad three times, quickly. The sign says
Boxes: 3. - Watch: box 1 goes first, then box 2, then box 3, each two seconds after the last. The sign counts down and names each one as it goes.
- Spawn a few more and press the button. They all go, and the sign reads 0.
Under the Hood
The box’s gates are not in the spawner’s chip. To look at them, copy the
box’s code into a file of its own and compile it. It has its own Var
gates for number and age. The spawner has its own Var for number too.
The two never touch, because a copy’s variables belong to that copy alone,
so the answer to the question is “nothing”.
The same holds for imports. A copy that imports a module gets its own copy of that module’s variables, not the spawner’s.
The Destroy Spawned Prefab gate takes the copy’s own grid, from Read
Brick Grid, as its Entity. Wire a different spawned entity into it and
it removes that one instead: the spawner could do box.DestroySpawnedPrefab()
with an entity it kept.
When the player leaves
A copy that serves one player should go when that player does. A stored
reference to a player who has left the server turns into null, so a copy
can check for that once a second. A dead player’s stored character turns
into null too, some time after the death, so the same check also
removes the copy of a player who died, a little later. Write the check
inside the handler that
stores the player:
on CustomEvent("marker.init", isObject = true) -> (who: character) {
owner = who
ready = true
on Clock(interval = 1.0) {
if ready {
if owner == null {
ReadBrickGrid().DestroySpawnedPrefab()
}
}
}
}
The inner Clock is still a handler of its own, running from the moment the
copy appears, so it checks ready before it reads owner. Without such a
check, a copy whose player has gone keeps running until something removes
it.
The spawner can also tag a copy with its player’s user id as it spawns it,
copy.SetCustomProperty("owner", id). The copy reads its own tag with
ReadBrickGrid().GetCustomProperty("owner"), and the spawner can read any
copy’s tag from the entity it kept. One Chip per
Player uses it to tell the spawner which player’s
copy has gone.
Modify
- Make every box live five seconds, whatever its number. Which lines of the box change, and does the spawner change at all?
- Add the age to the report: send
"box.gone"withnumberandage, and show both on the sign. - Give each box an owner: send the player who stepped on the pad along
with the number, and have the box remove itself early, with a
"box.gone", when that player leaves the server.
Make
Make a fuse: each box counts down from 5 seconds, and on reaching 0 it
sends "box.boom" with its number and removes itself. The spawner shows
“Box 4 went off” for the most recent one, and a second pad clears all boxes
before they go off.
zone1 "Zone Ref" -> chip pad
zone2 "Zone Ref" -> chip clearPad
chip sign -> decal Text
Good to Know
- Each copy has its own variables, and so does each copy’s imports. Nothing is shared with the spawner or with other copies. Values travel by events.
- A copy whose work is done must remove itself (or be removed by
lifetimeordestroyAll). Nothing else cleans it up. DestroySpawnedPrefabon the copy’s own grid removes it at once. Send any last event before that line.- The box’s Clock only counts toward one moment. With
Waiting, the init handler can wait for that moment
itself,
await Sleep(_, delay = n * 2.0), with no Clock. - A stored reference to a player who has left reads as
null, andGetUserId()on it reads back"". A dead player’s stored character reads asnulltoo, after a delay that is not fixed. - The live count here counts steps on the pad. At the
limitit can drift, so keep the limit above what you expect to need.
See SpawnPrefab and per-entity fan-out in the reference.
Next: Nested Prefabs.
Nested Prefabs
Before this: Lifecycle and Storage
Next steps: One Chip per Player
A spawned copy is a full program, so it can spawn copies of its own. This lesson places a turret that fires a shot every second. The turret is a prefab, and the shot is a prefab inside it.
Contents
zone "Zone Ref" -> chip pad
button Held -> chip clear
Code
@left in pad: zone
@left in clear: exec
on ZoneEntered(zone = pad) -> (character) {
SpawnPrefab(
prefab = $```
@label("Turret")
on Clock(interval = 1.0) {
SpawnPrefab(
prefab = $```
@label("Shot")
on Clock(interval = 0.05) {
let grid = ReadBrickGrid()
grid.SetLocation(grid.GetLocation() + Vec(0.0, 20.0, 0.0))
}
```,
offset = Vec(0.0, 0.0, 30.0),
lifetime = 3.0,
limit = 10
)
}
```,
offset = Vec(0.0, 200.0, 50.0),
limit = 1,
destroyAll = clear
)
}
Three programs, one inside the next:
- The spawner places one turret 200 units to the side of itself when
someone steps on the pad (
limit = 1, so there is never more than one). - The turret has no events to wait for. Its clock starts when it appears, and every second it spawns a shot 30 units above itself. An offset is measured from the Spawn Prefab gate that uses it, and this one sits in the turret, so the shots start at the turret wherever it is.
- The shot moves itself 20 units along the world’s Y axis every
twentieth of a second, and its lifetime removes it after 3 seconds.
ReadBrickGrid()inside a copy is the copy’s own grid, andSetLocationmoves a spawned copy.
The clear button removes the turret. What happens to the shots already in
the air?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone for the pad and place a Button beside it.
- Wire the Zone Ref to
padand the Button’s Held toclear. - Step on the pad. A Turret chip appears, and a Shot chip rises out of it every second and flies off.
- Press the button while shots are flying.
Under the Hood
The spawner’s save holds the turret, and the turret holds the shot, so one compile packs both levels. Only the spawner has a pin. The turret and the shot run on their own.
The turret’s Spawn Prefab gate belongs to the turret, so limit = 10
counts the shots that one turret has made, and the spawner’s clear reaches
only what the spawner’s gate made: the turret. Shots already in the air were
made by the turret’s gate, not the spawner’s, so expect each one to go when
its own 3 seconds run out.
Prefabs can nest up to 8 levels deep, inline or as files.
As files
The same three programs as files, with the shot in a subfolder:
spawner.ws
turret.ws
parts/shot.ws
spawner.ws references $./turret.ws. Inside turret.ws:
@label("Turret")
on Clock(interval = 1.0) {
SpawnPrefab(
prefab = $./parts/shot.ws,
offset = Vec(0.0, 0.0, 30.0),
lifetime = 3.0,
limit = 10
)
}
A path inside a prefab file is read from that file’s folder, not the
spawner’s. Here the two are the same folder, but move turret.ws into
parts/ and its reference becomes $./shot.ws, while spawner.ws changes
to $./parts/turret.ws. Compiling spawner.ws builds all three, as in
Prefab Files.
Modify
- Fire the shots straight up instead: which numbers in the shot change?
- Give the turret a second clock that fires a second kind of shot, slower and longer-lived, every three seconds.
- Make the shot fall: each pulse, also lower it a little more than the last
time. Keep the drop so far in a
varinside the shot.
Make
Make a fountain: a pad places a fountain that, every half second, spawns a
drop going up. Each drop rises for a second, then falls for a second, then
removes itself with DestroySpawnedPrefab(). A button clears the fountain.
zone "Zone Ref" -> chip pad
button Held -> chip clear
Good to Know
destroyAllremoves what its own gate spawned, not what those copies spawned in turn. Give inner prefabs alifetime.- Every level carries the levels inside it. A large shot makes the turret large, and the turret makes the spawner large.
- A path inside a prefab file is relative to that file.
See Prefab References in the reference.
Next: One Chip per Player.
One Chip per Player
Before this: Nested Prefabs, Looping Over a Map, Seat Controls
Next steps: Performance, Multiplayer Architecture
Some work belongs to each player, all the time: following their keys, a readout that stays with them. One chip cannot grow new gates for every player who turns up, but it can spawn a small chip for each one that serves only that player. This lesson gives every player in a room a live clicker of their own.
Contents
zone "Zone Ref" -> chip room
Code
Every player who walks into the room gets a pad: a spawned chip that watches their Q key. Each press scores them a point.
@left in room: zone
var pads: Map<string, entity>
var taps: Map<string, int>
on ZoneEntered(zone = room) -> (character) {
let id = character.GetUserId()
let mine = pads.get(id)
if mine.Found {
mine.Value.SendCustomEvent("pad.init", character)
return
}
let pad = SpawnPrefab(
prefab = $```
@label("Pad")
var owner: character
var ready: bool = false
var id: string = ""
let keys = InputReader(owner.Value)
on CustomEvent("pad.init", isObject = true) -> (who: character) {
owner = who
ready = true
on Clock(interval = 1.0) {
if ready {
if owner == null {
id = ReadBrickGrid().GetCustomProperty("owner")
SendCustomEvent("pad.gone", id)
ReadBrickGrid().DestroySpawnedPrefab()
}
}
}
}
on CustomEvent("pad.stop", isObject = true) {
ReadBrickGrid().DestroySpawnedPrefab()
}
on keys.PressedQ {
SendCustomEvent("pad.q", owner)
}
```,
offset = Vec(0.0, 0.0, 300.0),
limit = 64
)
pad.SetCustomProperty("owner", id)
pads.set(id, pad)
pad.SendCustomEvent("pad.init", character)
}
on ZoneLeft(zone = room) -> (character) {
let id = character.GetUserId()
let mine = pads.get(id)
if mine.Found {
mine.Value.SendCustomEvent("pad.stop")
pads.remove(id)
}
}
on CustomEvent("pad.gone") -> (id: string) {
pads.remove(id)
}
on CustomEvent("pad.q") -> (who: character) {
let id = who.GetUserId()
let before = taps.get(id)
let count = if before.Found then before.Value + 1 else 1
taps.set(id, count)
who.ShowStatusMessage("Taps: ${count}")
}
The central chip spawns the pads, tracks which pad is whose, and counts the taps.
padsmaps each user id to that player’s pad, the entitySpawnPrefabreturned. A player who walks in gets a pad. The chip tags the pad with their user id (SetCustomProperty("owner", id)) and sends it"pad.init"with their character, targeted at that pad.- A player who already has a pad gets no second one. Their pad is sent the
character again instead.
returnends a handler early, and nothing after it runs. - A player who walks out is sent
"pad.stop"and forgotten withpads.remove(id), which deletes that key from the map. A pad whose player left the server or died reports"pad.gone"with the id, and that entry is removed the same way. tapscounts each player’s presses, andShowStatusMessageshows the count to that player only.
Each pad works for one player.
ownerstores the player from"pad.init", andreadyrecords that it has arrived.InputReader(owner.Value)follows that player’s keys, live, as in Seat Controls.on keys.PressedQruns once per press and sends"pad.q"back, with no target, so it reaches the central chip."pad.stop"makes the pad remove itself.- The Clock checks once a second that the stored character is still
there. It turns into
nullwhen the player leaves the server, and also some time after the player dies. The pad then reads its user id back from its own tag (ReadBrickGrid().GetCustomProperty), reports"pad.gone", and removes itself. The Clock is written inside the init handler to keep it next to theownerit checks, but it runs from the moment the pad appears, so it checksreadyfirst.
Ten players are in the room. How many Seat Control Splitters are there, and how many of them are in the central chip?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone
covering the room, and wire its Zone Ref to
room. - Walk in. A pad appears above the chip. Press Q: the status line says
Taps: 1, then counts up with each press. - Walk out: the pad vanishes, and Q does nothing. Walk back in: a new pad appears, and the count carries on from where it was.
- With a second player: each of you gets a pad and a count of your own, and presses at the same moment both count.
Under the Hood
The central chip has a Player Entered Zone Event and a Player Left
Zone Event, each followed by a Get User Id, a Get Map Element and
a Branch. The enter chain goes on to a Spawn Prefab, a Set Map
Element, and a Send Personal Custom Event with the new pad on its
Target input. A Personal Custom Event for "pad.q" starts the
counting chain.
The enter chain also has a Set Entity Custom Property that tags the
new pad. A third Personal Custom Event, for "pad.gone", removes a map
entry.
Open a pad. A Var holds the owner, and its value is wired into a
Seat Control Splitter’s Character. Pressed Q fires a Get
Variable and a Send Personal Custom Event. Two object-scoped
Personal Custom Event receivers handle "pad.init" and "pad.stop".
The second is a Read Brick Grid into a Destroy Spawned Prefab. The
Clock chain reads ready and owner, and for a player who has gone
runs a Get Entity Custom Property, a send and a second Destroy
Spawned Prefab.
Spawned chips are needed because a Seat Control Splitter reads one
character, wired in. A pasted chip has a fixed set of gates, and gates
cannot be added while the game runs, so one chip cannot hold a splitter for
every player who might come. Without spawned chips, it could only loop over
the room on a timer and read each player with GetInputs. That runs on
every pulse whether anyone presses or not, and misses a tap between two
pulses. A pad does nothing until its player presses a key, and then sends
exactly one event.
Each shape has its use.
- A
forsweep in one chip (Looping Over a Map) is the simpler choice when the work happens now and then and needs every player together: comparing them, ranking them, totalling them. Its cost grows with the number of players. - One chip per player suits work that belongs to one player: live controls, a display that follows them, work every tick. Each pad costs the same however many players there are, and is gone when its player is.
Keep the shared records, such as taps, in the central chip, and send out
only the per-player part.
Modify
- Add a second key: the pad also sends
"pad.e"onkeys.PressedE, and the central chip takes a point away for it. - Send the count back to the pad: after
taps.set, look up the player’s pad inpadsand send it a targeted"pad.score"with the count. In the pad, show it withowner.ShowStatusMessageinstead of the central chip doing it. - Show the best score in the room on a sign. In which chip does that belong, and why?
Make
Make a buzzer room for a quiz. Every player in the room gets a pad. When the host presses a Button, the first Q press from any pad wins: a sign shows the winner’s name, escaped as in Safe Text, and further presses are ignored until the host presses the button again.
zone "Zone Ref" -> chip room
button Held -> chip open
chip sign -> decal Text
Good to Know
- A copy’s handlers start when it appears, before
"pad.init"lands. Until thenowneris empty, and an empty character gives the splitter no player to read. - A respawn makes a new character, and the pad still holds the old one.
A death inside the room also leaves with an empty Character, so
"pad.stop"is not sent. The old character turns intonulla little after the death, and the pad then reports"pad.gone"and removes itself. If the player walks back in before that, their pad is sent the new character instead, found again in the central map by user id. Walking back in after it has gone spawns a new pad. - Each pad has its own send gate. A send gate fired many times in one tick delivers once, but many different gates on one channel all deliver, so presses from different players in the same tick all count.
- Over a long session a zone event can be missed. A missed leave leaves a
pad that still counts its player’s taps outside the room. A missed
entry leaves a player with no pad until they walk in again. Where that
matters, a slow Clock can fill an array with the room’s players
(
fillFromZonePlayers) and stop the pads of anyone not in it. limit = 64is a ceiling on pads. Raise it if more players may come.SpawnPrefabis the one thing that makes more gates while the game runs. A central chip has to be built for the largest crowd you expect. Pads cost only for the players who are there.
See per-entity fan-out, InputReader and Custom Events in the reference.
Next: pick a lesson from the tree on the Learn page.
Admin Gates
Before this: Is It Working?
Next steps: World Control, Persistent Players, Chat Commands
Some gates only an admin can load. They reach every player or the whole world: a chat line to everyone, the time of day, the list of players. This branch teaches them. Every page in it needs admin to load.
Needs admin to load: the examples here use Broadcast Chat Message.
Contents
button Held -> chip ring
chip sign -> decal Text
Code
@left in ring: exec
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"
on ring {
rings += 1
BroadcastChatMessage("The bell has rung ${rings} times")
}
A bell that counts its rings on a sign and tells the whole server about each
one. BroadcastChatMessage sends one chat line that every player sees.
You paste this in a world you host. A friend pastes the same file on a server where they are not an admin. What does each of you see when the bell rings?
Run
- Compile the chip and paste it in-game, in a world you host. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
ring, andsignto the Text Decal’s Text. - Press the button. The sign counts, and a chat line appears for everyone.
A player without admin rights who pastes it sees the paste report blocked bricks: the Broadcast Chat Message gate is missing, and its wires cannot connect. Only the admin gates are missing. The rest of the chip loads.
Under the Hood
Open the chip. The handler’s chain is the button, an Increment Variable
for rings, a Get Variable that reads the new count, then Broadcast
Chat Message with a Format String feeding its Message.
For a player who could not load the broadcast gate, the chain has a gap. The increment comes before the gap, so the sign should still count. Swap the two lines in the handler and the gap comes first: nothing after it runs, and the sign stays at 0.
The game marks each gate as admin or not. These Wirescript calls compile to an admin gate:
| Wirescript | Gate |
|---|---|
on ChatCommand(...) | Chat Command Event |
on CharacterSpawned, CharacterDied, CharacterDamaged, CharacterFiredWeapon | Player Spawned Event and the others |
on ControllerJoined, ControllerLeft, ControllerJoinedTeam, ControllerLeftTeam | Player Joined Event and the others |
BroadcastChatMessage, BroadcastStatusMessage | Broadcast Chat Message, Broadcast Status Message |
arr.fillFromPlayers(), arr.fillFromTeam(team) | Fill Array From Players, Fill Array From Team Members |
FindPlayer(name) | Find Player by Name |
ControllerOf(character) | Get Player (Persistent) |
Environment.Sky.TimeOfDay = ... (any environment write) | Set Environment Property |
SetTeam, SetTeamPinned, GetTeam, GetTeamByName, SetEntityTeam | Set Player Team and the others |
ForceRespawn, SetCanRespawn | Force Respawn Player, Set Can Respawn |
SetDamage, IncDamage, the ammo setters | Set Player Damage and the others |
leaderboards, PlayerWins, TeamWins, GetCurrentRound | Set Leaderboard Value and the others |
ShowMessageBox, ShowYesNoBox, ShowOkCancelBox, ShowTextInputBox | the four dialogs |
PlayGlobalAudio, SpawnExplosion, SpawnExplosionAt | Play Global Audio, Spawn Explosion |
PrintToConsole, SendGlobalCustomEvent | Print to Console, Send Global Custom Event |
Not admin, and used all through the other branches: Clock, zone events,
ShowChatMessage and ShowStatusMessage to one player, GetDisplayName,
GetUserId, HasRole, reading the environment, personal custom events,
fillFromZonePlayers, GetInputs and GetAim.
Finding the culprit
When a paste reports blocked bricks, look for these gates first. Open the
chip as an admin and find them by name, or search the compiled program. The
compiler prints it with wirescript compile bell.ws --dump-ir (or just ir bell.ws), one gate per line, by class name:
%9 = Exec_Gamemode_BroadcastChatMessage(Exec: %7.ExecOut, Message: %8) : ...
Search for ChatCommand, Event_Gamemode_, Broadcast, FillArrayFromPlayers,
FindPlayer, GetFromEntity and SetEnvironmentProperty, which cover most
cases. The full list of class names is in docs/admin-gates.json in the
compiler’s repository, and the Learn pages are checked against it: a page
not marked admin that compiles to one of them fails the check.
A debug line
Is It Working? showed how to watch a circuit with a
debug pin. A chat line is another way to watch one. Put a
BroadcastChatMessage in a handler, and each run prints the values it read.
@left in pressed: exec
@left in armed: bool
var count: int = 0
on pressed {
BroadcastChatMessage("pressed: armed=${armed} count=${count}")
if armed {
count += 1
}
}
No line when you press means the trigger is the problem, not the steps. Remove the line before you share the chip, or it stops a non-admin from loading it.
Modify
- Move the broadcast above
rings += 1. Nothing changes for you. For a non-admin, it is now the first step and nothing after it runs. - Make the bell work for everyone: remove the broadcast and keep only the sign. The chip has no admin gates left.
- Tell only the player who rang. The Button’s Character output says who
pressed it: wire it to a
@left in ringer: characterpin and replace the broadcast withringer.ShowChatMessage(...), which is not admin.
Make
Take a circuit from another branch, add a debug line to every handler, and use it to find which handler runs first when two fire on the same press. Then remove them all and compile again to check the chip has no admin gate left.
Good to Know
- A player without admin rights cannot load an admin gate. The rest of the chip loads, so the circuit half works instead of failing outright.
- A chain stops at the first missing gate. Put admin-only steps last in a handler when the rest should still work without them.
- In a world you host, you are the admin.
- Reading the environment, a player’s name or their role is not admin. Writing the environment, reaching every player, and the player events are.
See Broadcast Messaging in the reference.
Next: World Control.
World Control
Before this: Admin Gates, Forking and Merging
Next steps: Teams
A circuit can change the world itself: the time of day, the clouds, the rain. These are the settings in the game’s environment dialog, and every player sees the change at once. This lesson turns night on with a switch and rolls a storm in slowly.
Needs admin to load: writing a setting uses Set Environment Property.
Contents
switch Enabled -> chip night
Code
const DAY = 12.0
const NIGHT = 0.0
@left in night: bool
on night {
Environment.Sky.TimeOfDay = NIGHT
}
on !night {
Environment.Sky.TimeOfDay = DAY
}
Environment.Sky.TimeOfDay is one setting: Sky is the section of the
environment dialog, TimeOfDay the setting in it. Assigning to it inside a
handler writes the world’s time of day. on !night runs when the switch
turns off, as in Forking and Merging.
Flip the switch on. It goes dark. Now wait a few minutes. Does it stay dark?
Run
- Compile the chip and paste it in-game. Place a Switch.
- Wire the Switch’s Enabled to
night. - Flip it on: the sky turns to midnight. Flip it off: noon.
- Leave it on. If the world’s day cycle is running, the clock carries on from midnight and the sun rises again. The circuit set the time once and does not hold it there.
Under the Hood
Open the chip. There are two Set Environment Property gates, one per
handler. Each has the environment (Sky) and the setting (TimeOfDay) in
its own settings, and a Value input: 0 in one, 12 in the other. The
on !night gate has a NOT in front of its Exec.
Reading a setting is a step too, a Get Environment Property gate, so it only works in a handler. Reading is not admin. This clock sign reads the time once a second, and anyone can paste it:
var hour: float = 0.0
@right out text = "Hour: ${hour.Value}"
on Clock(interval = 1.0) {
hour = Environment.Sky.TimeOfDay
}
Read it straight into the pin instead, and the compiler refuses:
@right out hour: float = Environment.Sky.TimeOfDay
Error: [WS007] environment read 'Environment.Sky.TimeOfDay' outside an exec context
Every setting and its type is listed in
Environment Settings. A write that does not fit the
setting’s type is an error, and a name that does not exist is WS110.
Use it: a storm
A setting can be written again and again. A Clock that nudges three weather settings a little each quarter second brings a storm in over five seconds, and takes it away again when the switch goes off:
const STEPS = 20
@left in storm: bool
var level: int = 0
let moving = (storm && level.Value < STEPS) || (!storm && level.Value > 0)
on Clock(interval = 0.25, enabled = moving) {
if storm {
level += 1
} else {
level -= 1
}
let t = (level as float) / (STEPS as float)
Environment.Sky.CloudCoverage = t
Environment.Sky.Rain = t
Environment.Sky.Thunder = t
}
level counts from 0 to STEPS, and t turns it into a fraction from 0.0
to 1.0: as float turns a whole number into a decimal one, and / divides.
moving is true while the storm still has somewhere to go: rolling in
(storm on and level short of STEPS) or rolling out (storm off and
level above 0). || is “or”. It drives the Clock’s enabled, so the
Clock runs only while the storm is moving and stops once it arrives.
Modify
- Hold the night. Write
Environment.Sky.AnimateTimeOfDay = falsebefore the time inon night, and= trueafter it inon !night. - Add fog to the storm:
Environment.Sky.CloudyFogDensity = t. - Make a sunset button. Each press reads
Environment.Sky.TimeOfDay, adds one hour, and writes it back.
Make
Make a weather board with three buttons, Clear, Rain and Snow, that each roll their weather in over a few seconds and roll the others out. Show the current weather on a sign.
button1 Held -> chip clear
button2 Held -> chip rain
button3 Held -> chip snow
chip sign -> decal Text
Good to Know
- The environment is one for the whole world. Every player sees a change, and two circuits writing the same setting overwrite each other: the last write wins.
- The game converts and limits a written value as the environment dialog would, and leaves a setting the dialog has locked alone.
- A write happens once. The day cycle, or another circuit, can change the setting again straight after.
See Environment Settings in the reference.
Next: Teams.
Teams
Before this: World Control, Shuffling
A game mode can sort players into teams, and a circuit can do the sorting itself. This lesson splits everyone in a lobby into two random teams at the press of a button, then builds pads that let players pick their own team.
Needs admin to load: it uses Get Team By Name and Set Player Team.
Contents
zone "Zone Ref" -> chip lobby
button Held -> chip pick
Code
@left in lobby: zone
@left in pick: exec
var players: character[]
on pick {
players.fillFromZonePlayers(lobby)
players.shuffle()
let red = GetTeamByName("Red")
let blue = GetTeamByName("Blue")
for i, p in players {
if i % 2 == 0 {
p.SetTeam(red)
} else {
p.SetTeam(blue)
}
}
}
players.fillFromZonePlayers(lobby)fills the array with every player standing in the zone, replacing what was there.shuffle()puts them in a random order, as in Shuffling, and thei % 2split from there puts the even places on Red and the odd places on Blue.GetTeamByName("Red")finds the team with that name, andp.SetTeam(red)moves a player onto it.
Five players stand in the lobby. How many end up on each team? Press again: can a player land on the team they were already on?
Run
- Set up the world’s game mode with two teams named exactly
RedandBlue. - Compile the chip and paste it in-game. Give a brick an Attached Zone for the lobby, and place a Button.
- Wire the Attached Zone’s Zone Ref to
lobbyand the Button’s Held topick. - Stand in the lobby with your friends and press the button. Each player moves to Red or Blue. Press again for a new draw.
Under the Hood
Open the chip. The chain is Get Players In Zone, Shuffle Array, then two Get Team By Name gates, each with its team name typed in. The loop is a For Each Array microchip with a Modulo and a Branch inside, and one Set Player Team on each side of the Branch.
The two teams are found once, before the loop. Their Team outputs reach the Set Player Team gates through two pins on the side of the loop chip, the same way any wire crosses into a chip.
A team is an entity: a reference to a thing in the world, like a player
is. Set Player Team takes a player in its Player State input. The
loop’s players are characters, and a character wired into that input stands
for its player.
Modify
-
Pin the teams.
p.SetTeam(red, pin = true)pins the player to Red, so the game mode’s own team sorting does not move them later. -
Tell each player their team. Inside the loop, after the Branch, add
p.ShowChatMessage(...)with “You are on Red” or “You are on Blue”. -
Let players choose. Two pads, one per team, each with its own Attached Zone:
@left in redPad: zone @left in bluePad: zone on ZoneEntered(zone = redPad) -> (character) { let red = GetTeamByName("Red") character.SetTeam(red, pin = true) character.ShowChatMessage("You joined Red") } on ZoneEntered(zone = bluePad) -> (character) { let blue = GetTeamByName("Blue") character.SetTeam(blue, pin = true) character.ShowChatMessage("You joined Blue") }
Make
Make a captain draft. Two captains each stand on a pad. Every press of a button moves the next player from the lobby onto the team whose turn it is, alternating Red and Blue, until the lobby is empty.
zone1 "Zone Ref" -> chip lobby
zone2 "Zone Ref" -> chip redPad
zone3 "Zone Ref" -> chip bluePad
button Held -> chip pick
Good to Know
SetTeammoves a player whatever the team’s join rules say. A team that was not found is empty, and an empty team takes the player off their team. Check the team names in the game mode if players end up on no team.- Pinned players stay where they are put. Use
SetTeamPinned(false)on a player to let the game mode sort them again. - With one player in the lobby you only ever see Red. Test the split with friends, or check the Shuffling lesson’s typed-in names first.
See GetTeamByName, SetTeam and Zone array fills in the reference.
Next: Persistent Players.
Persistent Players
Before this: Admin Gates, Object References, Looping Over a List
Next steps: Player Control, Every Player
A character lasts one life. The player behind it stays for their whole visit. To keep hold of a player through their deaths, a circuit asks the game for the player itself, and that takes an admin gate. This lesson keeps a guest list that survives a respawn, then shows how far you get without the admin gate.
Needs admin to load: ControllerOf uses Get Player (Persistent).
Contents
zone "Zone Ref" -> chip door
button Held -> chip call
Code
@left in door: zone
@left in call: exec
var guests: controller[]
on ZoneEntered(zone = door) -> (character) {
let p = character.ControllerOf()
if !guests.find(p).Found {
guests.push(p)
p.ShowChatMessage("You are on the guest list")
}
}
on call {
for p in guests {
p.ShowChatMessage("Dinner is ready!")
}
}
character.ControllerOf()gives the player whose character walked in. Unlike the character, the player is the same through every death.guestsis a list of players.guests.find(p).Foundis true when this one is on it already, so each player is added once.- The button tells everyone on the list that dinner is ready, wherever they are.
A guest walks in, falls off the map and respawns somewhere else. Then you
press the button. Do they get the message? What if the chip had stored
character in the list instead?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone at the front door, and place a Button.
- Wire the Attached Zone’s Zone Ref to
doorand the Button’s Held tocall. - Walk through the door: chat says you are on the list. Walk through again: nothing, you are on it already.
- Respawn, walk away, and press the button. The message still reaches you.
Under the Hood
Open the chip. Between the zone event and Find Array Element sits Get Player (Persistent): the character goes in its Entity input, and its Player State output is what the list stores. The loop is a For Each Array microchip with one Show Chat Message inside.
Now store the character itself. The list’s type still says controller, and
this compiles with no warning:
@left in door: zone
var guests: controller[]
on ZoneEntered(zone = door) -> (character) {
guests.push(character)
}
Open it: there is no gate between the zone event and Push To Array. A
character goes into any player input, as
Object References showed, but nothing
turns it into the player. The list holds the character, and after a respawn
that character is gone. A character is never equal to its player’s
controller either, so guests.find(...) with one never matches the other,
and a character used as a Map<controller, ...> key is stored under that body.
The other way round, p.CharacterOf() gives a player’s current character,
through Get Player (Temporary). That gate is not admin. Use it for the
few things that need a body rather than a player, such as a hint:
p.CharacterOf().ShowHint("Dinner", "Come to the table").
Without the admin gate
Most circuits never need a persistent player. Three habits cover the rest:
- Use the character while you have it. Everything a handler does to the player who walked in, it can do with the character, wired straight in.
- Remember players by user id.
GetUserId()is the same through deaths and visits, and makes a good key for a map or a list, as in Per-Player State. - Let the players come back to you. A zone gives you the current characters again each time you read it.
This guest list follows all three, and anyone can paste it. The button calls only the guests standing in the house:
@left in door: zone
@left in house: zone
@left in call: exec
var guests: string[]
var here: character[]
on ZoneEntered(zone = door) -> (character) {
let id = character.GetUserId()
if !guests.find(id).Found {
guests.push(id)
character.ShowChatMessage("You are on the guest list")
}
}
on call {
here.fillFromZonePlayers(house)
for p in here {
if guests.find(p.GetUserId()).Found {
p.ShowChatMessage("Dinner is ready!")
}
}
}
here.fillFromZonePlayers(house) fills here with every player standing in
the house zone, replacing what was there.
Values on the player
A controller can hold values of its own. SetCustomProperty stores a value
on an entity under a name, and GetCustomProperty reads it back, with
.Found false when nothing is stored. On a controller the value stays with
the player through every death, so it works as per-player storage with no
map at all, for as long as they stay on the server:
@left in door: zone
on ZoneEntered(zone = door) -> (character) {
let p = character.ControllerOf()
let before = p.GetCustomProperty("visits")
let had: int = if before.Found then before.Value else 0
p.SetCustomProperty("visits", had + 1)
p.ShowChatMessage("Visit number ${had + 1}")
}
Store the values on the controller, not on the character: a property on the character goes with the body at the next respawn.
Modify
- Add the hint to the first chip: in the loop, after the chat message,
p.CharacterOf().ShowHint("Dinner", "Come to the table"). - Let the button empty the list after calling:
guests.clear()after the loop. - In the id version, greet a guest who comes back through the door with “Welcome back” instead of adding them again.
Make
Make a reservation desk. A zone at the desk adds the player to a list of at
most four. A button by the table seats the party: everyone on the list who
is standing at the table gets a chat message with their seat number, and
anyone missing is named on a sign. Do it without ControllerOf.
zone1 "Zone Ref" -> chip desk
zone2 "Zone Ref" -> chip table
button Held -> chip seat
chip sign -> decal Text
Good to Know
- A character dies with its player’s life. A player who is dead has no
character until they respawn, so
CharacterOf()on them gives nothing to show a hint to. - A
controllervariable or list holds whatever is wired into it. Given a character, it holds a character, which is not equal to the player’s controller. - Custom properties set on a controller stay with the player through their deaths, and are lost when they leave and reconnect. Set on a character, they are lost when that body respawns. To remember a player across a reconnect, use a map keyed by user id.
- A player who leaves the server cannot be reached through a stored reference, and one who comes back is a new player to the game. Keep the user id if you want to know them when they return.
See ControllerOf and CharacterOf in the reference.
Next: Player Control.
Player Control
Before this: Persistent Players
Next steps: Moderation
A circuit can decide when a player comes back to life, bring them back at once, and heal or hurt them. This lesson builds an elimination game: die and you are out until the next round, which a button starts.
Needs admin to load: it uses Player Died Event, Get Player (Persistent), Set Can Respawn and Force Respawn Player, and the health chip uses Set Player Damage and Inc Player Damage.
Contents
button Held -> chip revive
chip sign -> decal Text
Code
@left in revive: exec
var benched: controller[]
var shown: string = "Nobody is out"
@right out sign = shown.Value
on CharacterDied() -> (character) {
let p = character.ControllerOf()
p.SetCanRespawn(false)
benched.push(p)
shown = "Out: ${benched.length()}"
}
on revive {
for p in benched {
p.SetCanRespawn(true)
p.ForceRespawn()
}
benched.clear()
shown = "Nobody is out"
}
on CharacterDied() -> (character)runs when any player in the world dies, with the character that died.ControllerOf()turns that character into the player, as in Persistent Players. The player is what can be stopped from respawning, and it is still there to bring back later.SetCanRespawn(false)keeps them out.benchedremembers who is out.- The button lets everyone respawn again, respawns them at once with
ForceRespawn(), and empties the list.
Two players die, one after the other. What does the sign say? Then the button is pressed. Where do the two players appear?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
reviveandsignto the Text Decal’s Text. - Fall off the map, or ask a friend to. The player who died does not come back, and the sign counts them.
- Press the button. Everyone who was out respawns, at a spawn point as usual, and the sign resets.
Under the Hood
Open the chip. Player Died Event has no input: the game fires it. Its Character goes into Get Player (Persistent), and that gate’s Player State output feeds Set Can Respawn, with its Can Respawn input set off, and Push To Array.
In the loop, the same player reference feeds Set Can Respawn, now on, and Force Respawn Player. After the loop, Clear Array empties the list.
The list stores the persistent player rather than the character, because the character that died is gone. The player stays the same, so the list still reaches them when the button is pressed.
Health
A player takes damage until it reaches their limit, and then dies. Reading both numbers is not admin. This scale shows them for whoever steps on it:
@left in scale: zone
var shown: string = "Step on the scale"
@right out sign = shown.Value
on ZoneEntered(zone = scale) -> (character) {
let d = character.GetDamage()
shown = "Damage: ${d.Damage} of ${d.DamageLimit}"
}
Changing damage is admin. SetDamage sets it, so 0.0 heals fully, and
IncDamage adds to it, so a negative amount heals a little. This chip has a
pit that hurts everyone in it each second, and a pad that heals:
const HURT = 5.0
@left in pit: zone
@left in burning: bool
@left in pad: zone
var inside: character[]
on Clock(interval = 1.0, enabled = burning) {
inside.fillFromZonePlayers(pit)
for c in inside {
c.IncDamage(HURT)
}
}
on ZoneEntered(zone = pad) -> (character) {
character.SetDamage(0.0)
character.ShowStatusMessage("Healed")
}
fillFromZonePlayers fills the list with the characters standing in the pit
right now. Wire the pit’s Attached Zone Occupied to burning, so the
Clock only runs while someone is in it. Damage belongs to a body, so these
work on a character, not on a player.
Temporary permissions
character.SetTempPermission(name, enable) overrides one of the team’s
gameplay permissions for one character: true allows it, false denies it.
It is not admin. The name is one of the game’s gameplay permissions,
written as the game names it in a team’s settings. Because it is set on the
character, expect a respawn to start from the team’s settings again.
Modify
- Announce each elimination to the player who died:
p.ShowChatMessage("You are out until the next round"). - End the round automatically: after the push, if only one player is left
alive, revive everyone. You will need the number of players in the game,
which Every Player covers. Until then, set it with a
const PLAYERS = 4. - Turn the heal pad into a slow heal: a second Clock, enabled by the pad’s
Occupied, that runs
c.IncDamage(-2.0)for everyone on the pad each second.
Make
Make a lives system. Each player has three lives, kept in a
Map<string, int> under their user id. A death takes a life, and the player
is benched only when they reach zero. A button gives everyone three lives again
and revives the benched.
button Held -> chip revive
Good to Know
- A player who cannot respawn waits as a spectator. Players in builder mode are not moved to spectator.
ForceRespawn()removes the player’s current character, if they have one, and spawns a new one by the game mode’s rules.on CharacterDiedruns for every death in the world, from any cause: falling, weapons, damage from another circuit.SetDamagekeeps damage at zero or above, so a heal cannot give a player more than full health.
See Player info, Damage and SetTempPermission in the reference.
Next: Chat Commands.
Chat Commands
Before this: Admin Gates, Taking Text Apart, Safe Text
Next steps: Every Player
A chat command is a word typed into chat after a /, like /roll. A circuit
can add its own, read what the player typed after it, and answer them. This
lesson builds a dice roll, a lamp set from chat, a private message, and a
nickname announcement.
Needs admin to load: every command uses Chat Command Event. /pm also
uses Find Player by Name, and /nick Broadcast Chat Message.
Contents
Code
on ChatCommand("roll", "Roll a six-sided die") -> (who, arguments) {
let n = Random(1, 6)
who.ShowChatMessage("You rolled ${n}")
}
on ChatCommand("roll", ...)adds/rollto chat. The second text is the help line the game shows for it.Random(1, 6)picks a whole number from 1 to 6, different each time.-> (who, arguments)names what the event gives:whois the player who typed it, andargumentsis the rest of the line after/roll.who.ShowChatMessageanswers only that player.
A player types /roll. Who sees the result? Another types /roll 20. Do
they get a twenty-sided die?
Run
- Compile the chip and paste it in-game. Nothing needs wiring: the command is the input.
- Type
/rollin chat. You get a number from 1 to 6, and nobody else sees it. - Type
/roll 20. Still 1 to 6: the chip ignoresarguments.
Under the Hood
Open the chip. Chat Command Event has the command name and help text in its settings and no inputs. Its outputs are Exec Out, Player State (the player) and Arguments. The chain runs through Random and Show Chat Message, with a Format String building the text.
Arguments are always text, even when they look like a number.
Taking Text Apart covers reading them. Here /roll 20 asks for
a bigger die, and anything that is not a number gives the usual six:
on ChatCommand("roll", "Roll a die: /roll or /roll 20") -> (who, arguments) {
let asked = arguments.Trim().ParseInt()
let sides = if asked.Success && asked.Value >= 2 then asked.Value else 6
let n = Random(1, sides)
who.ShowChatMessage("You rolled ${n} on a d${sides}")
}
A setting and a number
/lamp brightness 20 sets one setting of a lamp. Split(" ") cuts the
arguments at the first space: the setting name on the left, the number on the
right. Anything that does not match gets the usage line back.
const MAX_BRIGHTNESS = 100.0
const MAX_RADIUS = 2000.0
const USAGE = "Usage: /lamp brightness 0 to 100, or /lamp radius 0 to 2000"
var level: float = 10.0
var reach: float = 500.0
@right out brightness = level.Value
@right out radius = reach.Value
on ChatCommand("lamp", "Set the lamp: /lamp brightness 20") -> (who, arguments) {
let parts = arguments.Trim().Split(" ")
let setting = parts.Left.ToLower()
let number = parts.Right.Trim().ParseNumber()
if !parts.Found || !number.Success || number.Value < 0.0 {
who.ShowChatMessage(USAGE)
} else if setting == "brightness" && number.Value <= MAX_BRIGHTNESS {
level = number.Value
who.ShowChatMessage("Brightness is now ${number.Value}")
} else if setting == "radius" && number.Value <= MAX_RADIUS {
reach = number.Value
who.ShowChatMessage("Radius is now ${number.Value}")
} else {
who.ShowChatMessage(USAGE)
}
}
Wire brightness and radius to a Point Light’s Brightness and
Radius. ToLower() lets /lamp Brightness 20 work too.
One player, or everyone
/pm name message whispers to another player. FindPlayer looks a player up
by name or user id. It is admin too, and Every Player
shows another way to search.
import { escape, sanitize } from "sanitize"
const MAX_LEN = 200
on ChatCommand("pm", "Whisper to one player: /pm name message") -> (who, arguments) {
let parts = arguments.Trim().Split(" ")
if !parts.Found {
who.ShowChatMessage("Usage: /pm name message")
return
}
let to = FindPlayer(parts.Left)
if to == null {
who.ShowChatMessage("Nobody is called ${sanitize(parts.Left)}")
return
}
let text = sanitize(parts.Right, MAX_LEN)
to.ShowChatMessage("[from ${escape(who.GetDisplayName())}] ${text}")
who.ShowChatMessage("[to ${escape(to.GetDisplayName())}] ${text}")
}
When nobody matches, to is empty, and to == null is the check
(Object References). return ends the handler there, so nothing after it runs.
Every piece of text a player typed goes through sanitize before it is shown, and every name through escape, as in
Safe Text. A message is text another player chose,
and it is shown to someone else.
/nick name tells the whole server, with BroadcastChatMessage:
import { escape, sanitize } from "sanitize"
on ChatCommand("nick", "Announce a nickname: /nick name") -> (who, arguments) {
let nick = sanitize(arguments)
if nick == "" {
who.ShowChatMessage("Usage: /nick name")
return
}
BroadcastChatMessage("${escape(who.GetDisplayName())} is now known as ${nick}")
}
Modify
- Add
/flip: answer “Heads” or “Tails” withRandom(0, 1). - Add
/roll2: roll two dice, and answer with both rolls and the total. Let/roll2 20set the sides of both, as/roll 20does. - Make
/pmtell the receiver how to answer: add “(reply with /pm name message)” after the text, with the sender’s name filled in.
Make
Make a /vote yes and /vote no poll. A sign shows the two tallies, and
/vote reset clears the poll. Anything else after /vote gets a usage line
back. Then add a /vote close that stops counting until the next reset.
chip sign -> decal Text
Good to Know
- A command fires for whoever types it, anywhere in the world. Check the player yourself if only some should be able to use it. Moderation shows how.
argumentsis the rest of the line as the player typed it: extra spaces, any case, any symbols. Trim it, and escape it before showing it.
See ChatCommand and FindPlayer in the reference.
Next: Every Player.
Every Player
Before this: Chat Commands, Looping Over a Map, Persistent Players, Mods
Next steps: Moderation
Zones and seats hand a circuit the players who come to it. An admin circuit can also ask for everyone on the server at once, and hear when players join, leave, spawn and die. This lesson builds a board of who is online, then adds the player events.
Needs admin to load: it uses Fill Array From Players, the player events, Chat Command Event and the broadcast gates.
Contents
chip board -> decal Text
Code
import { escape } from "sanitize"
var players: controller[]
var online: Map<string, string>
var shown: string = ""
@right out board = shown.Value
mod redraw() {
var text: string = ""
for id, name in online {
text = "${text}${name}<br>"
}
shown = "${online.length()} online<br>${text}"
}
on Loaded() {
players.fillFromPlayers()
for p in players {
online.set(p.GetUserId(), escape(p.GetDisplayName()))
}
redraw()
}
on ControllerJoined() -> (player, userId, userName) {
online.set(userId, escape(player.GetDisplayName()))
redraw()
}
on ControllerLeft() -> (userId, userName) {
online.remove(userId)
redraw()
}
players.fillFromPlayers()replaces everything inplayerswith every player on the server. It isfillFromZonePlayerswithout a zone.on Loaded()runs once, when the chip appears. It fills the list and stores each player’s escaped name inonline, under their user id.on ControllerJoinedandon ControllerLeftare player events: the game runs them when a player joins or leaves the server. A join adds the new player’s name, and a leave removes it by the id the event gives.mod redraw() { ... }names the steps that build the board, and eachredraw()pastes them in (Mods). The loop from Looping Over a Map writes one name per line.
A friend joins while the board is up. How long until their name appears? Another friend dies and is waiting to respawn. Are they still on the board?
Run
- Compile the chip and paste it in-game. Place a brick with a Text Decal.
- Wire
boardto the Text Decal’s Text. - Your name is on the board, and so is everyone else on the server. A player who joins appears at once, and one who leaves is gone at once.
Under the Hood
Open the chip. Read Brick Grid starts a chain into Fill Array From
Players, whose Array Var Ref is wired from the players Array
Var. Its Include Builder Mode input is not wired, so it is off. Then a
For Each Array loop reads each player’s Get User Id and Get User Display Name into a
Set Map Element. Player Joined Event and Player Left Event have no
inputs: the game fires them. Each of the three chains ends in its own copy of
redraw: a For Each Map loop and Get Map Length for the count.
The list holds players, not characters: the same references
Persistent Players made with ControllerOf. A dead
player is still a player, so they stay on the board while they wait to
respawn.
Nothing here runs on a timer. The board is rebuilt only when someone joins or leaves, which is the only time it changes. A Clock that filled the list every second would do the same work over and over, and still show a new player up to a second late.
Player events
The board uses two of the player events. There are more, and each fires for every player in the world, with no wiring:
import { escape } from "sanitize"
var deaths: Map<string, int>
on ControllerJoined() -> (player, userId, userName) {
BroadcastChatMessage("${escape(userName)} joined")
player.ShowChatMessage("Welcome! Type /deaths to see how often you have died")
}
on ControllerLeft() -> (userId, userName) {
BroadcastStatusMessage("${escape(userName)} left")
}
on CharacterDied() -> (character) {
let id = character.GetUserId()
let before = deaths.get(id)
deaths.set(id, if before.Found then before.Value + 1 else 1)
}
on ChatCommand("deaths", "How often have you died?") -> (who, arguments) {
let count = deaths.get(who.GetUserId())
let n = if count.Found then count.Value else 0
who.ShowChatMessage("You have died ${n} times")
}
ControllerJoinedgives the new player, already persistent, with their user id and user name.ControllerLeftgives only the id and the name, since the player is already gone.CharacterDiedgives the character that died. Its user id is the key, so the count survives the respawn.CharacterSpawned() -> (character)is the fourth, for every new life.
BroadcastStatusMessage shows the line in everyone’s status bar instead of
chat. Use chat for things to scroll back to, the status bar for things that
matter only now.
Finding a player
FindPlayer from Chat Commands needs a name that
matches. With the whole list, a search can be looser: this /find matches
any part of a name, in any case, and lists everyone who fits.
import { escape } from "sanitize"
var players: controller[]
on ChatCommand("find", "Find a player by part of their name: /find sam") -> (who, arguments) {
let query = arguments.Trim().ToLower()
if query == "" {
who.ShowChatMessage("Usage: /find part of a name")
return
}
players.fillFromPlayers()
var found: string = ""
for p in players {
let name = p.GetDisplayName()
if name.ToLower().Contains(query) {
found = "${found} ${escape(name)}"
}
}
if found == "" {
who.ShowChatMessage("Nobody matches")
} else {
who.ShowChatMessage("Found:${found}")
}
}
The names are compared before they are escaped, and escaped only to be shown.
Modify
-
Mark idle players on the board.
GetUnixTime()is the time in whole seconds, andp.GetAim().Directionis where a player looks. Remember both per user id, and show “(away)” after a minute without the view moving:import { escape } from "sanitize" const AFK_SECONDS = 60 var players: controller[] var lastLook: Map<string, vector> var lastActive: Map<string, int> var shown: string = "" @right out board = shown.Value on Clock(interval = 1.0) { players.fillFromPlayers() let now = GetUnixTime() var text: string = "" for p in players { let id = p.GetUserId() let look = p.GetAim().Direction let before = lastLook.get(id) if !before.Found || look != before.Value { lastActive.set(id, now) } lastLook.set(id, look) let since = lastActive.get(id) let name = escape(p.GetDisplayName()) if now - since.Value > AFK_SECONDS { text = "${text}${name} (away)<br>" } else { text = "${text}${name}<br>" } } shown = text } -
If you have done Ready Check, turn its ready board into one for the whole server: replace
fillFromZonePlayers(room)withfillFromPlayers(), and makeplayersacontroller[]. -
Add a death count to each name on the online board, from the
deathsmap of the second chip. The board now changes on a death too, so callredraw()fromon CharacterDiedas well.
Make
Make a team picker sign: a button puts everyone on the server in a random
order with players.shuffle(), and the sign lists the first, third, fifth
and so on under Red and the rest under Blue. Nobody’s real team changes. If
you have done Teams, add a second button that does change them,
with SetTeam.
button1 Held -> chip shuffle
button2 Held -> chip assign
chip board -> decal Text
Good to Know
fillFromPlayers()leaves its Include Builder Mode input off, which should leave out players who are in builder mode. Check with a friend in builder mode before you count on it.- The player events fire for everyone on the server, so a chip with
on CharacterDiedcounts deaths anywhere in the world, not only near it. ControllerLefthas no player to give. Keep anything you need about a player under their user id while they are still there.- The players in the list, and the one
ControllerJoinedgives, are controllers: values set on them withSetCustomPropertystay with the player through their deaths, until they leave the server (Persistent Players). - A board that loops over every player costs one pass per player each time it is rebuilt. Rebuild it when it changes, not on a timer.
See Array Methods, Broadcast Messaging and Built-in Events in the reference.
Next: Moderation.
Moderation
Before this: Player Control, Every Player
A chat command answers whoever types it. A command for running the server
has to check who that is first. This lesson builds an /a channel that only
admins can use and read, and a /reset that only trusted builders can run.
Needs admin to load: it uses Chat Command Event and Fill Array From Players.
Contents
Code
import { escape, sanitize } from "sanitize"
const ROLE = "Admin"
const ADMIN_COLOR = "ff8040"
const MAX_LEN = 200
var players: controller[]
on ChatCommand("a", "Message every admin: /a message") -> (who, arguments) {
if !who.HasRole(ROLE) {
who.ShowChatMessage("Only admins can use /a")
return
}
let name = escape(who.GetDisplayName())
let line = '<color="${ADMIN_COLOR}">[admin] ${name}:</> ${sanitize(arguments, MAX_LEN)}'
players.fillFromPlayers()
for p in players {
if p.HasRole(ROLE) {
p.ShowChatMessage(line)
}
}
}
who.HasRole(ROLE)is true when the player has the server role namedAdmin. Anyone else is told no, and the handler stops.- The line is built once: the sender’s escaped name, then their message,
cleaned with
sanitize, in the colors from Rich Text. - The loop from Every Player sends it to every player who has the role, the sender included.
A player without the role types /a hello. What do they see, and does any
admin see it? An admin types /a with nothing after it. What do the admins
get?
Run
- Check the name of your server’s admin role in its role settings, and set
ROLEto it. - Compile the chip and paste it in-game. Nothing needs wiring.
- Type
/a testing. You see the line in orange with your name. A friend without the role gets “Only admins can use /a”, and nobody sees their message.
Under the Hood
Open the chip. The chain starts at Chat Command Event and goes straight
into Has Role, whose Player State comes from the event and whose role
name is the constant Admin. A NOT of its Has Role output decides
the Branch: one side answers no, the other reads the name, calls
sanitize, then runs Fill Array From Players and the loop. Inside the loop, a second Has Role checks each
player.
Has Role itself is not admin. Any chip can check a role, so a circuit anyone can paste can still treat admins differently. This visitor counter leaves admins out of the count:
@left in door: zone
var visits: int = 0
@right out sign = "Visitors: ${visits.Value}"
on ZoneEntered(zone = door) -> (character) {
if character.HasRole("Admin") {
return
}
visits += 1
}
Who may do what
There are three checks on a player, and any chip can use them.
HasRole(name): the player has a role on this server, by its name.HasPermission(name): the player has one of the game’s gameplay permissions right now, the kind a team’s settings grant andSetTempPermissionoverrides.IsTrusted(): the player is trusted by the owner of the chip’s bricks. Use it for a build’s own controls, so the people the owner builds with can use them too.
This /reset puts the visitor count back to 0, for trusted builders only:
@left in door: zone
var visits: int = 0
@right out sign = "Visitors: ${visits.Value}"
on ZoneEntered(zone = door) {
visits += 1
}
on ChatCommand("reset", "Reset the visitor count") -> (who, arguments) {
if !who.IsTrusted() {
who.ShowChatMessage("Only builders the owner trusts can reset the count")
return
}
visits = 0
who.ShowChatMessage("Visitor count reset")
}
Modify
- Refuse an empty message: if
arguments.Trim()is empty, tell the sender how to use/aand stop. - Let
/resetsay who did it: send every admin a line naming the player who reset the count. - Add
/bench namefor admins: find the player withFindPlayer, stop them respawning withSetCanRespawn(false)as in Player Control, and/unbench nameto let them back.
Make
Make a moderated sign. Any player can set it with /sign text. Only
trusted builders can lock it with /sign lock and unlock it with
/sign unlock. While it is locked, everyone else is told it is locked. Keep
the text short with sanitize.
chip sign -> decal Text
Good to Know
- A role is checked by its name, as typed in the server’s role settings. A misspelled name is not an error: the check is false for everyone.
- Every check runs when the command is typed, so a player who gains or loses a role is treated by their new roles at once.
- A command is there for everyone to type. The check is the only thing that keeps it to some players, so put it first, before the command does anything.
See Player info and ChatCommand in the reference.
Next: pick a lesson from the tree on the Learn page.
Testing
Before this: Mods, Looping Over a List
Next steps: Const and Folding
A circuit can check itself. This lesson builds a chip that runs its own checks the moment it is pasted, stays quiet about what passed, and shows enough about a failure to find it.
Contents
chip report -> decal Text
Code
var pass: int = 0
var total: int = 0
var fails: string = ""
var hits: int[] = [0, 4, 5, 9, 10, 12345]
var wants: string[] = ["bronze", "bronze", "silver", "silver", "gold", "gold"]
@right out report = "medal checks: ${pass.Value}/${total.Value}${fails.Value}"
mod assert<T: int | float | string>(want: T, got: T, label: string) {
total += 1
let ok = want == got
pass += ok
if !ok {
fails = "${fails}<br>FAIL ${label}: want ${want} got ${got}"
}
}
mod medal(n: int) -> string {
return if n >= 10 then "gold" else if n >= 5 then "silver" else "bronze"
}
on Loaded() {
pass = 0
total = 0
fails = ""
assert(hits.length(), wants.length(), "table sizes")
for i in 0..hits.length() {
assert(wants[i], medal(hits[i]), "${hits[i]} hits")
}
}
medalis the code under test: the medal rule from Counting the Cost.hitsandwantsare a table of cases: each number of hits, and the medal it should give. The cases sit on the edges, where a rule usually breaks: 4 and 5, 9 and 10.assertcounts one check, and adds aFAILline when the two values differ.<T: int | float | string>lets the same mod compare ints, floats or text. Both values must be the same type.pass += okadds 1 for a trueokand 0 for a false one.on Loaded()runs once, when the chip appears. The checks run as you paste it, with nothing to press.- The handler sets the counters back to zero first, so a second run does not add to the first.
What does the sign say? Change >= 5 to > 5 in medal: what does it say
then?
Run
- Compile the chip and paste it in-game. Place a brick with a Text
Decal and wire
reportto its Text. - The checks ran when the chip appeared, before the wire was there, so the
sign shows the result as soon as it is wired:
medal checks: 7/7. - To run them again, paste the chip again.
The sign is the whole result. 7/7 with no other line means every check
passed. A failure adds a line saying what was wanted and what came out.
want silver got bronze tells a wrong answer apart from an answer that never
arrived, which would read got and nothing after it.
Under the Hood
Open the chip. The loop is a For Loop, and inside it is one copy of
medal: two Greater/Equal and two Select gates. Each pass reads a
case with Get Array Element, runs it through those gates and compares
the answer with an Equal. The FAIL line is a Format String behind
a Branch, so it is only written when the check fails.
The cases come from Array Vars on purpose. This version of the same test types the cases into the calls instead:
var pass: int = 0
var total: int = 0
var fails: string = ""
@right out report = "medal checks: ${pass.Value}/${total.Value}${fails.Value}"
mod assert<T: int | float | string>(want: T, got: T, label: string) {
total += 1
let ok = want == got
pass += ok
if !ok {
fails = "${fails}<br>FAIL ${label}: want ${want} got ${got}"
}
}
mod medal(n: int) -> string {
return if n >= 10 then "gold" else if n >= 5 then "silver" else "bronze"
}
on Loaded() {
pass = 0
total = 0
fails = ""
assert("bronze", medal(4), "4 hits")
assert("silver", medal(5), "5 hits")
assert("gold", medal(10), "10 hits")
}
It also shows 3/3 in game. Open it: there is no Greater/Equal, no Select
and no Equal. There are only the counters, the Set Variable gates that
reset them, and Increment Variable gates that add 1 to each. Every
input was a constant, so the compiler worked out each medal and each
comparison itself while compiling, and kept only the answers. The test still
catches a mistake in the rule, but it checks the compiler’s arithmetic, not
the gates the game will run when real hits arrive. The chip has 14 gates,
and the table version has 50.
So feed test inputs from storage: an array, a var, or a pin. A value read
from storage is only known when the game runs, so the compiler has to build
the gates. const and Folding covers what the
compiler works out ahead of time, and how to tell.
Modify
-
Break
medalon purpose: changen >= 5ton > 5. The sign now saysmedal checks: 6/7andFAIL 5 hits: want silver got bronze. Make every new test fail once like this. A test that has never failed may not be checking anything. -
Check one answer against another. A loop that adds up a list must agree with Sum Array (
scores.sum()), which adds up the whole array in one gate:var pass: int = 0 var total: int = 0 var fails: string = "" var scores: int[] = [3, 12345, 7, 0, 40] @right out report = "sum checks: ${pass.Value}/${total.Value}${fails.Value}" mod assert<T: int | float | string>(want: T, got: T, label: string) { total += 1 let ok = want == got pass += ok if !ok { fails = "${fails}<br>FAIL ${label}: want ${want} got ${got}" } } on Loaded() { pass = 0 total = 0 fails = "" var sum: int = 0 for s in scores { sum += s } assert(scores.sum(), sum, "loop agrees with sum()") assert(12345, scores[1], "control value") }Neither answer is typed in, so the check fails if either way is wrong, even if you would have typed the wrong number yourself.
12345is a control: a value nothing else in the chip could produce by accident. An unwired or empty read gives 0, and a check that expects 0 cannot tell. -
Test a chip from outside. A test bench is a second chip whose outputs drive the first chip’s input pins and whose inputs read its outputs. The first chip below is the one under test, and the second is its bench:
@left in hits: int @right out medal = if hits >= 10 then "gold" else if hits >= 5 then "silver" else "bronze"@top in go: exec @left in medal: string var pass: int = 0 var total: int = 0 var fails: string = "" var sent: int = 0 @right out hits = sent.Value @bottom out report = "bench: ${pass.Value}/${total.Value}${fails.Value}" mod assert<T: int | float | string>(want: T, got: T, label: string) { total += 1 let ok = want == got pass += ok if !ok { fails = "${fails}<br>FAIL ${label}: want ${want} got ${got}" } } mod check(n: int, want: string) { sent = n await Sleep(_, delay = 0.1) assert(want, medal, "${n} hits") } on go { pass = 0 total = 0 fails = "" check(4, "bronze") check(5, "silver") check(10, "gold") }Paste both, with the bench to the left of the medal chip. Wire the bench’s
hitsto the medal chip’shits, the medal chip’smedalto the bench’smedal, a Button’s Held to the bench’sgo, and the bench’sreportto a Text Decal. Press the button:bench: 3/3after about a third of a second. Each check sets the bench’s output, waits a tenth of a second for the answer to come back through the wires, then compares.await Sleep(_, delay = 0.1)is that wait: the handler stops there and carries on 0.1 seconds later (Waiting covers it). The bench’s own controls sit on its top and bottom, so its left and right pins pair up with the chip under test.
Make
Turn the search from Looping Over a List into a mod that takes a list of rolls and gives back the place of the first six, or 0 for none. Test it with a self-checking chip: an empty list, a list with no six, a six first, a six last, and two sixes. Give it a control value, and make every check fail once before you trust it.
chip report -> decal Text
Good to Know
on Loaded()runs once, when the chip appears, and again each time the world loads. Each pasted copy has its own counters and runs its own checks.- A read past the end of an array gives the previous value read, not 0. If
wantswere shorter thanhits, the last checks would compare stale values. Thetable sizescheck catches that. - A test that never ran reads
0/0. Show the total, not only the passes, so a test that did nothing cannot look like a pass. - The sign only shows where it is. Testing in the
reference sends each failure to chat with
BroadcastChatMessageinstead. That gate needs admin to load, so these lessons use a Text Decal.
See also: Testing in the reference.
Next: const and Folding.
const and Folding
Before this: Testing, Counting the Cost
Next steps: Performance
The compiler works out every value it can before the game ever sees the
chip, and types the answers into the gates. This is called folding. const
asks for it on purpose: it names a value that must be known while compiling,
and refuses to compile when it is not.
Contents
button Held -> chip fill
chip sign -> decal Text
Code
const WIDTH = 10
const MAX = 200
const STEP = MAX / 4
const PER_CELL = MAX / WIDTH
const TITLE = "Fuel, max ${MAX}"
@left in fill: exec
var fuel: int = 0
var bar: string = "----------"
@right out sign = "${TITLE}<br>[${bar.Value}] ${fuel.Value}"
on fill {
fuel = if fuel >= MAX then 0 else fuel + STEP
var text: string = ""
for i in 0..WIDTH {
text = text .. (if i < fuel / PER_CELL then "#" else "-")
}
bar = text
}
A fuel gauge: each press adds a quarter of a tank, and a full tank empties
on the next press. The settings sit at the top as consts, so changing the
tank or the bar is one edit. text .. x joins two pieces of text into one.
const is written like let, but its value must be worked out while
compiling. Two of the five are worked out from others: STEP is 50 and
PER_CELL is 20.
How many gates do the five const lines cost? Which numbers in the
handler does the game work out, and which does the compiler?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
fill, andsignto the Text Decal’s Text. - Press four times: the bar fills two or three cells at a time, to
[##########] 200. Press once more and it is empty.
Under the Hood
Open the chip and click through the gates. None of them belongs to a
const line. Their values are typed into the gates that use them:
- The sign’s Format String has
Fuel, max 200typed into its first input. The title was built while compiling. - The Add adds 50 and the Greater/Equal compares with 200.
- The For Loop has Count set to 10, and the Divide inside it divides by 20.
The game only works out what depends on fuel, which changes as it runs.
How to tell
Count the gates, as in Counting the Cost:
just ir gauge.ws prints 24 gate lines. Then turn folding off for the
whole file: put @nofold on the first line, with a blank line after it.
@nofold
const WIDTH = 10
...
Now it prints 29: two Divides that work out STEP and PER_CELL, a
Format String that builds TITLE, a pin that carries PER_CELL into the
loop, and a _Literal line for MAX. A _Literal is a constant the
compiler types into the gates it feeds, not a gate of its own. Both
versions do exactly the same thing. Folding only changes how much is built.
Counting with and without @nofold is the way to find out what folded. A
line you expected to fold that still shows up as a gate is reading
something only the game knows.
const and let
Change every const to let: still 24. A let folds too, whenever it
can. The difference is what happens when it cannot. Say the tank size comes
from a pin:
@left in fill: exec
@left in cap: int
const MAX = cap
var fuel: int = 0
@right out sign = "${fuel.Value}"
on fill {
fuel = if fuel >= MAX then 0 else fuel + 50
}
Error: [WS046] not a compile-time constant: 'cap' is a runtime value
With let MAX = cap it compiles, and the game works out the compare and
the add as it runs. With const, if a value you meant to be fixed turns
into a wire, the compiler reports it at the line that caused it.
What cannot fold
Folding needs every input known while compiling. These never are:
- Anything read from a
var, an array or a map, even one that never changes. - Pins, events,
Random(...)andReadBrickGrid(). - A gate whose answers were never recorded from the game. The compiler only folds a gate when the game’s own answer for that kind of input has been measured, so a folded chip gives exactly what the gates would.
const also refuses min, max, abs and clamp
(WS046, “max is not declared const mod”). A let folds them when
their inputs are constant.
Modify
-
Switch a debug readout on and off with a
const. Addconst DEBUG = falseandvar presses: int = 0. Putif DEBUG { presses += 1 }as the handler’s first line, and end the sign’s text with${if DEBUG then "<br>presses: ${presses.Value}" else ""}. Count it: 25 gates. SetDEBUG = true: 27. Withfalse, theifand everything in it are deleted while compiling, so a feature behind a falseconstcosts nothing but itsvar: thepressesVar stays. -
A
constparameter.SendCustomEventsends a message under a name to your other chips (Custom Events). The name is a setting typed into the gate, so it must be known while compiling. A mod that takes the name as a plainstringdoes not compile:@left in fill: exec var fuel: int = 0 mod report(chan: string, level: int) { SendCustomEvent(chan, level) } on fill { fuel += 50 report("fuel.level", fuel) }Error: [WS028] 'eventName' is constant-only gate config and cannot take a variable or computed valueInside the mod,
chanis a parameter, and a parameter could be anything. Mark itchan: const string. Then every call must pass a value known while compiling, the name is typed into each call’s Send Personal Custom Event gate, andlevelstays an ordinary wired input. A call that passes avaras the name isWS046. -
Name the levels with a
constlist. Addconst LEVELS = ["empty", "low", "half", "high", "full"], avar level: string = "empty", and in the handler, afterfuelchanges,level = LEVELS[fuel / STEP]. Show${level.Value}on the sign instead of the number. The list becomes one Array Var with the five words typed in, read with Get Array Element: the index depends onfuel, so the read happens in game. A read at a fixed index, such asLEVELS[4], folds to"full".
Make
Make a countdown sign whose start time, step and messages are all consts
at the top. Add a DEBUG const that shows how many times it has been
reset. Count the gates with DEBUG true and false, and with @nofold, and
explain each difference from the code.
button Held -> chip reset
chip sign -> decal Text
Good to Know
- A folded value prints the way the gate would print it. A folded
${1000}is1,000, as the Format String gate writes it. /between twoints drops the remainder, folded or not:MAX / 3withMAX = 200is 66.- Event names, prefabs and sounds are settings typed into the gate. They
take only a value known while compiling (
WS028). - A
varis never removed, even when every line that used it was folded away.
See also: const,
const Parameters and const mod
and Constant Folding in the reference.
Next: Performance.
Performance
Before this: Const and Folding, One Chip per Player
Next steps: Multiplayer Architecture
A circuit has three different costs, and they move separately: how many
gates are built, how many steps one pulse takes in a row, and how much the
game works out each tick. This lesson measures all three on one chip, then
uses them to choose between a fn and a mod, and between a loop and one
chip per player.
Contents
button Held -> chip add
chip sign -> decal Text
Code
@left in add: exec
var timers: int[]
var running: int = 0
@right out sign = "Running: ${running.Value}"
on add { timers.push(50) }
on Clock(interval = 0.1) {
var n: int = 0
for i in 0..timers.length() {
if timers[i] > 0 {
timers[i] -= 1
n += 1
}
}
running = n
}
Each press starts a five-second timer: 50 pulses of a tenth of a second. Every pulse counts all the timers down and counts how many are still running.
The list only grows. Press ten times: how many gates does the chip have? How much does one pulse do with ten timers? With ten thousand?
Run
- Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
- Wire the Button’s Held to
add, andsignto the Text Decal’s Text. - Press three times quickly:
Running: 3. Five seconds after the last press,Running: 0.
Under the Hood
Gates: what is built
just ir timers.ws gives 27 gate lines. The loop is a For Loop board,
and its body is built once: a For Loop Entry and 7 gates (two Get
Array Element, a Greater, a Branch, a Subtract, a Set Array
Element and an Increment Variable). Outside it, five gates (Subtract,
Multiply, Divide, Ceil, Max) work out how many passes to make, because the
length is only known as the game runs. A loop to a const has none of
them.
Ten timers or ten thousand, the chip stays 27 gates. Gates are what you paste and what the world loads, so they matter for size, not for speed.
Steps in a row
Follow the Exec wires from the Clock: Set Variable, Get Array Length, the For Loop, Get Variable, Set Variable. Five steps, with the loop as one of them. Inside the loop, each pass is five more after the entry: Get Array Element, Branch, Get Array Element, Set Array Element, Increment Variable.
So one pulse runs 5 steps plus 5 for every running timer, all within one tick, one after another. The gate count does not change with the number of timers. The steps do.
Work per tick
The game works out each gate in the loop once per pass, plus about 8 more
to enter the loop’s board (measured in game, see
When to use for).
That is about 16 per running timer. Measured in game, a gate takes about
44.5 nanoseconds, so a tick at 60 per second has room for about 375,000.
| Timers | Work per pulse | Share of one tick |
|---|---|---|
| 10 | about 160 | nothing |
| 1,000 | about 16,000 | about 4% |
| 23,000 | about 375,000 | all of it |
Past that, the tick takes longer than a tick should. Check these numbers before making anything faster. Ten timers are nowhere near any limit.
A fn or a mod
Three timers, each shown as minutes and seconds:
@left in a: exec
@left in b: exec
@left in c: exec
var ta: int = 0
var tb: int = 0
var tc: int = 0
var shown: string = ""
@right out sign = shown.Value
mod mmss(tenths: int) -> string {
let secs = (tenths + 9) / 10
let pad = if secs % 60 < 10 then "0" else ""
return "${secs / 60}:${pad}${secs % 60}"
}
on a { ta = 900 }
on b { tb = 1800 }
on c { tc = 3000 }
on Clock(interval = 0.1) {
if ta > 0 { ta -= 1 }
if tb > 0 { tb -= 1 }
if tc > 0 { tc -= 1 }
shown = "${mmss(ta)}<br>${mmss(tb)}<br>${mmss(tc)}"
}
The body of mmss is 7 gates. As a mod, every call is a copy: 7 gates a
call. As a fn, the body is built once with a Function Entry, a
Function Input, a Function Output and the Function Definition
board, 11 in all, and each call is one Function Call.
With fewer calls (the other timers shown as plain numbers), the counts are:
Calls of mmss | mod | fn |
|---|---|---|
| 1 | 38 | 43 |
| 2 | 45 | 44 |
| 3 | 52 | 45 |
From two calls on, the fn is smaller. The smaller the body, the more
calls it takes: with the 4-gate medal in
Counting the Cost, the mod stayed smaller even
at ten calls.
The fn is not free in steps. Each Function Call is a step in the
handler’s chain, and the body runs inside it: the longest chain is 16 steps
with the mod and 19 with the fn. The game works out a fn’s gates only when
it is called, though. Gates outside any board are worked out even when an
if around them is not taken.
A loop, or one chip per player
One Chip per Player counts Q presses
with a pad per player. The same job done by one chip would loop over the
room on a Clock, read each player with GetInputs, and keep a map of who
had Q down last time. Counted:
- The sweep is one chip of 21 gates. Its loop body is 15 gates with the entry, and it runs for every player in the room on every pulse, whether anyone presses or not.
- The per-player version is a 25-gate chip plus a 9-gate pad for each player: 115 gates with ten players. No chain runs until someone presses. Then the pad sends one event and the central chip counts it once.
Spawning a chip per player costs more gates. Here it also removes the pulse, and with it the taps a pulse can miss. Where the work has to happen on every pulse anyway, spreading it over many chips does not make it smaller, so choose by what the work needs, as that lesson lists, not by gate count.
Modify
-
Loop over the timers themselves:
for i, t in timersgives each element’s indexiand valuet(Index and Value). Useif t > 0 { timers[i] = t - 1 }. The chip drops from 27 gates to 19: no gates to work out the pass count, no Get Array Element, and each pass is 3 steps instead of 5. -
Stop the list from growing. In
add, reuse a finished timer:@left in add: exec var timers: int[] var running: int = 0 @right out sign = "Running: ${running.Value}" on add { let free = timers.find(0) if free.Found { timers[free.Index] = 50 } else { timers.push(50) } } on Clock(interval = 0.1) { var n: int = 0 for i, t in timers { if t > 0 { timers[i] = t - 1 n += 1 } } running = n }timers.find(0)is one Find Array Element gate:.Foundsays whether a 0 is in the list, and.Indexwhere. Now the list never holds more timers than were running at once, so the pulse never visits a timer that finished long ago. -
Add a fourth timer to the clocks. Count both versions: the
modgrows from 52 to 68 gates and thefnfrom 45 to 55. The timer itself is 9 gates in both, and the rest is the call.
Make
Make a sign that tracks every player who walks through a zone and shows how long ago the last one came in. Write it with a loop. Count its gates, count the steps one pulse takes for each player, and work out the share of a tick it needs for 10, 100 and 1,000 players. At what size would you change how it is built, and to what?
zone "Zone Ref" -> chip door
chip sign -> decal Text
Good to Know
- Loops that run more than 100,000 passes in one tick, across all loops,
are cut off partway. A body like the timers’ runs out of time long before
that.
let run = for ...givesrun.aborted, the loop’s Aborted output (Loops Across Ticks). - Measured in game, building a piece of text costs about a microsecond. Reading a variable or a map, and showing text, cost almost nothing. Measured costs has the table.
- Every pasted save is at least about 25 KB, whatever is in it. A small chip and a big one paste at much the same size.
See also: Best Practices: Gate Count & Scaling,
Profiling and
for Loops in the reference.
Next: Multiplayer Architecture.
Multiplayer Architecture
Before this: Performance, One Chip per Player, Per-Player State, Custom Events
Next steps: Writing Libraries
A circuit for one player can keep everything in one chip. With twenty players, where each piece of work lives decides what it costs, and the game puts limits on how much text and how many events get through. This lesson builds a shared scoreboard that ranks everyone in one place and draws the result on every player’s screen. You need a second player to see most of it.
Contents
zone "Zone Ref" -> chip join
target "Just Hit" -> chip point
target "Character That Just Hit" -> chip scorer
Code
import { escape } from "sanitize"
@left in join: zone
@left in point: exec
@left in scorer: character
var scores: Map<string, int>
var names: Map<string, string>
var huds: Map<string, entity>
var board: string = "No scores yet"
mod rank() {
var s1: int = 0
var s2: int = 0
var s3: int = 0
var n1: string = "-"
var n2: string = "-"
var n3: string = "-"
for id, n in scores {
let name = names.get(id)
if n > s1 {
s3 = s2
n3 = n2
s2 = s1
n2 = n1
s1 = n
n1 = name.Value
} else if n > s2 {
s3 = s2
n3 = n2
s2 = n
n2 = name.Value
} else if n > s3 {
s3 = n
n3 = name.Value
}
}
board = "1. ${n1} ${s1}<br>2. ${n2} ${s2}<br>3. ${n3} ${s3}"
}
on point {
let id = scorer.GetUserId()
let had = scores.get(id)
let mine = if had.Found then had.Value + 1 else 1
scores.set(id, mine)
names.set(id, escape(scorer.GetDisplayName()))
rank()
SendCustomEvent("board.top", board)
let hud = huds.get(id)
if hud.Found {
hud.Value.SendCustomEvent("board.mine", mine)
}
}
on ZoneEntered(zone = join) -> (character) {
let id = character.GetUserId()
let had = scores.get(id)
let mine = if had.Found then had.Value else 0
let old = huds.get(id)
if old.Found {
old.Value.SendCustomEvent("hud.init", character, mine, board)
return
}
let hud = SpawnPrefab(
prefab = $```
var owner: character
var id: string = ""
var mine: int = 0
var top: string = ""
var ready: bool = false
mod show() {
owner.DisplayText("You: ${mine}<br>${top}",
textId = 7200, anchorX = 1.0, anchorY = 0.0,
positionX = -40.0, positionY = 40.0, justify = Right,
lifetime = 0.0)
}
on CustomEvent("hud.init", isObject = true) -> (who: character, score: int, text: string) {
owner = who
id = who.GetUserId()
mine = score
top = text
ready = true
show()
on Clock(interval = 1.0) {
if ready {
if owner == null {
SendCustomEvent("hud.gone", id)
ReadBrickGrid().DestroySpawnedPrefab()
}
}
}
}
on CustomEvent("board.mine", isObject = true) -> (score: int) {
mine = score
show()
}
on CustomEvent("board.top") -> (text: string) {
top = text
if ready {
show()
}
}
```,
offset = Vec(0.0, 0.0, 300.0),
limit = 64
)
huds.set(id, hud)
hud.SendCustomEvent("hud.init", character, mine, board)
}
on CustomEvent("hud.gone") -> (id: string) {
huds.remove(id)
}
There are two kinds of chip here, one central chip and a HUD per player.
- The central chip stores scores and escaped names by user id, and
hudsrecords which HUD belongs to which player. rank()loops over every score, keeps the best three, and builds the board text. Thevars inside it start again at every call.- A HUD is a small chip spawned for each player who walks through
join. It draws one text on that player’s screen, and redraws it in the handler of each event that brings it a new value. Nothing redraws on a timer. It holds only what the central chip has sent it. owner.DisplayText(...)draws text on one player’s screen.anchorX = 1.0, anchorY = 0.0pins it to the top right corner, andpositionXandpositionYmove it in from there.lifetime = 0.0keeps it up until a later draw with the sametextIdreplaces it.- Each HUD checks once a second whether its stored character is still
there. It turns into
nullwhen the player leaves the server, and also some time after the player dies. The HUD then reportshud.goneand removes itself, and the central chip drops it fromhuds. The Clock is written insidehud.init, next to theownerit checks, but it runs from the moment the HUD appears.readystops a HUD whosehud.inithas not arrived yet, and whoseowneris empty too, from removing itself. - After a point, the central chip sends twice: the finished board to every
HUD on
board.top, and the new score to the scorer’s HUD alone onboard.mine. - Walking through the gate again sends your HUD the character you have now. After a respawn that is a new one. If your HUD already removed itself after the death, walking through spawns a new one.
Ten players have joined, and one of them scores. How many chips run the ranking loop? How many texts does each player’s screen gain?
Run
- Compile the chip and paste it in-game. Give a gateway an Attached
Zone and wire its Zone Ref to
join. - Place a Target. Wire its Just Hit to
pointand its Character That Just Hit toscorer. - Walk through the gateway. The top right of your screen shows
You: 0andNo scores yet. - Shoot the target. Your line reads 1, and the board lists you first.
- A second player walks through and shoots twice. Both screens change at
once: the board now has them first and you second, and each screen shows
its own
You:line.
Under the Hood
Where the work lives
The central chip is about 90 gates. About 55 of them are rank(): a For
Each Map loop, six Vars, and the tests and copies inside the loop.
Open a HUD (each one spawns 300 units above the central chip): about 43
gates, mostly Vars and the gates that set and read them, three
Personal Custom Event gates, the Clock that checks for a departed
player, and three Display Text gates, one for each place show() is
called (a mod’s gates are copied into every call).
To weigh a design, multiply each part by how many copies of it run. The central chip runs once. A HUD runs once per player. Ranking inside each HUD would mean every copy keeping its own maps of every score (a spawned copy has storage of its own and cannot read the central chip’s), its own loop, and its own copy of every point as it happens. Twenty players would carry twenty rankings, over a thousand gates, all working out the same answer.
So the central chip ranks once and sends the result. The board arrives as finished text, and the HUD only shows it. Building text is the one thing that costs measurable time each tick, which is one more reason to build it once. The HUD keeps only what is per-player: which screen to draw on and that player’s score. This is the split from One Chip per Player. The shared records stay in the central chip, each copy draws for its own player, and events carry everything between them. A text is redrawn only when its words change, and only for the players whose words changed.
One text per player
Each HUD draws one text. The You: line and the three board lines are
joined by <br> (Rich Text), and every redraw
uses textId = 7200, so it replaces the text already there. The scorer’s
HUD gets board.mine and board.top in the same tick and draws twice. The
second draw reads both new values, so the screen ends up right.
The game shows only about 32 texts on one player’s screen at once, and
every circuit that player sees shares that budget. Drawing the four lines
with four DisplayText calls would spend four of them. Past the limit, a
text silently never appears, and which one goes missing changes when you
change something unrelated. Count the texts on screen at one moment, not
the Display Text gates in the chip: a chip with many gates that each
draw now and then may show only a few at once.
Events: one gate, many channels
After a point, the chain sends on two channels, board.top and
board.mine. Both arrive on the next tick, together: different channels
do not wait for each other. One send on board.top reaches every HUD, and
each extra listener costs nothing.
The limit is on a single send gate firing again and again in one tick. Say you want to refresh every player’s own score at once:
@left in refresh: exec
var scores: Map<string, int>
var huds: Map<string, entity>
on refresh {
for id, hud in huds {
let s = scores.get(id)
hud.SendCustomEvent("board.mine", s.Value)
}
}
This compiles with no warning. Open it: the loop’s body holds one Send
Personal Custom Event gate, and the loop fires it once per HUD, all in
the same tick. The game caps one gate firing many times on one channel. It
logs Too many events at once! with the channel’s name and drops the rest,
and which sends get through is not fixed.
Separate gates on one channel are fine. Every HUD has its own hud.gone
send, so if many players leave in the same tick, every report arrives. That
gives three rules:
- Everyone needs the same thing: one send to all (
board.top). - One player’s value changed: one send to that player’s chip
(
board.mine). - Never put a send in a loop. Change the design so each send gate fires once per tick.
Modify
- Add a reset button. Give the central chip
@left in reset: exec. Its handler clearsscoresandnames, setsboardback to"No scores yet", and sendsboard.topand a newboard.resetonce each. Every HUD setsmineto 0 onboard.reset: one send, every player. - Show the gap to the leader:
You: 3 (leader 7). Send the leader’s score on its own channel,board.lead, right afterboard.top. Store it in the HUD and add it to the first line. - Show
Top 3!on the HUD of anyone on the board. Do not rank in the HUD: ship the third-place score on a channel of its own and let each HUD compare its own score with it. One comparison per copy is cheap, but a ranking per copy is not.
Make
Make a king of the hill for any number of players, with no admin gates.
Players join through a gateway as here. Each second, every player standing
in the hill zone scores a point. players.fillFromZonePlayers(hill) fills a
character[] with everyone standing in a zone
(Ready Check uses it). Every player sees their
own score and the top three in one text. Every player’s score can change
every second, and a loop of sends is capped, so find a shape where no send
gate fires more than once a tick. Check it with two players: both boards
agree, each player sees exactly one text from your circuit, and the game
log never says Too many events at once!.
zone1 "Zone Ref" -> chip join
zone2 "Zone Ref" -> chip hill
Good to Know
- A player’s screen shows about 32 texts at once, shared by every circuit they see. Past that, one text silently never appears, and nothing says which.
- One send gate fired many times in one tick is capped, and the rest are dropped. Separate send gates on one channel all deliver, in no fixed order.
- A spawned copy has storage of its own. It cannot read the central chip’s maps. Everything it knows came to it by event.
- A respawn makes a new character. Key everything by
GetUserId(), and send the HUD the new character when the player comes back. - A spawned copy keeps running until something removes it. Each HUD here
removes itself within a second of its player leaving the server, and a
little later after a death, once the old character turns into
null.
See per-entity fan-out and Measured costs in the reference.
Next: Writing Libraries.
Writing Libraries
Before this: Imports, Multiplayer Architecture
A library is a file of helpers written for other files to import. A good one works for more than one caller and leaves the decisions to the file that imports it. This lesson writes a small score library and a game that uses it.
Contents
chip "Arena"
button1 Held -> chip red
button1 Character -> chip redBy
button2 Held -> chip blue
button2 Character -> chip blueBy
chip sign -> decal Text
chip "Horn"
chip light -> light Enabled
chip sign -> decal Text
Code
The library, scores.ws:
// Helpers for keeping score in maps. No `on` handlers here:
// each entry file declares its own.
/// Reads `key` from `m`, or `fallback` when the key is missing.
mod getOr<K: int | string, V>(m: Map<K, V>, key: K, fallback: V) -> V {
let v = m.get(key)
return if v.Found then v.Value else fallback
}
/// Adds `by` to the number filed under `key`, starting a missing key at 0.
/// Returns the new total.
mod bump<K: int | string>(m: Map<K, int>, key: K, by: int = 1) -> int {
let total = getOr(m, key, 0) + by
m.set(key, total)
return total
}
/// Sends `scores.<name>` to every listening chip, carrying the values after `name`.
mod announce(name: const string, ...rest) {
SendCustomEvent("scores." .. name, ...rest)
}
The game that imports it, arena.ws:
import { getOr, bump, announce } from "scores"
@left in red: exec
@left in redBy: character
@left in blue: exec
@left in blueBy: character
var teams: Map<int, int>
var goals: Map<string, int>
var shown: string = "Red 0 - Blue 0"
@right out sign = shown.Value
mod goal(team: int, who: character) {
let total = bump(teams, team)
let mine = bump(goals, who.GetUserId())
shown = "Red ${getOr(teams, :red, 0)} - Blue ${getOr(teams, :blue, 0)}"
who.ShowChatMessage("Your goals: ${mine}")
announce("goal", total, mine)
}
on red {
goal(:red, redBy)
}
on blue {
goal(:blue, blueBy)
}
Four things are new:
- Type parameters.
<K: int | string>after a mod’s name saysKstands for a type the caller chooses, here eitherintorstring.bumpworks on aMap<int, int>and aMap<string, int>alike, andgetOradds a second parameter,V, for the kind of value stored. - Atoms.
:redand:blueare atoms: names that stand for fixed numbers.teamsis keyed by them, so the code reads as words while the map holdsints. - Variadic parameters.
...restinannouncecollects every argument aftername, however many there are, and...restin the body passes them all on.const stringmeansnamemust be text written in the code, so the channel name can be built at compile time. - Doc comments. A line starting
///describes the declaration under it. Editors show it when you point at a call.
bump is called with two different maps: one keyed by atoms, one by user
ids. How many copies of bump does the pasted chip hold? Does the game ever
see the word red?
Run
The Compile button on this page cannot see a file of yours, so build this one in the playground or VS Code, as in Imports:
- In the playground, press + under Files and name the new file
scores.ws. In VS Code, savescores.wsin the same folder asarena.ws. Paste the library into it and the game intoarena.ws. - Compile
arena.wsand paste the chip in-game. Place two Buttons and a Text Decal. - Wire one Button’s Held to
redand its Character toredBy, the other’s toblueandblueBy, andsignto the Text Decal’s Text. - Press the red button twice and the blue button once. The sign reads
Red 2 - Blue 1, and chat tells you your own goal count. - Point at
bumpinarena.ws: the editor shows the///lines from the library.
To see announce arrive, paste this chip too, with a Point Light and a
second Text Decal:
var lit: bool = false
var shown: string = "No goals yet"
@right out light = lit.Value
@right out sign = shown.Value
on CustomEvent("scores.goal") -> (teamTotal: int, mine: int) {
lit = !lit
shown = "Goal! Team total ${teamTotal}, scorer ${mine}"
}
Wire light to the Point Light’s Enabled and sign to the Text Decal’s
Text. Every goal toggles the light.
Under the Hood
What the chip holds
Open the chip. There is no library in it, only copies. goal is called
twice and calls bump twice, so there are four copies of bump, each a
Get Map Element, a Select, an Add and a Set Map Element. The
two that count a team have a number typed into their Key. The two that
count a player take their Key from a Get User Id. A
generic mod is built again at each call for the types used there, exactly
as if you had written a separate mod for each.
The atoms are those typed-in numbers. :red is a large number worked out
from its name, and the game sees only the number. Every file and every
compile works out the same number from the same name, so two chips built
separately agree on :red without sharing any code.
announce left one Send Personal Custom Event gate in each handler.
The channel scores.goal is typed into it, joined at compile time from
"scores." and "goal", and the two values after the name are wired into
Data In1 and Data In2.
bump calls getOr, and that works even in a file that imports only
bump: a library’s helpers can always call each other. The importing file
names only what it calls itself.
Bounds
K: int | string is a bound. Leave it off and K could be any value type,
bool among them. A map cannot be keyed by a bool, and the compiler
checks the body for every type K might be, not only the ones you call it
with:
@left in hit: exec
@left in scorer: character
var hits: Map<string, int>
mod bump<K>(m: Map<K, int>, key: K) -> int {
let v = m.get(key)
let total = if v.Found then v.Value + 1 else 1
m.set(key, total)
return total
}
on hit {
bump(hits, scorer.GetUserId())
}
Error: [WS039] map key type must be int, string, an object (entity/character/controller) or a brick, got bool
The bound tells callers what the helper accepts, and the compiler checks
every call against it. Generics lists the named
bounds (Scalar, Numeric, Variant).
Only a mod can have type parameters or ...rest. A fn compiles once,
to one plane with a fixed set of slots, so a generic or variadic fn is
WS114. Make a library helper a fn when it is large and called often
(sanitize in the sanitize library is one), and a mod when it must fit many types.
Modify
-
Add a third team. Give
arena.wsagreenand agreenBypin, a handler that callsgoal(:green, greenBy), and aGreencount on the sign. The library does not change, because it has no list of teams. -
Add a helper to the library with a doc comment:
/// The highest number in m, or 0 when it is empty.followed bymod best<K: int | string>(m: Map<K, int>) -> int. Loop over the map withfor k, v in mand keep the largest value in a localvar. Use it to addBest player: ${best(goals)} goalsto the chat message. -
See why handlers stay out of libraries. Add
var horn: bool = falseand a handleron CustomEvent("scores.goal") { horn = !horn }toscores.ws. Inarena.ws, addhornto the import and@right out light = horn.Value. Compile:WARN [WS014] `import { ... } from 'scores'` brings only the names it lists, so the 1 `on` handler(s)/anonymous chip(s) 'scores' installs are NOT included; write `import "scores"` to install them tooThe handler was left out, so the light would never change. Change the import to
import "scores"and the warning goes. Then move the handler back intoarena.ws: there, no way of importing the library can drop it.
Make
Write a library, cooldowns.ws, that other people could use without
reading its body. It holds a generic mod that takes a Map<K, int> of
the last time each key acted, a key, the current time from
GetUnixTime() (whole seconds), and a number of seconds, and answers whether the key may
act again (recording the time when it may). Give every helper a /// line
and keep the file free of on handlers. Then use it from two entry files:
a button that each player can press once every 3 seconds (keyed by user
id), and a door whose two sides share one 10-second cooldown (keyed by
atoms).
chip "Button"
chip2 "Door"
button1 Held -> chip press
button1 Character -> chip presser
chip sign -> decal Text
button2 Held -> chip2 front
button3 Held -> chip2 back
chip2 solid -> changer "Collision Enabled"
chip2 solid -> changer Visibility
Good to Know
- A pasted chip holds copies of the helpers it used. Two chips built from
one library share nothing in-game, not even a library
var. - Change the library, and chips already pasted stay as they were. Compile and paste each one again.
- An atom is a fixed number, and a fixed value passed to
SendCustomEventis typed into the gate instead of wired, so the receiver can read 0. Put an atom in avarbefore sending it. - Doc comments are mostly for editors. On a
chip, the///line also shows at the top of the chip’s plane in-game when it is opened.
See Generics, Atom Literals, Variadic Parameters, Doc Comments and Imports in the reference.
Next: pick a lesson from the tree on the Learn page.
Distance and Direction
Before this: Vectors and Colors, Reacting to the Game
Next steps: Dot and Cross
Every position in the world is a vector: three numbers, x, y and z.
Subtract two positions and you get the arrow from one to the other, with a
length (how far) and a direction (which way). This lesson builds a light that
brightens as a player walks toward it.
Contents
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip brightness -> light Brightness
chip readout -> decal Text
zone Occupied -> light Enabled
Code
const RANGE = 500.0
@left in room: zone
@left in occupied: bool
var visitor: character
var glow: float = 0.0
var shown: string = ""
@right out brightness = glow.Value
@right out readout = shown.Value
on ZoneEntered(zone = room) -> (character) {
visitor = character
}
on Clock(interval = 0.1, enabled = occupied) {
let here = GetOwnTransform().Location
let there = visitor.GetLocation()
let d = Distance(here, there)
glow = clamp(1.0 - d / RANGE, 0.0, 1.0) * 4.0
shown = "${round(d) as int} units away"
}
var visitor: character remembers the player who last walked into the zone.
Ten times a second, while someone is inside, the Clock handler measures:
GetOwnTransform().Locationis where the chip’s own brick is.visitor.GetLocation()is where the player is.Distance(here, there)is how far apart the two points are, in the game’s units.
d / RANGE is 0 at the chip and 1 at 500 units away, so 1.0 - d / RANGE
runs the other way: 1 up close, 0 at the edge. clamp(..., 0.0, 1.0) keeps it
from going below 0 farther out, and * 4.0 turns it into a brightness from 0
to 4. round(d) rounds to the nearest whole number and as int makes it an
int, so the sign shows no decimals.
A player stands 125 units from the chip. How bright is the light? And at 600 units?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone big enough to walk around in, and put the chip inside it. Place a Point Light and a brick with a Text Decal near the chip.
- Wire the Attached Zone’s Zone Ref to
room, and its Occupied tooccupiedand to the Point Light’s Enabled. - Wire
brightnessto the Point Light’s Brightness, andreadoutto the Text Decal’s Text. - Walk into the zone and toward the chip. If you were already inside when
you pasted the chip, step out and back in:
visitoris set when someone enters. The light grows brighter and the sign counts down. At 125 units the brightness is 3, at 250 it is 2, and from 500 units out it is 0. Leave the zone and the light goes off.
Under the Hood
Open the chip. The Clock starts a chain: Get Own Location and
Rotation for the chip’s position, a Get Variable for visitor, and
Get Location of Entity for the player. One Distance gate takes the two
positions. After it come a Divide, a Subtract, a Clamp and a
Multiply for the brightness, a Round, a Convert and a Format
String for the sign, and two Set Variable gates.
A distance is a number and a position is a vector, and the two cannot be compared. Asking whether the arrow between them is short fails to compile:
@left in here: vector
@left in there: vector
@right out near = there - here < 500.0
Error: [WS004] no overload for '<' on vector, float
Measure the arrow first, with Magnitude or Distance, and compare the
number.
Arrows between points
there - here subtracts part by part and gives the arrow from here to
there. With here at Vec(0.0, 0.0, 0.0) and there at
Vec(300.0, 400.0, 0.0), the arrow is Vec(300.0, 400.0, 0.0).
Magnitude(there - here)is the arrow’s length: 500.Distance(here, there)is the same number in one gate.Normalize(there - here)keeps the direction and makes the length 1:Vec(0.6, 0.8, 0.0). A length-1 vector is a pure direction.- A direction times a distance gives the arrow back:
Vec(0.6, 0.8, 0.0) * 500.0isVec(300.0, 400.0, 0.0). Sohere + Normalize(there - here) * 100.0is the point 100 units fromhereon the way tothere.
These also work as methods: (there - here).Magnitude(),
here.Distance(there). DistanceSq and MagnitudeSq give the same values
squared.
Modify
- Show which way the chip lies from the player. Add a second line to the sign:
shown = "${round(d) as int} units away<br>${Normalize(here - there)}".<br>starts a new line, and a vector shows asX=... Y=... Z=.... - Add a door that opens only within 200 units. Add
const CLOSE = 200.0,var near: bool = falseand@right out open = near.Value, and in the handlernear = Distance(here, there) < CLOSE. - Measure along the floor only, so a player on a balcony above the chip
reads as close as one standing beside it. Flatten both points first:
Distance(Vec(here.x, here.y, 0.0), Vec(there.x, there.y, 0.0)).
Make
Make a “warmer, colder” sign for a hidden prize. The chip is the prize. Each
sample, compare the distance with the one from the sample before (keep it in
a var) and show Warmer when the player got closer, Colder when they
moved away, and Found it! within 50 units.
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip readout -> decal Text
Good to Know
- Distances are in the game’s units (Unreal units). A 1x1 brick is 10 units wide, so 500 units is 50 studs.
- A player’s location is a single point on their body, so standing right on the chip still reads a small distance.
visitoris whoever walked in last. With two players in the zone, the light follows the newer one. Reading every player in the zone takes a loop: Looping Over a List.Normalizeof a zero-length vector has no direction to keep. Check the distance before you use a direction between two points that may meet.
See Vector in the reference.
Next: Dot and Cross.
Dot and Cross
Before this: Distance and Direction
Next steps: Angles
Two directions can point the same way, at right angles, or opposite ways.
Dot turns that into one number, and Cross tells left from right. This
lesson builds a light that switches on when a player looks at it.
Contents
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip lit -> light Enabled
Code
const WITHIN = 0.985
@left in room: zone
@left in occupied: bool
var visitor: character
var seen: bool = false
@right out lit = seen.Value
on ZoneEntered(zone = room) -> (character) {
visitor = character
}
on Clock(interval = 0.1, enabled = occupied) {
let target = GetOwnTransform().Location
let aim = visitor.GetAim()
let toTarget = Normalize(target - aim.Origin)
seen = Dot(aim.Direction, toTarget) > WITHIN
}
visitor.GetAim() gives two vectors: Origin, the point the player looks
from, and Direction, the way they look, with length 1. toTarget is the
direction from the player’s eyes to the chip, also length 1, from the last
lesson’s Normalize.
Dot(a, b) compares the two directions. It is 1 when they point the same
way, 0 at right angles and -1 when opposite. Above 0.985 the player is looking
within about 10 degrees of the chip.
The player looks straight at the chip, then turns to face away. What does the dot product read in each case? Does stepping back change it?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone, put the chip inside it, and place a Point Light next to the chip.
- Wire the Attached Zone’s Zone Ref to
roomand its Occupied tooccupied. Wirelitto the Point Light’s Enabled. - Walk into the zone and look around. The light comes on while you look at the chip and goes off when you look away. Distance does not matter: from across the zone, looking at the chip still lights it.
Under the Hood
Open the chip. The Clock’s chain runs Get Own Location and Rotation, a
Get Variable for visitor, and one Get Aim gate, whose Origin and
Direction outputs both come from that one gate. Then a Subtract makes
the arrow to the chip, Normalize shortens it to length 1, a Dot
Product compares it with the aim, and a Greater checks the threshold
before the Set Variable.
What a dot product measures
Dot(a, b) multiplies the vectors part by part and adds the results:
a.x * b.x + a.y * b.y + a.z * b.z. For two length-1 directions, that sum
is the cosine of the angle between them:
| Angle between | Dot product |
|---|---|
| 0 degrees | 1 |
| 10 degrees | 0.985 |
| 30 degrees | 0.866 |
| 60 degrees | 0.5 |
| 90 degrees | 0 |
| 180 degrees | -1 |
A threshold picks a cone around the target: > 0.866 is anywhere within 30
degrees, > 0 is anywhere in front, < 0 is behind.
The cosine only holds when both vectors have length 1. Longer vectors scale
the answer: Dot(Vec(3.0, 0.0, 0.0), Vec(2.0, 0.0, 0.0)) is 6, not 1. That is
why toTarget is normalized.
Which side: Cross
Cross(a, b) gives a third vector at right angles to both. Cross the aim with
the direction to the target and its z part says which way to turn:
@left in looking: vector
@left in toTarget: vector
let side = Cross(looking, toTarget).z
@right out hint = if side > 0.0 then "turn right" else "turn left"
if ... then ... else picks one of two values. Looking along x, a target
along y gives Cross(Vec(1.0, 0.0, 0.0), Vec(0.0, 1.0, 0.0)), which is
Vec(0.0, 0.0, 1.0): z is positive. Swap the target to the other side,
Vec(0.0, -1.0, 0.0), and z is -1. In the game’s axes, z points up and y
lies to the right of x, so a positive z means the target is on the right.
Modify
- Widen the cone to 30 degrees:
const WITHIN = 0.866. Then try0.0: the light is on whenever the chip is anywhere in front of the player. - Watch the number. Add
var shown: string = ""and@right out readout = shown.Value, and in the handlershown = "${Dot(aim.Direction, toTarget)}". Wirereadoutto a Text Decal’s Text and turn on the spot: it runs from 1 down to -1 and back. - Give the player a hint. Compute
let side = Cross(aim.Direction, toTarget).zin the handler, and set the sign to"on it"whenseenwould be true, else"turn right"or"turn left"fromside.
Make
Make a “don’t look” light: it glows while the visitor’s back is turned to the chip and switches off the moment they turn to look. Add a sign that counts how many times they looked.
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip lit -> light Enabled
chip sign -> decal Text
Good to Know
a * bon two vectors multiplies part by part and gives a vector. It is not the dot product. UseDot(a, b)ora.Dot(b)for that.- The dot product ignores distance only because both directions are
normalized. Compare positions with
Distanceand directions withDot. Cross(a, b)andCross(b, a)point opposite ways. Keep the aim first and the target second, or left and right swap.- Looking straight up or down, the cross product’s
zis close to 0, and the left-right hint flickers. Which way to turn is a question about the floor plan, and the next lesson measures it as an angle.
See Vector and GetAim in the reference.
Next: Angles.
Angles
Before this: Dot and Cross
Next steps: Rotations
A direction vector says which way something points. People think in angles
instead: turn 90 degrees, look up 30. This lesson turns a direction into a
heading and a tilt with atan2, builds a rotator from them, and goes back
from angles to a direction with cos and sin. The project is a compass sign
that shows which way a player is looking.
Contents
- Code
- Run
- Under the Hood
- Angles into a rotator
- From angles back to a direction
- Modify
- Make
- Good to Know
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip readout -> decal Text
Code
@left in room: zone
@left in occupied: bool
var visitor: character
var shown: string = ""
@right out readout = shown.Value
on ZoneEntered(zone = room) -> (character) {
visitor = character
}
on Clock(interval = 0.1, enabled = occupied) {
let d = visitor.GetAim().Direction
let yaw = Rad2Deg(atan2(d.y, d.x))
let flat = sqrt(d.x * d.x + d.y * d.y)
let pitch = Rad2Deg(atan2(d.z, flat))
shown = "Heading ${round(yaw) as int}<br>Looking up ${round(pitch) as int}"
}
d is the way the player looks, a vector of length 1. Two angles describe it:
- Yaw is the heading on the floor plan, ignoring up and down: the angle
from the x axis around toward the y axis.
atan2(d.y, d.x)measures it. - Pitch is the tilt above or below level.
flatis the length of the direction’s shadow on the floor, andatan2(d.z, flat)is the angle between that shadow and the direction itself.
The trig gates work in radians, where half a turn is pi (about 3.14).
Rad2Deg turns radians into degrees. <br> starts a new line on the sign.
A player looks along the y axis, level. What does the sign read? And looking straight down at their feet?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone, put the chip inside it, and place a brick with a Text Decal where you can read it.
- Wire the Attached Zone’s Zone Ref to
roomand its Occupied tooccupied. Wirereadoutto the Text Decal’s Text. - Walk into the zone and turn on the spot. The heading runs through 0 (looking along x), 90 (along y), 180 or -180 (back along x) and -90. Look up and down: the second line runs from 90 (straight up) to -90 (straight down).
Under the Hood
Open the chip. After Get Aim, a Split Vector takes the direction
apart into x, y and z. Yaw is one Atan2 and a Radians To Degrees.
Pitch adds two Multiply gates, an Add and a Sqrt for flat, then
its own Atan2 and Radians To Degrees. Two Round and two
Convert gates feed the Format String.
Why atan2
atan2(y, x) takes the two parts of a direction separately. The older
one-number atan takes their ratio, atan(y / x), and the ratio throws
information away:
| Direction (x, y) | atan(y / x) in degrees | atan2(y, x) in degrees |
|---|---|---|
| (3, 4) | 53.1 | 53.1 |
| (-1, 2) | -63.4 | 116.6 |
| (1, -2) | -63.4 | -63.4 |
| (-1, 0) | 0 | 180 |
| (0, 1) | divides by zero | 90 |
| (0, -1) | divides by zero | -90 |
(-1, 2) and (1, -2) point opposite ways, but their ratio is the same -2,
so atan gives them the same answer. Straight along y, y / x divides
by zero, which a Divide gate answers with 0, so atan reads 0 degrees for a
direction that is really 90. atan2 uses the signs of both parts and gives
the true heading all the way round, from -180 to 180. It takes y first,
then x.
Which unit where
| Takes or gives radians | Takes or gives degrees |
|---|---|
sin cos tan asin acos atan atan2 | Rotation(pitch, yaw, roll), .pitch .yaw .roll, .ToEuler() |
Deg2Rad(180.0) is pi and Rad2Deg goes back. Mixing them up is not an
error, only a wrong angle, so convert at the edge between the two columns.
The 0.985 threshold in Dot and Cross is the cosine of 10
degrees: let WITHIN = cos(Deg2Rad(10.0)) at the top of a chip works it out
when the chip compiles, to 0.98481.
Angles into a rotator
A rotator holds three angles in degrees: pitch, yaw and roll. Roll is a
twist around the direction itself, like tilting your head. A direction has no
twist, so a rotator made from one gets roll 0:
@left in pressed: exec
var turns: int = 0
let facing = Rotation(-30.0, turns.Value * 45.0, 0.0)
let parts = facing.ToEuler()
@right out aim = facing
@right out text = "pitch ${facing.pitch}, yaw ${parts.Yaw}, roll ${parts.Roll}"
on pressed {
turns = (turns + 1) % 8
}
Rotation(pitch, yaw, roll) takes pitch first, yaw second. Writing
Rotation(yaw, pitch, 0.0) by habit tilts instead of turning, and the
compiler cannot tell. Each press here turns the yaw 45 degrees further while
the pitch stays 30 degrees down.
There are two ways to read the angles back. .pitch, .yaw and .roll read
one each. .ToEuler() gives a record with Pitch, Yaw and Roll fields.
Each read goes through its own Split Rotation gate.
From angles back to a direction
The way back uses cos and sin. Pitch splits the length-1 direction into
a flat part, cos(pitch), and a height, sin(pitch). Yaw then splits the
flat part between x and y:
const YAW = 45.0
const PITCH = 30.0
let y = Deg2Rad(YAW)
let p = Deg2Rad(PITCH)
@right out dir = Vec(cos(p) * cos(y), cos(p) * sin(y), sin(p))
Everything here is constant, so the compiler works it out:
Vec(0.612, 0.612, 0.5), a direction halfway between x and y and 30 degrees
up, with length 1. Run the compass math on it and you get yaw 45 and pitch 30
back. A yaw of 90 with pitch 0 gives Vec(0.0, 1.0, 0.0), straight along y.
Rotation(PITCH, YAW, 0.0).ToDirection() gets the same direction from the
game’s Quaternion to Direction gate. The next lesson uses it.
Modify
- Show the heading from 0 to 360 instead of -180 to 180:
let heading = fmod(yaw, 360.0).fmodwraps negative angles round:fmod(-90.0, 360.0)is 270. The%operator keeps the sign instead, so-90.0 % 360.0stays -90. - Make a flashlight that points where you look. Add
var look: rotator = Rotation(0.0, 0.0, 0.0)and@right out beam = look.Value, and in the handlerlook = Rotation(pitch, yaw, 0.0). Place a Spot Light without turning its brick, and wirebeamto its Rotation and a Switch to its Enabled. - Add compass letters. Call the x axis north: turning from x toward y is
turning right, so the heading from step 1 works like a compass bearing,
with 90 as east. Show
N,E,SorWfor the nearest of the four.
Make
Make a sign on the chip that says where the visitor stands, seen from the
chip: their bearing, how far above or below the chip they are as an angle,
and how far away, such as Visitor at 120, 15 up, 340 units. Use the arrow
from the chip to the player instead of the aim.
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip readout -> decal Text
Good to Know
- Trig is in radians and rotators are in degrees. A degree value fed to
singives a wrong answer with no warning. atan2(0.0, 0.0)is 0. Looking straight up or down,flatis nearly 0, so the heading swings wildly at the slightest movement.- Yaw jumps from 180 to -180 as you turn past it. Both name the same heading.
Compare headings after
fmod, or compare directions withDotinstead. - A direction cannot carry a roll. Roll only comes from somewhere that has one, such as another rotator.
See Math / Trigonometry and Rotation / Quaternion in the reference.
Next: Rotations.
Rotations
Before this: Angles, Looping Over a List
The last lesson built a rotator from angles by hand. The game has gates that
work with whole rotations instead: point along a direction, turn a vector,
undo a turn. Most of them use a second rotation type, the quat. This lesson
builds a spotlight that tracks the nearest player in a zone.
Contents
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip aim -> light Rotation
switch Enabled -> light Enabled
Code
@left in room: zone
@left in occupied: bool
var players: character[]
var facing: rotator = Rotation(0.0, 0.0, 0.0)
@right out aim = facing.Value
on Clock(interval = 0.1, enabled = occupied) {
players.fillFromZonePlayers(room)
let here = GetOwnTransform().Location
var nearest: float = -1.0
var target: vector = here
for p in players {
let spot = p.GetLocation()
let d = Distance(here, spot)
if nearest < 0.0 || d < nearest {
nearest = d
target = spot
}
}
if nearest >= 0.0 {
facing = (target - here).ToRotation()
}
}
Ten times a second while someone is in the zone,
players.fillFromZonePlayers(room) fills the list with everyone standing in
it. The loop keeps the closest one, comparing their distances. nearest
starts at -1, so if the list comes back empty it stays there and the light
keeps its last aim.
(target - here).ToRotation() is the whole of the last lesson’s compass math
in one gate: it turns the arrow toward the player into a rotation that points
along it. The answer is a quat, and storing it in the rotator variable
converts it.
Two players stand in the zone, one 300 units away and one 400. Which one does the light follow? What happens when the near one leaves?
Run
- Compile the chip and paste it in-game. Give a brick an Attached Zone covering the area to watch. Place a Spot Light without turning its brick, and put the chip right next to it: the chip measures from its own position, and the beam starts at the light.
- Wire the Attached Zone’s Zone Ref to
roomand its Occupied tooccupied. Wireaimto the Spot Light’s Rotation, and a Switch to its Enabled. - Walk into the zone. The beam follows you. Bring a friend: it switches to whoever is closer, and stays where it last pointed when the zone empties.
Under the Hood
Open the chip. The Clock’s chain runs Get Players In Zone, Get Own
Location and Rotation, and two Set Variable gates for the loop’s
starting values. The For Each Array Entry chip holds a Get Location of
Entity, a Distance, and the Less, OR and Branch
gates of the comparison. After the loop, a Greater/Equal and a
Branch check that someone was found, then a Subtract, one Direction
to Quaternion, and the Set Variable for facing. No gate sits between
the quaternion and the rotator variable: the wire carries either kind.
A quat holds no angles. Its parts are four numbers, .x .y .z .w,
that only mean something together, so asking it for a pitch fails:
@left in dir: vector
@right out tilt = dir.ToRotation().pitch
Error: [WS010] no field `pitch` on quat
dir.ToRotation().ToEuler().Pitch converts first and reads the angle.
Rotator or quat
Both types hold a rotation, and each converts to the other with no gate: pass a rotator to a quat method, or store a quat in a rotator.
Use a rotator for | Use a quat for |
|---|---|
| angles you read or write as numbers | pointing along a direction: dir.ToRotation() |
| pins that take a rotation, such as a Spot Light’s Rotation | turning a vector: v.Rotate(q) |
Rotation(pitch, yaw, roll) and .pitch .yaw .roll | undoing a turn: q.Invert() |
the turn between two directions: a.RotationTo(b) |
The quat gates work on rotators too, so facing.Value.ToDirection() is fine.
Turning vectors
v.Rotate(q) turns the vector v by the rotation q. q.ToDirection() is
the way q points, length 1. q.Invert() is the turn that undoes q.
@left in turret: rotator
@left in offset: vector
let local = offset.Rotate(turret.Invert())
@right out muzzle = Vec(100.0, 0.0, 0.0).Rotate(turret)
@right out inFront = local.x > 0.0
@right out onRight = local.y > 0.0
Vec(100.0, 0.0, 0.0)is 100 units along x, the way a rotation of zero points. Turned byturret, it is 100 units along the turret’s aim: the same asturret.ToDirection() * 100.0.offsetis an arrow in world directions, such as from the turret to a player. Turning it by the inverse undoes the turret’s turn and gives the arrow in the turret’s own directions:xahead,yto its right,zup. A positivelocal.xmeans the player is in front of the turret.
a.RotationTo(b) is the turn that carries direction a onto direction b,
so a.Rotate(a.RotationTo(b)) points along b.
q.ToAxisAngle().Angle is how far a turn goes, in degrees, and
a.AngleTo(b) is the angle between two rotations, also in degrees.
Modify
- Watch the turn. Add
var shown: string = ""and@right out readout = shown.Value. In theif, computelet wanted = (target - here).ToRotation(), setshown = "Turned ${round(facing.AngleTo(wanted)) as int} degrees", and thenfacing = wanted. Walk sideways across the beam and the number grows. - Mount the light on a wall, so it should never aim behind itself. Read the
chip’s own rotation with
let own = GetOwnTransform()(thenown.Locationandown.Rotation), and in the loop skip players behind the chip: computelet local = (spot - here).Rotate(own.Rotation.Invert())and addlocal.x > 0.0 &&to theif. Turn the chip and see which side it guards. - Show where the beam lands 300 units out: add
here + facing.Value.ToDirection() * 300.0to the sign. Stand still and compare it with your own position.
Make
Make a security camera. When the zone is empty, the Spot Light sweeps slowly
left and right, a few degrees of yaw per Clock tick. When a player comes
within 30 degrees of where it points (use Dot from
Dot and Cross), it locks on and follows them, and it goes
back to sweeping when they leave.
zone "Zone Ref" -> chip room
zone Occupied -> chip occupied
chip aim -> light Rotation
switch Enabled -> light Enabled
Good to Know
- The beam snaps to each new aim. For a light that swings smoothly, see
Blending Values and
Over Time:
BlendandTweentake rotations too. a * bon two rotations combines the two turns into one. See Arithmetic Operators.axis.RotationByAngle(angle)is a turn byanglearoundaxis, such asVec(0.0, 0.0, 1.0).RotationByAngle(90.0)for a quarter turn about the up axis. The game’s gate takes the angle in degrees.- A player’s location is a point on their body, so the beam aims at their middle, not their feet.
ToRotationon a zero-length arrow has no direction to point along. If a player can stand exactly on the chip, check the distance before aiming.
See Rotation / Quaternion and Zone array fills in the reference.
Next: pick a lesson from the tree on the Learn page.
Blending Values
Before this: Numbers, Vectors and Colors
Next steps: Easing
A blend picks a value part way between two others, such as a color between
blue and red. This lesson fades a light with Blend and turns one range of
numbers into another with Remap.
Contents
button Held -> chip pressed
chip lamp -> light Color
chip text -> decal Text
switch Enabled -> light Enabled
Code
@left in pressed: exec
var presses: int = 0
let t = presses.Value / 10.0
@right out lamp = Blend(Color(0.0, 0.2, 1.0), Color(1.0, 0.1, 0.0), t, clampAlpha = true)
@right out text = "${presses.Value} presses"
on pressed {
presses += 1
}
Blend(a, b, t) mixes a and b. t says how far along: 0.0 gives a,
1.0 gives b, and 0.5 gives the value halfway. It works channel by channel:
each channel is a + (b - a) * t.
presses counts the button’s presses, the way
Remembering Values does, and presses.Value / 10.0 turns it
into t: a tenth more blue-to-red for each press. clampAlpha = true
stops t at 1.0, so after ten presses the light stays red.
After five presses, what are the light’s red, green and blue amounts? What color is it after fifteen?
Run
- Compile the chip and paste it in-game. Place a Button, a Point Light and a brick with a Text Decal.
- Turn off the Point Light’s Use Brick Color: while it is on, the light takes the brick’s color and ignores the wire.
- Wire the Button’s Held to
pressed,lampto the Point Light’s Color, andtextto the Text Decal’s Text. Give the light’s Enabled a Switch, flipped on. - Press the button. The light starts blue and moves toward red, one step per press. At five presses it is purple: red 0.5, green 0.15, blue 0.5. From ten presses on it stays red.
Under the Hood
Open the chip. Past the counter’s Var and its add, there is a
Divide for t and one Blend gate, with the two colors typed into its
Input A and Input B and t wired to its Blend input. Clamp Alpha is ticked in the Blend gate’s settings.
lerp(a, b, t) is the same Blend gate without the setting. lerp is the
name most other languages use, and Blend adds clampAlpha.
The setting is part of the gate, chosen when the chip is built, so it takes only a constant. A value worked out while the circuit runs is an error:
@left in presses: int
@left in stop: bool
@right out lamp = Blend(Color(0.0, 0.2, 1.0), Color(1.0, 0.1, 0.0), presses / 10.0, clampAlpha = stop)
Error: [WS028] 'clampAlpha' is constant-only gate config and cannot take a variable or computed value
Other values blend too
Blend and lerp take two numbers, two vectors, two colors, two rotators or
two quaternions, and give back the same kind. On two points, t = 0.5 is
the point halfway between them:
@left in pressed: exec
var presses: int = 0
let start = Vec(0.0, 0.0, 0.0)
let finish = Vec(100.0, 0.0, 50.0)
let spot = lerp(start, finish, presses.Value / 4.0)
@right out text = spot as string
on pressed {
presses += 1
}
Two presses make t 0.5, and the sign shows X=50.000 Y=0.000 Z=25.000.
Four presses reach finish. With no clampAlpha, six presses carry on past
it to X=150.000 Y=0.000 Z=75.000.
On two rotations, the blend turns the shortest way round. Rotations covers rotators and quaternions.
From one range to another
Remap moves a number from one range to the same place in another. A Rigid
Slider reports how far along it is, and Remap turns that into a brightness:
@left in up: bool
@left in position: float
@right out target: float = if up then 100.0 else 0.0
@right out brightness = Remap(position, 0.0, 100.0, 0.0, 4.0)
Remap(value, inMin, inMax, outMin, outMax) finds where value sits between
inMin and inMax, then gives the number at the same place between
outMin and outMax. A position of 25 is a quarter of the way from 0 to
100, so the brightness is a quarter of the way from 0 to 4: 1.0. It is a
lerp that works out its own t:
lerp(outMin, outMax, (value - inMin) / (inMax - inMin)).
if up then 100.0 else 0.0 picks 100 while the switch is on and 0 while it
is off (Choosing a Value).
- Place a Rigid Slider, a short platform welded to its moving end, a Switch and a Point Light. In the slider’s settings, turn on Positions Are Percentages.
- Wire the Switch’s Enabled to
up,targetto the slider’s Target Position, and the slider’s Current Position toposition. Wirebrightnessto the Point Light’s Brightness. - Flip the switch. The platform slides out, and the light brightens as it goes. Flip it back: the light dims as the platform returns.
Modify
- Make the light dim as the platform slides out: swap the output range,
Remap(position, 0.0, 100.0, 4.0, 0.0). - Color the light by the slider’s position too. Add
@right out lamp = Blend(Color(0.0, 0.2, 1.0), Color(1.0, 0.1, 0.0), position / 100.0, clampAlpha = true)to the Remap chip and wire it to the light’s Color. - Make the fade in the first chip start over.
(presses.Value % 11) / 10.0counts 0.0 to 1.0 in tenths and then goes back to 0.0.
Make
Make a heat lamp with three colors. A counted button runs it from green
through yellow to red over twenty presses: green to yellow for the
first ten, yellow to red for the next ten. Blend twice, and choose between
the two blends with if presses.Value < 10 then ... else ....
button Held -> chip pressed
chip lamp -> light Color
switch Enabled -> light Enabled
Good to Know
tmust be afloat.presses.Value / 10is anintdivided by anint, which drops the fraction: it is 0 until the tenth press and then jumps (Numbers). The compiler accepts it without a warning. Write10.0.Remapdoes not stop at the ends: a value pastinMaxgives a number pastoutMax. Wrap it inclamp(...)when the input can overshoot.Blendon two colors mixes the channel numbers.a.ColorBlend(b, t)mixes colors in a color space of your choice (blendSpace = Oklab), which can look more even to the eye.- A Point Light’s Use Brick Color overrides a wired Color. Turn it off whenever a wire sets the color.
See Math, Color, Value conversions and Gate config properties in the reference.
Next: Easing.
Easing
Before this: Blending Values, Reacting to the Game
Next steps: Over Time
A blend moves at one steady speed. Real things rarely do: a door picks up speed and slows down again before it stops, and a ball lands and bounces. An easing curve bends the progress of a blend so its speed changes on the way. This lesson plays three curves side by side.
Contents
button Held -> chip go
chip linear -> light1 Brightness
chip quad -> light2 Brightness
chip bounce -> light3 Brightness
switch1 Enabled -> light1 Enabled
switch2 Enabled -> light2 Enabled
switch3 Enabled -> light3 Enabled
Code
const FRAMES = 30 // steps in one run: 30 steps of 0.05 seconds is 1.5 seconds
@left in go: exec
var frame: int = FRAMES
let t = frame.Value / (FRAMES as float)
@right out linear = Easing(0.0, 4.0, t)
@right out quad = Easing(0.0, 4.0, t, function = Quad, direction = In)
@right out bounce = Easing(0.0, 4.0, t, function = Bounce, direction = Out)
on go {
frame = 0
}
on Clock(interval = 0.05, enabled = frame.Value < FRAMES) {
frame += 1
}
t is the progress: 0.0 at the start of the run and 1.0 at the end. The
chip drives it itself. A press sets frame back to 0, and the Clock counts
it up by one every 0.05 seconds until it reaches 30. t divides frame by
FRAMES as float: as float turns the whole number 30 into 30.0, so the
division keeps its fraction.
Easing(a, b, t) is a blend from a to b like Blend, but first it bends
t along a curve. function picks the curve and direction picks which
end it shapes:
Lineardoes not bend: the same aslerp. It is the default.QuadwithInsquares the progress, so it starts slowly and speeds up.BouncewithOutrushes to the end and bounces there, like a dropped ball.
Halfway through a run, t is 0.5. How bright is each light?
Run
- Compile the chip and paste it in-game. Place a Button and three Point Lights in a row, each with its Enabled on a Switch that is flipped on.
- Wire the Button’s Held to
go, andlinear,quadandbounceto the three lights’ Brightness. - At load all three are at full:
framestarts at 30, the end of a run. - Press the button. All three go dark and climb back over a second and a half. The first climbs steadily. The second hardly moves at first and catches up at the end. The third shoots up, then dips and rises a few times before it settles.
Halfway, the first light is at 2.0. The second is at 1.0: a quarter of the way, since 0.5 squared is 0.25. The third is at about 3.06: Bounce Out is already past three quarters of the way.
Under the Hood
Open the chip. The Clock’s Enabled is wired from a Less gate
comparing frame with 30, so it stops by itself at the end of a run. A
Divide turns frame into t, and t feeds three Easing gates.
Each Easing gate has its curve in its settings: Function and
Direction. They are the game enums EasingFunction and
EasingDirection (Game Enums shows the full lists
and the numbers the game saves). Like clampAlpha on a Blend, they are
fixed when the chip is built, so they take only a constant. A variable here
is the WS028 error from Blending Values, and so is a
name that is not on the list:
@left in t: float
@right out level = Easing(0.0, 4.0, t, function = Quad, direction = Both)
Error: [WS028] unknown enum member 'Both' for EBREasingDirection; expected one of: In, Out, InOut
Directions
In puts the curve’s shape at the start, Out at the end, and InOut at
both ends, meeting in the middle. The table gives the eased values of a few
curves at three points of progress, for a blend from 0 to 1.
| Curve | t = 0.25 | t = 0.5 | t = 0.75 |
|---|---|---|---|
Linear | 0.25 | 0.5 | 0.75 |
Quad, In | 0.0625 | 0.25 | 0.5625 |
Quad, Out | 0.4375 | 0.75 | 0.9375 |
Quad, InOut | 0.125 | 0.5 | 0.875 |
Bounce, Out | about 0.47 | about 0.77 | about 0.97 |
Quad Out is Quad In turned around: the slow part moves to the end.
Its value at 0.25 is 1 minus In’s value at 0.75. InOut is slow at both
ends and quick in the middle.
Sine, Cubic, Quart, Quint, Expo and Circ are the same idea,
from gentle to sharp. Back and Elastic go past the end and come back, and
Bounce bounces.
Modify
-
Change the second light to
direction = InOut. It now starts slowly and ends slowly too. -
Ease a color.
Easingtakes the same kinds of value asBlend. Add@right out lamp = Easing(Color(0.0, 0.2, 1.0), Color(1.0, 0.1, 0.0), t, function = Elastic, direction = Out)and wire it to a fourth light’s Color (with Use Brick Color off). The color swings past red, then wobbles back. -
Drive the progress from the time instead of a counter.
ServerUptime()is the server’s running time in seconds, and the time since a press is the uptime now minus the uptime at the press:const SECONDS = 1.5 // how long one run takes @left in go: exec var started: float = -10.0 let t = clamp((ServerUptime() - started.Value) / SECONDS, 0.0, 1.0) @right out level = Easing(0.0, 4.0, t, function = Quad, direction = Out) on go { started = ServerUptime() }No Clock: the subtraction follows the time on its own.
clampholdstat 1.0 once the run is over.
Make
Make a light that breathes. It rises from dark to bright over a second, falls
back over a second, and repeats forever. Count frame from 0 to 39 on a
0.05-second Clock and wrap it back to 0, turn the first half of the count
into a rising t and the second half into a falling one, and ease both with
Sine and InOut.
chip glow -> light Brightness
switch Enabled -> light Enabled
Good to Know
- The progress must be a
floatfrom 0.0 to 1.0.frame.Value / FRAMESdivides twoints and is 0 until the very end, and the compiler accepts it without a warning. Turn one side into afloatfirst, asFRAMES as floatdoes in the chip. - Keep the progress between 0 and 1 with
clamp. A curve is only shaped for that range. ServerUptime()starts again from 0 when the server restarts, but a storedstartedsurvives. After a restart the stored time can be ahead of the uptime, andtstays at 0 until the uptime catches up or someone presses again. Use it for short runs, not for stored deadlines.GetUnixTime()counts whole seconds, too coarse to animate with.- A Clock-driven run moves in steps: twenty a second here. A shorter
interval is smoother. The next lesson’s
Tweenmoves every tick by itself. Remaphas the same curves. Set them with the gate’s own setting names:Remap(position, 0.0, 100.0, 0.0, 4.0, Function = Quad, Direction = Out).
See Misc and Gate config properties in the reference.
Next: Over Time.
Over Time
Before this: Easing, Seat Controls
The last lesson drove the progress by hand, with a counter on a Clock. Two gates keep the time themselves. A Tween glides to a new target over a set time along an easing curve. A Dampen follows a target that keeps moving, and smooths away its jumps. This lesson builds a door with one and a steady seat control with the other.
Contents
switch Enabled -> chip wanted
chip position -> mover "Target Position"
chip sign -> decal Text
Code
const OPEN_AT = 100.0 // slider position with the door open, in percent
const SECONDS = 1.5 // how long one move takes
@left in wanted: bool
var status: string = "Shut"
let door = Tween(if wanted then OPEN_AT else 0.0, SECONDS, function = Cubic, direction = InOut)
@right out position: float = door
@right out sign = status.Value
on wanted {
status = "Opening"
}
on !wanted {
status = "Closing"
}
on door.Arrived {
status = if wanted then "Open" else "Shut"
}
Tween(target, duration) is a value that moves to target over duration
seconds. Change the target and it sets off again. function and direction
are the curves of Easing: Cubic InOut starts gently and
stops gently, the way a heavy door moves.
A Tween gives two things. door on its own reads its Value, the moving
number. door.Arrived is an exec that fires when it reaches the target, so
on door.Arrived runs once per finished move. on !wanted runs when the
switch turns off.
Flip the switch on. What does the sign say during the move, and after it? What should happen if you flip it back halfway?
Run
- Compile the chip and paste it in-game. Build a door: a Rigid Slider with the door welded to its moving end, so it slides sideways out of the doorway. Place a Switch and a brick with a Text Decal.
- In the slider’s settings, turn on Positions Are Percentages. The slider has its own Smooth Time and Top Speed: set Smooth Time low and Top Speed high, so the slider follows the Tween’s curve instead of adding a smoothing of its own.
- Wire the Switch’s Enabled to
wanted,positionto the slider’s Target Position, andsignto the Text Decal’s Text. - Flip the switch on. The sign says “Opening”, the door eases out over a second and a half, and the sign says “Open”. Flip it off: “Closing”, then “Shut”.
Under the Hood
Open the chip. A Select picks 100 or 0 from the switch, and feeds the
Tween gate’s Target. The Tween’s Duration has 1.5 typed in, its
Function 3 (Cubic) and its Direction 2 (InOut). Its Value
output goes to the pin, and its Arrived output is the exec that starts
the last handler: a second Select chooses the text, and a Set
Variable stores it. The on !wanted handler starts from a NOT gate.
On a Tween, Function and Direction are inputs, not settings. A
variable can choose the curve while the circuit runs, as
Game Enums does. The settings of Blend and
Easing, and the curve of Remap, are fixed when the chip is built and
take only a constant (WS028).
Smoothing a jumpy input
A seat’s left and right keys jump: input.Right goes straight from 0 to 1
and back. Steering a turret or a wheel from it would snap between angles.
Dampen turns those jumps into glides:
@left in rider: character
let input = rider.InputReader()
@right out angle: float = Dampen(input.Right * 45.0, 0.3)
input.Right * 45.0 is the angle the rider asks for: -45, 0 or 45
(Seat Controls). Dampen(target, smoothTime)
is a value that follows it. When the target jumps, the value picks up speed,
then slows as it closes in. The second number is the Smooth Time: larger
values catch up more slowly.
- Place a Seat and a Bearing (Servo) with something attached to turn, like a turret barrel. Paste the chip.
- Wire the Seat’s Sitting Character to
riderandangleto the Servo’s Target Angle, and turn on the Servo’s Enabled. - Sit and tap left and right. The turret swings to each side and back to the middle without snapping.
Dampen follows any of the kinds Blend takes, and gives back the same
kind. A point that jumps around follows smoothly as a point:
@left in spot: vector
@right out smooth = Dampen(spot, 0.3)
Which one to use
- Tween for a target that changes now and then, like open and shut, or a floor number. Every move takes the same time, follows a curve you pick, and ends with an Arrived pulse.
- Dampen for a target that changes all the time, like keys, a sensor or a score. There is no set time and no end pulse: it keeps following.
- Easing when the circuit already has its own progress from 0 to 1, as in the last lesson.
Modify
- Make a door that drops and bounces:
function = Bounce, direction = Outon a slider that moves the door down. Use the slider’s Reversed setting if it moves the wrong way. - Light the doorway with the door. Add
@right out lamp: color = Tween(if wanted then Color(0.0, 1.0, 0.0) else Color(1.0, 0.0, 0.0), SECONDS)and wire it to a Point Light’s Color (with Use Brick Color off). It fades between red and green in step with the door. - Steer a second servo with the forward keys:
Dampen(input.Forward * 30.0, 0.5). With a Smooth Time of 0.5 it lags behind the first.
Make
Make an elevator with three floors. Three Buttons choose the floor, a
Rigid Slider carries the car, and a Tween moves it between 0, 50 and
100 percent. A sign says “Moving” during the ride and “Floor 2” (or 1, or 3)
when the car arrives. Store the chosen floor in a var.
button1 Held -> chip floor1
button2 Held -> chip floor2
button3 Held -> chip floor3
chip position -> mover "Target Position"
chip sign -> decal Text
Good to Know
- Flip the switch back during a move, and the Tween sets off for the new target from where it is, not from an end. The way back takes the full Duration again, even from halfway.
- The door’s slider smooths its own moves too. If the door lags or rounds off the curve, lower its Smooth Time and raise its Top Speed.
BackandElasticgo past the target and come back. A door eased with them swings past its end before it settles.- A Dampen closes in more and more slowly. To ask whether it has nearly
arrived, use
NearlyEqual(value, target, 0.5), not==.
See Misc and Gate config properties in the reference.
Next: pick a lesson from the tree on the Learn page.
Messages to One Player
Before this: Reacting to the Game
Next steps: Text on Screen
A circuit can put words in front of one player without anyone else seeing them: a line in their chat, a short status message, or a hint with a title. This lesson builds a lava pit that greets, warns and helps each player who walks in.
Contents
zone "Zone Ref" -> chip pit
Code
import { escape } from "sanitize"
@left in pit: zone
on ZoneEntered(zone = pit) -> (character) {
character.ShowChatMessage("Welcome to the lava pit, ${escape(character.GetDisplayName())}.")
character.ShowStatusMessage("Careful: the floor is lava")
character.ShowHint("Crossing the Pit", "Jump from stone to stone. Do not touch the orange floor.")
}
All three go to character, the player who walked in:
ShowChatMessageadds a line to that player’s chat, like a whisper.ShowStatusMessageshows a short status message on their screen.ShowHintshows a hint: a title (the first text) and its words (the second).
The display name is escaped with escape, as in Reacting to the
Game, because the player chose it.
Three players walk into the pit together. How many messages does each one get? Does anyone see another player’s welcome?
Run
- Compile the chip and paste it in-game. Give a brick an Attached
Zone that covers the pit, and wire its Zone Ref to
pit. - Walk into the pit. Your chat gets a line with your name, a status message warns you about the floor, and a hint titled “Crossing the Pit” appears.
- Ask a second player to walk in while you watch. They get their own three messages, and nothing new appears on your screen.
Under the Hood
Open the chip. The Player Entered Zone Event starts one exec chain: Get User Display Name, Show Chat Message, Show Status Message, then Show Hint, each Exec Out wired into the next one’s Exec. The event’s Character wires into all of them. The two message gates call that input Player State, and a character wires straight in.
The chat line is built from your name, so a Replace in String (from
escape) and a Format String sit beside the chain and feed Show
Chat Message’s Message. The status message has no wire on its
Message: a fixed text is typed into the gate.
Show Hint has no Message input at all. Its title and text are settings typed into the gate, so they cannot change while the game runs. Try to build the hint from a value:
@left in pit: zone
@left in stones: int
on ZoneEntered(zone = pit) -> (character) {
character.ShowHint("Crossing the Pit", "There are ${stones} stones")
}
Error: [WS028] 'text' is constant-only gate config and cannot take a variable or computed value
Pick the call by whether the words need to change:
| Call | Gate | The words can change |
|---|---|---|
ShowChatMessage(text) | Show Chat Message | yes, wired into Message |
ShowStatusMessage(text) | Show Status Message | yes, wired into Message |
ShowHint(title, text) | Show Hint | no, fixed when you compile |
To tell every player at once there are Broadcast Chat Message and
Broadcast Status Message, but both are admin gates
(Admin Gates). For text that stays put on the
screen, where you choose, the next lesson uses DisplayText.
Modify
-
Say goodbye. Add a second handler that runs when a player leaves:
@left in pit: zone on ZoneLeft(zone = pit) -> (character) { character.ShowStatusMessage("You made it out") }Walk out of the pit: a status message says goodbye.
-
Count the crossings. Add
var crossings: int = 0and, in the enter handler,crossings += 1. Put the count in the status message:"Careful: the floor is lava (crossing ${crossings})". Try the same in the hint, and read the error. -
Add a help button at the edge of the pit. Wire the Button’s Press Count to
@left in help: intand its Character to@left in asker: character. Press Count is how many players are holding the button right now, so it goes up on a press and down on a release.on Change(help)runs on both, andhelp > lastkeeps only the presses, by anyone. Character is the player who pressed:@left in help: int @left in asker: character var last: int = 0 on Change(help) { if help > last { asker.ShowHint("Crossing the Pit", "The stones that look cracked will sink.") } last = help }
Make
Make a shop doorway. A Switch behind the counter marks the shop open or
closed. Every player who walks through the door gets a hint with the
opening hours, and a status message that says Open or Closed, read
from the switch (if ... then ... else picks one of two values). Check it
with the switch off: walk in and read Closed. Then flip it, walk out and
back in, and read Open.
zone "Zone Ref" -> chip door
switch Enabled -> chip open
Good to Know
- Each of these reaches one player only. Nobody else sees it.
- A hint’s title and text are fixed when you compile (
WS028). Put anything that changes in a chat or status message instead. - The Player Left Zone Event also fires when a character is destroyed inside the zone, for example by dying there, and then its Character is empty. A goodbye sent to an empty character reaches no one.
- Chat and status messages read the same markup as other game text:
<b>,<color="...">and the rest (Rich Text). ShowHinttakes a character, and the two message calls take a player state. A character wires into either.
See ShowChatMessage, ShowStatusMessage and ShowHint in the reference.
Next: Text on Screen.
Text on Screen
Before this: Messages to One Player, Rich Text
Next steps: Keeping It Current
A message from the last lesson shows up where the game puts it.
DisplayText draws text on one player’s screen at a place you choose, in
the size and color you choose. This lesson shows a power switch’s state to
the player who flipped it.
Contents
switch Enabled -> chip power
switch Character -> chip flipper
Code
@left in power: bool
@left in flipper: character
on Change(power) {
let word = if power then '<color="44dd44">ON' else '<color="dd4444">OFF'
flipper.DisplayText('<b>Power</>: ${word}',
anchorX = 0.0, anchorY = 0.0,
positionX = 40.0, positionY = 40.0,
justify = Left, fontSize = 28)
}
on Change(power)runs each time the switch turns on or off.flipperis wired from the Switch’s Character, which holds the last player who flipped it. The text goes to that player only.- The text is ordinary rich text:
Powerin bold, then the state in green or red. - The settings after the text say where it goes and how it looks.
Two pairs of numbers set where it goes:
- The anchor (
anchorX,anchorY) picks a point on the screen, as a fraction of its width and height:0is the left or top edge,0.5the middle,1the right or bottom edge.0.0, 0.0is the top left corner. - The position (
positionX,positionY) moves the text from that point, in screen units, which are about one pixel each on a 1080p screen.40.0, 40.0is 40 units right and 40 down from the corner.
justify = Left lines the text up on its left side, and fontSize = 28
makes it larger than the default 16.
You flip the switch on, and a moment later a friend flips it off. What does each of you see, and where?
Run
- Compile the chip and paste it in-game. Place a Switch.
- Wire the Switch’s Enabled to
powerand its Character toflipper. - Flip the switch. Near the top left corner of your screen,
Power: ONappears withONin green. A few seconds later it goes away. - Flip it again:
Power: OFF, in red. - Ask a friend to flip it. The text appears on their screen, not yours.
Under the Hood
Open the chip. There are four gates besides the pins: a Change
Detector (Exec) on power, a Select that picks the colored word, a
Format String that builds the text, and the Display Text gate
itself. The Change Detector (Exec)’s On Changed fires Display
Text’s Exec. flipper wires into Player State, and the Format
String into Text.
Everything else is typed into Display Text as settings: Anchor is
(0, 0), Position is (40, 40), Font Size 28, Justification
Left. Anchor and Position each have two halves, X and Y,
and a value that changes while the game runs wires into one half: write
positionY = row * 30.0 and the result wires into Position’s Y.
So there is no position parameter that takes a vector:
@left in power: bool
@left in flipper: character
on Change(power) {
flipper.DisplayText("Power", position = Vec(40.0, 40.0, 0.0))
}
Error: [WS041] 'DisplayText' has no parameter 'position'
Every setting you leave out keeps the gate’s default: the anchor in the middle of the screen, no position offset, size 16, white text with an outline, and gone after 5 seconds. DisplayText lists every setting.
Modify
-
Move the readout to the bottom middle of the screen:
anchorX = 0.5,anchorY = 1.0,positionY = -120.0, andjustify = Center. The negativepositionYmoves it up from the bottom edge. -
Greet the player in a Seat. Wire the Seat’s Is Occupied to a
seatedpin and its Sitting Character torider:@left in seated: exec @left in rider: character on seated { rider.DisplayText('<size="40"><b>Welcome aboard</></><br>Buckle up', anchorX = 0.5, anchorY = 1.0, positionY = -160.0, justify = Center, lifetime = 3.0) }<size="40">makes the first line larger than the rest, and<br>starts the second line inside the same text.lifetime = 3.0keeps it up for 3 seconds. -
Add the flipper’s name on a second line:
import { escape } from "sanitize" @left in power: bool @left in flipper: character on Change(power) { let word = if power then '<color="44dd44">ON' else '<color="dd4444">OFF' let name = escape(flipper.GetDisplayName()) flipper.DisplayText('<b>Power</>: ${word}<br><color="ffffff">by ${name}', anchorX = 0.0, anchorY = 0.0, positionX = 40.0, positionY = 40.0, justify = Left, fontSize = 28) }The second color tag turns the second line white again.
Make
Make a parking sign for a Seat. When a player sits down, they see a
card in the top right corner: a bold title, the name of the spot, and a
line in gray that says how many times anyone has sat there (a var you
count up). Use anchorX = 1.0, a negative positionX, and justify = Right. Check it: sit down twice and read 1, then 2.
seat "Is Occupied" -> chip seated
seat "Sitting Character" -> chip sitter
Good to Know
- Anchor is a fraction of the screen, and position is in screen units.
positionX = 0.9does not mean 90% across: it is less than one unit from the anchor. Text placed off the screen looks exactly like text that never drew, so check the numbers first. - From a corner, move inward: from the right edge
positionXis negative, and from the bottom edgepositionYis negative. - The default lifetime is 5 seconds. The next lesson keeps text up for as long as you need and updates it in place.
- The player input is called Player State, and a character wires straight in.
- A player’s name can contain tags. Escape it before it goes into text (Safe Text).
See DisplayText Screen Placement in the reference.
Next: Keeping It Current.
Keeping It Current
Before this: Text on Screen, Object References
Next steps: A HUD for Everyone
Parts that use this: Card Table, Team Match
Text that shows a changing value has to change with it. It is drawn when it is needed, redrawn when the value changes, and taken down when it is not. This lesson builds a control seat whose panel shows the state of a gate and how many players are waiting at it, for as long as someone sits there.
Contents
seat "Is Occupied" -> chip seated
seat "Sitting Character" -> chip sitter
switch Enabled -> chip gate
zone "Occupant Count" -> chip waiting
Code
const PANEL = 7310 // a text id no other circuit uses
@left in seated: bool
@left in sitter: character
@left in gate: bool
@left in waiting: int
var rider: character
let state = if gate then '<color="44dd44">OPEN' else '<color="dd4444">SHUT'
let panel = 'Gate: ${state}<br><color="ffffff">Waiting: ${waiting}'
on seated {
rider = sitter
sitter.DisplayText(panel, textId = PANEL, lifetime = 0.0,
anchorX = 0.0, anchorY = 0.0, positionX = 40.0, positionY = 40.0,
justify = Left, fontSize = 24)
}
on Change(panel) {
if seated {
rider.DisplayText(panel, textId = PANEL, lifetime = 0.0,
anchorX = 0.0, anchorY = 0.0, positionX = 40.0, positionY = 40.0,
justify = Left, fontSize = 24)
}
}
on !seated {
rider.DisplayText("", textId = PANEL)
}
The three handlers draw, redraw and remove the text:
- Sit down (
on seated): remember who sat (rider) and draw the panel for them. - The panel changes (
on Change(panel)): the gate moved or the queue grew, so the words are different. If someone is seated, draw it again. - Stand up (
on !seated, whenseatedturns off): draw an empty text over it, which takes it down.
All three use textId = PANEL, so they all mean the same text on that
player’s screen. A draw with a textId that is already showing replaces
that text instead of adding another. lifetime = 0.0 means the text
never runs out on its own: it stays until something replaces it.
You sit down, and the gate opens and closes three times while you sit.
How many texts from this chip are on your screen at the end? What would
it be without the textId?
Run
- Compile the chip and paste it in-game. Place a Seat, a Switch for the gate, and a brick with an Attached Zone over the spot where players wait.
- Wire the Seat’s Is Occupied to
seatedand its Sitting Character tositter. - Wire the Switch’s Enabled to
gate, and the Attached Zone’s Occupant Count towaiting. - Sit down. The top left corner shows the panel:
Gate: SHUTin red andWaiting: 0under it. - Ask a friend to flip the switch and stand in the waiting zone. The
panel changes as they do:
OPENin green, thenWaiting: 1. It stays one panel, in one place. - Stay seated for a minute with nothing changing: the panel stays up. Stand up: it is gone at once.
Under the Hood
Open the chip. There are three Display Text gates, all with Text
Id 7310. The two that draw have Lifetime 0 and take their Text
from the same Format String. The third has an empty Text. A
Change Detector (Exec) watches the Format String’s output, so it
fires only when the panel would read differently, and a Branch checks
seated before the redraw.
One id, one text
With no textId (the default, 0) each Display Text gate draws a text
of its own. Leave the ids out and the redraw gate would put a second panel
on top of the first, and the empty draw would take down neither. The shared
id lets three gates work on one text.
Ids are shared further than that: every circuit that draws on a player’s
screen uses the same numbers. Two circuits that both use 7310 replace each
other’s text. Pick ids for each circuit that are unlikely to clash, and
keep them in a const at the top.
Lifetime and redraws
The default lifetime is 5 seconds. Leave out lifetime = 0.0 and the
panel vanishes 5 seconds after you sit down, then comes back only when the
gate or the queue changes. Text that is redrawn on a change should have
lifetime 0, and text with lifetime 0 needs a draw that takes it down.
Every id this chip draws has one: the empty draw in on !seated.
The other way to keep text up is to redraw it on a Clock, with a lifetime
a little longer than the interval. That keeps building and sending the
same words when nothing has changed, and it still shows a change late, by
up to one interval. Change redraws once, right when the words change.
One text or several
The panel’s two lines are one text, joined by <br>. A player’s screen
shows only about 32 texts at once, and every circuit they see shares that
budget. Past it, a text silently never appears, and which one is missing
changes when you change something unrelated. Two lines in one text spend
one of the 32. Use separate texts only for things that must sit in
different places on the screen.
Modify
-
Add a third line for the lights. Wire a second Switch’s Enabled to
@left in lights: bool, pick a word withif, and add<br>Lights: ${lightWord}topanel. It is still one text and one id. -
Delete
lifetime = 0.0from both draws. Sit down and wait: the panel goes after 5 seconds. Flip the gate switch, and it comes back for 5 more. Put it back. -
Use a control room instead of a seat. This version shows one player in the room a panel, and takes it down when they walk out:
const PANEL = 7311 @left in room: zone @left in gate: bool var watcher: character = null let panel = if gate then "Gate: OPEN" else "Gate: SHUT" on ZoneEntered(zone = room) -> (character) { if watcher != null && watcher != character { watcher.DisplayText("", textId = PANEL) } watcher = character character.DisplayText(panel, textId = PANEL, lifetime = 0.0) } on Change(panel) { if watcher != null { watcher.DisplayText(panel, textId = PANEL, lifetime = 0.0) } } on ZoneLeft(zone = room) -> (character) { if character == watcher || character == null { watcher.DisplayText("", textId = PANEL) watcher = null } }watcherholds the player who has the panel, andnullwhen nobody does (Object References).&&is “and”, and||is “or”. It keeps onewatcher, so a second player who walks in takes the panel over, and the first one’s text is taken down first: with lifetime 0 it would otherwise stay on their screen for good. A HUD for Everyone gives each player in the room their own.
Make
Make a shop clerk’s desk. A Seat behind the counter shows the seated
clerk how many items are left in stock, a var that starts at 10.
Customers buy with a Button: wire its Held to an exec pin and take
one item off in on that pin.
At 0, the line turns red and reads Sold out. Check it with the clerk
seated: a customer presses and the count drops at once. The clerk stands
up and the text is gone. The clerk sits back down and sees the current
count.
seat "Is Occupied" -> chip seated
seat "Sitting Character" -> chip clerk
button Held -> chip buy
Good to Know
lifetime = 0.0means forever, not “vanish now”. Every id you draw with lifetime 0 needs a path that draws""over it. If the chip is removed while the text is up, nothing will.- A text id is a number shared by every circuit on that player’s screen. Two circuits that pick the same id overwrite each other.
- About 32 texts per player, shared by every circuit. Count the texts on screen at one moment, not the Display Text gates.
- The Player Left Zone Event gives an empty Character when a
player dies inside the zone. The room version treats an empty one like
the watcher leaving, and takes the text down through
watcher, the one it stored on the way in. on !seatedruns whenseatedturns off.
See DisplayText and Default values in the reference.
Next: A HUD for Everyone.
A HUD for Everyone
Before this: Keeping It Current, Per-Player State, Looping Over a Map, Index and Value
Parts that use this: Scoreboard HUD
The last lesson kept one player’s panel current. This one gives every player in an arena a text of their own: their score and the leader, drawn when they walk in, redrawn when a score changes, and taken down when they walk out. A Clock checks who is in the arena once a second, but it never redraws a text that has not changed. You need a second player to see most of it.
Contents
zone "Zone Ref" -> chip arena
button "Press Count" -> chip holding
button Character -> chip presser
Code
import { escape } from "sanitize"
const HUD = 7320 // a text id no other circuit uses
@left in arena: zone
@left in holding: int
@left in presser: character
var scores: Map<string, int>
var top: int = 0
var leader: string = "nobody"
var last: int = 0
var here: character[]
var shown: character[]
mod show(p: character, mine: int) {
p.DisplayText("You: ${mine}<br>Leader: ${leader} (${top})",
textId = HUD, lifetime = 0.0, anchorX = 0.5, anchorY = 0.0,
positionY = 80.0, justify = Center, fontSize = 28)
}
mod scoreOf(id: string) -> int {
let s = scores.get(id)
return if s.Found then s.Value else 0
}
on Clock(interval = 1.0) {
here.fillFromZonePlayers(arena)
for p in shown {
if !here.find(p).Found {
p.DisplayText("", textId = HUD)
}
}
for p in here {
if !shown.find(p).Found {
show(p, scoreOf(p.GetUserId()))
}
}
shown.copyFrom(here)
}
on Change(holding) {
let rose = holding > last
last = holding
here.fillFromZonePlayers(arena)
if !rose || !here.find(presser).Found {
return
}
let id = presser.GetUserId()
let mine = scoreOf(id) + 1
scores.set(id, mine)
if mine > top {
leader = escape(presser.GetDisplayName())
top = mine
} else {
show(presser, mine)
}
}
on Change(top) {
here.fillFromZonePlayers(arena)
for p in here {
show(p, scoreOf(p.GetUserId()))
}
}
here.fillFromZonePlayers(arena)fillsherewith everyone standing in the arena. Zone events can be missed over a long game, so the chip asks the zone itself each time it needs to know who is inside.shownis who the Clock drew for last time. Someone inherebut not inshownjust walked in and gets a text. Someone inshownbut not inherewalked out and gets an empty one. Thenshownbecomes a copy ofhere, so it is never more than a second out of date.scoreskeeps each player’s points by user id (Per-Player State), so they survive a respawn and a walk out and back in.showdraws one player’s text: their own score on the first line, the leader on the second. Every draw uses the same id,HUD, so each player has one text that each draw replaces.- A press on the Button scores a point for whoever pressed, if the
fill finds them inside. Only that player’s text changes, unless they
took the lead.
The Button’s Press Count is how many players hold it right now, so it
also changes when someone lets go.
roseis true only when it went up since the last change (last), and a release returns early.lastis updated before the earlyreturn, so it never goes stale. - When
topchanges, the leader line is different for everyone, soon Change(top)fillshereagain and redraws every text in the arena (Looping Over a List).
Five players are in the arena. One of them, not the leader, scores. How many texts are redrawn? Then the leader scores again. How many now?
Run
- Compile the chip and paste it in-game. Give a brick an Attached
Zone over the arena, and wire its Zone Ref to
arena. - Place a Button inside. Wire its Press Count to
holdingand its Character topresser. - Walk in. Within a second the top of your screen shows
You: 0andLeader: nobody (0). - Press the button. Both lines change:
You: 1, and you lead with 1. - A second player walks in and within a second sees
You: 0, with you as the leader. They press twice: on the second press both screens show them leading with 2. Your own line still readsYou: 1. - Walk out. Your text goes within a second. Walk back in:
You: 1again.
Under the Hood
Open the chip. It has 74 gates. Each call of show is its own
Display Text gate and its own Format String, and there are three
calls: one for a newcomer in the Clock’s second loop, one for the scorer,
and one in the Change(top) loop. Each for becomes a For Each Array
chip, three in all, and each fillFromZonePlayers is a Get Players In
Zone gate, one in each handler. The fourth Display Text, in the
Clock’s first loop, draws the empty text over a leaver’s. All four use Text Id
7320, and since each player’s screen has ids of its own, one id gives each
player one text.
Count the redraws. A point that does not change the lead redraws one text. A new top score redraws one per player inside. Joining and leaving draw one each, on the next Clock pulse. The Clock draws nothing for a player who was already inside. Text is built only when its words change, and only for the players whose words changed. Redrawing every text on the Clock instead would rebuild each player’s text every second, changed or not.
A text could also take itself down with a short lifetime, but then the
Clock would have to redraw every text before it ran out. Keeping shown
lets the Clock draw only for players who came or went.
The leader’s name is stored before top. The redraw is started by top
changing, and it reads leader, so the name has to be there first.
When the work gets heavy
Here each player’s text is cheap: one map read and one text. If each player needed more, such as a readout of their own speed or distance that changes every moment, this one chip would have to do it for everyone, over and over. That is when to give each player a chip of their own, as in One Chip per Player. The central chip keeps the scores and sends each copy what it needs, and the copy draws its own player’s text and removes itself when its player leaves. Multiplayer Architecture builds that split for a scoreboard. A copy does not need a clock to stay current either: it can redraw in the handler of the event that brought the new value.
Modify
-
Add a reset. Wire a second Button’s Held to
@left in reset: exec, and clear everything:on reset { scores.clear() leader = "nobody" top = 0 }Setting
topto 0 starts the redraw, and every player readsYou: 0. Iftopwas already 0, nothing needs redrawing. -
Add a third line,
Players: ${count}, to the text inshow. The Clock already fillshereonce a second, so take the count there, right after the fill, and redraw everyone only when the number changes:var count: int = 0 // in the Clock, after the fill: count = here.length() on Change(count) { for p in here { show(p, scoreOf(p.GetUserId())) } } -
Show your rank:
Rank: 2 of 5. Count the scores higher than yours withfor id, n in scores. Your rank can change when someone else scores, so work out whose text needs a redraw before you write it.
Make
Rebuild this HUD with one chip per player. The central chip keeps
scores, top and leader. When its Clock fill finds a new player, it
spawns a copy for them (or reuses theirs) and sends it the character and
current text. A point sends the new score to the scorer’s copy alone, and
a new top sends the leader line to every copy with one send. Each copy
draws its own text in the handlers of those events, with no Clock for
drawing. When the fill no longer finds a player, the central chip tells
their copy, which takes its text down and removes itself, as it also does
when its player leaves the server. Check it with two players: both
screens agree on the leader, each shows its own score, and walking out
takes your text down.
zone "Zone Ref" -> chip arena
button "Press Count" -> chip holding
button Character -> chip presser
Good to Know
- A player who dies inside drops out of the fill, and the next Clock pulse
sends the empty text to their old character. A dead player’s character
becomes
nulla little after the death, and so does the character of a player who leaves the server. - A respawn makes a new character. When it walks back in, the Clock finds
it missing from
shownand draws its text, with the same score, since scores are kept by user id. - The loop fires one Display Text gate once per player, all in one tick, as the Scoreboard HUD part does. Do not do the same with a custom event send: one send gate fired many times in a tick is capped (Multiplayer Architecture).
- A player’s screen shows about 32 texts, shared by every circuit. This chip uses one per player, since both lines are one text.
- Escape a name when you store it.
leaderis shown to every player.
See DisplayText and per-entity fan-out in the reference.
Next: pick a lesson from the tree on the Learn page.
Parts
Ready-made circuits for builders. Each part is one chip: paste it in-game,
wire it to the components listed, and tick off the checklist to see that it
works. Settings are the consts at the top of the code. Change them with
Open in playground and compile again.
You do not need the lessons to use a part. Each one names the lessons it builds on, for when you want to change how it works.
From the lessons
Several lessons end with a part you can use as it is:
| Part | Uses | Lesson |
|---|---|---|
| Light switch | Switch, Point Light | First Circuit |
| Two-way hall light | 2 Switches, Point Light | Gates and Wires |
| Toggle door | Button, Brick Property Changer | Pure and Exec |
| Press-count sign | Button, Text Decal | Remembering Values |
| Code door | 4 Switches, Brick Property Changer | Code Door |
| Score card | Buttons, Switch, Text Decal | Score Card |
| Dice sign | Button, Text Decal | Dice |
| Flickering firelight | Switch, Point Light | Random Colors |
| Traffic light | Switch, Point Light | Enums |
| Seat dimmer | Seat, Point Light, Text Decal | Seat Controls |
Catalog
Building Parts
| Part | Uses | Builds on |
|---|---|---|
| Toggle Light | Button, Point Light | Reacting to the Game |
| Combination Lock | Buttons, Brick Property Changer, Point Light | Mods, Lists |
| Countdown Sign | Button, Text Decal, One Shot Audio, Point Light | Exec Pins |
| Color-Cycling Light | Switch, Point Light | Lists |
| Doorbell | Button, One Shot Audio | Exec Pins |
| Zone Chime | Attached Zone | Reacting to the Game |
| Drawbridge | Servo, Button, Point Light | Reacting to the Game, Choosing a Value |
| Proximity Door | Attached Zone, Brick Property Changer | Waiting |
| Clock Sign | Text Decal | Reacting to the Game, Number Options |
Player Parts
| Part | Uses | Builds on |
|---|---|---|
| Pressure Pad Door | Touch, Brick Property Changer | Reacting to the Game, Waiting |
| Welcome Zone | Attached Zone, Text Decal | Maps |
| Teleporter Pad | Attached Zone, Teleport Destination | Maps |
| Shooting Gallery | Target, Text Decal, Button | Looping Over a Map, Rich Text, Per-Player State |
| Healing Zone (needs admin to load) | Attached Zone | Looping Over a List |
| Launch Pad | Attached Zone | Reacting to the Game, Vectors and Colors |
| Item Dispenser | Button, Text Decal | Maps |
| Paint Gun | Switch, Button, Point Light | Remote Control, Forking and Merging, Game Enums, Weighted Loot |
| Hold to Use | Button, Brick Property Changer | Waiting |
| Moving Platform | Rigid Slider, Touch, Button | Waiting, Choosing a Value |
| Keycard Door | Button, Attached Zone, Brick Property Changer | Maps, Waiting |
Game Systems
| Part | Uses | Builds on |
|---|---|---|
| Checkpoints | Attached Zone | Maps, Mods, Per-Player State |
| Race Timer | Attached Zone, Text Decal | Number Options, Mods, Per-Player State |
| Scoreboard HUD | Attached Zone, Target, Button | Rich Text, Per-Player State, Looping Over a Map, A HUD for Everyone |
| Round Manager | Button, Text Decal, Point Light | Payloads, Exec Pins, Number Options |
| King of the Hill | Attached Zone, Button, Text Decal, One Shot Audio | Ready Check, Exec Pins, Per-Player State, Looping Over a Map |
| Shop | Button, Target | Mods, Maps, Per-Player State |
| Voting | Button, Text Decal | Looping Over a Map, Mods, Exec Pins, Per-Player State |
Capstones
| Part | Uses | Builds on |
|---|---|---|
| Obstacle Course | Attached Zone, Text Decal, Button | Custom Events, Mods, Per-Player State, Looping Over a Map, Number Options, Safe Text |
| Whack-a-Light | Point Light, Button, Text Decal | Payloads, Mods, Dice, Safe Text, Waiting |
| Card Table | Attached Zone, Button, Text Decal | Shuffling, Ready Check, Index and Value, Mods, Safe Text, Keeping It Current |
| Team Match | Attached Zone, Button, Text Decal, Point Light | Payloads, Ready Check, Looping Over a Map, Per-Player State, Rich Text, Number Options, Keeping It Current |
Toggle Light
Before this: Reacting to the Game
One button turns a light on, and the next press turns it off again. Put it by a door to light a room.
Contents
You need
- a Button
- a brick with a Point Light
button Held -> chip pressed
chip light -> light Enabled
Code
const START_ON = false // whether the light is on when the chip is pasted
@left in pressed: exec
var lit: bool = START_ON
@right out light = lit.Value
on pressed {
lit = !lit
}
Wire it
- Compile the chip and paste it in-game. Place the Button and the Point Light.
- Wire the Button’s Held to
pressed. - Wire
lightto the Point Light’s Enabled.
Check it in game
- Press the button: the light comes on, and stays on after you let go.
- Press it again: the light goes off.
- Hold the button down: nothing more happens until you let go and press again.
Change it
- Start with the light on: set
START_ON = true. - Add a second button at the other end of the room. Add
@left in pressed2: bool, wire the second Button’s Held to it, makepressedabooltoo, and change the handler toon pressed || pressed2. The OR reads both pins’ values, which is why they arebools. A press on one button while the other is held down does nothing: the OR is already on. - Dim the light instead of switching it off:
@right out light: float = if lit.Value then 4.0 else 0.5.if A then X else Ygives X while A is true and Y otherwise. Wirelightto the Point Light’s Brightness, and leave its Enabled on.
How it works
lit is a variable the chip remembers (Remembering Values). on pressed
runs once each time the button’s Held turns on, and lit = !lit flips
the variable (Pure and Exec). light follows the
variable all the time, so the light shows whatever was stored last.
Toggles and Latches builds the same part
from the game’s Toggle gate, and a latch with separate on and off
buttons.
Combination Lock
A door that opens when four buttons are pressed in the right order. A wrong press starts the code over, and any press while the door is open locks it again.
Contents
You need
- four Buttons
- a door: a few bricks, one with a Brick Property Changer
- optional: a brick with a Point Light, to show when the lock is open
button1 Held -> chip b1
button2 Held -> chip b2
button3 Held -> chip b3
button4 Held -> chip b4
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
chip unlocked -> light Enabled
Code
const CODE = [3, 1, 4, 2] // the buttons to press, in order
@left in b1: exec
@left in b2: exec
@left in b3: exec
@left in b4: exec
var step: int = 0
@right out unlocked = step.Value == CODE.length()
@right out solid = step.Value != CODE.length()
mod press(key: int) {
if step == CODE.length() {
step = 0
} else if CODE[step] == key {
step += 1
} else {
step = 0
}
}
on b1 { press(1) }
on b2 { press(2) }
on b3 { press(3) }
on b4 { press(4) }
Wire it
- Compile the chip and paste it in-game. Build the door and place the four Buttons in a row beside it.
- Wire the Buttons’ Held outputs to
b1,b2,b3andb4, left to right. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility. One output can feed both. - Optional: wire
unlockedto the Point Light’s Enabled, and give the light a green color.
Check it in game
- Press buttons 3, 1, 4, 2: the door vanishes after the last press, and the light comes on.
- With the door open, press any button: the door is back and the light is off.
- Press 3, 1, 2: nothing opens. Then press 3, 1, 4, 2 again: the door opens, because the wrong press started the code over.
- Press 3, 3, 1, 4, 2: the door stays shut. The second 3 was wrong, so the code started over, and that 3 did not count as its first press.
Change it
-
Set your own code:
const CODE = [2, 2, 1, 4, 3]. The code can be any length, and a button can appear more than once. -
Use more buttons: add
@left in b5: execandon b5 { press(5) }. -
Lock the door by itself. Add a handler that runs when the lock opens:
on step.Value == CODE.length() { await Sleep(_, delay = 5.0) step = 0 }Five seconds after the door opens, it locks again.
How it works
step counts how many presses of the code have been right so far
(Remembering Values). Each button’s handler calls the mod press
(Mods) with that button’s number. A mod can read
and write the chip’s own variables, as press does with step. CODE is a
const array: a fixed list that never changes while the game runs, read by
index like any array (Lists). If the
next number in the code matches, step goes up. If not, it goes back to 0.
When step reaches the length of the code, the lock is open: solid turns
off and unlocked turns on. The mod is copied into each of the four
handlers, so the chip holds four copies of its gates.
Countdown Sign
Before this: Exec Pins
Press a button and a sign counts down the seconds. At zero the chip sends a pulse: open a door, ring a sound, start a round.
Contents
You need
- a Button
- a brick with a Text Decal
- something to happen at zero, such as a One Shot Audio brick
- optional: a brick with a Point Light, lit while the count runs
button Held -> chip start
chip text -> decal Text
chip done -> speaker Play
chip counting -> light Enabled
Code
const SECONDS = 10 // where the count starts
@left in start: exec
@right out done: exec
var remaining: int = SECONDS
var running: bool = false
@right out text = "${remaining.Value}"
@right out counting = running.Value
on start {
if !running {
remaining = SECONDS
running = true
}
}
on Clock(interval = 1.0, enabled = running) {
remaining -= 1
if remaining == 0 {
running = false
emit done
}
}
Wire it
- Compile the chip and paste it in-game. Place the Button, the Text Decal brick and the One Shot Audio brick. Open the One Shot Audio’s settings and pick a sound.
- Wire the Button’s Held to
start. - Wire
textto the Text Decal’s Text. - Wire
doneto the One Shot Audio’s Play. - Optional: wire
countingto the Point Light’s Enabled.
Check it in game
- Before any press, the sign shows
10. - Press the button: the sign counts down by one each second, and the light is on.
- At
0the sound plays once and the light goes off. - Press the button during a count: nothing changes, and the count carries on.
- Press it after the count has ended: the sign starts again from
10.
Change it
- Count from another number:
const SECONDS = 30. - Show minutes and seconds:
"${remaining.Value / 60}:${remaining.Value % 60}"./on two whole numbers drops the remainder, and%gives the remainder. 65 seconds shows as1:5. Thepad2helper in Number Options makes it1:05. - Let a press restart the count at any time: remove the
if !runningcheck, keeping the two lines inside it. - Add a stop button:
@left in stop: execandon stop { running = false }. The sign keeps the number it stopped at.
How it works
running turns the Clock on and off: while it is on, the Clock runs its
handler once a second (Reacting to the Game),
and each run takes one off remaining. At zero the handler turns running
off, which stops the Clock, and fires the done pin
(Exec Pins). The if !running check in the start
handler ignores presses while a count is going. text turns the number
into text for the sign, and follows remaining all the time.
Color-Cycling Light
Before this: Lists
A light that steps through a list of colors, one each second, for a party floor, a shop sign or a stage. A switch turns it on and off.
Contents
You need
- a Switch
- a brick with a Point Light
switch Enabled -> chip running
chip lamp -> light Color
chip lit -> light Enabled
Code
const STEP_SECONDS = 1.0 // how long each color stays on
const COLORS = [
Color(1.0, 0.0, 0.0),
Color(1.0, 0.5, 0.0),
Color(1.0, 1.0, 0.0),
Color(0.0, 1.0, 0.0),
Color(0.0, 0.3, 1.0),
Color(0.6, 0.0, 1.0),
]
@left in running: bool
var shown: color = Color(1.0, 0.0, 0.0)
@right out lamp = shown.Value
@right out lit = running
on Clock(interval = STEP_SECONDS, enabled = running) {
shown = COLORS[Cycle(COLORS.length())]
}
Wire it
- Compile the chip and paste it in-game. Place the Switch and the Point Light.
- Open the Point Light’s settings and turn off Use Brick Color. While it is on, the light takes the brick’s color and ignores the wire.
- Wire the Switch’s Enabled to
running. - Wire
lampto the Point Light’s Color, andlitto its Enabled.
Check it in game
- Flip the switch on: the light comes on and changes color once a second, in the list’s order: red, orange, yellow, green, blue, purple.
- After purple it goes back to red and carries on.
- Flip the switch off: the light goes out. Flip it on again: it carries on from the color it stopped at.
Change it
- Change the speed:
const STEP_SECONDS = 0.25for a disco. - Use your own colors: add, remove or reorder the
Color(red, green, blue)lines. Each channel runs from 0.0 to 1.0. The cycle follows the length of the list, so nothing else needs to change. - Fade from one color to the next instead of jumping:
@right out lamp: color = Tween(shown.Value, 0.5). A Tween moves toward its target over 0.5 seconds (Game Enums). - Keep the light on with the switch off, frozen on one color: wire the
Point Light’s Enabled from a Switch of its own instead of
lit.
How it works
The Clock runs its handler every STEP_SECONDS while running is on
(Reacting to the Game). Cycle(n) is the
game’s Counter gate: each pulse moves it on by one, from 0 up to n - 1
and then back to 0. Its number picks a color from COLORS, a const
array: a fixed list that never changes while the game runs, read by index
like any array (Lists). The handler
stores the color in shown, which drives the light’s Color
(Vectors and Colors).
Doorbell
Before this: Exec Pins
A button by a front door that plays a doorbell sound where the visitor stands, and sends a pulse you can use to ring a second bell inside.
Contents
You need
- a Button
- optional: a One Shot Audio brick inside the house
button "Press Count" -> chip holding
button Character -> chip visitor
chip rang -> speaker Play
Code
const SOUND = $BrickOneShotAudioDescriptor/OBA_DoorBell_1 // the sound it plays
const PITCH = 1.0 // 1.0 is the sound as recorded; higher is higher
@left in holding: int
@left in visitor: character
@right out rang: exec
var last: int = 0
on Change(holding) {
if holding > last {
emit rang
visitor.PlayAudioAt(SOUND, pitch = PITCH)
}
last = holding
}
Wire it
- Compile the chip and paste it in-game. Place the Button beside the door.
- Wire the Button’s Press Count to
holding, and its Character tovisitor. Press Count is how many players are holding the button right now. Character is the player who pressed it last. - Optional: place the One Shot Audio brick inside, open its settings, pick a
sound, and wire
rangto its Play.
Check it in game
- Press the button: the doorbell sound plays once, around you.
- Hold the button down: it plays only once until you let go and press again.
- Let go: nothing plays.
- Ask a friend to stand a little way off: they hear it too, quieter.
- While your friend holds the button down, press it yourself: it rings again, around you.
- With the One Shot Audio wired, its sound plays inside at the same moment.
Change it
- Pick another sound: type
$BrickOneShotAudioDescriptor/in the playground, and the editor lists the sounds.OBA_DoorBell_2,OBA_DoorBell_SciFiandOBA_Door_Knock_Softsuit a door. - Make it a ding-dong: after the
PlayAudioAtline, addawait Sleep(_, delay = 0.6)and a secondvisitor.PlayAudioAt(SOUND, pitch = 0.8). - Count the visitors: add
var rings: int = 0,rings += 1in the handler, and@right out sign = "Rings: ${rings.Value}"for a Text Decal.
How it works
Press Count goes up on each press and down on each release. on Change(holding) runs
on both (Change), and
holding > last keeps only the rises, so the bell rings once per press by
anyone and never on a release. last remembers the count from the run
before. Held would miss some presses: it stays on while anyone holds the
button, so a second visitor’s press does not turn it on again, though
Press Count and Character both change.
$BrickOneShotAudioDescriptor/... names one of the game’s sounds. visitor.PlayAudioAt(SOUND) plays it at the visitor’s position, so
players nearby hear it and players far away do not.
emit rang sends a pulse out of the rang pin
(Exec Pins), wired to anything with an exec input.
Zone Chime
Before this: Reacting to the Game
A shop-door chime: a player who walks into an area hears a short sound. Only the player who walked in hears it, so a busy doorway stays quiet for everyone else.
Contents
You need
- a brick with an Attached Zone, covering the doorway or area
zone "Zone Ref" -> chip room
Code
const SOUND = $BrickOneShotAudioDescriptor/OBA_CashRegister_Ding // the chime
const PITCH = 1.0 // 1.0 is the sound as recorded; higher is higher
@left in room: zone
on ZoneEntered(zone = room) -> (character) {
character.PlayClientAudio(SOUND, pitch = PITCH)
}
Wire it
- Compile the chip and paste it in-game. Give a brick an Attached Zone that covers the doorway.
- Wire the Attached Zone’s Zone Ref to
room.
Check it in game
- Walk into the zone: you hear the chime once.
- Stand inside: nothing more plays.
- Walk out and back in: it chimes again.
- Ask a friend to watch from outside while you walk in: they hear nothing.
Change it
-
Pick another sound: type
$BrickOneShotAudioDescriptor/in the playground, and the editor lists the sounds.OBA_UI_Checkpoint_Acquire_Tune_Cuemakes a checkpoint chime. -
Play a lower note on the way out:
on ZoneLeft(zone = room) -> (character) { character.PlayClientAudio(SOUND, pitch = 0.7) } -
Let everyone nearby hear it: change
PlayClientAudiotoPlayAudioAt, which plays the sound at the player’s position for all players in range.
How it works
@left in room: zone takes the zone from the Attached Zone’s Zone Ref,
and on ZoneEntered(zone = room) runs each time a player walks in, naming
that player character (Reacting to the Game).
$BrickOneShotAudioDescriptor/... names one of the game’s sounds.
PlayClientAudio plays it for that one player only, with no direction or
distance: it sounds the same wherever they stand.
Drawbridge
Before this: Reacting to the Game, Choosing a Value
A bridge on a hinge that a button raises and lowers. A warning light shines while it moves, and presses during the move are ignored.
Contents
You need
- a Bearing (Servo) brick, the hinge (its component is called Servo)
- the bridge deck: a piece built on its own and attached to the servo
- a Button
- optional: a brick with a Point Light, the warning light
button Held -> chip pressed
chip angle -> mover "Target Angle"
chip warning -> light Enabled
Code
const DOWN_ANGLE = 0.0 // the servo's angle with the bridge down
const UP_ANGLE = 90.0 // the servo's angle with the bridge up
const MOVE_SECONDS = 4.0 // how long the warning light stays on after a press
@left in pressed: exec
var raised: bool = false
var moving: bool = false
@right out angle: float = if raised.Value then UP_ANGLE else DOWN_ANGLE
@right out warning = moving.Value
on pressed {
if !moving {
raised = !raised
moving = true
await Sleep(_, delay = MOVE_SECONDS)
moving = false
}
}
Wire it
- Compile the chip and paste it in-game. Place the Bearing (Servo) at the end of the gap, where the bridge should pivot. Build the deck and attach it to the servo. The servo’s description in the game says how (with the placer or the manipulator).
- Wire the Button’s Held to
pressed. - Wire
angleto the Servo’s Target Angle. - Optional: wire
warningto the Point Light’s Enabled.
Check it in game
- Before any press, the bridge stays as you built it: flat across the gap.
- Press the button: the warning light comes on and the bridge swings up.
- Press again while the light is on: nothing happens.
- After the light goes off, press again: the bridge comes back down.
- If the bridge swings down into the gap instead of up, set
UP_ANGLE = -90.0, or turn on the servo’s Reversed setting. - If the bridge does not move at all, check that the servo’s Enabled setting is on.
Change it
- Raise it only partway:
const UP_ANGLE = 45.0. - Match the light to the bridge: time how long the bridge takes to move, and
set
MOVE_SECONDSto that. - Run it from a Switch instead: wire the Switch’s Enabled to a new
@left in up: boolpin and write@right out angle: float = if up then UP_ANGLE else DOWN_ANGLE. The bridge then follows the switch, and the handler and both variables can go. - Make it a gate or a barrier arm: the same chip turns any servo between two angles.
How it works
raised remembers which way the bridge should be (Remembering Values).
angle picks one of the two angles from it with
if ... then ... else (Choosing a Value),
and the servo turns the deck until it reaches that angle. The handler flips
raised, then holds moving on for MOVE_SECONDS with
await Sleep (Pure and Exec). While moving is on,
the if !moving check skips the press, and warning lights the lamp. The
chip never measures the bridge: the light’s time is a guess you set.
Proximity Door
Before this: Waiting
A door that opens by itself when a player walks up to it, and closes a moment after the last player has gone.
Contents
You need
- a door: a few bricks, one with a Brick Property Changer
- a brick with an Attached Zone, covering the space in front of the door (and behind it, to open from both sides)
zone Occupied -> chip near
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Code
const STAY_OPEN_SECONDS = 2.0 // how long the door stays open after the last player leaves
@left in near: bool
@right out solid = !Sleep(near, delay = 0.0, hold = STAY_OPEN_SECONDS)
Wire it
- Compile the chip and paste it in-game. Build the door. Give a brick next to it an Attached Zone that covers the floor in front of the door.
- Wire the Attached Zone’s Occupied to
near. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility. One output can feed both.
Check it in game
- Walk toward the door: it vanishes as you enter the zone.
- Stand in the zone: it stays open.
- Walk away: two seconds after you leave the zone, the door is back.
- Walk out and back in within two seconds: the door stays open throughout.
- With a friend: the door stays open until both of you have left.
- If the door never opens, check the Attached Zone’s Detect Players setting.
Change it
- Close faster or slower:
const STAY_OPEN_SECONDS = 0.5. - Open a moment late: set
delay = 1.0. The door then opens about a second after a player steps into the zone. - Lock it: add
@left in locked: bool, wire a Switch’s Enabled to it, and write@right out solid = locked || !Sleep(...). While the switch is on, the door stays shut.
How it works
The chip has no handler and no variable: it is all pure wires
(Pure and Exec). The zone’s Occupied is on while
anyone is inside it. Sleep(near, delay = 0.0, hold = ...) is one
Buffer (Seconds) gate, the same gate await Sleep uses
(Waiting). Its output follows near, but when
near turns off it stays on for hold more seconds. If near turns on
again in that time, the output never goes off at all. ! turns “someone is
near” into “the door is solid”.
Clock Sign
Before this: Reacting to the Game, Number Options
A sign that shows the real time, updated every second. Hang it in a lobby or over a station platform.
Contents
You need
- a brick with a Text Decal
chip text -> decal Text
Code
const FORMAT = "%H:%M:%S" // %H hours, %M minutes, %S seconds
const USE_UTC = true // true for UTC, false for the server's local time
var now: int = 0
@right out text = FormatDate(now.Value, FORMAT, useUTC = USE_UTC).Output
on Clock(interval = 1.0) {
now = GetUnixTime()
}
Wire it
- Compile the chip and paste it in-game. Place the Text Decal brick.
- Wire
textto the Text Decal’s Text.
The chip has no inputs: it starts as soon as it is pasted.
Check it in game
- Within a second of pasting, the sign shows a time such as
14:05:32. - Watch it: the seconds go up by one each second.
- Compare it with a clock set to UTC: the hours and minutes match.
Change it
- Drop the seconds:
const FORMAT = "%H:%M", and setinterval = 10.0on the Clock so the sign updates less often. - Show the date too:
const FORMAT = "%Y-%m-%d %H:%M", with%Ythe year,%mthe month and%dthe day. - Show the server’s own time zone:
const USE_UTC = false. - Put the date on a second line: join two
FormatDatecalls with..and a"<br>"between them, which starts a new line on a sign (Rich Text). Use"%H:%M"for the first and"%Y-%m-%d"for the second.
How it works
GetUnixTime() reads the server’s clock as a whole number of seconds,
counted from 1 January 1970. Once a second the Clock stores that number in
now (Reacting to the Game), and
FormatDate turns it into text using the codes in FORMAT
(Number Options). Its .Output is the
text. It also has a .Success, which is false when the date could not be
formatted. A Clock’s first pulse comes one interval after it starts, and
until then now is 0, so the sign shows the start of 1970 for its first
second.
Pressure Pad Door
Before this: Reacting to the Game, Waiting
Step on a pad and the door in front of you opens. It stays open while anyone stands on the pad, and for a few seconds after the last player steps off.
Contents
You need
- a brick with a Touch component, as the pad
- a door: a few bricks, one of them with a Brick Property Changer
touch Touching -> chip pad
touch Character -> chip stepper
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Code
// Seconds the door stays open after the last player steps off.
const STAY_OPEN = 3.0
// What a player sees when they step on the pad.
const MESSAGE = "The door is open while you stand here"
@left in pad: bool
@left in stepper: character
@right out solid = !Sleep(pad, delay = 0.0, hold = STAY_OPEN)
on pad {
stepper.ShowStatusMessage(MESSAGE)
}
Wire it
- Compile the chip and paste it in-game next to the door. Place the pad brick with the Touch where players walk up to the door.
- Wire the Touch’s Touching to
padand its Character tostepper. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility inputs.
Check it in game
- Before anyone steps on the pad: the door is there and you cannot walk through it.
- Step on the pad: the door vanishes at once, and a status message tells you the door is open.
- Stand on the pad for ten seconds: the door stays open the whole time.
- Step off: about three seconds later the door is back.
- Step off and back on within three seconds: the door never closes.
Change it
- Change
STAY_OPENfor a longer or shorter wait.0.0closes the door the moment the pad is free. - Light the pad while someone stands on it: add
@right out lit = padand wire it to a Point Light’s Enabled. - Put two pads on either side of the door and open it from both:
add
@left in pad2: booland useSleep(pad || pad2, ...).
How it works
Sleep(pad, delay = 0.0, hold = STAY_OPEN) is one Buffer (Seconds)
gate. Its output follows pad after delay seconds and, once pad turns
off, holds on for another hold seconds. Outside an await it is a steady
signal, not a step, so the door needs no handler at all
(Pure and Exec). The handler only sends the message:
on pad fires once when the pad turns on, and ShowStatusMessage shows a
line only that player sees (Reacting to the Game).
Waiting has the same gate inside an await, and the
Proximity Door opens the same way from a zone instead
of a pad.
Welcome Zone
Before this: Maps
A zone at the entrance of a build that greets each player. A first-time visitor gets a hint with the rules and a chat welcome. A returning one gets a short “welcome back”. A sign counts the different visitors.
Contents
You need
- a brick with an Attached Zone covering the entrance
- a brick with a Text Decal, for the visitor count
zone "Zone Ref" -> chip room
chip sign -> decal Text
Code
import { escape } from "sanitize"
// The name of the place, shown in the hint title.
const PLACE = "the workshop"
// The hint text a first-time visitor sees.
const RULES = "Be kind. Build freely. Ask before editing."
@left in room: zone
var visits: Map<string, int>
var guests: int = 0
@right out sign = "${guests.Value} visitors so far"
on ZoneEntered(zone = room) -> (character) {
let id = character.GetUserId()
let before = visits.get(id)
let count = if before.Found then before.Value + 1 else 1
visits.set(id, count)
let name = escape(character.GetDisplayName())
if count == 1 {
let number = guests + 1
guests = number
character.ShowHint("Welcome to ${PLACE}", RULES)
character.ShowChatMessage("Welcome, ${name}! You are visitor number ${number}.")
} else {
character.ShowStatusMessage("Welcome back, ${name} (visit ${count})")
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Attached Zone’s Zone Ref to
room. - Wire
signto the Text Decal’s Text.
Check it in game
- Before anyone walks in: the sign says
0 visitors so far. - Walk into the zone: a hint pops up titled “Welcome to the workshop” with
the rules, a chat line welcomes you by name as visitor number 1, and the
sign says
1 visitors so far. - Walk out and back in: no hint, a status message says “Welcome back” with visit 2, and the sign still says 1.
- With a second player: their first visit makes them visitor number 2.
Change it
- Change
PLACEandRULESto your build’s name and rules. - Fix the “1 visitors” grammar with a word form: build the sign with
Fmt("{A}|plural(one={A} visitor,other={A} visitors) so far", guests.Value)(Word Forms). - Only greet returning players once a day: store
GetUnixTime(), the server’s clock in whole seconds, per player in a second map, and skip the message when the last visit was less than 86400 seconds ago.
How it works
The zone event gives the player who walked in
(Reacting to the Game). The visit count is a
map keyed by the player’s user id, which stays the same across respawns and
visits. .Found tells a first visit from a missing key
(Maps). if ... then ... else
picks one of two values. The display name is escaped before it is shown,
because a player chooses it (Safe Text). The hint,
the chat line and the status message each go to that one player only.
Teleporter Pad
Before this: Maps
Walk onto a pad and you are sent to a destination. Each player then has to wait a few seconds before the pad takes them again, so nobody bounces back and forth, and one player’s trip never blocks anyone else.
Contents
You need
- a brick with an Attached Zone over the pad
- an Entity Teleport Point brick where players should arrive (it carries the Teleport Destination component)
zone "Zone Ref" -> chip pad
teleport "This Destination" -> chip target
Code
// Seconds a player waits before the pad takes them again.
const COOLDOWN = 5
@left in pad: zone
@left in target: teleport
var readyAt: Map<string, int>
on ZoneEntered(zone = pad) -> (character) {
let id = character.GetUserId()
let now = GetUnixTime()
let next = readyAt.get(id)
if next.Found && now < next.Value {
character.ShowStatusMessage("Teleporter recharging: ${next.Value - now}s")
} else {
readyAt.set(id, now + COOLDOWN)
character.Teleport(target)
}
}
Wire it
- Compile the chip and paste it in-game. Place the Entity Teleport Point outside the pad’s zone. While you test, put it a few steps from the pad, so you can walk back before the cooldown ends.
- Wire the Attached Zone’s Zone Ref to
pad. - Wire the Teleport Destination’s This Destination to
target.
Check it in game
- Walk onto the pad: you arrive at the teleport point.
- Walk back onto the pad before five seconds are up: you stay put, and a status message says how many seconds are left.
- Wait until the time is up, step off and on again: you are sent again.
- With a second player: while you are recharging, they still go through at once.
Change it
- Change
COOLDOWN. It counts whole seconds. - Make a two-way link: paste a second copy next to the teleport point, and
wire its
targetto a second teleport point beside the first pad. Keep the arrival points outside both zones, or players land in the other pad. - Charge for the trip: keep a second map of coins per player and only teleport a player who has enough.
How it works
The zone event gives the player who walked in
(Reacting to the Game). The cooldown is a map
from the player’s user id to the time their pad is ready again, so every
player has their own and a respawn does not reset it. .Found covers a
player the pad has never seen (Maps).
GetUnixTime() is the server’s clock in whole seconds. It keeps counting
through a server restart, so a stored time stays meaningful. teleport is a
pin type for a teleport point: it can only be wired, like zone.
Shooting Gallery
Before this: Looping Over a Map, Rich Text, Per-Player State
A target and a scoreboard. Every hit scores a point for the player who fired the shot, the shooter sees their running total, and a sign lists everyone’s hits. A button clears the board for the next round.
Contents
You need
- a Target brick
- a brick with a Text Decal, for the scoreboard
- a Button, to clear the board
target "Just Hit" -> chip hit
target "Character That Just Hit" -> chip shooter
button Held -> chip reset
chip board -> decal Text
Code
import { escape } from "sanitize"
// The first line of the board.
const TITLE = "<b>Shooting Gallery</b>"
@left in hit: exec
@left in shooter: character
@left in reset: exec
var hits: Map<string, int>
var names: Map<string, string>
var shown: string = TITLE
@right out board = shown.Value
on hit {
let id = shooter.GetUserId()
let old = hits.get(id)
let count = if old.Found then old.Value + 1 else 1
hits.set(id, count)
names.set(id, escape(shooter.GetDisplayName()))
shooter.ShowStatusMessage("Hit! ${count} so far")
var text: string = TITLE
for key, n in hits {
let name = names.get(key)
text = "${text}<br>${name}: ${n}"
}
shown = text
}
on reset {
hits.clear()
names.clear()
shown = TITLE
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Target’s Just Hit to
hitand its Character That Just Hit toshooter. - Wire the Button’s Held to
reset. - Wire
boardto the Text Decal’s Text.
Check it in game
- Before any shot: the sign shows only the title.
- Shoot the target: the board shows a line with your name and
1, and a status message says “Hit! 1 so far”. - Shoot it again, waiting a moment between shots: your line goes up by one each time.
- With a second player: each of you has a line, and each hit counts for the one who fired it.
- Press the button: the board is back to the title, and the next hit starts from 1.
Change it
- Count fast shots: the Target’s Just Hit stays on for its On Time setting, so a second hit while it is still on is likely to be missed. Lower On Time for fast weapons.
- Score by damage instead of hits: wire the Target’s Damage to a new
@left in damage: floatpin, makehitsaMap<string, float>, and adddamagein place of the 1. - Put several targets on one board: wire each Target into its own copy of
hitandshooter, with a handler for each that calls a shared mod to score the hit.
How it works
on hit fires once per hit, on the moment Just Hit turns on, and
Character That Just Hit says who fired. Scores live in two maps keyed by
user id, one for the count and one for the name, so a player who respawns
keeps their score (Maps). The board
is rebuilt after every hit with a loop over the map
(Looping Over a Map). <br> starts a new
line and <b> makes the title bold (Rich Text).
A map of records cannot be looped over directly, so the name and the count
are kept in two maps.
Healing Zone
Before this: Looping Over a List
Needs admin to load: it changes players’ health with the Set Player Damage gate, which only an admin can place.
Stand in the zone and your wounds heal a little every second. Turned around, the same chip makes a zone that hurts.
Contents
You need
- a brick with an Attached Zone covering the healing area
zone "Zone Ref" -> chip room
Code
// Damage healed per tick of the zone.
const HEAL = 10.0
// Seconds between ticks.
const EVERY = 1.0
@left in room: zone
var inside: character[]
on Clock(interval = EVERY) {
inside.fillFromZonePlayers(room)
for p in inside {
let hurt = p.GetDamage()
if hurt > 0.0 {
p.SetDamage(max(hurt - HEAL, 0.0))
p.ShowStatusMessage("Healing...")
}
}
}
Wire it
- Compile the chip and paste it in-game, as an admin.
- Wire the Attached Zone’s Zone Ref to
room.
Check it in game
- Get hurt first (a second copy of this chip, made into a hurting zone as in Change it, can do that), then walk into the zone: once a second a status message says “Healing…”, and your health climbs back.
- Stay in the zone at full health: no message.
- With a second player: both of you heal at once.
- Walk out: the healing stops.
Change it
- Change
HEALandEVERYfor faster or slower healing. - Make a hurting zone: use
p.SetDamage(hurt + HEAL)and drop theif hurt > 0.0check. A player dies when their damage reaches the limit thatp.GetDamage().DamageLimitreports. - Heal only players who carry a tag: pass it as
inside.fillFromZonePlayers(room, "medic"), and give players the tag withcharacter.SetTag("medic"), from a button for example.
How it works
The Clock fires every EVERY seconds
(Reacting to the Game), fills a list with the
players standing in the zone, and loops over it
(Looping Over a List). A player’s health
is kept as damage taken: 0 is unhurt, and GetDamage() reads it.
max(a, b) gives the larger of two numbers, so healing never takes the
damage below 0. The list is filled again on every tick, so a player who
walked out is no longer in it.
Launch Pad
Before this: Reacting to the Game, Vectors and Colors
Run onto the pad and it throws you into the air, and forward in the direction you are looking.
Contents
You need
- a brick with an Attached Zone just above the pad
zone "Zone Ref" -> chip pad
Code
// Upward push.
const UP = 1500.0
// Push in the direction the player looks.
const AHEAD = 1000.0
@left in pad: zone
on ZoneEntered(zone = pad) -> (character) {
let look = character.GetAim().Direction
let flat = Vec(look.x, look.y, 0.0)
character.AddVelocity(linear = flat * AHEAD + Vec(0.0, 0.0, UP))
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Attached Zone’s Zone Ref to
pad.
Check it in game
- Walk onto the pad looking straight ahead: you fly up and forward.
- Walk on looking to one side: you fly toward where you look, not where you walked.
- Walk on looking straight up or down: you go almost straight up.
- Stand still on the pad: nothing more happens until you step off and back on.
Change it
- Change
UPandAHEADfor a bigger or smaller jump. Start small: the numbers are speeds, and a large one sends a player very far. - Always launch the same way, whatever the player looks at: replace the
whole push with a fixed vector, such as
Vec(800.0, 0.0, 1500.0). - Turn the pad off with a switch: add
@left in armed: bool, wired from a Switch’s Enabled, and wrap the push inif armed { ... }.
How it works
The zone event gives the player who stepped on
(Reacting to the Game). GetAim() reads where
they look as a direction vector. Its height is set to 0, so the forward
push stays level. It is weaker the more steeply the player looks up or
down. The upward push is added on top
(Vectors and Colors). * AHEAD scales all
three parts of the vector at once. AddVelocity adds to the player’s speed
rather than replacing it, so a running start carries into the jump.
Item Dispenser
Before this: Maps
Press a button and an item lands in your inventory. The dispenser holds a limited stock, each player may take only so many, and a second button restocks it. A sign shows how many are left.
Contents
You need
- a Button, to take an item
- a second Button, to restock (put it somewhere only you can reach)
- a brick with a Text Decal, for the stock
button1 "Press Count" -> chip holding
button1 Character -> chip taker
button2 Held -> chip restock
chip sign -> decal Text
Code
// The item each press hands out.
const ITEM = $BRItemBase/Weapon_HealthPotion
// How many the dispenser holds until it is restocked.
const STOCK = 10
// How many one player may take until it is restocked.
const PER_PLAYER = 1
@left in holding: int
@left in taker: character
@left in restock: exec
var stock: int = STOCK
var taken: Map<string, int>
var last: int = 0
@right out sign = "${stock.Value} left"
on Change(holding) {
if holding > last {
let id = taker.GetUserId()
let had = taken.get(id)
let mine = if had.Found then had.Value else 0
if stock <= 0 {
taker.ShowStatusMessage("Sold out")
} else if mine >= PER_PLAYER {
taker.ShowStatusMessage("You already took yours")
} else {
taker.AddInventoryItem(ITEM)
taken.set(id, mine + 1)
stock -= 1
}
}
last = holding
}
on restock {
stock = STOCK
taken.clear()
}
Wire it
- Compile the chip and paste it in-game.
- Wire the take Button’s Press Count to
holdingand its Character totaker. - Wire the restock Button’s Held to
restock. - Wire
signto the Text Decal’s Text.
Check it in game
- Before anyone presses: the sign says
10 left. - Press the take button: a health potion appears in your inventory, and the
sign says
9 left. - Press it again: nothing is added, and a status message says you already took yours.
- With a second player: they get one too, and the sign goes down again.
- Hold the take button down while a second player presses it: they still get theirs.
- Press restock: the sign is back to
10 left, and everyone may take one more.
Change it
- Hand out something else: change
ITEM. The editor lists the item names after you type$BRItemBase/. - Change
STOCKandPER_PLAYER. A largeSTOCKmakes a free dispenser. - Give each player a random item: put three items in a
constarray and pick one withRandom(Weighted Loot).
How it works
A Button’s Character is the player who pressed it last, so the handler
knows whom to give the item to. Press Count is how many players hold the
button right now. The handler runs whenever it changes
(Change), and holding > last
lets only a rise through: one new press, by anyone. A release lowers the
count and gives nothing. Held stays on while anyone holds the button, so
it would miss a second player pressing during that time.
$BRItemBase/... names one of the game’s items.
It is written into the gate, so it needs no wire. How many items each player
took is a map keyed by user id, and .Found covers a player who has not
taken any yet (Maps). if ... else if ... else checks the stock first, then the player’s share
(Reacting to the Game). Restocking clears the
map, so everyone starts over.
Paint Gun
Before this: Remote Control, Forking and Merging, Game Enums, Weighted Loot
A paint studio. Flip the switch to pick up the brush, look at a brick and click: the brick turns the current paint color. A button steps through a palette, and a light shows the color you will paint with.
Contents
You need
- a Switch, to pick up the brush
- a Button, to pick the next color
- a Point Light, as the color swatch
switch Character -> chip painter
button Held -> chip next
chip swatch -> light Color
Code
// How far the painter can reach.
const REACH = 1000.0
// The colors the button steps through.
const PALETTE = [
Color(0.9, 0.1, 0.1),
Color(0.1, 0.6, 0.1),
Color(0.1, 0.3, 0.9),
Color(1.0, 0.85, 0.1),
Color(1.0, 1.0, 1.0),
]
@left in painter: character
@left in next: exec
var pick: int = 0
var paint: color = Color(0.9, 0.1, 0.1)
@right out swatch = paint.Value
let keys = InputReader(painter)
on next {
pick = (pick + 1) % PALETTE.length()
paint = PALETTE[pick]
}
on keys.PressedLeftMouse {
let aim = painter.GetAim()
let r = Sweep(aim.Origin, aim.Direction, REACH, ignore = painter, detectBricks = true)
on r.Hit {
r.HitBrick.SetBrickProperty(BrickProperty.Color, paint)
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Switch’s Character to
painter. - Wire the Button’s Held to
next. - Wire
swatchto the Point Light’s Color, and turn off the light’s Use Brick Color, or it ignores the wired color.
Check it in game
- Before any press: the swatch light is red.
- Flip the switch, look at a brick you built and click: the brick turns red. Empty your hands first, so the click does nothing else.
- Hold the mouse button and sweep your view across other bricks: only the first brick changes. Let go and click again for the next one.
- Press the button: the swatch turns green, and the next brick you paint is green. Five presses bring it back to red.
- With a second player: they flip the switch, and now their clicks paint and yours do nothing.
Change it
- Add colors to
PALETTE, or change them. The first one should match the startingpaint. - Shorten
REACHso the painter must walk up to what they paint. - Paint only while the switch is on: add
@left in active: bool, wire the Switch’s Enabled to it, and writeon keys.PressedLeftMouse && active. - Let several players paint at once, each with their own brush: give each painter a chip of their own, as in One Chip per Player.
How it works
The Switch’s Character holds the last player who flipped it, and
InputReader turns that player’s keys into live wires
(Remote Control). on keys.PressedLeftMouse
runs once per click, with no timer. GetAim() gives where the painter looks
from and the direction, and Sweep casts a ray along it. on r.Hit runs
only when the ray met a brick
(Forking and Merging).
SetBrickProperty(BrickProperty.Color, ...) recolors that brick
(Game Enums). The palette is a const array, read
in a handler (Weighted Loot).
Hold to Use
Before this: Waiting
A door that opens only for a player who holds its button down for two seconds. A status message tells them to keep holding. If they let go early, nothing happens.
Contents
You need
- a Button beside the door
- a door: a few bricks, one of them with a Brick Property Changer
button Held -> chip held
button Character -> chip holder
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Code
// Seconds the button must be held.
const HOLD = 2.0
// Seconds the door stays open.
const OPEN_SECONDS = 5.0
@left in held: bool
@left in holder: character
var opened: bool = false
@right out solid = !opened.Value
let unlock: exec
on held {
holder.ShowStatusMessage("Keep holding...")
await !held || Sleep(_, delay = HOLD)
if held {
holder.ShowStatusMessage("Done!")
emit unlock
} else {
holder.ShowStatusMessage("Too short")
}
}
on unlock {
opened = true
await Sleep(_, delay = OPEN_SECONDS)
opened = false
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Button’s Held to
heldand its Character toholder. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility inputs.
Check it in game
- Tap the button: the message says “Keep holding…”, then “Too short”, and the door stays shut.
- Hold the button: after two seconds the message says “Done!” and the door vanishes.
- Five seconds later the door is back.
- Hold for one second, let go, and hold again: the two seconds start over from the second press.
Change it
- Change
HOLDfor a longer or shorter hold, andOPEN_SECONDSfor how long the door stays open. - Count the uses on a sign: add
var uses: int = 0, putuses += 1at the top ofon unlock, and add@right out text = "Used ${uses.Value} times", wired to a Text Decal’s Text. - Put a second button on the far side of the door: add a second pair of
pins and a copy of the
on heldhandler that reads them, and fire the sameunlock.
How it works
The Button’s Held is on for as long as the button is held down, and
on held runs when it turns on (Pure and Exec). The
handler then races two waits
(Waiting): !held turns on the moment the button is
let go, and the Sleep after two seconds. Whichever comes first resumes the
chain, and if held tells them apart: still held means the full two
seconds passed. The unlock signal starts a second handler that holds the
door open with another await. Character is the player who pressed, so
the messages go to them alone.
Moving Platform
Before this: Waiting, Choosing a Value
A platform that carries players between two landings. Step on and it leaves after a short wait, and a call button at each landing brings it over.
Contents
You need
- a Rigid Slider laid along the path, with the platform welded to its moving end (the slider’s own hint says how: weld it with the placer or manipulator)
- a brick with a Touch component on the platform’s deck
- two Buttons, one at each landing
touch Touching -> chip rider
touch Character -> chip who
button1 Held -> chip callNear
button2 Held -> chip callFar
chip target -> mover "Target Position"
Code
// The slider position of the far landing, with Positions Are Percentages on.
const FAR = 100.0
// Seconds between stepping on and leaving.
const DEPART = 2.0
@left in callNear: exec
@left in callFar: exec
@left in rider: bool
@left in who: character
var atFar: bool = false
@right out target: float = if atFar.Value then FAR else 0.0
on callNear {
atFar = false
}
on callFar {
atFar = true
}
on rider {
who.ShowStatusMessage("Departing in ${DEPART} seconds")
await Sleep(_, delay = DEPART)
if rider {
atFar = !atFar
}
}
Wire it
- Compile the chip and paste it in-game near the near landing.
- In the Rigid Slider’s settings, turn on Positions Are Percentages.
- Wire
targetto the slider’s Target Position. - Wire the deck Touch’s Touching to
riderand its Character towho. - Wire the near landing’s Button Held to
callNear, and the far landing’s tocallFar.
Check it in game
- Before anyone steps on: the platform rests at the near landing.
- Step onto the platform: a status message says it departs in two seconds, and then it slides to the far landing with you on it.
- Step off and back on: it carries you back.
- Step on and off again within two seconds: it stays where it is.
- Stand at the near landing with the platform away and press the button there: it comes back empty.
Change it
- Change
DEPARTfor a longer or shorter wait. - Make it travel part of the way: a
FARof50.0stops halfway along the slider. - Make it shuttle by itself: add an
on Clock(interval = 10.0)handler that flipsatFar, so the platform goes back and forth every ten seconds. - Change how fast it moves with the slider’s Top Speed and Smooth Time settings.
How it works
The chip decides only where the platform should be: atFar is one stored
bool, and if ... then ... else turns it into the slider’s target, a steady
signal the slider follows on its own (Choosing a
Value). The call buttons set atFar directly.
Stepping on starts a wait (Waiting). After it,
if rider checks that someone is still on the deck before the platform
sets off, so a player who jumped straight off does not send it away empty.
Keycard Door
A door that opens only for players who have picked up the keycard. Press the keycard button somewhere in your build, then walk up to the door: it opens for you, and stays shut for everyone without a card.
Contents
You need
- a Button, where players pick up the keycard
- a brick with an Attached Zone in front of the door
- a door: a few bricks, one of them with a Brick Property Changer
- a second Button, to take every keycard back (put it somewhere only you can reach)
button1 "Press Count" -> chip holding
button1 Character -> chip grabber
zone "Zone Ref" -> chip door
button2 Held -> chip reset
chip solid -> changer "Collision Enabled"
chip solid -> changer Visibility
Code
// Seconds the door stays open for a card holder.
const OPEN_SECONDS = 4.0
@left in holding: int
@left in grabber: character
@left in door: zone
@left in reset: exec
var cards: Map<string, bool>
var opened: bool = false
var last: int = 0
@right out solid = !opened.Value
on Change(holding) {
if holding > last {
cards.set(grabber.GetUserId(), true)
grabber.ShowStatusMessage("You picked up the keycard")
}
last = holding
}
on ZoneEntered(zone = door) -> (character) {
if cards.has(character.GetUserId()) {
opened = true
await Sleep(_, delay = OPEN_SECONDS)
opened = false
} else {
character.ShowStatusMessage("Locked. You need the keycard.")
}
}
on reset {
cards.clear()
}
Wire it
- Compile the chip and paste it in-game.
- Wire the keycard Button’s Press Count to
holdingand its Character tograbber. - Wire the Attached Zone’s Zone Ref to
door. - Wire
solidto the Brick Property Changer’s Collision Enabled and Visibility inputs. - Wire the second Button’s Held to
reset.
Check it in game
- Walk up to the door without a card: it stays shut, and a status message says you need the keycard.
- Press the keycard button: a status message says you picked it up.
- Walk up to the door again: it opens, and four seconds later it shuts.
- Respawn and walk up again: it still opens. The card belongs to the player, not to the character.
- With a second player who has no card, walking up alone: the door stays shut for them. While it is open for a card holder, anyone can walk through.
- Press the reset button: the door is locked for everyone again.
Change it
- Change
OPEN_SECONDS. - Make the card single use: add
cards.remove(character.GetUserId())right afteropened = true. - Several doors, several cards: give each card a name, store it in a
Map<string, string>, and let each door check for its own name. - Show who holds a card on a sign: loop over
cardswithfor id, has in cards(Looping Over a Map), with a second map from user id to display name.
How it works
There is no keycard item. The card is an entry in a map keyed by the player’s
user id (Maps). The button’s
Character says who pressed it last, and the zone event says who walked
up (Reacting to the Game). The button’s
Press Count is how many players hold it right now. The grab handler runs
on every change of it (Change),
and holding > last keeps only the rises. A press counts even while someone
else holds the button and Held stays on, and letting go does nothing.
has() asks whether
that player’s key is in the map. A card holder starts the same timed opening
as an auto-closing door (Waiting). Because the user
id stays the same across respawns, the card survives a death. reset clears
the map to take every card back.
Checkpoints
Before this: Maps, Mods, Per-Player State
Checkpoints along a course. Each player’s furthest checkpoint is remembered, and a player who falls off, or comes back from the spawn point, is sent back to it.
Contents
You need
- Three bricks with an Attached Zone each: the checkpoints, in course order.
- One Attached Zone under the course, where a falling player lands.
- One Attached Zone across the way out of the spawn area, where every player walks after spawning.
zone1 "Zone Ref" -> chip cp1
zone2 "Zone Ref" -> chip cp2
zone3 "Zone Ref" -> chip cp3
zone4 "Zone Ref" -> chip fall
zone5 "Zone Ref" -> chip spawn
Code
const COUNT = 3 // checkpoints on the course, shown in the message
@left in cp1: zone
@left in cp2: zone
@left in cp3: zone
@left in fall: zone
@left in spawn: zone
var reached: Map<string, int>
var spot: Map<string, vector>
mod reach(who: character, n: int) {
let id = who.GetUserId()
let had = reached.get(id)
if had.Found && had.Value >= n {
return
}
reached.set(id, n)
spot.set(id, who.GetLocation())
who.ShowStatusMessage("Checkpoint ${n} of ${COUNT}")
}
mod sendBack(who: character) {
let saved = spot.get(who.GetUserId())
if saved.Found {
who.SetLocation(saved.Value)
}
}
on ZoneEntered(zone = cp1) -> (character) { reach(character, 1) }
on ZoneEntered(zone = cp2) -> (character) { reach(character, 2) }
on ZoneEntered(zone = cp3) -> (character) { reach(character, 3) }
on ZoneEntered(zone = fall) -> (character) { sendBack(character) }
on ZoneEntered(zone = spawn) -> (character) { sendBack(character) }
Wire it
- Compile the chip and paste it in-game near the course.
- Wire each checkpoint’s Attached Zone Zone Ref to
cp1,cp2andcp3, in the order players reach them. - Wire the zone under the course to
fall, and the zone at the way out of spawn tospawn. - Make the checkpoint zones tall and wide enough that nobody can jump past one, and the fall zone wide enough to catch everyone.
Check it in game
- Walk into the first checkpoint: a status message says “Checkpoint 1 of 3”.
- Walk back through it: no message, since you have reached it already.
- Reach the second checkpoint, then jump off the course: you land in the fall zone and appear back at the second checkpoint.
- Walk back into the first checkpoint and fall again: you still go back to the second one. Progress only moves forward.
- Die (or reset), and walk out of spawn: you are moved to your checkpoint.
- A friend who has not reached any checkpoint falls: nothing moves them.
Change it
- Add a fourth checkpoint: a
cp4pin, a handler that callsreach(character, 4), andCOUNT = 4. - Make players take the checkpoints in order: in
reach, readlet last = if had.Found then had.Value else 0and return unlesslast == n - 1. - Add a restart button at the start: wire a Button’s Press Count
and Character to two new pins (an
intand acharacter). Press Count is how many players hold the button right now, so in anon Change(...)handler on it, keep the last count in avarand act only when the count went up:if holding > last { ... }thenlast = holding. Inside theif, remove that player’s entries fromreachedandspot. That catches every press, even one made while another player still holds the button, and ignores releases. - The game also has its own Checkpoint brick, which sets a player’s spawn point without any wires. Try it next to this part.
How it works
Every zone has its own on ZoneEntered handler (Reacting to the
Game), and the checkpoint handlers share one
mod, reach, which takes the player and the
checkpoint’s number. Progress lives in two maps
keyed by GetUserId(), so it survives a respawn, when the player gets a new
character but keeps their user id. spot stores where the player stood when
they reached the checkpoint, a vector, and
SetLocation puts them back there. .Found guards every read, since a
player who has reached nothing has no entry.
This part does not choose where a player respawns. The spawn zone works
around that: a player who respawns walks out of the spawn
area, enters the zone, and is moved on to their checkpoint.
Back to Parts.
Race Timer
Before this: Number Options, Mods, Per-Player State
A timed course with a start zone, two split zones and a finish zone. Each
racer sees their splits and finish time as mm:ss, the circuit keeps every
racer’s best time, and a sign shows the course record.
Contents
You need
- Four bricks with an Attached Zone each: start, first split, second split, finish.
- A brick with a Text Decal for the record.
zone1 "Zone Ref" -> chip start
zone2 "Zone Ref" -> chip split1
zone3 "Zone Ref" -> chip split2
zone4 "Zone Ref" -> chip finish
chip sign -> decal Text
Code
import { escape } from "sanitize"
const SPLITS = 2 // split zones between start and finish
@left in start: zone
@left in split1: zone
@left in split2: zone
@left in finish: zone
var startedAt: Map<string, int>
var next: Map<string, int>
var best: Map<string, int>
var record: int = 0
var holder: string = ""
mod pad2(n: int) -> string {
let s = "0${n}"
return s.Substring(s.Length() - 2, 2)
}
mod clock(seconds: int) -> string {
return "${pad2(seconds / 60)}:${pad2(seconds % 60)}"
}
@right out sign = if holder.Value == "" then "No record yet" else "Record: ${holder.Value} ${clock(record.Value)}"
mod split(who: character, n: int) {
let id = who.GetUserId()
let at = next.get(id)
let began = startedAt.get(id)
if !at.Found || at.Value != n {
return
}
next.set(id, n + 1)
who.ShowStatusMessage("Split ${n}: ${clock(GetUnixTime() - began.Value)}")
}
on ZoneEntered(zone = start) -> (character) {
let id = character.GetUserId()
startedAt.set(id, GetUnixTime())
next.set(id, 1)
character.ShowStatusMessage("Go!")
}
on ZoneEntered(zone = split1) -> (character) { split(character, 1) }
on ZoneEntered(zone = split2) -> (character) { split(character, 2) }
on ZoneEntered(zone = finish) -> (character) {
let id = character.GetUserId()
let at = next.get(id)
let began = startedAt.get(id)
if !at.Found || at.Value != SPLITS + 1 {
return
}
next.remove(id)
let time = GetUnixTime() - began.Value
let old = best.get(id)
if !old.Found || time < old.Value {
best.set(id, time)
character.ShowStatusMessage("Finished in ${clock(time)}, a new best!")
} else {
character.ShowStatusMessage("Finished in ${clock(time)} (best ${clock(old.Value)})")
}
if holder == "" || time < record {
record = time
holder = escape(character.GetDisplayName())
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the four Attached Zones’ Zone Ref outputs to
start,split1,split2andfinish. - Wire
signto the Text Decal’s Text. - Make each zone span the whole track, so nobody can go around it.
Check it in game
- The sign reads “No record yet”.
- Walk through the start: “Go!”.
- Walk through the first split: “Split 1: 00:04”, or however long it took.
- Walk through the second split, then the finish: “Finished in 00:12, a new best!”, and the sign shows your name and time.
- Walk from the start straight to the finish, around the splits: nothing happens. A run only counts with every split in order.
- Run again, slower: “Finished in 00:20 (best 00:12)”, and the record stays.
Change it
- Add a third split: a
split3pin and handler callingsplit(character, 3), andSPLITS = 3. - Show each racer their best time on a status message when they walk into the start.
- Add a reset button that clears
best, setsholderback to"", and so clears the record.
How it works
Three maps keyed by GetUserId() hold each
racer’s state: when they started, which split they must cross next, and
their best time. The next map is the guard. A split only counts when it is
the one expected, and the finish only when every split has been crossed, so
a shortcut or a wrong turn leaves the run unfinished. The finish removes the
racer’s next entry, so walking through it again does nothing. pad2 and clock
turn whole seconds into mm:ss, as in Number
Options. Times come from GetUnixTime(),
whole seconds of the server’s clock, which carries on through a restart.
The racer’s name is escaped once, when it is stored (Safe
Text).
Back to Parts.
Scoreboard HUD
Before this: Rich Text, Per-Player State, Looping Over a Map, A HUD for Everyone
Scores shown on each player’s own screen instead of on a sign. Everyone in the arena sees their own score and the leader at the top of the screen. The text is redrawn only when a score changes, and taken down when they leave.
Contents
You need
- A brick with an Attached Zone covering the arena.
- Something that scores a point and says who scored: a Target (Just Hit and Character That Just Hit) or a Button (Held and Character).
zone "Zone Ref" -> chip arena
target "Just Hit" -> chip point
target "Character That Just Hit" -> chip scorer
button Held -> chip point
button Character -> chip scorer
Code
import { escape } from "sanitize"
const TEXT_ID = 7100 // a number no other circuit on the server uses
const FONT_SIZE = 28 // size of the text
@left in arena: zone
@left in point: exec
@left in scorer: character
var scores: Map<string, int>
var leader: string = "nobody"
var top: int = 0
var here: character[]
var shown: character[]
mod scoreOf(id: string) -> int {
let s = scores.get(id)
return if s.Found then s.Value else 0
}
mod show(p: character, mine: int) {
p.DisplayText("Your score: ${mine}<br>Leader: ${leader} (${top})",
textId = TEXT_ID, anchorX = 0.5, anchorY = 0.0, positionY = 80.0,
fontSize = FONT_SIZE, justify = Center, lifetime = 0.0)
}
on Clock(interval = 1.0) {
here.fillFromZonePlayers(arena)
for p in shown {
if !here.find(p).Found {
p.DisplayText("", textId = TEXT_ID)
}
}
for p in here {
if !shown.find(p).Found {
show(p, scoreOf(p.GetUserId()))
}
}
shown.copyFrom(here)
}
on point {
let id = scorer.GetUserId()
let mine = scoreOf(id) + 1
scores.set(id, mine)
if mine > top {
leader = escape(scorer.GetDisplayName())
top = mine
} else {
here.fillFromZonePlayers(arena)
if here.find(scorer).Found {
show(scorer, mine)
}
}
}
on Change(top) {
here.fillFromZonePlayers(arena)
for p in here {
show(p, scoreOf(p.GetUserId()))
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Attached Zone’s Zone Ref to
arena. - For a Target: wire Just Hit to
pointand Character That Just Hit toscorer. For a Button, wire Held topointand Character toscorer. Held stays on while anyone holds the button, so it misses a second player’s press while the first still holds it. To catch that too, wire Press Count (how many players hold the button right now) to anintpin instead, and score inon Change(...)only when it went up, as the Doorbell does.
Check it in game
- Walk into the arena: “Your score: 0” and “Leader: nobody (0)” appear at the top of your screen within a second.
- Score a point: your line reads 1, and the leader line shows your name.
- A second player walks in: within a second they see their own score and the same leader.
- The second player scores twice: both screens show them leading with 2, and your own line still reads 1.
- Stand still with nothing scoring: the text stays up.
- Walk out of the arena: the text disappears within a second.
- Walk back in: your score is still there.
Change it
- Add a reset button: a
reset: execpin whose handler clearsscores, setsleaderback to"nobody"and thentopto 0. Changingtopredraws everyone inside. - Move the text to the top-right corner:
anchorX = 1.0,positionX = -40.0andjustify = Right. - Show a rank: count the entries in
scoresabove the player’s score withfor id, n in scores(Looping Over a Map), and addRank: ${rank}as a third line of the same text. Anyone’s point can change someone else’s rank, so redraw everyone inside after each point.
How it works
DisplayText draws text on one player’s screen, and a character wires
straight into the gate’s player input. Zone events can be missed over a
long game, so the chip asks the zone who is inside with
fillFromZonePlayers each time it needs to know. Once a second the Clock
compares that list with shown, the
players it drew for last time: a newcomer gets a text, a player who walked
out gets an empty one, and then shown becomes a copy of the fill. A
point redraws only the scorer’s text, unless it changes top: then the
leader line is different for everyone, and on Change(top) redraws every
player inside with a loop. A short
lifetime could also take a leaver’s text down, but then the Clock would
have to redraw every text before it ran out. The leader’s name is stored before
top, because the redraw reads it. Every draw uses the same textId, so
each redraw replaces the text already on that player’s screen instead of
adding another, and lifetime = 0.0 keeps it up until it is replaced. The
Clock never redraws a text that has not changed. A HUD for
Everyone builds the same design step by
step.
The game draws only about 32 of these texts on one player’s screen at
once, and the budget is shared by every circuit that player sees. Past it,
a text silently never appears. This part spends one: both lines are in one
text, split by <br> (Rich Text). Scores are
keyed by GetUserId(), so a respawn keeps them, and the leader’s name is
escaped when it is stored (Safe Text). A player
who dies inside drops out of the fill, and the next Clock pulse sends the
empty text to their old character, which becomes null a little after
the death. Their new character is a newcomer to the Clock when they walk
back in. See
DisplayText and DisplayText Screen
Placement for every
setting.
Back to Parts.
Round Manager
Before this: Payloads, Exec Pins, Number Options
Runs a game round: a lobby, a countdown, a timed round and a results pause, then back to the lobby. It tells the rest of your build when a round begins and ends, and a sign shows the phase and the time left.
Contents
You need
- Two Buttons: start and end early.
- A brick with a Text Decal for the phase and the time.
- Whatever the round controls: a Point Light, a door, other chips.
button1 Held -> chip start
button2 Held -> chip stop
chip sign -> decal Text
chip playing -> light Enabled
chip began -> circuit "exec input"
chip ended -> circuit "exec input"
Code
const COUNTDOWN = 5 // seconds from start to the round beginning
const ROUND = 120 // seconds a round lasts
const RESULTS = 10 // seconds to show "Round over" before the lobby
enum Phase { Lobby, Countdown { until: int }, Playing { until: int }, Over { until: int } }
mod pad2(n: int) -> string {
let s = "0${n}"
return s.Substring(s.Length() - 2, 2)
}
@left in start: exec
@left in stop: exec
var phase: Phase = Phase.Lobby
var secondsLeft: int = 0
@right out sign = match phase {
Lobby => "Press start",
Countdown { .. } => "Starting in ${secondsLeft.Value}",
Playing { .. } => "${pad2(secondsLeft.Value / 60)}:${pad2(secondsLeft.Value % 60)}",
Over { .. } => "Round over",
}
@right out playing = phase is Phase.Playing
@right out began: exec
@right out ended: exec
on start {
if phase is Phase.Lobby {
secondsLeft = COUNTDOWN
phase = Phase.Countdown { until: GetUnixTime() + COUNTDOWN }
}
}
on stop {
if phase is Phase.Playing {
phase = Phase.Playing { until: GetUnixTime() }
}
}
on Clock(interval = 0.25, enabled = !(phase is Phase.Lobby)) {
let now = GetUnixTime()
match phase {
Lobby => {}
Countdown { until } => {
secondsLeft = until - now
if now >= until {
secondsLeft = ROUND
phase = Phase.Playing { until: now + ROUND }
emit began
}
}
Playing { until } => {
secondsLeft = until - now
if now >= until {
phase = Phase.Over { until: now + RESULTS }
emit ended
}
}
Over { until } => {
if now >= until {
phase = Phase.Lobby
}
}
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the start Button’s Held to
startand the other Button’s Held tostop. - Wire
signto the Text Decal’s Text. - Wire
playingto anything that should be on only during a round, such as a Point Light’s Enabled. Wirebeganandendedto exec inputs that should fire once at each edge of the round, such as a chip’s reset pin.
Check it in game
- The sign reads “Press start” and the light is off.
- Press start: “Starting in 5”, 4, 3, 2, 1.
- The round begins: the sign counts down from
02:00, the light comes on, and whateverbeganis wired to fires once. - Press start during the round: nothing changes.
- Press end early: within a quarter of a second the sign reads “Round
over”, the light goes off and
endedfires. - Ten seconds later the sign reads “Press start” again.
Change it
- Change the times:
ROUND = 300for five minutes. - Carry a winner into the results: add
winner: stringtoOver, pass a name in through a pin, and show"${winner} wins!"in the sign’sOverarm. - Start automatically: wire a zone’s Occupant Count to a new
@left in waiting: intpin, and addon Change(waiting)with the body ofon startinsideif waiting >= 2 { ... }.
How it works
The round is one enum value, and each phase except
the lobby carries until: the Unix time at which it ends. Outside the
lobby the Clock reads the time four times a second, to count the sign down
and to move the phase on when now passes until, so one match holds
all the rules. In the lobby nothing counts down, and the Clock is off until
start. “End early” sets the playing phase’s end to now, and the Clock
ends the round at its next pulse. The exec outputs (Exec Pins)
are emitted only from the Clock handler, so every phase change happens in
one place.
The times are GetUnixTime(), the server’s clock in whole seconds, and the
phase is a var saved with the bricks. After a server restart the round
carries on where the clock says it should be, and a phase whose end passed
while the server was down moves on at the Clock’s next pulse. The mm:ss text uses
pad2 from Number Options.
Back to Parts.
King of the Hill
Before this: Ready Check, Exec Pins, Per-Player State, Looping Over a Map
A hill that players fight over. Whoever stands on it alone collects a second of hold time each second, and the first to reach a minute is king. A sign shows who holds the hill and the longest hold so far.
Contents
You need
- A brick with an Attached Zone covering the hilltop.
- A Button to start a new game.
- A brick with a Text Decal for the sign.
- Something to celebrate the king: a One Shot Audio, a light, a door.
zone "Zone Ref" -> chip hill
button Held -> chip reset
chip sign -> decal Text
chip crowned -> speaker Play
Code
import { escape } from "sanitize"
const WIN_SECONDS = 60 // seconds on the hill to win
@left in hill: zone
@left in reset: exec
var players: character[]
var held: Map<string, int>
var running: bool = true
var status: string = "The hill is empty"
var bestName: string = "nobody"
var bestTime: int = 0
@right out sign = "${status.Value}<br>Longest: ${bestName.Value} ${bestTime.Value}s"
@right out crowned: exec
on reset {
held.clear()
bestName = "nobody"
bestTime = 0
status = "The hill is empty"
running = true
}
on Clock(interval = 1.0, enabled = running) {
players.fillFromZonePlayers(hill)
let count = players.length()
if count == 0 {
status = "The hill is empty"
return
}
if count > 1 {
status = "Contested!"
return
}
let king = players[0]
let name = escape(king.GetDisplayName())
let id = king.GetUserId()
let had = held.get(id)
let time = (if had.Found then had.Value else 0) + 1
held.set(id, time)
if time > bestTime {
bestTime = time
bestName = name
}
if time >= WIN_SECONDS {
status = "${name} is king!"
running = false
emit crowned
} else {
status = "${name} holds the hill"
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Attached Zone’s Zone Ref to
hilland the Button’s Held toreset. - Wire
signto the Text Decal’s Text. - Wire
crownedto the One Shot Audio’s Play, or any other exec input.
Check it in game
- The sign reads “The hill is empty” and “Longest: nobody 0s”.
- Stand on the hill: within a second the sign reads “(your name) holds the hill”, and the longest hold goes up by one each second.
- A second player steps on: “Contested!”, and nobody’s time goes up.
- They step off: your time carries on from where it stopped.
- Reach 60 seconds: “(your name) is king!”,
crownedfires once, and the times stop. - Press the button: everything starts over.
Change it
- A shorter game:
WIN_SECONDS = 30. - Let a crowd hold the hill: score whenever anyone is on it (
count > 0), giving each player inplayersa second withfor p in playersaround the scoring. - Play it in teams: keep a
Map<string, int>of each player’s team, as in Team Match. Each pulse, count each team’s players inplayerswithfor p in players, and give the hill to a team only while no other team has anyone on it. - Show everyone’s hold time: build a line per entry with
for id, t in held(Looping Over a Map), keeping each player’s name in a second map.
How it works
Once a second, while the game runs, the Clock fills an
array with the characters standing in the
zone, as in Ready Check, and the length decides
the case: nobody, contested, or one player. The single player is
players[0], read only after the length check, because an empty array
gives back an old value instead of nothing. That player gets a second of
hold time. Hold times live in a map keyed by GetUserId(), so dying on the
hill keeps the time collected so far, and the .Found guard starts a
newcomer at 0. running turns the Clock off and freezes the sign when
someone wins, and the crowned pulse leaves the chip from that one handler
(Exec Pins).
The hill asks the zone who is on it at every pulse instead of following
ZoneEntered and ZoneLeft. Over a long game a few of those events can be
missed, and a count kept from them drifts. The fill is always right. A
Clock’s first pulse comes one second after it is turned on, so a new game
starts scoring a second after the button.
Back to Parts.
Shop
Before this: Mods, Maps, Per-Player State
A coin counter for every player and two shop buttons. Players earn coins with one button and spend them on items with the others. An item goes straight into the buyer’s inventory.
Contents
You need
- A Button that pays out coins (or a Target: see Change it).
- Two Buttons for the shop, one per item.
button1 "Press Count" -> chip earn
button1 Character -> chip earner
button2 "Press Count" -> chip buy1
button2 Character -> chip buyer1
button3 "Press Count" -> chip buy2
button3 Character -> chip buyer2
Code
const EARN = 1 // coins per press of the earn button
const PRICE_1 = 3 // price of item 1
const PRICE_2 = 10 // price of item 2
const ITEM_1 = $BRItemBase/Weapon_Bandage // item 1
const ITEM_2 = $BRItemBase/Weapon_Pickaxe // item 2
@left in earn: int
@left in earner: character
@left in buy1: int
@left in buyer1: character
@left in buy2: int
@left in buyer2: character
var coins: Map<string, int>
var lastEarn: int = 0
var last1: int = 0
var last2: int = 0
// true when a button's Press Count went up: a new press, not a release
mod pressed(holding: int, prev: *int) -> bool {
let up = holding > prev
prev = holding
return up
}
mod balance(who: character) -> int {
let had = coins.get(who.GetUserId())
return if had.Found then had.Value else 0
}
mod pay(who: character, price: int) -> bool {
let have = balance(who)
if have < price {
who.ShowStatusMessage("You need ${price} coins, you have ${have}")
return false
}
coins.set(who.GetUserId(), have - price)
who.ShowStatusMessage("Bought! ${have - price} coins left")
return true
}
on Change(earn) {
if pressed(earn, lastEarn) {
let now = balance(earner) + EARN
coins.set(earner.GetUserId(), now)
earner.ShowStatusMessage("Coins: ${now}")
}
}
on Change(buy1) {
if pressed(buy1, last1) {
if pay(buyer1, PRICE_1) {
buyer1.AddInventoryItem(ITEM_1)
}
}
}
on Change(buy2) {
if pressed(buy2, last2) {
if pay(buyer2, PRICE_2) {
buyer2.AddInventoryItem(ITEM_2)
}
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the earn Button’s Press Count to
earnand its Character toearner. - Wire the first shop Button’s Press Count and Character to
buy1andbuyer1, and the second’s tobuy2andbuyer2. - Label the shop buttons with their item and price, on a sign or in each Button’s Prompt Custom Label setting.
Check it in game
- Press the first shop button with no coins: “You need 3 coins, you have 0”, and nothing is added to your inventory.
- Press the earn button three times: “Coins: 1”, “Coins: 2”, “Coins: 3”. Letting go gives nothing.
- Press the first shop button: “Bought! 0 coins left”, and a bandage is in your inventory.
- A second player earns and buys: their coins are their own.
- Hold the earn button down while a second player presses it: they still get their coin.
- Die and respawn: your coins are still there.
Change it
- Sell other items: the editor lists every item name after
$BRItemBase/. ChangeITEM_1andPRICE_1. - Earn coins from a Target instead of a button: make
earnanexecpin, change its handler toon earnwithout thepressedcheck, and wire Just Hit toearnand Character That Just Hit toearner. Or give coins at the end of a course, in a zone’son ZoneEnteredhandler. - Add a third item: two more pins, a
last3var and anon Change(buy3)handler.pressedandpaywork for it unchanged.
How it works
Every player’s coins are one entry in a map
keyed by GetUserId(), so they survive a respawn and are saved with the
bricks. Two mods hold the rules. balance reads a
player’s coins, 0 when they have no entry yet. pay checks the price and
takes the coins, and its -> bool result tells the handler whether to hand
over the item. Each Button’s Character output says who pressed it, so
one button serves every player. A Button’s Press Count is how many
players hold it right now. The handlers run on every Change of it
(Change), and the pressed mod
lets one through only when the count went up since the last change: a new
press, never a release. Each button keeps its own last count (lastEarn,
last1, last2). Held stays on while anyone holds the button, so a
second player’s press during that time would not turn it on again, though
Press Count and Character both change. AddInventoryItem adds an
item to the buyer’s inventory. The item is a constant $BRItemBase/...
asset reference, chosen when the chip is compiled.
Back to Parts.
Voting
Before this: Looping Over a Map, Mods, Exec Pins, Per-Player State
A yes-or-no vote. A start button opens it for 30 seconds, players press a
yes or a no button, and when time is up the circuit fires passed or
failed. Each player has one vote, and pressing the other button changes
it.
Contents
You need
- Three Buttons: start the vote, yes, no.
- A brick with a Text Decal for the question and the count.
- Whatever the result should do: a door, a light, a round manager.
button1 Held -> chip begin
button2 "Press Count" -> chip yes
button2 Character -> chip yesVoter
button3 "Press Count" -> chip no
button3 Character -> chip noVoter
chip sign -> decal Text
chip passed -> circuit "exec input"
chip failed -> circuit "exec input"
Code
const QUESTION = "Play another round?" // shown on the sign
const VOTE_SECONDS = 30 // how long a vote stays open
@left in begin: exec
@left in yes: int
@left in yesVoter: character
@left in no: int
@left in noVoter: character
var votes: Map<string, bool>
var counting: bool = false
var yesCount: int = 0
var noCount: int = 0
var timeLeft: int = 0
var lastYes: int = 0
var lastNo: int = 0
@right out sign = if counting.Value
then "${QUESTION}<br>Yes ${yesCount.Value} - No ${noCount.Value}<br>${timeLeft.Value}s left"
else "${QUESTION}<br>Yes ${yesCount.Value} - No ${noCount.Value}"
@right out passed: exec
@right out failed: exec
// true when a button's Press Count went up: a new press, not a release
mod pressed(holding: int, prev: *int) -> bool {
let up = holding > prev
prev = holding
return up
}
mod cast(who: character, choice: bool) {
if !counting {
who.ShowStatusMessage("No vote is open")
return
}
votes.set(who.GetUserId(), choice)
var y: int = 0
var n: int = 0
for id, v in votes {
if v {
y += 1
} else {
n += 1
}
}
yesCount = y
noCount = n
who.ShowStatusMessage(if choice then "You voted yes" else "You voted no")
}
on begin {
if !counting {
votes.clear()
yesCount = 0
noCount = 0
timeLeft = VOTE_SECONDS
counting = true
}
}
on Change(yes) {
if pressed(yes, lastYes) { cast(yesVoter, true) }
}
on Change(no) {
if pressed(no, lastNo) { cast(noVoter, false) }
}
on Clock(interval = 1.0, enabled = counting) {
timeLeft -= 1
if timeLeft == 0 {
counting = false
if yesCount > noCount {
emit passed
} else {
emit failed
}
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the start Button’s Held to
begin. - Wire the yes Button’s Press Count and Character to
yesandyesVoter, and the no Button’s tonoandnoVoter. - Wire
signto the Text Decal’s Text, andpassedandfailedto what each result should do.
Check it in game
- Press yes before any vote: “No vote is open”.
- Press start: the sign shows the question, “Yes 0 - No 0” and “30s left”, counting down.
- Press yes: “Yes 1 - No 0”. Press yes again: still 1.
- Press no: “Yes 0 - No 1”. Your vote moved.
- A second player votes yes: “Yes 1 - No 1”.
- Two players press yes at once, one still holding it down: both votes count.
- Let the time run out: the countdown line goes, and with a tie
failedfires once. - Press start again: the count starts from zero.
Change it
- Vote by standing: replace the buttons with two zones and
on ZoneEntered(zone = yesZone) -> (character) { cast(character, true) }. - Pass only with two thirds of the votes, and at least one:
if yesCount > 0 && yesCount * 3 >= (yesCount + noCount) * 2. - Ask each player in a pop-up box instead:
ShowYesNoBoxdoes that, but every dialog gate needs admin to load, so a chip using it is blocked for anyone else who pastes it.
How it works
Each vote is one entry in a map keyed by
GetUserId(), holding true for yes. Setting the entry again replaces the
old vote, so each player has one vote. After each vote, cast
counts the whole map with for id, v in votes (Looping Over a
Map), so a changed vote moves from one
count to the other with no extra code. cast is a
mod shared by both buttons. A Button’s Press
Count is how many players hold it right now, and each handler runs on a
Change of it (Change). The
pressed mod lets it through only when the count went up: once per press,
even while someone else is still holding the button and Held stays on,
and never on a release. While counting is on, a Clock takes one second
off timeLeft each second, the same countdown as the Countdown
Sign. At zero it closes the vote, and the result leaves
the chip as one of two exec pulses (Exec Pins)
from that one handler.
Back to Parts.
Obstacle Course
Before this: Custom Events, Mods, Per-Player State, Looping Over a Map, Number Options, Safe Text
A timed course with a start, three checkpoints taken in order, a finish, and a fall zone that puts players back at their last checkpoint. A board by the start keeps each player’s best time and the course record.
Contents
You need
- Five bricks with an Attached Zone each: the start, three checkpoints and the finish.
- One Attached Zone under the course, where a falling player lands.
- A brick with a Text Decal for the board, and a Button to clear it.
chip "Course"
zone1 "Zone Ref" -> chip start
zone2 "Zone Ref" -> chip cp1
zone3 "Zone Ref" -> chip cp2
zone4 "Zone Ref" -> chip cp3
zone5 "Zone Ref" -> chip finish
zone6 "Zone Ref" -> chip fall
chip "Board"
button Held -> chip reset
chip board -> decal Text
Code
The part is two chips. The course chip watches the zones. The board chip needs no wire to it: when a player finishes, the course chip sends a custom event, and the board hears it.
The course chip:
const CHECKPOINTS = 3 // checkpoint zones between the start and the finish
@left in start: zone
@left in cp1: zone
@left in cp2: zone
@left in cp3: zone
@left in finish: zone
@left in fall: zone
var startedAt: Map<string, int>
var reached: Map<string, int>
var spot: Map<string, vector>
var falls: Map<string, int>
mod elapsed(id: string) -> int {
let began = startedAt.get(id)
return GetUnixTime() - began.Value
}
mod checkpoint(who: character, n: int) {
let id = who.GetUserId()
let at = reached.get(id)
if !at.Found || at.Value != n - 1 {
return
}
reached.set(id, n)
spot.set(id, who.GetLocation())
who.ShowStatusMessage("Checkpoint ${n} of ${CHECKPOINTS}, ${elapsed(id)}s")
}
on ZoneEntered(zone = start) -> (character) {
let id = character.GetUserId()
startedAt.set(id, GetUnixTime())
reached.set(id, 0)
spot.set(id, character.GetLocation())
falls.set(id, 0)
character.ShowStatusMessage("Go!")
}
on ZoneEntered(zone = cp1) -> (character) { checkpoint(character, 1) }
on ZoneEntered(zone = cp2) -> (character) { checkpoint(character, 2) }
on ZoneEntered(zone = cp3) -> (character) { checkpoint(character, 3) }
on ZoneEntered(zone = fall) -> (character) {
let id = character.GetUserId()
let saved = spot.get(id)
if !saved.Found {
return
}
character.SetLocation(saved.Value)
let at = reached.get(id)
if !at.Found {
return
}
if at.Value == 0 {
startedAt.set(id, GetUnixTime())
character.ShowStatusMessage("Back to the start. Go!")
return
}
let f = falls.get(id)
falls.set(id, f.Value + 1)
character.ShowStatusMessage("Back to checkpoint ${at.Value}")
}
on ZoneEntered(zone = finish) -> (character) {
let id = character.GetUserId()
let at = reached.get(id)
if !at.Found || at.Value != CHECKPOINTS {
return
}
let time = elapsed(id)
let f = falls.get(id)
reached.remove(id)
SendCustomEvent("course.finish", character, time, f.Value)
}
The board chip:
import { escape } from "sanitize"
const TITLE = "<b>Obstacle Course</b>" // the first line of the board
@left in reset: exec
var best: Map<string, int>
var names: Map<string, string>
var record: int = 0
var holder: string = ""
var shown: string = "No finishers yet"
@right out board = "${TITLE}<br>${shown.Value}"
mod pad2(n: int) -> string {
let s = "0${n}"
return s.Substring(s.Length() - 2, 2)
}
mod clock(seconds: int) -> string {
return "${pad2(seconds / 60)}:${pad2(seconds % 60)}"
}
on CustomEvent("course.finish") -> (runner: character, time: int, falls: int) {
let id = runner.GetUserId()
let name = escape(runner.GetDisplayName())
names.set(id, name)
let old = best.get(id)
if !old.Found || time < old.Value {
best.set(id, time)
runner.ShowStatusMessage("Finished in ${clock(time)}, falls: ${falls}. A new best!")
} else {
runner.ShowStatusMessage("Finished in ${clock(time)}, falls: ${falls}. Best: ${clock(old.Value)}")
}
if holder == "" || time < record {
record = time
holder = name
}
var text: string = "Record: ${holder} ${clock(record)}"
for key, t in best {
let n = names.get(key)
text = "${text}<br>${n.Value}: ${clock(t)}"
}
shown = text
}
on reset {
best.clear()
names.clear()
record = 0
holder = ""
shown = "No finishers yet"
}
Wire it
-
Compile both chips and paste them in-game: the course chip near the course, the board chip by the Text Decal.
-
Wire the zones’ Zone Ref outputs to the course chip: the start to
start, the checkpoints tocp1,cp2andcp3in course order, the finish tofinish, and the zone under the course tofall. -
Wire the board chip’s
boardto the Text Decal’s Text, and the Button’s Held toreset. -
Make every checkpoint zone span the whole course, so nobody can go around one, and the fall zone wide enough to catch everyone.
Paste both chips yourself: custom events reach only circuits pasted by the same player.
Check it in game
- The board reads “Obstacle Course” and “No finishers yet”.
- Walk into the start: “Go!”.
- Jump off before the first checkpoint: you are back at the start, and the run starts over.
- Reach checkpoint 1: “Checkpoint 1 of 3, 6s”, or however long it took.
- Jump off: “Back to checkpoint 1”, and you stand where you reached it.
- If your course lets you reach checkpoint 3 before checkpoint 2, try it: nothing happens. Checkpoints count only in order.
- Take checkpoints 2 and 3, then the finish: “Finished in 00:31, falls: 1. A new best!”, and the board shows the record and your line.
- Walk into the finish again: nothing. Fall again: you are back at checkpoint 3, with no message.
- Run again, slower: the status shows your best, and the board keeps it.
- Press the button: the board is cleared.
Change it
- Add a checkpoint: a
cp4pin, a handler that callscheckpoint(character, 4), andCHECKPOINTS = 4. - Add a second course: paste another course chip with its own zones, and a
channel name of its own (
"course2.finish") for a second board. - Make falls cost time: on the board, use
time + falls * 5everywheretimeis compared or stored.
How it works
This is Checkpoints and Race Timer joined
into one course. The course chip keeps each runner’s state in four
maps keyed by GetUserId()
(Per-Player State), so a runner who
respawns keeps their progress. The maps hold when they started, the last
checkpoint reached, where they stood there, and how often they fell.
reached is the guard: a checkpoint counts only when it is the next one,
and the finish only when all three are reached. The start records a spot
too, so a fall before checkpoint 1 sends the runner to the start and
restarts their clock. The finish removes the runner’s reached entry, so a
second finish does nothing, and a later fall only puts them back at the
last checkpoint.
The finish sends one custom event carrying
the runner’s character, their time and their falls. The board uses the
character to read the name, escaped (Safe Text),
and to answer with a status message. Its lines come from a loop over the
map of best times, and clock writes
seconds as mm:ss (Number Options).
Times come from GetUnixTime(), in whole seconds. A send gate delivers
once per tick, so if two runners finish in the same tick, the board may
hear only one of them.
Back to Parts.
Whack-a-Light
An arcade game on a 3 by 3 board of lights, each with a button in front of it. One light at a time comes on: press its button before it goes out. Every few hits the round goes up and the lights get faster. Three misses end the game, and a sign keeps the high score.
Contents
You need
- Nine Point Lights in a 3 by 3 grid, with a Button in front of each.
- One more Button to start a game.
- A brick with a Text Decal for the score.
button1 Held -> chip b1
button2 Held -> chip b2
button3 Held -> chip b3
button4 Held -> chip b4
button5 Held -> chip b5
button6 Held -> chip b6
button7 Held -> chip b7
button8 Held -> chip b8
button9 Held -> chip b9
button10 Held -> chip start
button10 Character -> chip player
chip l1 -> light1 Enabled
chip l2 -> light2 Enabled
chip l3 -> light3 Enabled
chip l4 -> light4 Enabled
chip l5 -> light5 Enabled
chip l6 -> light6 Enabled
chip l7 -> light7 Enabled
chip l8 -> light8 Enabled
chip l9 -> light9 Enabled
chip sign -> decal Text
Code
import { escape } from "sanitize"
const LIGHTS = 9 // lights on the board, numbered 1 to 9
const LIVES = 3 // misses before the game ends
const PER_ROUND = 5 // hits to finish a round
const WINDOW = 15 // tenths of a second a light stays on in round 1
const FASTER = 2 // tenths of a second less each round
const SHORTEST = 5 // the window never gets shorter than this
const GAP = 4 // tenths of a second between two lights
const FLASH = 8 // tenths of a second the board flashes after a miss
enum Phase { Ready, Gap(int), Lit { light: int, left: int }, Flash(int), Over }
@left in start: exec
@left in player: character
@left in b1: exec
@left in b2: exec
@left in b3: exec
@left in b4: exec
@left in b5: exec
@left in b6: exec
@left in b7: exec
@left in b8: exec
@left in b9: exec
var phase: Phase = Phase.Ready
var score: int = 0
var lives: int = 0
var name: string = ""
var best: int = 0
var bestName: string = "nobody"
var button: int = 0
let round = score.Value / PER_ROUND + 1
let lit = match phase {
Lit { light, .. } => light,
_ => 0,
}
let all = phase is Phase.Flash
let idle = phase is Phase.Ready || phase is Phase.Over
@right out l1 = lit == 1 || all
@right out l2 = lit == 2 || all
@right out l3 = lit == 3 || all
@right out l4 = lit == 4 || all
@right out l5 = lit == 5 || all
@right out l6 = lit == 6 || all
@right out l7 = lit == 7 || all
@right out l8 = lit == 8 || all
@right out l9 = lit == 9 || all
@right out sign = match phase {
Ready => "Press start<br>Best: ${bestName.Value} ${best.Value}",
Over => "Game over! ${name.Value} scored ${score.Value}<br>Best: ${bestName.Value} ${best.Value}",
_ => "Round ${round}<br>Score ${score.Value} Lives ${lives.Value}",
}
let press: exec
mod window() -> int {
return max(WINDOW - (round - 1) * FASTER, SHORTEST)
}
mod miss() {
let left = lives - 1
lives = left
if left > 0 {
phase = Phase.Flash(FLASH)
return
}
phase = Phase.Over
if score > best {
best = score
bestName = name
}
}
on press {
match phase {
Lit { light, .. } => {
if light == button {
score += 1
phase = Phase.Gap(GAP)
} else {
miss()
}
}
_ => {}
}
}
on start {
if phase is Phase.Ready || phase is Phase.Over {
score = 0
lives = LIVES
name = escape(player.GetDisplayName())
phase = Phase.Gap(GAP * 3)
}
}
on b1 { button = 1 emit press }
on b2 { button = 2 emit press }
on b3 { button = 3 emit press }
on b4 { button = 4 emit press }
on b5 { button = 5 emit press }
on b6 { button = 6 emit press }
on b7 { button = 7 emit press }
on b8 { button = 8 emit press }
on b9 { button = 9 emit press }
on Clock(interval = 0.1, enabled = !idle) {
match phase {
Gap(left) => {
if left > 1 {
phase = Phase.Gap(left - 1)
} else {
phase = Phase.Lit { light: Random(1, LIGHTS), left: window() }
}
}
Lit { light, left } => {
if left > 1 {
phase = Phase.Lit { light: light, left: left - 1 }
} else {
miss()
}
}
Flash(left) => {
if left > 1 {
phase = Phase.Flash(left - 1)
} else {
phase = Phase.Gap(GAP)
}
}
_ => {}
}
}
Wire it
- Compile the chip and paste it in-game.
- Number the lights 1 to 9, left to right and top to bottom. Wire each
light’s Enabled from the pin with its number:
l1to light 1, up tol9. - Wire the Held of the button in front of light 1 to
b1, and so on tob9. Check each pair: a button wired to the wrong pin turns every hit on that light into a miss. - Wire the start Button’s Held to
startand its Character toplayer. - Wire
signto the Text Decal’s Text.
Check it in game
- The lights are off, and the sign reads “Press start” and “Best: nobody 0”.
- Press start: the sign shows “Round 1”, “Score 0” and “Lives 3”. About a second later one light comes on.
- Press its button while it is lit: the score goes up, the light goes out, and another comes on soon after.
- Let a light go out without pressing: all nine lights flash, and the lives drop to 2.
- Press a button whose light is off while another is lit: the same miss.
- Score 5: the sign says “Round 2”, and each light stays on for less time.
- Miss three times: “Game over!” with your name and score, the lights stay off, and if you scored at least one hit, the best score shows your name.
- Press start again: a new game, and the best score stays.
Change it
- A bigger board: add
l10tol16andb10tob16pins with their one-line handlers, andLIGHTS = 16for a 4 by 4 grid. - An easier game:
WINDOW = 20,LIVES = 5. - Never light the same light twice in a row: keep the last light in a var,
and in the
Gaparm, whenRandomgives that one again, take the next light instead (n % LIGHTS + 1). - A sound for every hit: add
@right out scored: exec, putemit scoredafterscore += 1, and wirescoredto a One Shot Audio’s Play.
How it works
The whole game is one enum value, phase. Ready
and Over wait for the start button. The other three carry a countdown in
Clock pulses: Gap is the pause before the next light, Lit holds which
light is on and how long it has left, and Flash is the pause after a miss.
Ten times a second the Clock’s match counts the current phase down or
moves on to the next one, so all the timing is in one place. idle turns
the Clock off between games. Each button’s
handler stores its number in button and fires press, the chip’s own
signal, so the nine buttons share one press handler. It acts only in
Lit: the right button scores, any other is a miss. A press during a pause
does nothing, so a second press on the same light is safe.
Every output is computed from phase, never stored on its own. lit is
the number of the light that is on, all is true while the board flashes,
and each light compares its own number with them. The sign is a match on
the same value, and round is worked out from the score, so nothing can
drift out of step. Random(1, LIGHTS) picks the light
(Dice), max keeps the window from getting too short,
and the player’s name is escaped when it is stored (Safe
Text). miss is a
mod, so its two callers, a wrong press and a
light that ran out, each get a copy. The buttons fire one shared signal
because nine copies of the press logic, one per button, would cost far more.
Back to Parts.
Card Table
Before this: Shuffling, Ready Check, Index and Value, Mods, Safe Text, Keeping It Current
A card table for two to four players. Each player stands on a seat pad, a deal button shuffles a deck and deals three cards to everyone, and each player sees their own hand on their screen and nobody else’s. Players take turns playing a card with three buttons: the highest card takes the trick, and whoever takes the most tricks wins.
Contents
You need
- Four bricks with an Attached Zone each, the seat pads, small enough for one player.
- A Button to deal.
- Three Buttons in the middle of the table, one per card in a hand.
- A brick with a Text Decal for the table, where everyone can see it.
zone1 "Zone Ref" -> chip seat1
zone2 "Zone Ref" -> chip seat2
zone3 "Zone Ref" -> chip seat3
zone4 "Zone Ref" -> chip seat4
button1 Held -> chip deal
button2 "Press Count" -> chip card1
button2 Character -> chip by1
button3 "Press Count" -> chip card2
button3 Character -> chip by2
button4 "Press Count" -> chip card3
button4 Character -> chip by3
chip sign -> decal Text
Code
import { escape } from "sanitize"
const HAND = 3 // cards per player, one Button per card
const TEXT_ID = 7300 // a number no other circuit on the server uses
const FONT_SIZE = 32 // size of the hand on each screen
@left in seat1: zone
@left in seat2: zone
@left in seat3: zone
@left in seat4: zone
@left in deal: exec
@left in card1: int
@left in by1: character
@left in card2: int
@left in by2: character
@left in card3: int
@left in by3: character
var crowd: character[]
var seated: character[]
var ids: string[]
var names: string[]
var points: int[]
var deck: int[]
var hands: int[]
var playing: bool = false
var turn: int = 0
var trick: int = 0
var played: int = 0
var high: int = -1
var leader: int = 0
var table: string = "Stand on a seat and press deal"
var last: string = ""
var held1: int = 0
var held2: int = 0
var held3: int = 0
var presser: character
var pick: int = 0
@right out sign = "${table.Value}<br>${last.Value}"
let card: exec
let redraw: exec
// true when a button's Press Count went up: a new press, not a release
mod pressed(holding: int, prev: *int) -> bool {
let up = holding > prev
prev = holding
return up
}
mod cardName(c: int) -> string {
let rank = "23456789TJQKA".Substring(c / 4, 1)
let suit = "SHDC".Substring(c % 4, 1)
return "${rank}${suit}"
}
mod sit(pad: zone) {
crowd.fillFromZonePlayers(pad)
if crowd.length() > 0 {
let p = crowd[0]
let id = p.GetUserId()
if !ids.find(id).Found {
seated.push(p)
ids.push(id)
names.push(escape(p.GetDisplayName()))
points.push(0)
}
}
}
mod tell(text: string) {
for p in seated {
p.ShowStatusMessage(text)
}
}
mod finish() {
var top: int = 0
var tied: bool = false
for i, n in points {
if n > points[top] {
top = i
tied = false
} else if i != top && n == points[top] {
tied = true
}
}
playing = false
if tied {
table = "A tie at ${points[top]} tricks!<br>Press deal to play again"
tell("A tie!")
} else {
table = "${names[top]} wins with ${points[top]} tricks!<br>Press deal to play again"
tell("${names[top]} wins!")
}
}
mod play(who: character, slot: int) {
if !playing {
return
}
if who.GetUserId() != ids[turn] {
who.ShowStatusMessage("Wait for your turn")
return
}
let at = turn * HAND + slot
let c = hands[at]
if c < 0 {
who.ShowStatusMessage("You played that card already")
return
}
hands[at] = -1
seated[turn] = who
let sep = if played == 0 then " " else ", "
table = "${table}${sep}${names[turn]} ${cardName(c)}"
if c > high {
high = c
leader = turn
}
played += 1
if played < seated.length() {
turn = (turn + 1) % seated.length()
} else {
points[leader] += 1
last = "${table}: ${names[leader]} takes it"
tell("${names[leader]} takes the trick with ${cardName(high)}")
trick += 1
if trick == HAND {
finish()
} else {
turn = leader
played = 0
high = -1
table = "Trick ${trick + 1}:"
}
}
emit redraw
}
on deal {
for p in seated {
p.DisplayText("", textId = TEXT_ID)
}
seated.clear()
ids.clear()
names.clear()
points.clear()
sit(seat1)
sit(seat2)
sit(seat3)
sit(seat4)
if seated.length() < 2 {
table = "Need two players on the seats"
playing = false
return
}
deck.clear()
for i in 0..52 {
deck.push(i)
}
deck.shuffle()
hands.clear()
for i in 0..HAND * 4 {
hands.push(deck[i])
}
turn = 0
trick = 0
played = 0
high = -1
playing = true
table = "Trick 1:"
last = ""
tell("Cards dealt. ${names[0]} goes first")
emit redraw
}
on Change(card1) {
if pressed(card1, held1) {
presser = by1
pick = 0
emit card
}
}
on Change(card2) {
if pressed(card2, held2) {
presser = by2
pick = 1
emit card
}
}
on Change(card3) {
if pressed(card3, held3) {
presser = by3
pick = 2
emit card
}
}
on card {
play(presser, pick)
}
on redraw {
for i, p in seated {
if !playing {
p.DisplayText("", textId = TEXT_ID)
} else {
var text: string = "Your hand:"
for k in 0..HAND {
let c = hands[i * HAND + k]
let shown = if c < 0 then "--" else cardName(c)
text = "${text} [${k + 1}] ${shown}"
}
let note = if i == turn then "Your turn: press a card's button" else "Waiting for ${names[turn]}"
p.DisplayText("${text}<br>${note}<br>Tricks: ${points[i]}",
textId = TEXT_ID, anchorX = 0.5, anchorY = 1.0, positionY = -180.0,
fontSize = FONT_SIZE, justify = Center, lifetime = 0.0)
}
}
}
A card is a number from 0 to 51. c / 4 is its rank and c % 4 its suit,
so a higher number is a higher card. cardName writes it as two letters:
the rank (2 to 9, then T for ten, J, Q, K, A) and the suit
(S, H, D, C). Two cards of the same rank are told apart by suit, in
that order: clubs are highest.
Wire it
- Compile the chip and paste it in-game.
- Wire the four seat zones’ Zone Ref outputs to
seat1toseat4. The seat order is the turn order. - Wire the deal Button’s Held to
deal. - Wire the first card Button’s Press Count to
card1and its Character toby1, and the same for the second (card2,by2) and third (card3,by3). - Wire
signto the Text Decal’s Text.
Check it in game
You need a second player for most of this list.
- The sign reads “Stand on a seat and press deal”.
- Alone on a seat, press deal: “Need two players on the seats”.
- With two players seated, press deal: each sees “Cards dealt”, and their own hand at the bottom of the screen: three cards, different for each player.
- The first seat’s screen says “Your turn”, the other “Waiting for” the first player.
- The second player presses a card button: “Wait for your turn”, and nothing is played.
- The first player presses button 2: their second card shows
--, the sign reads “Trick 1:” with their name and card, and the turn passes on. - The second player plays: everyone sees who takes the trick, and the sign’s second line keeps the finished trick.
- On your turn, press the button for a card you already played: “You played that card already”.
- After three tricks the sign names the winner, or says “A tie”, and the hands vanish from the screens.
- Press deal again: the old hands are replaced by new ones.
Change it
- Five cards a hand:
HAND = 5, two more card buttons withcard4/by4andcard5/by5pins,held4andheld5vars, and theiron Change(...)handlers, settingpickto 3 and 4. With four players that deals 20 of the 52 cards. - Low card wins: start
highat 52 instead of -1 (three places), and inplaytestc < high. - Follow suit: remember the suit of the first card in a trick, and refuse a card of another suit while the player still holds one of that suit.
- Keep a score across games: a
Map<string, int>of wins keyed byGetUserId(), added to infinishand shown on the sign.
How it works
The players are fixed when the cards are dealt: sit fills an
array with whoever stands on each pad, as in
Ready Check, and keeps the first one. From then
on a player is a number, their seat, and every list (seated, ids,
names, points) lines up by that number. A player already in ids is
not seated twice. Their hand is three slots in hands, starting at
seat * HAND, and a played card becomes -1. The deck is the numbers 0 to
51, shuffled, and the first cards go to the
hands.
A card button says only “someone pressed card 2”. Its Press Count is
how many players hold it right now: it goes up on a press and down on a
release. on Change(card2) runs on both
(Change), and the pressed mod
lets only a rise through, so a release plays nothing. Held would miss a
press made while another player still holds the same button, since it stays
on. Each button’s handler stores who pressed (its Character) and which
card, then fires the chip’s own card signal, so the three buttons share
one card handler and play is built once instead of once per button.
play compares the presser’s GetUserId() with the one whose turn it is,
so pressing out of turn does nothing. When every player has
played, the highest card’s player gets a point and leads the next trick.
At the end, finish finds the most tricks and checks whether anyone else
has as many.
The hands are private because each one is drawn with DisplayText, which
shows text on one player’s screen, as in Scoreboard
HUD. They are drawn only when something changes: the
deal and every card played end by firing redraw, which draws each hand
with the same textId, so each draw replaces the last. lifetime = 0.0
keeps a hand up until the next draw, and once the game is over redraw
draws an empty text instead, which takes the hands down. A new deal first
takes down the hands of the last game’s players. Status messages go to
every seated player with the tell mod, and the
names are escaped when they are stored (Safe
Text).
The chip holds each player’s character from the deal. A player who dies or respawns during a game has a new character, so their hand stops updating until their next turn: playing a card stores the new character in their seat. Their turns still work, since those go by user id. A player who leaves never takes their turn: press deal to start over.
Back to Parts.
Team Match
Before this: Payloads, Ready Check, Looping Over a Map, Per-Player State, Rich Text, Number Options, Keeping It Current
A two-team game. Players pick a side by standing on the red or the blue pad, then race to score: running into the goal at the far end of the field scores a point for your team and sends you back to your pad. A round lasts three minutes, everyone on the field sees the score and the clock on their screen, and a sign announces the winner.
Contents
You need
- Two bricks with an Attached Zone each, the red and the blue pads, at either end of the field.
- Two more Attached Zones for the goals: the red goal is where Red scores, at the blue end, and the blue goal at the red end.
- One large Attached Zone over the whole field, pads included.
- A Button to start a round.
- A brick with a Text Decal for the sign, and two Point Lights, one red and one blue, to show which team leads.
zone1 "Zone Ref" -> chip redPad
zone2 "Zone Ref" -> chip bluePad
zone3 "Zone Ref" -> chip redGoal
zone4 "Zone Ref" -> chip blueGoal
zone5 "Zone Ref" -> chip arena
button Held -> chip start
chip sign -> decal Text
chip redLeads -> light1 Enabled
chip blueLeads -> light2 Enabled
Code
const ROUND = 180 // seconds a round lasts
const RESULTS = 10 // seconds the result stays up before the lobby
const TEXT_ID = 7400 // a number no other circuit on the server uses
const FONT_SIZE = 30 // size of the text on each screen
const RED = 1 // team numbers in the team map
const BLUE = 2
enum Phase { Lobby, Playing { until: int }, Over { until: int } }
@left in redPad: zone
@left in bluePad: zone
@left in redGoal: zone
@left in blueGoal: zone
@left in arena: zone
@left in start: exec
var phase: Phase = Phase.Lobby
var team: Map<string, int>
var home: Map<string, vector>
var redScore: int = 0
var blueScore: int = 0
var secondsLeft: int = 0
var result: string = ""
var players: character[]
mod pad2(n: int) -> string {
let s = "0${n}"
return s.Substring(s.Length() - 2, 2)
}
let score = '<color="ff4040">Red ${redScore.Value}</> - <color="4080ff">${blueScore.Value} Blue</>'
@right out sign = match phase {
Lobby => "Pick a team on a pad, then press start<br>${result.Value}",
Playing { .. } => "${score}<br>${pad2(secondsLeft.Value / 60)}:${pad2(secondsLeft.Value % 60)}",
Over { .. } => "${result.Value}<br>${score}",
}
@right out redLeads = redScore > blueScore
@right out blueLeads = blueScore > redScore
let redraw: exec
mod join(who: character, side: int, label: string) {
if !(phase is Phase.Lobby) {
return
}
let id = who.GetUserId()
team.set(id, side)
home.set(id, who.GetLocation())
who.ShowStatusMessage("You are on team ${label}")
emit redraw
}
mod goal(who: character, side: int) {
if !(phase is Phase.Playing) {
return
}
let id = who.GetUserId()
let t = team.get(id)
if !t.Found || t.Value != side {
return
}
if side == RED {
redScore += 1
} else {
blueScore += 1
}
who.ShowStatusMessage("You scored!")
let back = home.get(id)
if back.Found {
who.SetLocation(back.Value)
}
}
on ZoneEntered(zone = redPad) -> (character) { join(character, RED, "Red") }
on ZoneEntered(zone = bluePad) -> (character) { join(character, BLUE, "Blue") }
on ZoneEntered(zone = redGoal) -> (character) { goal(character, RED) }
on ZoneEntered(zone = blueGoal) -> (character) { goal(character, BLUE) }
on ZoneEntered(zone = arena) {
emit redraw
}
on ZoneLeft(zone = arena) -> (character) {
if character != null {
character.DisplayText("", textId = TEXT_ID)
}
}
on start {
if !(phase is Phase.Lobby) {
return
}
players.fillFromZonePlayers(arena)
var reds: int = 0
var blues: int = 0
for p in players {
let t = team.get(p.GetUserId())
if t.Found && t.Value == RED {
reds += 1
} else if t.Found && t.Value == BLUE {
blues += 1
}
}
if reds == 0 || blues == 0 {
result = "Each team needs a player"
return
}
redScore = 0
blueScore = 0
secondsLeft = ROUND
phase = Phase.Playing { until: GetUnixTime() + ROUND }
}
on Clock(interval = 0.5, enabled = !(phase is Phase.Lobby)) {
let now = GetUnixTime()
match phase {
Lobby => {}
Playing { until } => {
secondsLeft = until - now
if now >= until {
if redScore > blueScore {
result = "Red wins!"
} else if blueScore > redScore {
result = "Blue wins!"
} else {
result = "A draw!"
}
phase = Phase.Over { until: now + RESULTS }
}
}
Over { until } => {
if now >= until {
phase = Phase.Lobby
}
}
}
emit redraw
}
on redraw {
players.fillFromZonePlayers(arena)
for p in players {
let t = team.get(p.GetUserId())
let mine = if !t.Found then "Pick a team" else if t.Value == RED then "You are Red" else "You are Blue"
let top = match phase {
Lobby => "Waiting for the round",
Playing { .. } => "${pad2(secondsLeft / 60)}:${pad2(secondsLeft % 60)} left",
Over { .. } => result,
}
let fade = if phase is Phase.Lobby then 0.0 else 1.0
p.DisplayText("${top}<br>${score}<br>${mine}",
textId = TEXT_ID, anchorX = 0.5, anchorY = 0.0, positionY = 60.0,
fontSize = FONT_SIZE, justify = Center, lifetime = fade)
}
}
Wire it
- Compile the chip and paste it in-game.
- Wire the Zone Ref outputs: the red pad to
redPad, the blue pad tobluePad, the goal at the blue end (where Red scores) toredGoal, the goal at the red end toblueGoal, and the zone over the field toarena. - Wire the Button’s Held to
start. - Wire
signto the Text Decal’s Text,redLeadsto the red light’s Enabled andblueLeadsto the blue one’s.
Check it in game
This needs at least two players.
- The sign reads “Pick a team on a pad, then press start”. On the field, your screen says “Waiting for the round” and “Pick a team”.
- Step on the red pad: “You are on team Red”, and your screen says so.
- Walk off the field: the text leaves your screen at once. Walk back on: it is back.
- Press start with nobody on Blue: the sign adds “Each team needs a player”.
- A second player steps on the blue pad. Press start: the sign shows
“Red 0 - 0 Blue” and the clock,
03:00counting down. - Step on the blue pad during the round: you stay on Red.
- Run into the red goal: “You scored!”, you are back at the red pad, the score reads 1 - 0, and the red light comes on.
- The Blue player runs into the red goal: nothing happens. Only Red scores there.
- At
00:00the sign and every screen on the field show the winner, “Red wins!”, “Blue wins!” or “A draw!”. - Ten seconds later the sign is back to “Pick a team” with the last result under it, and the teams are as they were.
Change it
- A shorter round:
ROUND = 60. - Shuffle the teams instead of letting players pick: in the lobby, fill an array from the arena, shuffle it, and put even places on Red and odd places on Blue, as in Index and Value.
- Score by holding a point instead: in the Clock’s
Playingarm, fill an array from a middle zone, count each team’s players with theteammap, and give a point to the team with more. That scores twice a second. - End the round at a target score: in
goal, after the point, when either score reaches 5, setresultto the winner and move toOver.
How it works
The round is an enum, as in Round
Manager: Lobby, then Playing and Over, each
carrying the Unix time it ends, and a Clock that moves it on. The Clock
runs only outside the lobby. Team membership is a map from GetUserId() to 1
for Red or 2 for Blue (Per-Player State),
so a player who respawns stays on their team. The game’s own team gates
need admin to load. The map does the same job here, and anyone can paste
it. home keeps where each player stood when they joined, and a goal sends
the scorer back there. start fills an array with the players on the field,
as in Ready Check, counts each team among them,
and refuses to begin with an empty side. A player who has left the server
is still in the map but no longer on the field, so they do not count.
Who is on the field always comes from a fill, never from a count kept by
ZoneEntered and ZoneLeft: over a long game a few of those events can
be missed, and the fill is always right.
Each player on the field sees one text, drawn with DisplayText as in
Scoreboard HUD: the clock or the result, the score,
and the player’s team. The redraw signal draws it for everyone on the
field. In the lobby it fires only when something changes: a player walks
onto the field or picks a team. During a round the clock changes every
second, so each Clock pulse fires it too. Walking off the field draws an
empty text over yours. In the lobby the text is drawn with lifetime 0,
which keeps it up until it is replaced. During a round it is redrawn twice a second with a
lifetime of one second, so it fades by itself from a player who is no
longer on the field, such as one who died there (the leave event gives no
character for them). The score is one let with color
tags, shared by the sign and every screen.
Back to Parts.
Syntax Reference
Contents
- Source Structure
- Imports
- Comments
- Statement Terminators
- Line Continuation
- Blocks
- Identifiers
- Keywords
- Literals
- Operators
- Top-Level Declarations
Source Structure
A Wirescript file (.ws) is a sequence of top-level declarations. There is no required entry point or wrapper. Declarations appear at the top level of the file.
in trigger: exec
var count: int = 0
on trigger {
count = count + 1
}
out total = count
Imports
Import declarations bring symbols from other .ws files into scope. The .ws extension is implicit.
import "lib" // import all exportable declarations
import { swap, clamp } from "lib" // selective import
import { swap as mySwap } from "lib" // aliased import
import * as utils from "lib" // namespace import, called as 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.
An enum of a namespace import is the type ns.Name, and its variants are
ns.Name.Variant (var d: ns.Dir = ns.Dir.S); inside the module it is Name.
A match arm names such a variant by the variant alone (S =>). A program has
one enum of a name, whichever way it is imported, so a namespace import of a
module declaring an enum the program declares too is WS013.
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 no name to select. The compiler warns WS014, naming the handlers left out, and still compiles.
The receiver gate is never emitted, so the events it would have caught go nowhere.
This usually happens through an 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 safer arrangement is to keep on handlers in the entry file in every
case. Expose the logic from the library as a mod, and let each entry file
declare the thin handler that calls it. Then 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 Event_Custom
A file calls only the mods it can name. Code in a file calls, as f(x) or
as x.f(), only the mods and chips that file declares or that its own
imports name. A module’s private helper, present because an imported body
calls it, is not callable from the importer, and an importer’s own mods are not
callable from the bodies it imports. A library that needs one declares it, or
imports it from a module both files import, since a module can’t import the
file that imports it. A plain call to a mod the file can’t name is WS002, and
a method call is WS036. Where a builtin of the same name exists, the builtin
is called instead.
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.
Diagnostic Directives
A // comment containing ws-ignore-line:WS014 or ws-ignore-file:WS014 turns
that warning off for the line or the file. See
Silencing a warning.
Statement Terminators
Statements are terminated by newlines or semicolons. Both are interchangeable, so 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
A line that starts with * is the exception: it begins a new statement (a
deref, such as *h = 5), never the continuation of the line above. To continue
a multiplication, end the first line with *. A bare deref line whose value
nothing uses gets a WSP001 warning, since it is almost always a split
multiplication:
let area = width *
height
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, so blank lines are fine.
Identifiers
Identifiers start with a letter or underscore and continue with letters, digits, or underscores.
valid_name
_private
counter2
myVar
Identifiers are case-sensitive. count and Count are different names.
Keywords
The following words are reserved and cannot be used as identifiers:
| Keyword | Purpose |
|---|---|
var | Mutable variable declaration |
let | Immutable binding |
buffer | Buffered value (delayed one tick) |
array | Array declaration |
chip | Chip declaration (anonymous or named) |
mod | Inline chip (expanded at call sites) |
fn | Function (compiled once, run by a Function Call at each call site) |
on | Event handler |
in | Input port declaration |
out | Output port binding |
emit | Emit a user-defined event |
if | Conditional (statement or expression) |
else | Else branch |
then | Used in if-then-else expressions |
match | Reserved; no expression form is implemented |
return | Early return from handler |
import | Import declarations from another file |
from | Used with import { } from "path" |
as | Alias in imports or namespace |
true | Boolean literal |
false | Boolean literal |
ref | Reference type or ref-of expression |
type | Record type declaration |
static | Persistent-variable modifier (inside handlers/mods) |
await | Suspend an exec chain until a signal fires |
Using a reserved word (e.g. from) as a variable or parameter name produces
a series of WSP001 expected Ident, got '<word>' (Kw) parse errors that
hide 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
An integer constant in a place typed float is that float. An integer constant here is a literal, a name bound to an integer constant such as const N = 3, or an expression the compiler computes from them, such as N + 2, -N, an element of a constant array, a field of a constant record or a const mod call. A place typed float is a float var, parameter (of a mod, chip, namespaced call or enum payload), return value, output, record or tuple field, container element, or an annotated let/const. An annotated let/const then holds a float in constant evaluation too. So with mod half(v: float) -> float { return v / 2 }, half(3), half(N) and half(N + 2) are 1.5, 1.5 and 2.5. Each read decides on its own: N read into an int place beside them stays an integer. A runtime value is not converted: an int variable, input or computed value passed to a float place keeps its integer wire, so half(n) with n: int divides as integers. An if or match whose arms join to float makes its integer arms floats ((if c then 3 else 2.5) / 2 is 1.5 or 1.25), unless the whole value goes into an int place, which keeps them integers. A record, tuple, array or map literal fits a target whose fields or elements are float where its own are integers. A builtin math call such as clamp(n, 0, 8) takes its literals as written. min, max, clamp, abs, sign, floor, ceil and round of integers are integers, and are float once an operand is a float.
Float Literals
Floating-point literals use decimal notation with an optional exponent.
3.14
0.5
1e10
2.5e-3
1_000.0
A float literal requires a digit after the decimal point: 1. alone is not a float literal (it would be parsed as integer 1 followed by a dot).
String Literals
String literals are delimited by double quotes " or single quotes '.
"hello world"
'hello world'
Escape Sequences
| Escape | Character |
|---|---|
\\ | Backslash |
\" | Double quote (in double-quoted strings) |
\' | Single quote (in single-quoted strings) |
\n | Newline |
\t | Tab |
\r | Carriage return |
\$ | Literal dollar sign (prevents interpolation) |
\0 | Null character |
String Interpolation
Both single- and double-quoted strings support ${expr} interpolation. Any expression can be embedded:
"Hello, ${name}!"
"Score: ${score + bonus}"
'Position: ${pos.x}, ${pos.y}'
Interpolated expressions are converted to strings. Use \$ to include a literal dollar sign.
Boolean Literals
true
false
Operators
Operators are listed here for reference. See Expressions for full details on precedence and behavior.
Arithmetic
+, -, *, /, %, ** (power)
Comparison
==, !=, <, <=, >, >=
Logical
&&, ||, !
Bitwise
&, |, ^, ~, <<, >>
String
.. (concatenation)
Other
= (assignment), -> (return type / outputs), => (fat arrow, reserved)
Top-Level Declarations
The following forms are valid at the top level of a script:
var name: type = expr: Mutable variablelet name = expr: Immutable bindingbuffer name = expr: Buffered valuearray name: type[]: Arrayin name: type: Input portout name = expr: Output portchip name(params) -> outputs { body }: Named chipchip { body }: Anonymous chipchip let name = expr: Anonymous chip with let bindingschip on trigger { body }: Anonymous chip with handlermod name(params) { body }: Inline chip (macro-like)fn name(params) -> outputs { body }: Function, compiled once into a function definition planeon trigger { body }: Event handlerlet name = on trigger { body }: Captured event with handlerimport "path": Import all declarations from fileimport { names } from "path": Selective importimport * as ns from "path": Namespace importif cond { body }: Conditional (in exec context)return: Early return from handlertarget = expr: Assignment (in exec context)expr: Expression statement
Types
Wirescript has a static type system that maps directly to the port types of Brickadia’s logic gates. The type checker validates that wires connect compatible ports and inserts coercion gates where needed.
Contents
Primitive Types
| Type | Description | Default Value |
|---|---|---|
bool | Boolean (true / false) | false |
int | 64-bit signed integer | 0 |
float | 64-bit floating point | 0.0 |
string | Text string | "" |
vector | 3D vector (x, y, z floats) | (0, 0, 0) |
rotator | Euler rotation (pitch, yaw, roll floats) | (0, 0, 0) |
quat | Quaternion (x, y, z, w); produced by the rotation conversion gates (dir.ToRotation(), …) | identity |
color | RGBA color (r, g, b, a floats) | (0, 0, 0, 0) |
entity | Reference to a game entity | null |
character | Reference to a player character | null |
controller | Reference to a player controller | null |
exec | Execution signal (trigger) | – |
exec Type
The exec type represents an execution trigger signal. It is not a data value. It represents “this event fired” or “this code path should execute.” Inputs of type exec are used as handler triggers:
in reset: exec
on reset {
count = 0
}
Special Types
| Type | Description |
|---|---|
any | Universal type, compatible with everything: a value whose kind is decided at run time. See any Type below. |
never | Bottom type: no value inhabits this type. Used internally. |
any Type
any is a wildcard annotation for a value that flows through a wire without the checker fixing its concrete type. test & 1, test == "x", and every other operator overload still resolve against the operand type on the other side, instead of erroring as an unknown type would. An any value is accepted anywhere, but the checker can’t warn you if the operator it selects isn’t the one you meant.
For a mod parameter that passes a value through, prefer a generic type parameter (<T>) over any, because it keeps the type and the checker’s help with it. See any vs. a Generic Parameter.
any is valid wherever a value 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 can also be stored. A variable, an array or a map value of type any holds a value of any kind, and each value keeps its own kind (the game’s Any Value):
var last: any = 0
var log: any[] = [1, "two", 3.5]
var props: Map<string, any>
A map key cannot be any (WS039): keys are int, string, object or brick.
To find out what an any holds, test it with getType(). Inside a match arm or an if that tested it, the value has the kind the test found, so it needs no as:
var log: any[] = [1, "two", 3.5]
var total: float = 0.0
in go: exec
on go {
for x in log {
match x.getType() {
Int => { total += x }
Float => { total += x }
_ => {}
}
}
}
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, and refs to storage (see Reference values). An object reference must be wired in from its own brick, so it can’t sit in a constant initializer. Build the array with .push(...) inside an exec handler instead. Writing var songs: entity[] = [$Asset/…] silently drops the elements, and the compiler warns (WS024).
zone & teleport references
| Type | Description | Produced by | Consumed by |
|---|---|---|---|
zone | Reference to a Zone brick | a Zone brick’s output (wire it into an in z: zone port) | zone = … on the zone events; fillFromZone* |
teleport | Reference to a Teleport Destination (a “teleport point”) | a Teleport Destination brick (wire it into an in p: teleport port) | Teleport / RelativeTeleport dest/source |
These are reference-only types: 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 unlike a *T ref value, which can be
stored, selected and compared, a zone, teleport or prefab reference can
not be:
- stored in a
var/buffer(WS025), because there is no wire variant that holds one; - selected with an if-then-else (
WS031), because the Select gate routes a value, and these have none; - 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, zone) has no null value and reports
WS051. Use its own empty form ([], {}) instead. A *T var or element
that was never assigned is null (see Reference values).
A bare let x = null with no target types as any.
Compared with == or !=, null takes the other operand’s type, so
x == null tests for an unset reference on an entity, character,
controller, brick or *T: an Equal (or Not Equal) gate against the empty
reference. On a value type it compares with that type’s zero (n == null is
n == 0).
var holder: character = null
in go: exec
on go {
if holder != null { BroadcastChatMessage("taken") }
}
An object is false on a bool port when it is unset, so !holder reads the
same as holder == null.
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 compiler 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. When 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 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. A record whose fields are references still cannot be stored in
a variable, array, or map (WS049), because storage needs a real value per
field.
Reference values
A *T is also a value: a ref to one var of type T, which can be stored,
copied, compared and sent like any other value.
var a: int = 0
var b: int = 0
var h: *int // holds a ref; null until assigned
var refs: (*int)[] // an array of refs
on Loaded() {
h = &a // `&x` is x's ref as a value
*h = 5 // write through it: a is now 5
let n = *h + 1 // read through it
refs.push(&a)
refs.push(&b)
*refs[1] += 2 // b is now 2
}
&xmakes a ref and*ereads or writes through one, in an exec context.- A ref never converts to its target type:
h + 1is an error,*h + 1is the read. ==and!=compare two refs of the same type. A ref equals its copy and differs from a ref to any other var. Comparing a ref with a number isWS084: the game treats1 == <ref>as true.- Passing a
*Tvalue to a*Tparameter binds the parameter to the var the ref points at. Passing a plain var still binds that var. - Write an array of refs with parentheses,
(*int)[]. A plain*int[]is a var that holds an array ref (below). - A ref to a ref (
**int, or&hwherehholds a ref) isWS086. - An initializer can hold refs:
var h: *int = &aorvar refs: (*int)[] = [&a, &b]starts out pointing ataandb. The refs are baked into the save, so no gate runs. Each one must name a var, array or map declared above it (WS003).
A null ref halts the chain. A *T var or element that was never assigned holds a null ref. Reading or writing through it stops the exec chain in game, with no message. A *T var declared inside a handler must have an initializer (WS025), since a local var resets every run and a ref has no literal to reset to.
Places and ref values in a record
A record’s *T field is one of two things, fixed by where the record comes from:
- A place names a var. A field filled from a var (
{ cpsr },{ p: n }) or from a*Tparameter is a place, and so is every*Tfield of a record parameter. A place reads as its var’s value (cpu.cpsr + 1) and binds a*Tparameter to that var, and&of it is that var’s ref. It stays a place through alet, a field (let s = o.c), a tuple element, a destructuring pattern and animport * asnamespace. - A ref value carries a ref, as a
*Tvar does. A field filled from&x, from a var holding a ref, or from an array element, map read or event field is a ref value, and so is every*Tfield of a call’s result and of a record aniformatchchooses: a place that amodorchipreturns leaves it as that place’s ref. A ref value reads as the ref (*r.preads through it,r.p + 1isWS004) and binds a*Tparameter to the var it points at.
&r of a whole record is a record of refs, one per field, as * on a record type distributes over its fields: after let x = &r, *x.a reads r.a.
A record parameter’s fields stay places whatever the caller passes. A ref value there makes the field the var the ref points at, so it reads and writes that var in an exec context. A pure read of it is WS006, as for any runtime ref.
type R = { p: *int }
var a: int = 0
var n: int = 0
mod bump(q: R) { q.p = q.p + 1 }
mod pick() -> R { return { p: &a } }
on Loaded() {
bump({ p: a }) // a place: bumps a
bump({ p: &a }) // a ref value: bumps a too
let r = pick()
n = *r.p + 1 // a call's result carries the ref
}
Arrays of arrays
An array or map is only ever handled by reference, so an array value is its own ref. It is used directly: & is optional and * is an error (WS085).
var row0: int[]
var row1: int[]
var rows: int[][]
var grid: int[][] = [row0, row1] // starts holding both
var cur: *int[] // a var holding an array ref
on Loaded() {
rows.push(row0)
rows.push(row1)
rows[1].push(7) // pushes into row1
rows[1][0] = 8
cur = rows[0]
cur.push(1) // pushes into row0
}
Maps work the same way, as values (Map<string, int[]>, Map<string, *color>), though not as keys.
A map nested in a map closes with >>, as in Map<string, Map<string, int>>, and the closing > may run straight into an initializer’s = (var m: Map<string, int>= {}).
Bricks
brick is a value type. It can be stored in a var, a brick[] or a map value, copied, compared with == / !=, printed, and passed through ports, parameters and custom events.
var anchor: brick // point it at a brick in the editor after pasting
var seen: brick[]
on Loaded() {
seen.push(anchor)
if seen[0] == anchor {
BroadcastChatMessage("same brick")
}
}
Nothing in the language creates a brick, and no gate reads one. A brick comes from the editor, a port or an event.
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), because 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 the elements of a tuple value with .0, .1, .2 etc. (A multi-output mod’s result is read by its output names instead, r.lo; .0 on it is WSP001.)
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)).
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 the same way. 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, which 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 a special case:
pts.field.sort(descending?) sorts the whole record by that field and reorders
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.) Every other
operation on a column acts on that column alone. Mutating one field’s array by
itself (pts.x.push(1) without a matching pts.y.push(...)) breaks the row
correspondence that the whole-array operations rely on. Prefer the whole-record
operations (push/pop/pts[i]) unless you want to work on 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 same lowered condition (or, for
a match, the same __disc read). In source it is written like any other
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 a logic gate 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.)
A record variable’s array or map field is itself a container. When its elements
are records (type H = { pts: Point[] }), h.pts is stored the same way as a
top-level var pts: Point[], one column per field, and supports the same
operations (h.pts.push(p), h.pts[i].y, h.pts.x[i]). Two shapes are
rejected with WS049 because a column holds one value per element. First, a
record or tuple stored in an array or map cannot contain an array or map at any
depth (type H = { xs: int[] } then var hs: H[]). Second, an array or map of
records, or a record holding one, cannot cross a port (an in/out port or a
chip parameter or output), since a pin carries one reference. Pass it to a
mod instead. A record literal that initializes or is assigned to a record
variable fills its container fields the same way the field assignments would
(h = { pts: [{ x: 1, y: 2 }], m: { "a": 1 }, n: 0 }), and a ...spread
supplies every field it names.
A record assignment is a parallel assignment. Its sources are evaluated in
source order and every field sees the values from before the assignment, so
p = { x: p.y, y: p.x } swaps, and a value computed from a let that reads the
record is held before the first field is written. As everywhere (see
Evaluation Order), a record literal’s fields
are evaluated in source order and each keeps its value, so { x: q, y: bump() }
keeps the q from before bump(). A container field is copied when its write
runs, not read up front. A container copied from one that the assignment also
replaces is copied first. Two containers copied from each other
(h = { pts: h.qs, qs: h.pts }) are WS087. One that a later field’s code may
change first (h = { ps: src, n: popper() }, where popper clears src) is
WS089, and a write through a ref, an array element or a parameter pin may
change any container of its kind. Inside a handler, a record var with an
initializer is reset to it every run, like a scalar or container var. One
without an initializer resets each scalar field to its zero value, as
var x: int does. A static var keeps its baked initializer.
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.
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>).
An if-then-else expression does not produce a union of its branch
types. It widens them to a single common type instead (see
Widening Inference and the
if-expression note).
if condition then 42 else 3.14 has type float, not int | float.
Enum Types
An enum is a nominal type with a fixed set of named variants. Unlike
Type::Union above (which is structural: any value of a matching type
qualifies), two enums stay distinct types even if their variants look the
same. A variant can be a bare unit or carry a payload, which makes enum how
Wirescript expresses a tagged union:
enum Shape { Empty, Circle(float), Rect(float, float) }
An enum value is represented at compile time as a record: a hidden
discriminant field plus one slot per payload field, so it follows the same
Records as storage rules when it backs a var.
match, if let, and let ... else are how you branch on a value’s variant
and pull its payload back out. See Enums for the full reference:
declaration, construction, .Discriminant, match, if let / let else,
generic enums, and the built-in Option/Result.
Generics
mod and chip declarations can take type parameters: one implementation
that specializes per call site instead of one copy per concrete type. A
generic mod inlines and monomorphizes at each call: the compiler infers
the concrete type(s) from the arguments and emits concrete gates for that
type, same as a hand-written non-generic mod would.
Generic chips work too: a generic chip is monomorphized per distinct
type instantiation into its own microchip template (Box<int> and
Box<vector> become two separate grids; two Box<int> calls share one), so
the wire-level behavior mirrors a hand-written non-generic chip at each type.
mod pick<T>(c: bool, a: T, b: T) -> T {
return if c then a else b
}
in go: exec
in i: int
on go {
let x = pick(true, i, i) // T = int, inferred from the arguments
}
Multiple type parameters are declared as <T, U, ...>, each inferred
independently from its own arguments:
mod first<T, U>(a: T, b: U) -> T {
return a
}
See examples/generics.ws for a complete,
just check-clean file exercising every form on this page.
Constraint Classes (Bounds)
A type parameter can be bounded to a named class of types with <T: Class>.
There are three built-in classes, and unbounded <T> means <T: Variant>:
| Class | Members |
|---|---|
Scalar | int, float |
Numeric | int, float, vector, rotator, quat, color |
Variant | Numeric + bool, string, entity, character, controller (all value variants) |
Scalar ⊆ Numeric ⊆ Variant. bool is a member of Variant only.
Scalar and Numeric contain only 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 that set of
types instead of a named class:
mod pickAxis<T: int | vector>(v: T) -> T {
return v
}
A union bound may name an enum or a record type, which an unbounded
<T> never admits. The body is still checked for every member, so match,
is or .Discriminant on a T that may be int is WS066, and == on one
that may be an enum is WS004.
Widening Inference
T is inferred as the join (least upper bound) of all the arguments’
types, over a widening-only lattice, with no narrowing:
- Numeric types widen toward the wider type:
intwidens tofloat, sopick(flag, 1, 2.0)infersT = float(theintargument casts up). - Object types widen toward
entity:characterandcontrollerboth widen toentity, sopick(flag, aCharacter, aController)infersT = entity. - Incompatible operands (e.g.
intandvector) have no common widening and are a compile error (WS033):
in n: int
in v: vector
let bad = pick(true, n, v)
// ERROR WS033: cannot infer 'T': it's int from one argument but vector
// from another -- all 'T' arguments must be the same type
The same join is used by the built-in blend-family gates (Blend, lerp,
Easing) and by if-then-else expressions.
See the widening note there.
Body Checking Is Per-Mask-Member
A generic mod’s body is type-checked against every type in its bound’s
mask, not only the types it is called with. The body must be valid for the
whole bound. This is the most common mistake with generics: <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. Reported 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 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
}
The same holds for a method: an unbounded T may be a bool, so a string
method on it is rejected at the definition however the mod is called. Bound
T to the type the body needs:
mod size<T>(v: T) -> int {
return v.Length()
}
// ERROR WS003: `.Length()` is a string method, and `T` is unbounded, so `v`
// may be a `bool`; bound it: `<T: string>`
mod textSize<T: string>(v: T) -> int {
return v.Length() // OK
}
With several type parameters the combinations multiply. Past 64 of them the
body is checked for a sample (each parameter varied across its bound while the
others stay fixed) and for every combination a call uses, including calls from
inside other generic bodies, so an error only a called combination has is
still reported at the definition. Calls that use more than 1024 combinations
beyond the ones the bounds cover are WS096.
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, so 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. Use any only when the type of what
flows through doesn’t matter and you 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 rather than hanging the compiler).
Every alias body, generic or not, is read where the alias is declared. A
generic mod whose type parameter is named T hides an alias T only in what
the mod itself writes, never inside another alias it uses, and a library’s
aliases are the library’s own: a body imported from it reads its F, whatever
the importing file calls F. Enums are read the same way: a library names the
enums it declares or imports, and never one only its importer declares.
A program’s own type named like a builtin generic is the program’s wherever it
is declared: type Map<K, V> = { k: K, v: V } makes Map<int, int> that
record, in every stage of the compiler. Array<T> and Ref<T> are how T[]
and *T are written, so a type or enum named Array or Ref with one type
parameter could never be named, and is a WS107 error. With another number of
type parameters it is the program’s own type, as Map is.
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. On a builtin they are ignored with a
WS037 warning, because 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.
Type Coercion
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
A float constant that is the whole value of an int place is the integer it
truncates to: var n: int = 2.5 and const K: int = 2.5 both hold 2, with a
WS106 warning that the fraction is dropped. The constant is then an integer
wherever it goes, so var u = K declares an int and u / 4 divides as
integers. The same holds for a chip argument, a field of a record or tuple
literal, an element and a return. Custom-event data is not an int place. A
float constant sent to a receiver that declares int is sent as the float, and
WS030 warns of the mismatch. A float constant that no int can hold (1e30)
is a WS028 error.
Because bool coerces to int automatically, you do not need if x then 1 else 0. 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 coercion means s != "", and the compiler inserts a CompareNotEqual(s, "") gate at every such coercion point:
in name: string
if name {
// taken whenever `name` is non-empty (compiles as `if name != ""`)
}
let hasName: bool = name // also `name != ""`
| String value | Bool |
|---|---|
"" | false |
anything else (including "0", "false", " ") | true |
String literals in bool positions (var v: bool = "0", array flags: bool[] = ["x", ""], Select("0", a, b)) are converted at compile time by the same != "" rule: the baked value is already a bool, so "0" bakes as true.
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 rule is certified per build against the in-game gate-semantics table, the same certification that drives constant folding of conditions (see folding.md). If you want the content-aware behavior, wire it manually. If you write if someString, you get the deterministic != "".
This direction is one-way: bool to string still goes through the format gate above and renders "true"/"false" text, not the other way around.
Pulsing Coercion to Exec
Value types that “pulse” (change over time) can trigger exec inputs. This means bool, int, float, vector, entity, character, and controller values can be connected to exec inputs: the exec fires whenever the value changes:
// A bool value can trigger a handler
chip let moved = position != position.prev
on moved {
// Fires whenever 'moved' transitions
}
Reference Invariance
Reference types (ref T) do not coerce. A ref int cannot be passed where a ref float is expected, even though int and float coerce freely. This prevents accidentally wiring incompatible variable storage:
var x: int = 0
var y: float = 0.0
// This would be an error: ref int != ref float
// someChip(x, y) // if both params expect ref int
Coercion Summary Table
| From | To | Rule |
|---|---|---|
int | float | Coerce |
float | int | Coerce |
bool | int | Coerce |
bool | float | Coerce |
int | bool | Coerce |
float | bool | Coerce |
character | entity | Coerce (subtype) |
controller | entity | Coerce |
entity | character | Coerce (wired directly, since an entity wire can carry a player, e.g. a sweep hit) |
entity | controller | Coerce (wired directly) |
character | controller | Coerce (wired directly) |
controller | character | Coerce (wired directly) |
rotator | quat | Coerce (interchangeable rotation values) |
quat | rotator | Coerce |
| any primitive | string | Via format gate |
string | bool | Coerce (compiler inserts != "", so only empty is false) |
exec | bool | Coerce (true for one frame) as a condition, a logical operator’s operand or an on trigger. Stored or passed as a bool, it is WS003 (see Logical Operators) |
bool/int/float/vector/entity/character/controller | exec | Pulsing coerce |
ref T | ref U (T != U) | Mismatch |
any | anything | Same |
| anything | any | Same |
“Wired directly” converts nothing in game. A character wired into a
controller slot is still the character: it is not equal to that player’s
controller, and as a Map<controller, V> key it files under the body, so a
respawn starts a new key. Only ControllerOf (an admin gate) gives the
persistent player.
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), a 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, which turns the
type into a tagged union. A value of an enum type always knows which variant
it currently is. Use match, if let, and let ... else to branch on the
variant and pull the payload back out.
Unlike type aliases, which are structural, enum
is nominal: two enums with identically-shaped variants are still different
types.
Contents
- Declaration
- Construction
.Discriminantis- Enum and int conversion
matchif let/let else- Changing a payload
- Payloads cannot hold containers
unsafe: unchecked payload access- Generic enums
- Built-in
OptionandResult - Built-in game enums
Declaration
Discriminants (the integer tag backing each variant) auto-number from 0. An
explicit = N resets the counter, so 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, such as
Shape.Circle { } for a positional variant or Shape.Box(1.0, 2.0) for a
named one, is a WS065 error. This keeps a variant’s shape
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, with no Option. / Result. qualifier:
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 shorthand for the discriminant comparison above:
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 `==`
An enum has no ==: comparing two values would have to compare every payload
slot too, so s == Shape.Empty is a WS004 error. Test the
variant with is, or compare .Discriminants.
The test compares tags only, so a payload variant is tested by its tag and binds
nothing. Use match or if let to get a payload
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
equivalents 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 only reads 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), the same
as FromInt and null. With no enum-typed context it can’t tell which enum
the integer names, and that is an error. A constant value folds to the enum
record directly, and 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 equivalent 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 equivalent 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.
An arm can also be a block that ends in return (A => { return 0 }), which
leaves the enclosing mod or handler instead of giving the match a value. A
block arm that can end any other way is WS095. So is a block arm of a value
match outside an exec context (a top-level out or let), where it never runs.
An arm may be a record literal, which chooses a record per field:
A => { x: 1, y: 2 }. An arm written {} or { name } is a block, as in the
statement form.
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 Loaded() {
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. A _ after arms for every variant is not
one: it runs for a tag that names no variant, such as Enum.FromInt(n) with
n out of range.
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.
A payload that is an int, float, bool or string can be matched by a
literal, a range or a string pattern, as a match on a single
value is:
enum Cmd { Say(string), Move(int), Quit }
static var c: Cmd = Cmd.Quit
out text = match c {
Say("") => "(silent)",
Say("/" .. command) => "command ${command}",
Say(words) => words,
Move(0) => "stay",
Move(_) => "move",
Quit => "bye",
}
Alternatives and guards
A | B => ... runs one arm for either variant, and builds it once. An
alternative cannot bind a payload (WS121), since only one of
them matches; use _ in its place. pattern if condition => ... runs the arm
only when the condition, which can read the pattern’s bindings, is also true;
otherwise matching goes on with the next arm. A guarded arm covers nothing for
WS054, since its guard may be false.
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Empty
out kind = match s {
Circle(r) if r > 10.0 => "big round",
Circle(_) | Rect(_, _) => "shape",
Empty => "nothing",
}
Enums at ports
An enum value is its tag plus a slot per payload field, and a port pin carries a
single wire. So only an enum with no payloads crosses a port, and only a
top-level in or out port. It travels as its tag (the .Discriminant), and
on the other side the name is the enum value again, ready to match, compare
with is, or store. A built-in game enum has no payloads, so it crosses the same
way. An array or map of such an enum crosses a top-level port as one column of
tags, and each element reads back as the enum. An out of one is set like any
other output: a default, emit name = value, or an out in a handler.
enum Mode { Idle, Run }
in mode: Mode
in ease: EasingFunction
var last: EasingFunction = EasingFunction.Linear
var n: int = 0
in go: exec
out seen: Mode = Mode.Idle
on go {
last = ease
n = match mode { Idle => 0, Run => 1 }
emit seen = mode
}
out current: EasingFunction = last
Everything else is a WS049 error: an enum with a payload at
any port, and any enum inside a record or tuple port, or at a chip parameter
or output. A mod parameter is fine (a mod is inlined, so nothing crosses a
port), and a chip parameter written *E wires each part. To bring an enum
with payloads in from outside the unit, pass its parts as plain values and build
it inside:
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,
}
To send an enum with payloads out, expose a derived scalar, such as
.Discriminant or a match result.
A match scrutinee can be any enum value in storage, or a let that aliases
one. 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, so matching it is the WS066
“requires an enum scrutinee” error, which names 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 Loaded() {
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 is required to diverge: it
must end in return / emit, or be an if/match whose arms all diverge.
This 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 Loaded() {
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. Reading a capture reads that slot live
too, so a call in the arm that writes the matched value is visible through the
capture:
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 is that variant,
which 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. 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 prevents that.
The same applies to a generic enum instantiated with a container, when the
parameter is one a variant stores, so Option<int[]> is a WS069 at
the annotation. A parameter no variant stores is unaffected.
Keep the container in its own var and put something scalar in the payload
that refers to it, such as an index, a key, or a length:
var items: int[]
enum Loadout {
Empty,
Carrying { first: int, count: int },
}
var loadout: Loadout = Loadout.Empty
in pickUp: exec
on pickUp {
items.push(7)
loadout = Loadout.Carrying { first: 0, count: items.length() }
}
unsafe: unchecked payload access
unsafe <value>.<Variant>.<field> reads or writes one payload slot without
proving the value is that variant. It costs no gate: the access resolves
straight to the slot.
enum Job { Idle, Running { label: string, ticks: int } }
var job: Job = Job.Running { label: "start", ticks: 0 }
var seen: string = ""
in tick: exec
on tick {
unsafe job.Running.ticks = unsafe job.Running.ticks + 1
seen = unsafe job.Running.label
}
The variant is required and is what selects the slot, so a field two variants
share is never ambiguous. A record-typed payload keeps projecting
(unsafe job.Running.spec.width). An unknown variant is a
WS060, an unknown field a WS010, a missing segment a
WS070.
The tag is asserted, never tested and never written:
- Reading a variant the value is not returns that slot’s stale contents, with no error and no default.
- Writing sets the slot only. If
jobisIdle,unsafe job.Running.ticks = 5fillsRunning’s slot whilejobstaysIdle, and a latermatchstill takes theIdlearm.
Prefer a destructure, which tests the tag first. Use 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 the same way as 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. They need no
declaration, 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. 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.
BrickMaterial (Plastic, Glass, TranslucentPlastic, Glow, Metal,
Hologram) is built in the same way from the game’s brick material enum. It is
the value @material
takes rather than a gate setting. Its members are bare only as that argument
(@material(Glow)); everywhere else write them qualified, BrickMaterial.Glow.
Type is what GetValueType and .getType()
give: the kind of value on a wire (Bool, Int, Float, Color, Vector,
Rotator, Quat, String, Enum, Object, Brick, Component, Exec).
It is built from the game’s wire-kind enum, with the member names Wirescript
uses for its types. Its members are bare only as the arms of a match on a
Type; everywhere else write them qualified, Type.Int.
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, and .Discriminant gives back
the integer the game’s own schema assigns that member. This is the only way 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. 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 logic gates: each operator or function call becomes one or more gates with their ports wired together.
Contents
- Operator Precedence
- Evaluation Order
- Arithmetic Operators
- Comparison Operators
- Logical Operators
- Bitwise Operators
- String Concatenation
- String Interpolation
- Conditional Expressions (if-then-else)
- Atom Literals
- Record Literals
- Tuple Literals
- Field Access
- Index Access
- Tuple Pick
- Function Calls
- Ref and Deref
- Type Conversion (
as) - Parenthesized Expressions
- Common mistakes
- Asset References
- Prefab References
Operator Precedence
Operators are listed from lowest (loosest binding) to highest (tightest binding):
| Precedence | Operators | Associativity | Description |
|---|---|---|---|
| 2 | || ^^ | Left | Logical OR, Logical XOR |
| 3 | && | Left | Logical AND |
| 4 | | | Left | Bitwise OR |
| 5 | ^ | Left | Bitwise XOR |
| 6 | & | Left | Bitwise AND |
| 7 | == != is | Left | Equality, enum variant test |
| 8 | < <= > >= | Left | Comparison |
| 9 | << >> | Left | Bitwise shift |
| 10 | + - .. | Left | Addition, subtraction, string concat |
| 11 | * / % | Left | Multiplication, division, modulo |
| 12 | ** | Right | Exponentiation |
| 13 | as | Left | Type conversion (x as float) |
| – | - ! ~ * ref | – | Unary prefix operators |
| – | .field [i] (args) | Left | Postfix: field access, index, call |
Parentheses ( ) override precedence as usual.
// ** is right-associative:
let x = 2 ** 3 ** 2 // = 2 ** (3 ** 2) = 2 ** 9 = 512
// Standard math precedence:
let y = 1 + 2 * 3 // = 1 + (2 * 3) = 7
// String concat is at the same level as +/- and groups left to right,
// so `"x=" .. x + 1` means ("x=" .. x) + 1, which is a WS004 error.
let s = "x=" .. (x + 1)
Evaluation Order
Wherever an expression has several parts, they are evaluated in source order,
and each part keeps the value it had when it was evaluated. That covers an
operator’s operands, a call’s arguments (named ones too), a container method’s
arguments, an index or ref and the value written through it (xs[i] = v,
*r = v), the slots of an interpolated string, an if-expression’s condition and
branches, and the fields of a record, tuple or enum payload. So a part that reads storage (a var, a let
computed from one, an out) is read before a later part runs code that writes
that storage:
var q: int = 0
var o: int = 0
let l = q * 3
mod bump() -> int {
q = q + 1
return 1
}
in go: exec
on go {
o = l + bump() // `l` from before bump()
o = bump() + l // `l` from after it
}
q = q + bump() likewise reads q before bump() runs. An array or map is a
reference, read where it is used, except where a whole-record assignment copies it
(see Records as storage).
Arithmetic Operators
| Operator | Operation | Operand Types | Result Type |
|---|---|---|---|
+ | Addition | int, int | int |
+ | Addition | float, float | float |
+ | Addition | int, float or float, int | float |
+ | Addition | int, bool or bool, int | int |
- | Subtraction | (same as +) | (same as +) |
* | Multiplication | (same as +) | (same as +) |
/ | Division | (same as +) | (same as +) |
% | Modulo | (same as +) | (same as +) |
+ - * / % | Vector math | vector, vector (or vector + scalar) | vector |
+ - * / % | Color math | color, color (or color + scalar) | color |
+ - * / % | Rotation math | quat, quat / rotator, rotator (or a mix) | quat/rotator |
+ - * / % | Object operand | int/float + an object (player, entity, …) | numeric |
** | Exponentiation | int/float (not vectors) | (same as +) |
-x | Negation (unary) | int | int |
-x | Negation (unary) | float | float |
Mixed int/float arithmetic promotes the result to float. bool values are treated as 0/1 when mixed with int.
+ - * / % also operate component-wise on two vector operands, lowering to
the same math gates (whose inputs accept the vector, f64 and i64 wire
variants). Mixing a vector with a scalar broadcasts the scalar across the
components (v * 2.0, 10.0 * v, v / 4), and 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. Mixing a color with a number broadcasts that number across the
channels. Add a number to lighten (c + 0.1) or multiply to scale (tint * 0.5
dims every channel including alpha), with the number on either side (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. Operands of the same type keep their type. A
quat/rotator mix yields a quat, which coerces freely 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. The compiler inserts this for you, so write the arithmetic as usual.
let a = 10 + 3 // 13: int
let b = 10.0 + 3 // 13.0: float
let c = 2 ** 10 // 1024: int
let d = -42 // -42: int (negative literal folded at parse time)
let p = Vec(1.0, 2.0, 3.0) + Vec(4.0, 5.0, 6.0) // (5, 7, 9): vector
let q = Vec(1.0, 2.0, 3.0) * 2.0 // (2, 4, 6): vector × scalar
let blend = c1 * 0.5 + c2 * 0.5 // RGBA channel-wise color blend: color
let lighter = tint + 0.1 // add a number to every channel: color
let dimmer = tint * 0.5 // multiply every channel: color
let spin = a1.ToRotation() * a2.ToRotation() // compose two rotations: quat
let n = 1 + player // object → (player || false)
Comparison Operators
All comparison operators return bool.
| Operator | Operation | Operand Types |
|---|---|---|
== | Equal | any wire variant pair |
!= | Not equal | (same as ==) |
< | Less than | (same as ==) |
<= | Less or equal | (same as ==) |
> | Greater than | (same as ==) |
>= | Greater or equal | (same as ==) |
Comparison accepts all wire variant types (int, float, bool, string, entity, controller, character) in any combination.
value is Enum.Variant tests which variant an enum value holds and also
returns bool; see enums.
let isZero = count == 0
let isPositive = score > 0
let sameTeam = teamA == teamB
Logical Operators
| Operator | Operation | Operand Types | Result |
|---|---|---|---|
&& | Logical AND | any wire variant pair | bool |
|| | Logical OR | any wire variant pair | bool |
^^ | Logical XOR | any wire variant pair | bool |
! | Logical NOT (unary) | any wire variant | bool |
Wire variant types are: bool, int, float, exec, string, entity, controller, character. The engine coerces all of these to bool on bool ports (truthy/falsy). An exec value is true for the tick it fires, and works directly as an operand of the logical operators (&&, ||, ^^, !), as an if or if ... then ... else condition and as an on trigger, read directly or through a let. Otherwise it is not a bool value. Storing it (in a bool variable, array, map, record field or output), passing it to a bool parameter or returning it from a -> bool mod is WS003, and comparing it with == is WS004. An exec carried through an output pin of no type (out o: any) reads as true where a bool is read or written. Read as any other type (a number, text, a color), it carries no value, which is WS094:
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.
No short-circuiting
&& and || do not short-circuit. Both operands are wires into one gate, so
both are always computed: in pure code both sides are evaluated every tick, and
in a handler a call with side effects on either side runs whatever the other
side is. Here take() runs on every go, armed or not:
in go: exec
in armed: bool
var stock: int = 5
mod take() -> bool {
stock -= 1
return stock >= 0
}
on go {
if armed && take() { } // take() runs even when armed is false
}
To guard a side effect, nest the ifs, so the call sits in a branch that only
runs when the guard holds:
in go: exec
in armed: bool
var stock: int = 5
mod take() -> bool {
stock -= 1
return stock >= 0
}
on go {
if armed {
if take() { }
}
}
Bitwise Operators
All bitwise operators produce int results. Operands are int. A float or bool operand coerces to int first (1.5 & 2 truncates 1.5 to 1), so only a non-numeric operand (string, vector, …) is a WS011 error.
| Operator | Operation |
|---|---|
& | Bitwise AND |
| | Bitwise OR |
^ | Bitwise XOR |
~ | Bitwise NOT (unary) |
<< | Left shift |
>> | Right shift |
~(a & b) and ~(a | b) are automatically fused into single NAND/NOR gates.
let mask = 0xFF
let high = (value >> 8) & mask
let combined = a | b
let flipped = ~flags
let shifted = 1 << bitIndex
String Concatenation
The .. operator concatenates strings. It automatically converts numeric types to their string representation.
| Left | Right | Result |
|---|---|---|
string | string | string |
string | int | string |
int | string | string |
string | float | string |
float | string | string |
int | int | string |
let greeting = "Hello, " .. name
let label = "Score: " .. score
let coords = x .. ", " .. y .. ", " .. z
String Interpolation
Strings (both "double" and 'single' quoted) support ${expr} interpolation. The embedded expression is evaluated and converted to a string.
let msg = "Player ${name} scored ${points} points"
let debug = 'pos=(${pos.x}, ${pos.y}, ${pos.z})'
let nested = "result: ${a + b * c}"
Interpolated expressions can be arbitrarily complex:
let status = "Health: ${if hp > 50 then "OK" else "LOW"}"
Use \$ to include a literal $:
let price = "Cost: \$${amount}"
Both arms evaluate. An
ifexpression compiles to aSelectfed by both results, so the arm that is not chosen is computed and discarded. The condition only picks which value is used and skips no 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.The exception is an arm that does more than give its value. On an exec chain (a handler or a
modit calls), an arm that writes a variable or an output, sends, mutates a container, runs an action builtin, orreturns (directly, through amatchblock arm, or inside amodit calls) runs only when it is taken. In that case theifcompiles like anifstatement whose branches store the value, and the value is read after they rejoin. Anifwhose value is a record, tuple or enum can’t do this (WS097), so write anifstatement instead. Amatchused as a value treats its arms the same way. This applies only to what the arm itself runs. Anonhandler inside amodorchipthe arm calls runs whenever its own trigger fires, whether or not the arm is taken (WS102).
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 and 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 _, since 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 stays a plain colon, as in a type annotation (x: int), a
record field ({ x: 1 }), or a map’s string/atom key separator ("red": 1,
:red: 1).
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 gates, and each field resolves directly to the underlying binding of its value expression. A record used as storage (a var, array, or map) is the exception: it decomposes into one gate per field. See Records as storage.
Tuple Literals
Tuple literals construct tuple values with positional elements:
let pair = (1, 2)
let triple = (true, 3.14, "hello")
Access elements with .0, .1, .2, etc.
Field Access
Use dot notation to access fields on values:
let x = position.x // vector field
let r = myColor.r // color field
let p = myRotator.pitch // rotator field
Vector / color / rotation components
Every component-typed value can be read one component at a time:
| Type | Components |
|---|---|
vector | .x .y .z |
color | .r .g .b .a |
rotator | .pitch .yaw .roll |
quat | .x .y .z .w |
The names are also accepted upper-case. They work on any expression of that
type (a Vec(...) literal, an input, a stored variable, or a let binding)
and lower to the matching Split gate (SplitVector, SplitColor, SplitRotation,
SplitQuaternion), which outputs the single float component:
in a: vector
in b: vector
let sum = a + b // vector
let height = sum.z // float: the z component
in tint: color
let red = tint.r // float
in facing: rotator
let turn = facing.yaw // float, in degrees
let spin = a.ToRotation() // quat
let scalar = spin.w // float
Reading several components of the same value costs a single gate, not one per component, because the split gates are identical and merge.
A component name belongs to only one type, so using one on another type (v.w,
facing.x, tint.z) is a WS010 error. It is never silently read as some
other component.
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, a bare name reads the var’s value (the same as .Value); &x is the var’s ref as a value. See Reference values.
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 =.
Four cases resolve through .exec:
-
A call that returns a bare
exec. Some builtins produce an exec whose underlying port has a gate-specific name (e.g. aChangedetector’s exec port isOnChanged)..execdenotes that exec directly:let ch = Change(score) // exec: fires when `score` changes on ch.exec { // handle it ctrl.DisplayText("score changed") } -
A chip or mod called with
exec =. An exec chip/mod call returns its completion exec as an.execfield on the result record, so callers can sequence on the body having finished:let t = InitTables(exec = reset) on start { emit reset await t.exec // resumes after InitTables ran } -
A call inside a handler.
.execbinds that call’s exec output without advancing the current chain, so the call runs as a branch and the statement after it runs right away.emitin value position names the chain point the branch forks from. See Forking the Exec Chain:var m: int = 0 var n: int = 0 in start: exec chip Later() -> (count: int) { m = m + 1 out count = m.Value } on start { let d = Later().exec // Later runs; the chain carries on n = 1 // runs alongside Later, not after it await d // rejoins once Later has finished } -
An event that also carries data. A data-carrying event expression (a custom event with data outputs) is a record whose exec output comes first, so
.execnames that trigger explicitly and lets the event compose with other exec signals:in reset: exec on Union(reset, CustomEvent("ping").exec) { // fires on reset or the event ctrl.DisplayText("announced") }
Union also takes an exec receiver, so a.Union(b) is Union(a, b) and a wide
fan-in reads as a left-associative chain instead of nested Union(Union(...)):
in reset: exec
on reset.Union(CustomEvent("ping").exec).Union(Change(score)) {
ctrl.DisplayText("announced")
}
See Explicit Exec Argument and Exec Chips.
Index Access
Use bracket notation to index into arrays:
let item = myArray[i]
// item.value: the element value
// item.bOutOfBounds: bool, true if index was out of range
Tuple Pick
Use .N (dot followed by an integer) to pick an element from a tuple:
let pair = someTupleExpr
let first = pair.0
let second = pair.1
Function Calls
Call functions and chips by name with parenthesized arguments:
// Positional arguments
let dist = Distance(posA, posB)
let s = sin(angle)
// Named arguments (kwargs)
DisplayText(ctrl, "Hello",
positionX = 0.0,
positionY = -100.0,
fontSize = 24
)
Named arguments use name = value syntax. They can be mixed with positional arguments, but positional arguments must come first (matching the parameter order).
Arguments are evaluated in source order, named ones included, and each keeps the value it had when it was evaluated (see Evaluation Order). A record argument naming a var’s storage is bound to that storage, as a *T parameter is.
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 instead of running as part of 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
ref (or &) makes a ref value: let r = ref someVar and let r = &someVar are the same. The * prefix operator reads or writes through a ref, in an exec context. See Reference values for the full rules.
in go: exec
var someVar: int = 0
on go {
// ref (or &) makes a ref value
let r = ref someVar
// * reads through it
let val = *r
}
In practice, ref and * are primarily used in chip parameter passing. When a chip parameter has type ref T (or *T), you pass a variable and the chip can read/write it:
mod increment(counter: *int) {
counter = counter + 1
}
var n: int = 0
on tick {
increment(n) // passes ref to n; n is mutated
}
*var: Explicit Deref in Exec Context
Inside exec context, prefixing a variable with * explicitly reads its current-tick value via a Var_Get gate. This is identical to the implicit auto-deref that happens with a bare variable name in exec context. It exists for clarity or to disambiguate:
var x: int = 0
on tick {
let a = x // implicit deref (Var_Get)
let b = *x // explicit deref (same Var_Get, same result)
}
*var is not allowed in pure context. It produces error WS006 (“use .Value for pure reads”). In pure context, use x.Value instead.
Variable Read Modes Summary
| Expression | Context | Gate | Meaning |
|---|---|---|---|
x (bare) | Exec | Var_Get | Current tick’s value |
*x | Exec | Var_Get | Current tick’s value (explicit) |
*x | Pure | none | Error WS006: use .Value |
x.Value | Pure or Exec | .Value port | Previous tick’s value (delayed) |
x.prev | Pure or Exec | .Value port | Previous tick’s value (same as .Value) |
x (bare) | Pure | .Value port | The same read as x.Value; an unannotated out of it is WS017 |
&x | Pure or Exec | VarRef port | x’s ref, as a value |
Type Conversion (as)
value as T converts a value to another type with the game’s Convert gate. It
binds tighter than every binary operator: a + b as float converts b, and
-x as int converts -x.
in count: int
out ratio = count as float / 3.0
out shown = count as string
Converts bool, int, float and string into one another; a number into a
vector, rotator or color (it fills every channel, a color’s clamped to
0..1); a rotator into a quat and back; and a vector, rotator, quat or
color into a string (X=1.000 Y=2.000 Z=3.000, FFBC00FF). An any value
converts to any type. These are the conversions measured in game. Every other
pair (a vector as a number or a color, a string as a vector, anything to or
from an object) comes out of the gate as the target type’s default, so it is
WS109.
A float converts to an int by truncating toward zero (-2.7 as int is -2),
a number is true when it is not zero, and a string reads as its leading
number ("42abc" as int is 42, "1e3" as int is 1, "abc" is 0). A
constant whose result is known this way is converted at compile time, so
const half = 3 as float works and costs no gate.
A record or tuple converts field by field, and both sides need the same fields.
A field already of its target type is not converted, and a conversion nothing
reads costs nothing, so (a, b) as (bool, int) is one gate while b is an
int, and none when the result goes unread.
type Cell = { x: int, y: int }
type Pos = { x: float, y: float }
in at: int
var last: Pos = { x: 0.0, y: 0.0 }
in go: exec
on go {
let c: Cell = { x: at, y: 2 }
last = c as Pos
}
A conversion as cannot make yet (an array or map, an enum, a ref, exec,
records whose fields differ) is warning WS109, and the value keeps its own
type. Inside a for source, put a conversion in parentheses:
for i in 0..(n as int).
Parenthesized Expressions
Parentheses group expressions to override precedence:
let result = (a + b) * c
let check = !(x && y)
Common mistakes
Bitwise & is lower precedence than ==/!=
This matches C. x >> 31 & 1 != 0 would parse as x >> 31 & (1 != 0),
which is almost never what was meant, so a comparison used directly as an
operand of &, | or ^ warns (WS117). Parenthesize either way:
let bit = ((x >> 31) & 1) != 0 // tests the bit
let flag = x & (y != 0) // takes the comparison's result
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, because 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).
Components
$Component/<name> names a component type: the setting of the Add Component,
Get Component and Get/Set Component Property gates. The name is the
component’s display name without its spaces, and the editor completes it
after $Component/. $Component/<name>/<property> names one of that
component’s properties as well, completed after the second /:
$Component/PointLight
$Component/OneShotAudioPlayer
$Component/PointLight/Brightness
Only the types the gates’ Component Type setting offers are accepted: the
components a player can add to a brick (lights, audio, buttons, spawners,
zones and the like). A logic gate, Input Splitter, a component’s class name,
a misspelled name or a property the component does not have is WS110.
Property names come from the component’s fields in the save schema, so the
list may hold fields the game’s own picker does not offer (unverified). The
type goes to GetComponent and AddComponent (see
Components). It is a constant setting, not a value
a program can hold.
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))
}
offset is relative to the Spawn Prefab gate, so both copies appear 50 units
above the spawner. For a world position, spawn and then SetLocation the
returned entity.
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.
on CharacterSpawned() -> (who) {
let e = SpawnPrefab(prefab = $```
var owner: character
on CustomEvent("init", isObject = true) -> (p: character) {
owner = p
on ServerUptime() {
if owner == null { ReadBrickGrid().DestroySpawnedPrefab() }
}
}
```, lifetime = 0.0, limit = 64)
e.SendCustomEvent("init", who) // targeted: the copy's receiver needs isObject
}
Why this form exists. A back-edge loop advances one iteration per tick, so a central chip sweeping N entities with one costs N ticks per pass. A
forsweep finishes in one tick but runs its body N times. Spawning one chip per entity moves that work into parallel gate instances, and the per-tick cost stops depending on N. The nestedon ServerUptime()above is not registered by the init handler. The circuit is static, so its trigger is live from the moment the prefab spawns, beforeinitlands, which is why real code guards it on an init-set value. See per-entity fan-out and Loops.
At compile the referenced .brz is read and embedded into the output
bundle (content-addressed at Prefabs/Uploads/<hash>.brz), and the gate’s
Prefab property is set to that embedded path, so the compiled program is
self-contained. Typing $./ completes available .brz files (the editor scans
the project directory; the web playground has a Prefabs panel where you upload
or drag in .brz files).
Statements
Statements are the building blocks of Wirescript programs. They declare data, define behavior, and control execution flow.
Contents
var: Mutable Variablelet: Immutable Bindingconst: Compile-Time Bindingbuffer: Buffered Value- Arrays:
var name: elementType[] - Maps:
var name: Map<K, V> in: Input Portout: Output Port@left/@right/@top/@bottom: Outer Rerouter Pinsif: Conditional Statementmatch: Branch on a Valueif let/let else: Refutable BindsforLoopsfn: Functionon: Event Handlerlet on: Event Declarationemit: Emit Signalawait: Suspend Exec Chain- Assignment
- Expression Statement
- Built-in Events
return
var: Mutable Variable
Declares a mutable variable backed by a 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 Variable gate, whose value is a wire
variant, so a var can hold any variant member type: int, float, bool,
string, vector, and object types (entity, controller, character).
static var: Persistent Variable
A static var keeps its value across handler/mod invocations. The initial value is set once when the save loads. Use this for accumulators, counters, or state that must survive across calls.
static var total: int = 0 // persists across calls
static var highScore: int = 0
on RoundStart() {
total = total + 1 // accumulates over time
}
Top-level (module-scope) var declarations are always persistent. static is only meaningful inside handlers and mods, so static var at top/root level is a no-op (just use var).
Variable Identity
Internally, a var x: T has type ref T. This means:
- In exec context (inside
onhandlers):xauto-dereferences to typeTwhen used in expressions. When passed to a*Tparameter, it remains a reference. - In pure context (outside handlers):
xreads the var’s value (the same asx.Value);x.prevreads the previous tick’s value.&xis the var’s ref as a value. See Reference values.
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 }
}
Declaring a Name Again
A top-level const or let may declare a name the top level of the same file declares already (another const or let, or a var, buffer, array or map). The file is then read by position. A read names the latest declaration written before it, and the body of a mod, chip, generic or const mod reads as code written where that declaration is, wherever it is called from. The later declaration hides the earlier one from its position on, for values and for constant-only settings (an event name, a folded if) alike, and the compiler warns WS108 at it, naming both lines. Storage is declared ahead of everything, so a read above every declaration of the name reads the storage. A var initializer is the exception: it reads the declaration before the var (let k = 5 then var k: int = k starts the var at 5), as a let initializer reads the one before the let. A buffer initializer reads the buffer itself, which is its value the tick before. With no storage, a mod or chip written above every declaration reads the first one, as it reads the only one when there is one. A library reads its own duplicates the same way, in the order it writes them, however it is imported (flat, by name, both, or by several paths). Its importer reads the last one, which is the one a named import binds. A module imported with import * as holds only the last one. Every read of that module names it, including reads through a flat import of the same module in the same program (one WS108 says so). Two storage declarations of one name are WS013.
in go: exec
in later: exec
var sent: string = ""
const channel = "first"
on go { SendCustomEvent(channel, 1) } // sends on "first"
const channel = "second" // WS108: hides line 4 from here on
on later {
sent = channel // "second"
SendCustomEvent(channel, 2) // sends on "second"
}
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, 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 applies, and the compiler reports it when it is hit. 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. The compiler reports this as WS028 instead of
dropping the value. Pass the value in as a const parameter.
chip Outer(t: exec) {
const ch = "evt"
chip Inner(u: exec) { on u { send(ch) } } // WS028: ch does not reach Inner
chip Ok(u: exec, c: const string) { on u { send(c) } } // pass it in instead
let q = Ok(t, ch)
}
What’s const-evaluable
-
Literals, and any other
const/named constant. -
Arithmetic, bitwise, shift, comparison, logical, and
..string concatenation over constant operands, with the same semantics as the gates they would otherwise have compiled to: 64-bit wrapping integers, divide-by-zero as0, and a non-finite float as0. -
String interpolation (
"a${1 + 1}b") and the certified string/math builtin methods (.ToUpper(),.Trim(),.Length(),sin,sqrt, …), when every operand is constant. -
abs,min,max,clampfold in aletbut are notconst.let m = max(3, 7)folds to theint7(andmax(3.0, 7.0)to thefloat), keeping the type of the arguments, butconst m = max(3, 7)isWS046. Bind the result withlet, or write the constant directly where aconstis required. -
The
Vec/Rotation/Colorconstructors. -
Array and map literals, indexing (
arr[i],m[k]), and.length(). -
Record literals and field access (
.field), including nested records. -
A call to a
const mod(seeconstParameters andconst mod), including one nested inside an operator, a unary operator, or aVec/Rotation/Colorconstructor argument (positional or named):const mod double(n: int) -> int { return n * 2 } const seven = double(3) + 1 // 7: nested in an operator const negSix = -double(3) // -6: nested in a unary operator const v = Vec(double(1), y = 2.0, z = 3.0) // nested in a named constructor argumentA call nested one level deeper than that, as an argument to a call whose own callee is not itself a
const mod, still is not evaluated, because that callee has no compile-time form of its own to descend through:const mod double(n: int) -> int { return n * 2 } mod scaleUp(n: int) -> int { return n } const total = scaleUp(double(3)) // WS046: scaleUp is not itself a `const mod`Bind the call first, then pass the result:
const d = double(3), thenscaleUp(d).
An out-of-range array index, and a missing map key or record field, are refused outright rather than falling back to a stale or default value. Unlike a runtime out-of-range array read, which keeps the gate’s previous value, there is no previous value to fall back on at compile time.
A const value reaches every position that requires a literal, not just
another const binding: gate config fields, a custom event’s channel name
on both the sending and the receiving side, and the contents of a baked array
or map:
const CHANNEL = "evt_" .. "died"
in go: exec
var n: int = 0
on go { SendCustomEvent(CHANNEL, n) }
on CustomEvent(CHANNEL) -> (v: int) { n = v }
A const array built at the top level can be indexed there too, in both a
baked initializer and a runtime wire operand:
const t = [10, 20]
const z = t[1] // 20
var counts: int[] = [z, 12345] // baked into the array's initial contents
var rv: int = 0
in go: exec
on go {
if z == rv { BroadcastChatMessage("match") } // baked as a literal operand
}
const containers at runtime
A const array or map is a compile-time value and a runtime container. It
folds wherever the answer is known at compile time (t[1] above costs nothing),
and the first runtime read builds a real container gate with the constant
contents baked into its initial value:
const t = [10, 20, 30]
const m = { "a": 1, "b": 2 }
mod pick(ys: int[], at: int) -> int { return ys[at] }
var i: int = 1
var k: string = "b"
in go: exec
on go {
BroadcastChatMessage(t[i]) // 20, read at a runtime index
BroadcastChatMessage(t.length()) // 3
BroadcastChatMessage(pick(t, i)) // 20, passed as a `T[]` argument
BroadcastChatMessage(m[k]) // 2, read at a runtime key
}
The container is built only where something needs it, and only once, so a
const table used purely at compile time costs no gates and many runtime reads
of one table share a single gate.
Outside a const mod body a const container is immutable: t.push(4),
t.clear() and t[0] = 4 are all rejected, so the compile-time value and the
runtime contents can never disagree. Declare it var to make it mutable.
A let bound to a constant array or map (let x = t, let xs = [1, 2, 3]) is
the same kind of constant: it folds, builds its gate on the first runtime read,
and is immutable. So is a constant array literal passed to a T[] parameter
(pick([10, 20, 30], i)), and a constant container used as a value anywhere
else (an if branch, a record field, a return, a chip argument).
Compile-time mutation
Inside a const mod body, a const array or map can be mutated in
place using push/set/clear/append on an array and
set/remove/clear on a map, so a collection can be assembled conditionally and still bake
with zero gates:
const mod rooms(n: int) -> int[] {
const t = [10]
if n >= 2 { t.push(20) }
if n >= 3 { t.push(30) }
return t
}
const layout = rooms(2) // [10, 20], computed entirely at compile time
See const Parameters and const mod
for calling const mods, const parameters, and how a const-evaluable if
condition drops its untaken branch.
Diagnostics
| Code | Meaning |
|---|---|
| WS046 | Not a compile-time constant. The value names a runtime value, a call to a mod that isn’t const, an unsupported syntactic form, an out-of-range index, or a missing map key/record field. The message names the actual offender. |
| WS047 | The certified evaluator refuses to compute the value even though every operand is constant: overflow, a non-ASCII string operand, or a constructor declining its arguments. The fix differs from WS046, because the value is not a stray runtime read. The evaluator declines to guess it. |
| WS048 | Const evaluation gave up because the call chain is too deep or took too many steps. Guards a runaway or self-referential const mod call chain, which fails with this diagnostic rather than a stack overflow. |
| WS028 | Reused from ordinary constant-config checking: a value that is fully constant but has no scalar form for the slot it’s used in, such as a const record used as a gate’s config field, which has no wire representation and must be consumed at compile time (read a field off it instead of handing the whole record to the slot). |
| WS044 | Reused from the container-method backstop: a mutating method (push, clear, set, sort, …) called on a const array or map, which is immutable. Declare it var, or do the mutation inside a const mod body, where it happens at compile time. |
| WS007 | Reused from the writable-target check: an index write (t[0] = 4) to a const array or map, rejected for the same reason as a mutating method. |
buffer: Buffered Value
Declares a value that is delayed by one tick. Buffers are useful for creating feedback loops where a value depends on its own previous state without creating a circular dependency.
buffer name = expression
buffer name: type = expression
buffer prevScore = score
buffer delayed: int = count
The optional type annotation is useful when the expression type needs clarification (e.g., for self-referential buffers).
Arrays: var name: elementType[]
An array holds multiple values of the same element type. Declare one as a var
whose type ends in [] (there is no separate array keyword):
var name: elementType[]
var scores: int[]
var positions: vector[]
var names: string[]
var flags: bool[]
The type annotation must end with [] to indicate it is an array type. The
element type selects the backing array variant (int -> Int64 array, float ->
double array, bool, string, vector, and object types each map to their
matching array kind), so elements keep their declared type rather than all
being stored as doubles.
An array can be given constant initial contents with an = [ ... ]
initializer. At the top level (outside an exec handler) the contents are baked
straight into the array gate, so every element must be a compile-time
constant. The array loads pre-populated with no runtime setup:
var scores: int[] = [100, 50, -10]
var names: string[] = ["alice", "bob"]
A constant is a literal (numbers including negatives, strings, and bools), or
any expression built from literals and top-level let constants. So a table can
name its constants instead of restating their values:
let C_FROZEN = 3
let WIDTH = 8
var masks: int[] = [1 << C_FROZEN, 1 << C_FROZEN | 1]
var cells: int[] = [WIDTH * WIDTH, WIDTH - 1]
Constants resolve through chains (let B = A + 1) and in any declaration order.
Arithmetic, bitwise, shift, comparison, logical and .. string concatenation all
fold, using the same semantics as the gates they would otherwise have compiled to:
64-bit wrapping integers, divide-by-zero as 0, and a non-finite float as 0.
Initializers may span multiple lines: newlines are allowed after [, around
commas, and before ], with an optional trailing comma:
var names: string[] = [
"alice",
"bob",
]
An element that is not a compile-time constant (a runtime value such as an
in port, a call, or a ...spread) is an error at the top level, because there
is no exec context in which to populate it. Build the array from runtime values
inside a handler instead (see below).
Inferred element type
The element type is taken from the annotation, or inferred from the literal when there’s no annotation. Mixed numbers join to the wider type, as they do in an expression:
var queue: int[] = [1, 2, 3] // annotated
var queue = [1, 2, 3] // element type inferred -> int[]
var ratios = [1, 2.5] // int and float join -> float[]
An empty literal says nothing about what the array holds, so an empty []
(or an empty map {}) with no annotation is WS098.
Annotate the declaration with the type it holds:
in go: exec
var fo: float = 0.0
var hist: float[] = [] // `var hist = []` is WS098
on go {
hist.push(0)
hist.push(fo * 2.0)
fo = hist.sum()
}
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. The one exception is a literal that starts with a spread of the array
being assigned: work = [...work, 6] keeps the old contents and appends the rest,
in order, so an element such as work.length() sees what the elements before it
added. A spread of that array anywhere else (work = [0, ...work]) would read it after
the clear, and is WS088. Use work.insert(0, v) to put values in front.
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), because 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. Assigning a whole map from another map (m = m2) is unsupported, so
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 gates 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 come from whatever is wired to them outside the script.
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: carries the variable's ref
A *T output carries a ref, so its value is written &myVar. A bare myVar there is the variable’s value, which is a type mismatch (WS003). The annotation also disambiguates 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.
Handler-declared outputs
A top-level on handler can declare its own port, hoisted to the module
boundary:
on Clock(interval = 0.5) {
@top out blink: bool = Toggle()
}
A single unconditional site wires straight into the port. Two sites, or one
inside an if, share a backing variable so the writes cannot fan in.
An anonymous chip { } shares the surrounding scope, so an out in its body is
a module-boundary port too, readable from anywhere in the file:
chip { out shared: int = 1 }
in go: exec
on go {
var v: int = shared
}
A named chip or mod is its own boundary, so an out there binds to that
chip’s port. Hoisting reaches only top-level handlers: an out in a handler
inside any chip, named or anonymous, is WS073. Declare it in the chip body or
its signature.
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:
- The valid sides are
left,right,topandbottom, with one annotation per declaration. - Only top-level
in/outof the compiled file may be annotated. Insidechip {}ormodbodies the annotation is an error (WS023). - Unannotated ports get no rerouter. The feature is fully opt-in.
Placement:
- Rerouters sit flush against the chosen side of the chip brick, bottom-aligned with it, spaced 2 grid units apart and starting from the top corner (left/right sides) or left corner (top/bottom sides) of the edge.
- Ports on the same side appear in declaration order, with
inandoutfreely interleaved. Left/right sides run top to bottom; top/bottom sides run left to right. - Each rerouter is colored 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, so 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, following 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).
@color / @material: Brick Color and Material
@color(<color>) and @material(<BrickMaterial>) set the color and material
of a declaration’s brick. They go where @label goes on a chip and a var:
alone, together, or beside @label, in any order.
@color(Color(1.0, 0.5, 0.0)) @material(Glow)
var score: int = 0
@material(Metal)
chip {
@color(ColorSRGB(40, 120, 255, 255))
var hits: int = 0
}
- On a chip (anonymous or named) they style its microchip brick. On a
varthey style its Variable gate brick, or every field’s gate for a record. @colortakes acolor, fromColor(r, g, b)(linear, 0 to 1) orColorSRGB(r, g, b, a)(bytes, 0 to 255).@materialtakes aBrickMaterial:Plastic,Glass,TranslucentPlastic,Glow,MetalorHologram(see Built-in game enums). A bare member name works as the whole argument,@material(Glow), as well as the qualifiedBrickMaterial.Glow. A value of another type isWS003.- A constant value is set on the brick itself. On a top-level
varthe value may also be a runtime one: the brick then also gets a Brick Property Changer component, and the value is wired into its Color or Material input, so the brick changes as the value does.
in hit: exec
var heat: float = 0.0
on hit { heat = heat + 0.1 }
@color(Color(heat, 0.0, 0.0))
var shown: int = 0
A runtime value on a chip or a nested var has no wire to drive and is a
compile error (WS040), as a runtime @label there is.
The component sets every property it has, so each one it does not wire starts
as the brick’s own value, constants included: a runtime @color keeps the
brick’s material and material alpha, and a runtime @material keeps its
color. The brick stays visible and solid.
At the top of the file, separated from the first declaration by a blank
line, they style the root microchip instead, under the same placement rule
as a module-level @label. A runtime value is allowed here too, and the
expression may name a declaration below it:
@color(Color(0.2, 0.2, 0.2))
@material(kind)
var kind: BrickMaterial = BrickMaterial.Metal
in go: exec
on go { kind = BrickMaterial.Glow }
They are not allowed on ports, mods, fns or for loops, and either one
given twice on a declaration is an error.
@nofold: Suppress Constant Folding
@nofold: suppress constant folding/elision for everything lowered from this declaration (let/out/var/chip/on, including captured eventslet e = on trigger { … }and await bindings). It is 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, following the same blank-line rule as module doc comments. Sites where it can have no effect (anonymous chips,indeclarations) emit a warning. Used by semantics-verification circuits that need real gates for known values.- Two module-level rules: leave a blank line between a module doc block and
a module-level
@nofold(a directly-adjacent pair registers as neither). A module-level@nofoldalso applies only to the file compiled as the entry. An imported library’s own module-level@nofolddoes not carry into the importer (annotate the individual declarations instead). - A module-level
@nofolddisables the entire constant-fold pass for that compile, with the same effect as--no-foldon the CLI. @nofoldalso preserves literal-conditionifbranches. Normally anifwhose condition is a literaltrue/falsehas its dead side stripped during lowering as a shortcut, ahead of the fold pass proper. Under@nofold(including a module-level one) that shortcut is suppressed too, so both branches stay real gates. See Constant Folding for the full pass.
@fold: Constant Folding (on by default)
- Constant folding runs on every compile by default, so
@foldis now redundant. It is still accepted for backward compatibility but enables nothing that isn’t already on. To turn folding off, use@nofold(above) or--no-fold. - Placement, if you do write it:
@foldat the very top of the entry file (after any module doc comment), separated from the first declaration by a blank line (the same module-level, blank-line rule as@nofold), and module-level only (there’s no decl-scoped@fold). A directly-adjacent module-doc /@foldpair registers as neither and produces a module-level-only error. - If both a module-level
@foldand@nofoldare present,@nofoldwins and the parser warns that the two conflict. --foldon the CLI also only re-affirms the default, and--no-folddisables folding. See Constant Folding for how folding is enabled and disabled.
@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, so@foldand@layout("code")can appear together. - Entry-file-only, same as module-level
@fold/@nofold: an@layout("code")at the top of an imported file does not carry into the importer and has no effect. - Nodes with no real position in the entry file (values from an imported file, or synthetic nodes the compiler generates without a source range) adopt the row of whichever node consumes or produces them; a node with no such neighbor lands on an overflow row below the last source line.
- Three wrapping tiers keep large modules inside their placement budgets. A line wider than the line-width budget soft-wraps into an indented continuation row. Lines stack into vertical bands capped at a height budget. A band that would push a page past its width budget starts a new page, stacked above the previous one. Each page is centered and flipped independently, so it reads top-down on its own.
- Nested chips inherit the mode from their parent, so a chip’s interior also
renders source-shaped when the entry file has
@layout("code"). - Values that many rows read run down a gutter bus rather than fanning out as one long diagonal wire per reader. Such a value (a variable, an input port, a value handed into or read back out of a chip) gets a lane: a column of rerouter bricks standing in the gutter between the input pins and the code body, chained downward from beside the value’s own producer. At every row that reads the value the lane branches off sideways and runs straight across into that row’s gates, so a read reads as a right angle instead of a diagonal. Lanes are packed so a value only holds a column for as long as it is read, and the longest-lived, most-read values take the outermost columns. A value read on a single row keeps its direct wire unless it is stored state, a port, or crosses a chip wall. An exec chain running from one statement to the next always stays a direct wire along its own rows and never gets a lane.
- 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 is placed on one plane only: 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, because the code already occupies that row. Doc comments (///) are unaffected: they keep rendering on the plane header of what they document. - There is no CLI-flag equivalent (unlike
@fold/--fold):@layoutis a source annotation only. "code"and"cube"are the accepted arguments; an unknown name (@layout("grid")) or a missing/malformed argument (@layout,@layout(5)) is a compile error. Naming a layout twice warns and the last one wins.@layout("cube")emits no per-gate labels. A cube packs gates shoulder to shoulder, so the floating name on a var, an I/O gate, a var tag, or a chip brick cannot be read there, and each one costs a text component. Dropping them typically removes most of the components in the save. The shell label and every plane header stay, so the block is still identifiable and a chip still shows its title when opened, as does a runtime@label(expr), whose text is a value the program computes rather than decoration.
@layout("code")
var total: int = 0
in tick: exec
// This note gets a row of its own in the plane.
on tick {
total = total + 1 // ...but this one is not rendered.
}
@layout("cube"): Compact 3D Packing
@layout("cube") packs gates into a cube (rows of bricks, stacked into
layers along the plane’s depth axis) without analyzing the wiring 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 makes the opposite trade from @layout("code"). Wires are ignored, so the
placed gates show nothing about how signals flow, but the bricks take up as
little space as possible and placement cost does not grow with the number of
wires. Use 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.
@flat changes placement only. Chips have never been a scoping boundary
(wire references cross them freely either way), so a flattened program
computes the same results as the nested one. 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, @closed, @color and
@material on a chip have nothing to apply to under @flat. They are accepted without an error and
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 a Value
match tests a value against a list of patterns and runs the first arm that
matches. The value is an enum, whose arms name its variants and
bind their payloads, or a single int, float, bool or string, whose arms
are literals, ranges and string patterns. 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 Loaded() {
match s {
Circle(r) => { lastArea = r * r }
Empty => { lastArea = 0.0 }
}
}
Literals and ranges
A literal arm matches a value equal to it: a string, a number, true or
false, or an atom. A range lo..hi matches a
number at least lo and below hi, the same half-open range a for loop
counts over. An int literal also matches a float equal to it.
in go: exec
in program: string
var ip: int = 0
var cell: int = 0
var running: bool = true
let op = program.Substring(ip, 1)
on go {
match op {
"+" => { cell = (cell + 1) % 256 }
"-" => { cell = (cell + 255) % 256 }
"" => { running = false }
_ => {}
}
ip += 1
}
in x: int
out size = match x {
0 => "none",
1..10 => "a few",
10..100 => "many",
_ => "lots",
}
String patterns (..)
.. joins string literals and names into a pattern for a whole string, the
way .. joins strings into one. Each name (or _) is a hole that matches any
run of characters, possibly none, and binds what it matched:
| Pattern | Matches | Binds |
|---|---|---|
"say " .. rest | a string that starts with say | rest: everything after it |
name .. ".ws" | a string that ends with .ws | name: everything before it |
key .. "=" .. value | a string with an = in it | key and value: the text either side of the first = |
"<" .. inner .. ">" | a string that starts with < and ends with > | inner: what is between |
_ .. "x" .. _ | a string with an x in it | nothing |
The same pattern can be written as one string, with each hole in ${}:
"say ${rest}" is "say " .. rest, "${key}=${value}" is key .. "=" .. value, and "tp ${_}" is "tp " .. _. Only a name or _ goes inside the
${}. Unlike ${} in an ordinary string, it binds a new name rather than
reading one: a hole named like a value already in scope is a WS123
warning, since "hi ${player}" would match any name, not just player’s. To
compare against a value you have, bind another name and add a guard
("hi ${p}" if p == player).
A literal with no hole before it must start the string, and a literal with no
hole after it must end it. A literal between two holes is found at its first
occurrence. Two holes cannot sit side by side (WS122), since nothing would
say where one ends. String patterns are case-sensitive, and count characters
as the game’s string gates do. match cmd.ToLower() matches regardless of
case.
in go: exec
in cmd: string
var target: string = ""
var speed: int = 0
on go {
match cmd {
"go " .. place => { target = place }
"speed ${text}" => { speed = text.ParseInt() }
"stop" => { speed = 0 }
_ => {}
}
}
Alternatives (|)
p | q matches when either pattern does, so one arm handles several values.
The arm is built once. An alternative cannot bind a name (WS121), since only
one of them matches:
enum Dir { N, E, S, W }
in d: Dir
out vertical = match d {
N | S => true,
E | W => false,
}
Guards (if)
pattern if condition => runs the arm only when the pattern matches and the
condition is true. When the condition is false, matching goes on with the
next arm. The condition can read the names the pattern binds:
enum Opt { Some(int), None }
static var o: Opt = Opt.None
out label = match o {
Some(n) if n > 100 => "big",
Some(0) => "zero",
Some(_) => "some",
None => "none",
}
Covering every value
A match must cover every value its scrutinee can hold, or end in a _ arm
(or a name, which catches the rest and binds it). An uncovered value is a
WS054 error that names it. A match on an int, float or string always
needs that last _ arm, since no list of literals covers every value; true
and false together cover a bool. An arm with a guard covers nothing, since
its guard may be false. An arm an earlier one already covers is a WS061
warning. See Enums for nested patterns and the scrutinee
limitation (an enum match target must be a var/let/param/const, not an
enum in port).
Each arm costs one test (an equality gate, or a few for a range or a string
pattern) and one Branch (statement) or Select (expression): the same gates
as the if/else if chain it replaces. A string pattern’s tests are its
StartsWith, EndsWith and Find gates, plus a Substring for each name it
binds.
if let / let else: Refutable Binds
Single-pattern shorthand for a one-armed match. if let PATTERN = scrutinee { ... } else { ... } runs the block only when the scrutinee matches the
pattern, 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 Loaded() {
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
}
Both take any pattern a match arm takes, string patterns included:
in go: exec
in cmd: string
var target: string = ""
on go {
let "go " .. place = cmd else { return }
target = place
}
See Enums for the full reference.
for Loops
for iterates a range, an array, or a map. The same keyword covers all three,
and the kind of loop follows from what you iterate.
in go: exec
var sum: int = 0
on go {
for i in 0..10 {
sum = sum + i
}
}
in go: exec
var xs: int[]
var total: int = 0
on go {
xs.push(4)
xs.push(9)
for v in xs {
total = total + v
}
}
in go: exec
var scores: Map<string, int>
var total: int = 0
on go {
scores.set("a", 1)
for k, v in scores {
total = total + v
}
}
A loop’s body is its own microchip plane, driven by the loop rather than by
wires from the surrounding grid. Reading and writing a var declared outside
the loop still works from inside the body: it is an ordinary boundary
crossing, and costs the same as one into a chip { }. for is exec-position only.
Range Sources
a..b excludes b. .step(k) and .rev() modify a range. count(n, from = .., step = ..) is the same range spelled without ..; from defaults to 0
and step to 1.
for i in 0..n // 0, 1, .. n-1
for i in a..b // a, a+1, .. b-1
for i in (0..n).step(2) // stride 2
for i in (0..n).rev() // n-1, n-2, .. 0
for i in count(n, from = 10, step = 5) // 10, 15, 20, .. for n = 4
.. still means string concatenation everywhere outside a for source; the
range reading only applies where a range is expected.
.step(k) and .rev() apply to a range and nothing else. On an array or map
source, or written twice on one range, they are rejected (WS083).
.step(k) changes how many iterations there are, the way Rust’s
step_by does: the count is a ceiling divide, not a truncating one.
(0..10).step(3) runs four times, visiting 0, 3, 6 and 9, not three. A
stride may be a float, which steps by its value: (0..6).step(0.5) runs
twelve times. A constant stride that is not greater than zero is WS077. The
bounds and a count are integers: a float constant there is the integer it
truncates to (WS106).
Bindings
A binding fills slots left to right, one name per slot. A { } pattern is a
range-only form, described below:
| source | slots | a single binding means |
|---|---|---|
range / count(...) | index | index |
| array | index, value | value |
| map | key, value, index | key |
in go: exec
var xs: int[]
var control: int = 0
on go {
xs.push(4)
xs.push(9)
for i, v in xs {
if i == 1 { control = v }
}
}
{ } names the loop’s own entry ports, and only a range accepts it. An array
or map slot holds a value rather than a set of ports, and no slot of either
kind is a record, so { } on one is rejected (WS076). Name the slot
instead.
in go: exec
var total: int = 0
on go {
for { index } in count(3, from = 10, step = 5) {
total = total + index
}
}
index is the counter value and respects from/step. A range has no
first/last flags or iteration ordinal; compare index against the bounds
instead.
A record-typed array cannot be iterated. Both for { x, y } in points and
the single-binding for p in points are rejected over a Point[] (WS082).
Such an array is stored as one array gate per field (see
Records as storage), so there is no single
array reference for the loop to bind. Iterate the index instead:
type Point = { x: int, y: int }
in go: exec
var points: Point[]
var total: int = 0
on go {
points.push({ x: 1, y: 2 })
for i in 0..points.length() {
total = total + points[i].x
}
}
An array of enums or tuples, and a map whose values are enums or tuples, is
stored the same way but does iterate: the loop runs off one of its columns and
reads the others at each element’s index or key, so for h in hues binds h
as the enum.
Loop result (let run = for)
let run = for ... binds the loop’s result to a record readable after it
ends: { aborted, iterations, running }. running is true while the loop is
still in progress, which only a loop running across ticks can be after it
starts.
in go: exec
var seen: int = 0
on go {
let run = for i in 0..6 {
seen = seen + 1
}
if run.aborted {
BroadcastChatMessage("cut short after ${run.iterations}")
}
}
break
break stops the loop and ends the current iteration: nothing after it in
the same body runs, even on the iteration that hit it.
in go: exec
var breakSum: int = 0
on go {
for i in 0..10 {
if i == 4 { break }
breakSum = breakSum + i
}
}
A break in the body does not set run.aborted. That field reports the
runtime cutting a loop off on its own (an invocation limit, for instance),
which a break is not.
Sliced loops (@sliced)
A loop finishes within the tick it starts. @sliced before the for
turns on the loop chip’s Run Across Ticks setting, which lets the game spread
the iterations over several ticks; the game decides how many run in each.
Code after the loop runs when the loop completes, as it does without the
annotation.
in go: exec
var total: int = 0
var busy: bool = false
on go {
@sliced
let run = for i in 0..1000 {
total = total + i
}
busy = run.running
}
@sliced goes on a for or a let run = for and nothing else. The body
rules below still apply: a body cannot await, even when the game runs it
across ticks. Only the outermost loop can be sliced: @sliced on a loop
inside another loop’s body, written there or brought in by a mod, is a
WS119 warning. Put it on the outer loop instead.
Naming the loop chip (@label)
@label names a loop’s microchip, as it names a chip’s: the name shows on
the loop’s brick and as the title of its plane. It takes a string or a
constant expression (a runtime value is WS040), and sits beside @sliced in
either order. A loop takes no other annotation.
const ROW_LABEL = "rows"
in go: exec
var total: int = 0
on go {
@label("sum") @sliced
for i in 0..100 {
total = total + i
}
@label(ROW_LABEL)
let run = for i in 0..10 {
total = total + 1
}
}
What a Loop Body Can’t Do
continue. No primitive exists to lower it to.return. A loop body cannot cross the plane boundary back to its caller. Assign to avardeclared before the loop andreturnit after the loop ends instead.await. A loop body runs as one synchronous sweep within a single tick, so it cannot suspend across ticks. The compiler rejects anyawaitinside one (WS079), includingawait Sleep(...)/await SleepTicks(...). Spread work across ticks with a back-edge loop (see Loops underemit) outside theforinstead.@flat. A loop’s plane is a real execution boundary, not a layout grouping, so flattening it would compile a different program. The compiler rejects@flatover a file containing anyfor(WS080).buffer. Abufferbinding inside a loop body compiles today, but a tick-delayed value inside a body swept many times within one tick has no established meaning. Treat it as undefined and declare thebufferoutside the loop until the behavior is confirmed.
Cost
A loop’s gates are placed once no matter how many times it runs, but its
body’s computation is not: the same plane sweeps once per iteration, so
anything inside the body that doesn’t depend on the loop’s binding is
recomputed on every pass. Compute it once above the loop and read it inside
instead. for is the first loop in the language with this trade-off. A
mod’s gates are emitted once per call site at compile time, so a mod has
no runtime sweep to move work out of.
fn: Function
A fn is written like a mod, but compiles once. Its body becomes one
function definition plane, and every call is a Function Call gate bound to
that plane, so ten calls cost ten small gates instead of ten copies of the
body. The call runs the body to the end, synchronously, then continues the
caller’s chain with the results.
var total: int = 0
fn add(x: int, y: int = 1) -> int {
total += x
return x + y
}
in go: exec
on go {
let a = add(3) // y takes its default, 1
let b = add(4, 5)
BroadcastChatMessage("${a} ${b} ${total}")
}
The signature is a mod’s: parameters, -> T or -> (a: int, b: float),
and return. A trailing parameter may have a default value, a constant used
when a call leaves the argument out (a mod takes defaults too).
What crosses into the plane. Nothing is wired across a function plane’s
wall: the game runs a plane only through its call. Parameters arrive through
the call’s inputs and results leave through its outputs. A body may still use
storage declared outside it, a var, array or map: the compiler places a
handle inside the plane, a var holding a reference to that storage, and the
body reads and writes through it. A *T or container parameter is passed the
same way, as a reference the body uses in place.
var log: int[]
fn record(p: { x: int, y: int }) -> (sum: int, diff: int) {
log.push(p.x)
return (p.x + p.y, p.x - p.y)
}
fn bump(v: *int) {
*v = *v + 1
}
var count: int = 0
in go: exec
on go {
let r = record({ x: 3, y: 4 })
bump(&count)
BroadcastChatMessage("${r.sum} ${r.diff} ${count}")
}
A computed value from outside (an in port, a let) has no way in and is
WS113; pass it as a parameter. Constants fold into the body as usual.
Calling from pure context. A call needs an exec chain to fire on. Outside
a handler, give it one with exec = trigger. The call then does not join any
chain: its result carries the Function Call’s completion as an exec field,
so on can run a body once the call has finished, with the results bound by
-> (...). Inside a handler, f(...).exec names the same completion and
forks, as described in Forking the Exec Chain.
var last: int = 0
var done: int = 0
fn add(x: int, y: int = 1) -> int {
return x + y
}
fn tally(n: int) {
done += n
}
in go: exec
on add(2, exec = go) -> (v) {
last = v // runs when the call completes, v = 3
}
on tally(1, exec = go) {
BroadcastChatMessage("tallied ${done}")
}
No recursion. A fn may call another fn, but not itself, directly or
through another fn (WS020): the game does not run a recursive Function
Call. Use a for loop instead.
Limits. A call carries at most 8 values in and 8 out, and a record
parameter takes one per field (WS115). A fn is one plane, so it cannot be
generic, take ...rest, const or exec parameters, or return a record,
tuple, enum or exec (WS114). A call with neither an exec chain nor an
exec = trigger is WS111.
mod | fn | for | |
|---|---|---|---|
| Compiled | once per call site | once | once per loop |
| Runs | inline, as part of the caller | when its call fires, to the end | once per iteration, in one tick (several with @sliced) |
| Outer storage | wired directly | through handles in its plane | wired through the plane’s pins |
| Recursion | no (WS020) | no (WS020) | no |
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 Loaded() {
lives = 3
}
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: zone // wire an Attached Zone's Zone Ref here
on ZoneEntered(zone = room) -> (character) { } // room feeds the gate's Zone input
on ZoneLeft(zone = room) -> (character) { }
Frozen entities still fire entity zone events:
SetFrozen(true)does not suppressEntityZoneEntered. An entry fires only on a boundary crossing, soSetLocation-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, which is 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
}
|| is not a union: it joins two values into one, so on a || b fires when
that one value turns true. With an object operand (on reset || rider, rider a character)
the value is true for as long as the object exists, which the compiler warns
about (WS116).
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 a trigger: an event with its config, an exec pin, or a
trigger expression such as Union(...). Each on handler of the alias
compiles as if it wrote the trigger itself, so two handlers of tick are two
Clock gates, as two on Clock(interval = 1.0) handlers would be. A handler
binds the event’s outputs with ->, as it would on the event:
in front: exec
in back: exec
in room: zone
var visits: int = 0
let tick = on Clock(interval = 1.0)
let died = on CharacterDied()
let arrived = on ZoneEntered(zone = room)
let rang = on Union(front, back)
on tick { visits += 1 }
on died -> (character) { character.SetLocation(Vec(0.0, 0.0, 100.0)) }
on arrived -> (character) { visits += 1 }
on rang { BroadcastChatMessage("ding") }
An alias is declared at the top level, before the handlers that use it.
Captured Event
Wraps a trigger with a handler body that defines the event’s behavior. The
body runs as on <trigger> { .. } would, with the trigger’s config args and
-> output captures, and the name becomes a trigger that fires when the body
finishes. The trigger takes any form a handler’s does, a call such as
on f(5, exec = go) -> (r) included:
in room: zone
var visits: int = 0
let arrived = on ZoneEntered(zone = room) -> (character) {
visits += 1 // runs when a character enters `room`
}
on arrived { BroadcastChatMessage("visit ${visits}") }
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, so 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 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
A type alias of exec declares one the same way: with type Signal = exec, let ready: Signal is a signal. Every other type needs a value (let n: int alone is an error).
Buffered Emit
buffer emit sig routes the emit’s exec through a Buffer gate, delaying delivery by one tick. Crossing a tick this way is what makes emit/await loops legal. A back-edge emit after an await closes a cycle of gates, and every cycle must cross a Buffer or the compile fails (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. Omitting it gives-1, which means the same as the delay. - An
ssuffix selects the seconds gate; unadorned durations are ticks. - Constant durations bake into the gate; variables/expressions wire into the duration port.
Payload Ferrying
emit sig = value on a local exec signal ferries the value with the signal: each emitted value is written into a hidden per-signal store var on the emit chain, and await sig reads it back on the resumed chain, so the value survives the buffered tick crossing.
let loop: exec
buffer emit loop = 0 // scalar payload
let index = await loop // read it back
buffer 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, and 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
buffer 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
buffer 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:
-
The
awaitis what arms the resume, so the emit that starts a loop must be buffered (buffer emit loop): it lands the next tick, after the await is armed. A plainemit loopon the same chain fires before the await arms and the chain parks forever (WS053warns). The buffered back-edge re-arms the await every iteration. -
Emits from other handlers enter the signal directly and are guarded by the armed flag: the continuation only runs if the awaiting chain has reached the
await. -
An
awaitinside anifbranch keeps pure flag semantics (its arm only fires when the branch is taken) for a signal declared outside that branch. -
A back-edge loop inside an
ifdeclares its signal in the same branch. A signal declared in the await’s own branch (if !running { let loop: exec ... }, or in amodthe branch calls, since themodinlines there) can only be emitted inside that branch, so its back-edge re-arms the await as it does at a handler’s top level:on tick { if step == 4 { fill() } // fill() declares `loop`: every iteration runs }A signal declared outside the branch is not re-armed by an emit on the same chain, so a back-edge loop on it runs one iteration. The compiler warns (
WS118) at theawait. Declare the signal inside the branch, or give the loop a handler of its own and pulse it.The
ifguarding the loop’s own continuation (if idx < 25) is fine and required, because it is the exit test. -
A
buffer emitends its chain. Nothing after it on that chain runs: not a statement later in the same block, not one after anifwhose branches both end in one, not one after a call to amodthat ends in one. Each such statement is aWS058warning, and it is not compiled. Anawaitright after the emit is the exception, since that is how a loop starts: it arms on the chain the emit ended. -
Loop state must outlive the tick. A back-edge buffer crosses a tick, and a tick is a new call of whatever handler the loop sits in, so a non-static
vardeclared inside the loop’s own scope is reset before the next iteration reads it. A counter declared there sticks at its initial value forever, and the loop rewrites element 0 on every pass while looking like it is running. Put the counter (and any accumulator) at module level, or make itstatic, and reset it upstream of the kick:var idx: int = 0 // module level: survives the tick boundary mod fill(dest: int[]) { idx = 0 // reset BEFORE the kick, not inside the loop body let loop: exec buffer 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 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, sweep with a
forloop, which finishes within the tick, or give each entity its own chip instance. See per-entity fan-out.
Gate Cost
| Construct | Gates added |
|---|---|
emit sig (bare) | 0 (joins the signal’s union) |
buffer(...) emit sig | 1 Buffer (Ticks/Seconds) |
emit sig = scalar | 1 Var_Set per emit (+1 hidden store var per signal) |
emit sig = { F fields } | F Var_Set per emit (+F store vars per signal) |
await sig (per await) | ~5: armed-flag var, arm + reset Var_Set, Var_Get, Branch |
let { F fields } = await sig | +F Var_Get |
| per signal | 1 Union hub (+1 arm Var_Set when same-chain emits exist); a single-input hub is spliced away |
await: Suspend Exec Chain
Suspends the current exec chain and resumes from the awaited expression’s exec output. Everything after the await runs when that exec fires. Only valid in exec context.
await signal // resume when signal fires
let val = await signal // capture the signal's ferried payload
let { a, b } = await signal // destructure a record payload
let val = await value on trigger // capture value when trigger fires
let n: int = await CustomEvent("c") // wait for an event, capture its data
await a || b // race: first signal wins
await Sleep(_, delay = 1.0) // sleep 1 second using _ armed flag
await SleepTicks(_, delay = 5) // sleep 5 ticks
Each await creates an armed flag (static var bool) that guards the continuation. The continuation only fires once per arming, preventing repeated triggers.
Awaiting a Custom Event
await CustomEvent("chan") (or GlobalCustomEvent) suspends until a matching SendCustomEvent fires, and a binding captures its data:
in go: exec
var last: int = 0
on go {
let amount: int = await CustomEvent("dmg") // resume + capture DataOut1
last = amount
}
Annotate the binding’s type (let amount: int = ...). The event’s data ports are untyped in-game, so the annotation decides which variant the wire carries. Without it the value defaults to a float and mis-delivers non-float data (WS055). A tuple let (p, t) = await CustomEvent("c") captures the data outputs positionally (p = DataOut1, t = DataOut2) but has no place to annotate them, so prefer one typed binding per value, or the handler form on CustomEvent("c") -> (p: int, t: float) { ... } when you want several typed at once. A bare await CustomEvent("c") with no binding just waits for the event.
The _ Placeholder
Inside an await expression, _ refers to the await’s armed flag, a bool that becomes true when the exec chain reaches the await point. Use _ with Sleep/SleepTicks to wire the armed flag as the buffer gate’s input:
on start {
doSetup()
await SleepTicks(_, delay = 60) // _ = armed flag, delayed 60 ticks (~1s)
doAfterDelay() // runs after the delay
}
Sleep / SleepTicks
Sleep(input, delay, hold) and SleepTicks(input, delay, hold) are buffer gates that delay a value passing through.
| Function | Gate | Delay unit | Params |
|---|---|---|---|
Sleep | BufferSeconds | seconds (float) | input, delay, hold |
SleepTicks | BufferTicks | ticks (int) | input, delay, hold |
input: the value to delay (use_for the await armed flag)delay: how long to wait before the output follows the input (optional)hold: how long to hold the output after the input drops to zero (optional, set to -1 to use delay instead)
Examples
in start: exec
in done: exec
var count: int = 0
on start {
count = 1
await done // exec chain pauses here
count = 2 // runs when 'done' fires
}
// Capture a value when a signal fires
on start {
let val = await score on done
use(val)
}
// Sleep for 2 seconds
on start {
await Sleep(_, delay = 2.0)
count = 99
}
Assignment
Assigns a new value to a mutable variable. Requires exec context.
target = expression
on RoundStart() {
count = count + 1
score = 0
name = "Player " .. playerId
}
Only var declarations are valid assignment targets. Attempting to assign to a let binding, parameter, or other non-variable produces a type error.
Indexed assignment works for arrays:
on trigger {
scores[i] = newScore
}
Compound Assignment
Compound assignment operators combine an operation with assignment:
| Operator | Equivalent | Gate Used |
|---|---|---|
+= | x = x + expr | IncVar |
-= | x = x - expr | IncVar by -expr |
*= | x = x * expr | Var_Get + Mul + Var_Set |
/= | x = x / expr | Var_Get + Div + Var_Set |
%= | x = x % expr | Var_Get + Mod + Var_Set |
&= | x = x & expr | Var_Get + AND + Var_Set |
|= | x = x | expr | Var_Get + OR + Var_Set |
^= | x = x ^ expr | Var_Get + XOR + Var_Set |
<<= | x = x << expr | Var_Get + Shl + Var_Set |
>>= | x = x >> expr | Var_Get + Shr + Var_Set |
+= compiles to the dedicated IncVar gate (one gate instead of three), and -= to IncVar by the negated amount when both sides are ints or both floats (a constant amount is negated at compile time; any other takes one Negate gate). An int less a float, or a float less an int, keeps Var_Get + Sub + Var_Set. All others desugar to x = x OP expr (Var_Get + operation + Var_Set).
on tick {
counter += 1 // IncVar gate
health -= damage // Negate + IncVar
mask &= 0xFF // Var_Get -> BitAnd -> Var_Set
bits <<= 1 // Var_Get -> Shift -> Var_Set
}
Expression Statement
Any expression can be used as a statement. This is primarily useful for calling exec functions that have side effects:
on RoundStart() {
DisplayText(ctrl, "Round Started!", fontSize = 30)
SetLocation(entity, newPos)
}
Built-in Events
These events are available as handler triggers. Parameters listed can be bound using the on Event() -> (param) tuple capture (or -> { field: local } record capture). See Binding Event Data.
| Event | Parameters | Description |
|---|---|---|
Loaded | (none) | Fires once when the gate is placed, and once each time a world or prefab containing it loads (a spawned prefab included). Use it to initialize a circuit |
RoundStart | roundNumber: int | Game round started |
RoundEnd | roundNumber: int | Game round ended |
CharacterSpawned | character: character | A character spawned |
CharacterDied | character: character, killer: character, killerWeapon: entity, killerWeaponName: string | A character died (killer / killerWeapon are who/what killed it) |
ControllerJoined | controller: controller, userId: string, userName: string | A player joined |
ControllerLeft | userId: string, userName: string | A player left (the player is already gone, so there is no controller) |
ControllerJoinedTeam | controller: entity, team: entity, userId: string, userName: string | A player joined a team (team is the team they joined) |
ControllerLeftTeam | controller: entity, team: entity, userId: string, userName: string | A player left a team (controller is empty if they left the server) |
ZoneEntered | character: character | A character entered a zone |
ZoneLeft | character: character | A character left a zone |
EntityZoneEntered | entity: entity | An entity entered a zone |
EntityZoneLeft | entity: entity | An entity left a zone |
ProjectileZoneEntered | character: character, projectile: entity, weapon: entity, weaponName: string | A projectile entered a zone (character is the shooter) |
ProjectileZoneLeft | character: character, projectile: entity, weapon: entity, weaponName: string | A projectile left a zone |
CharacterDamaged | character: character, damage: float, attacker: character, attackerWeapon: entity, attackerWeaponName: string | A character took damage |
CharacterFiredWeapon | character: character, direction: vector, start: vector, weapon: entity, weaponName: string | A character fired a weapon (start / direction are the shot ray’s origin and aim) |
BrickChanged | brick: brick, character: character | A brick was added or changed in a zone (character is the player who changed it) |
BrickRemoved | brick: brick, character: character | A brick was removed from a zone (brick is the deleted brick, character the player who removed it) |
ChatCommand | controller: controller, arguments: string | A registered chat command was run. Takes config args for the command name + help text (see above) |
return
The return statement terminates the current exec chain early. It can be used inside:
onhandlerschip onhandlersifblocks within handlersmodbodies (when called from exec context)
on RoundStart() {
if score > 100 {
return // skip the rest of this handler
}
score = score + 1
}
chip on CharacterDied() -> (character) {
lives = lives - 1
if lives <= 0 {
return // don't process further
}
health = 100
}
mod process(v: *int) {
if v < 0 { return } // early exit from mod
v = v * 2
}
return expr: Return with Value
For mods with a single output (declared with -> (name: type)), return expr sets the output value and exits:
mod clamp(v: int, lo: int, hi: int) -> (result: int) {
if v < lo { return lo }
if v > hi { return hi }
return v
}
let clamped = clamp(x, 0, 255) // clamped is directly an int
Single-output chips and mods auto-unwrap: let f = Foo(5) gives f the output type directly (e.g. int), no .result field access needed.
How Multiple Returns Compile
A single return expr wires the value directly to the output port (pure, zero-tick).
When a mod has multiple return expr statements, the compiler inserts a variable to hold the return value. Each return expr becomes a Var_Set before jumping to the return union, and a Var_Get after the union reads the result. This means multi-return mods have a one-tick latency on the return value (the var write is visible on the next tick for pure reads, but available immediately for subsequent exec-chain reads via Var_Get).
return is not allowed in pure context (outside exec chains).
Chips
Chips are Wirescript’s primary mechanism for organizing and reusing 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 group related gates into a microchip brick and share the scope around them, except for a name the chip declares itself where the top level declares it too.
Names a Chip Shares With the Top Level
A top-level anonymous chip may declare a name (a var, array, map, buffer, const or let) that the top level declares as well. Inside the chip the name is the chip’s declaration: in the chip’s statements, and in the body of every mod or chip written inside it, wherever that mod or chip is called from. Everywhere else the top-level declaration is what the name means: before the chip and after it, in a constant-only setting such as an event name or a Sleep delay, and in a mod or chip written outside the chip even when it is called inside.
in tick: exec
in go: exec
let limit = 10
var hits: int = 0
chip {
var limit: int = 3
mod over(n: int) -> bool { return n > limit } // the chip's `limit`
on tick { limit += 1 }
}
on go {
if over(hits) { hits = 0 } // still the chip's `limit`
hits = hits + limit // the top-level `limit`, 10
}
A name only the chip declares is visible across the file. A top-level declaration is named from where it is written on, so a read above it reads the chip’s declaration of the name. Two declarations of one name in one chip are WS013. Two at the top level itself are read by position (WS108): a read names the latest one visible where it is written (storage, declared ahead of everything, above both), as described in Declaring a Name Again. A chip nested in another names its storage from where it is written on, past the enclosing chip.
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. Each call is a separate instance with its own local state. Names in the body resolve where the chip is declared, so a chip declared at module level can read and write module-level vars; each such read crosses the chip wall through a boundary pin (see Boundary Pins). Values from the call site come in 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, in which case 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 with a field per output
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. Doing so 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, because 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, and 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”
is not a property the declaration can have, as 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 the same way
as an ordinary multi-output mod’s does. One rule is specific to const: the
compiled gates keep whichever assignment to an output comes first in source order.
A valued return placed after an out to the same output would disagree with
the compiled gates, so it is rejected (WS046) instead of compiling to a
mismatch.
const mod pick(n: const int) -> (r: int) {
out r = 111
if n > 0 { return 222 } // WS046: disagrees with the compiled gates
}
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 reaches 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 lets one mod/chip serve call sites that share no common API. A
branch that would not compile for a given constant value is 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 once, before any call site exists, so there is no real
argument yet to decide the branch with. During that check a const
parameter holds a placeholder of its type, and the placeholder is never
allowed to decide a branch. If it could, the compiler would accept the
branch the placeholder happened to select and leave the other branch (the
one a real call takes) unchecked. 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() // still type-checked, even if every call site passes mode = 1
}
}
Both arms must compile for every possible constant value, not just the ones
a call site 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, with its bottom edge just above the
brick. Directly nested chips occupy a row above it, and deeper nesting stacks
higher. Rows are centered 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.
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.
Flattening does not change the program’s behavior. A chip is not a scoping or timing boundary, so a flattened program computes the same results as the nested one. What it removes is the tree of planes to open and the pin at each crossing.
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
}
@color(...) and @material(...) style a chip’s shell brick the same way:
@material(Glass) chip { ... } makes it Glass. On a chip the value must be a
constant; see
@color / @material.
mod declarations always inline into their callers and never become a
physical chip, so any annotation (@closed, @label, @color,
@material, 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.
Only the gates are expanded into the caller. Every name in the body resolves where the mod is declared, the same way a chip body’s names do: a var, let, parameter or nested mod the caller declares under the same name is never the one the body reads, writes or calls.
A fn takes the same signature but compiles once, into a function definition plane that every call runs through a Function Call gate. Use a fn when one body is called from many places. Use a mod when the call should cost no extra gate, needs type parameters, or must run in a pure context. A for loop is the third kind of plane: compiled once per loop, swept once per iteration. See fn.
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.
An output set by name (emit name = v or out name = v) works the same way when it is set in more than one place, under a branch or loop, or beside a return: every write goes to the hidden variable, and the call reads the value the path that ran left there. A mod output behaves like a chip output here, per call site for a mod and per instance for a chip. A path that does not write it leaves it holding its last value, which starts at the type’s default (0, "" or false). A write from an on handler nested in the mod lands whenever that handler fires, and the caller sees it until the caller’s own chain writes the output again. A return inside such a handler is one of those writes: it sets the outputs and ends that handler’s run, not the call. A return inside an anonymous chip { } is an ordinary return of the body.
Default and Named Arguments
A trailing mod or fn parameter may have a default value, a constant a call uses when it leaves the argument out. A call can also pass a defaulted parameter by name after its positional arguments, which skips the ones before it.
mod scale(v: int, by: int = 2, offset: int = 0) -> int {
return v * by + offset
}
in go: exec
in n: int
on go {
let a = scale(n) // by = 2, offset = 0
let b = scale(n, 3) // by = 3
let c = scale(n, offset = 1) // by keeps its default
BroadcastChatMessage("${a} ${b} ${c}")
}
A parameter without a default is always passed by position (WS022), and a name that is not a parameter is WS041. A namespaced call (u.scale(n, offset = 1)) takes defaults and names the same way. A chip parameter cannot have a default (WS114).
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) |
| Gates | Own grid | Expanded into caller |
| Names in the body | Resolved where declared | Resolved where declared |
Outputs with -> | Yes (multi) | Yes (single or named, with return) |
ref/* params | Yes | Yes |
| Reusable | Yes (separate instance per call) | Yes (expanded per call) |
A mod declares outputs with ->: one (-> int, set by return expr) or several named ones (-> (lo: int, hi: int), set by return (a, b)). It can also act through 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’s logic gates. Each function is either pure (returns a value, no exec context needed) or exec (requires exec context and chains into the current execution flow).
Contents
- Notation
- Math / Trigonometry (Pure)
- Bitwise (Pure)
- Vector (Pure)
- Rotation / Quaternion (Pure)
- Color (Pure)
- Stateful Exec Values
- Select / Swap (Pure)
- Edge / Change Detectors
- Logical XOR (
^^) - String Operations (Pure)
- String Formatting (Pure)
- Array Methods (Exec)
- Player Input (Exec)
- Controller / Character Conversions (Exec)
- Camera / Aim (Exec)
- Display (Exec)
- Entity Getters (Exec)
- Entity Manipulation (Exec)
- Gamemode (Exec)
- Character (Exec)
- Controller (Exec)
- Broadcast Messaging (Exec)
- Audio (Exec)
- Entity Tags (Exec)
- Entity Custom Properties (Exec)
- Bricks (Exec)
- Environment and Components (Exec)
- Misc (Pure / Exec)
- Gate config properties
- Clock (Event)
- ChatCommand (Event)
- Custom Events
- Prefab Spawning (Exec)
- Raycasting (Exec)
- Random (Exec)
- Sleep / Delay (Pure)
- Exec Override
- Newer builtins
- Generic type syntax
- Maps (
var m: Map<K, V>) - Exec-flow gates (Union / Branch)
- Callable gate builtins
Notation
- Pure functions are expressions: they produce a value and can be used anywhere.
- Exec functions require an active exec context (inside an
onhandler). They are called as statements or in exec expressions. - Parameters marked with
?are optional.
Receiver Method Syntax
Many functions support receiver method syntax, where the first parameter is written before the dot instead of as a positional argument. Both forms are equivalent:
// Receiver form (preferred)
entity.SetLocation(pos)
// Traditional form
SetLocation(entity, pos)
Functions that support receiver syntax show both forms in the documentation below.
Math / Trigonometry (Pure)
All trig functions take and return float. Angles are in radians unless converted.
| Function | Signature | Description |
|---|---|---|
sin(x) | (x: float) -> float | Sine |
cos(x) | (x: float) -> float | Cosine |
tan(x) | (x: float) -> float | Tangent |
asin(x) | (x: float) -> float | Arc sine |
acos(x) | (x: float) -> float | Arc cosine |
atan(x) | (x: float) -> float | Arc tangent |
atan2(y, x) | (y: float, x: float) -> float | Two-argument arc tangent |
sinh(x) | (x: float) -> float | Hyperbolic sine |
cosh(x) | (x: float) -> float | Hyperbolic cosine |
tanh(x) | (x: float) -> float | Hyperbolic tangent |
asinh(x) | (x: float) -> float | Inverse hyperbolic sine |
acosh(x) | (x: float) -> float | Inverse hyperbolic cosine |
atanh(x) | (x: float) -> float | Inverse hyperbolic tangent |
exp(x) | (x: float) -> float | e^x |
ln(x) | (x: float) -> float | Natural logarithm |
sign(x) | (x: float) -> float | Sign (-1, 0, or 1) |
abs(x) | (x: T) -> T | Absolute value; T is any math variant, per component |
sqrt(x) | (x: float) -> float | Square root |
pow(x, exponent) | (x: float, exponent: float) -> float | Power |
clamp(x, min, max) | (x: T, min: T, max: T) -> T | Clamp to range |
round(x) | (x: float) -> float | Round to nearest integer |
floor(x) | (x: float) -> float | Round down |
ceil(x) | (x: float) -> float | Round up |
min(a, b) | (a: T, b: T) -> T | Minimum of two values |
max(a, b) | (a: T, b: T) -> T | Maximum of two values |
log(x, base) | (x: float, base: float) -> float | Logarithm with arbitrary base |
lerp(a, b, t) | (a: T, b: T, t: float) -> T | Linear interpolation; T is any math variant (see Easing/Tween) |
Blend(a, b, alpha) | (a: T, b: T, alpha: float) -> T | The same gate as lerp, with a clampAlpha config (receiver on a) |
fmod(a, b) | (a: float, b: float) -> float | Floored modulo |
Deg2Rad(x) | (x: float) -> float | Degrees to radians |
Rad2Deg(x) | (x: float) -> float | Radians to degrees |
let angle = atan2(dy, dx)
let clamped = clamp(value, 0.0, 1.0)
let dist = sqrt(dx * dx + dy * dy)
let radians = Deg2Rad(90.0)
abs, min, max and clamp give the type of their arguments: an int for
ints, a float once one is a float, and a vector, rotator, quat or
color per component. A bool reads as an int. A constant call folds to a
constant of that type, so max(3, 7) is the int 7.
Bitwise (Pure)
| Function | Signature | Description |
|---|---|---|
BitCount(x) | (x: int) -> int | Count set bits (popcount) |
BitNand(a, b) | (a: int, b: int) -> int | Bitwise NAND (same as ~(a & b)) |
BitNor(a, b) | (a: int, b: int) -> int | Bitwise NOR (same as ~(a | b)) |
The compiler fuses ~(a & b) and ~(a | b) into single NAND/NOR gates.
let bits = BitCount(flags)
Vector (Pure)
| Function | Signature | Description |
|---|---|---|
Vec(x, y, z) | (x: float, y: float, z: float) -> vector | Construct a vector |
Dot(a, b) | (a: vector, b: vector) -> float | Dot product |
Cross(a, b) | (a: vector, b: vector) -> vector | Cross product |
Normalize(v) | (v: vector) -> vector | Normalize to unit length |
Magnitude(v) | (v: vector) -> float | Length of vector |
MagnitudeSq(v) | (v: vector) -> float | Squared length (avoids sqrt) |
Distance(a, b) | (a: vector, b: vector) -> float | Distance between two points |
DistanceSq(a, b) | (a: vector, b: vector) -> float | Squared distance (avoids sqrt) |
ScaleVec(v, s) | (v: vector, scalar: float) -> vector | Scale vector by scalar |
v.SplitVec() | (v: vector) -> {x, y, z: float} | Decompose vector (receiver on vector) |
Vector Receiver Methods
Dot, Cross, Normalize, Magnitude, MagnitudeSq, Distance, DistanceSq, and ScaleVec support receiver syntax on vector:
// Receiver form
let dsq = a.DistanceSq(b)
let msq = v.MagnitudeSq()
let n = v.Normalize()
// Traditional form
let dsq = DistanceSq(a, b)
let msq = MagnitudeSq(v)
let n = Normalize(v)
let pos = Vec(1.0, 2.0, 3.0)
let dir = Normalize(target - origin)
let dist = Distance(posA, posB)
let scaled = ScaleVec(velocity, 0.5)
Rotation / Quaternion (Pure)
Two rotation types: rotator is euler (pitch/yaw/roll, used by entity rotation),
quat is a quaternion produced by the conversion gates. Methods use the concise
receiver form.
| Function | Signature | Description |
|---|---|---|
Rotation(pitch, yaw, roll) | (float, float, float) -> rotator | Construct an euler rotator |
r.ToEuler() | (rotator) -> {Pitch, Yaw, Roll: float} | Split a rotator into components |
dir.ToRotation() | (vector) -> quat | Quaternion that points along dir |
q.ToDirection() | (quat) -> vector | Forward direction of q |
v.Rotate(q) | (vector, quat) -> vector | Rotate a vector by a quaternion |
q.Invert() | (quat) -> quat | Inverse rotation |
from.RotationTo(to) | (vector, vector) -> quat | Quaternion rotating from onto to |
a.AngleTo(b) | (quat, quat) -> float | Smallest angle between two quaternions (degrees) |
a.Slerp(b, alpha) | (quat, quat, float) -> quat | Spherical interpolation |
axis.RotationByAngle(angle) | (vector, float) -> quat | Quaternion from axis + angle (degrees) |
q.ToAxisAngle() | (quat) -> {Axis: vector, Angle: float} | Decompose into axis + angle (degrees) |
Quat(x, y, z, w) | (float, float, float, float) -> quat | Construct a quaternion from raw components |
q.SplitQuat() | (quat) -> {X, Y, Z, W: float} | Decompose into raw components |
a.QuatDot(b) | (quat, quat) -> float | Quaternion dot product |
let q = forward.ToRotation()
let spun = velocity.Rotate(q)
let mid = a.Slerp(b, 0.5)
let r = Rotation(0.0, 90.0, 0.0) // euler rotator
let yaw = r.ToEuler().Yaw
Color (Pure)
| Function | Signature | Description |
|---|---|---|
Color(r, g, b, a?) | (r: float, g: float, b: float, a?: float) -> color | Construct a color (linear RGBA, 0-1 range) |
ColorSRGB(r, g, b, a) | (int, int, int, int) -> color | Construct from sRGB bytes (0-255) |
ColorHex(hex) | (string) -> color | Construct from a hex string ("#ff8800") |
c.SplitColor() | (c: color) -> {r, g, b, a: float} | Decompose into linear components |
c.ToSRGB() | (color) -> {R, G, B, A: int} | Decompose into sRGB bytes |
c.ToHex() | (color) -> string | Hex string |
a.ColorBlend(b, alpha) | (color, color, float) -> color | Blend two colors (color-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 colors
as one of its variants but has no color-space selection.
let red = Color(1.0, 0.0, 0.0)
let orange = ColorSRGB(255, 128, 0, 255)
let hex = orange.ToHex()
let parts = red.SplitColor() // parts.r = 1.0, parts.g = 0.0, ...
let mixed = red.ColorBlend(orange, 0.5)
Stateful Exec Values
| Function | Signature | Description |
|---|---|---|
Cycle(count) | (count: int) -> int exec | Returns 0,1,…,count-1 advancing each exec pulse |
Toggle() | () -> bool exec | Flips between false/true each exec pulse |
Select / Swap (Pure)
| Function | Signature | Description |
|---|---|---|
Select(cond, a, b) | (cond: bool, a: T, b: T) -> T | Returns a if false, b if true |
Swap(cond, a, b) | (cond: bool, a: T, b: T) -> {Output, OutputB: T} | Conditionally swap two values |
let bigger = Select(x > y, y, x)
let result = Swap(shouldSwap, left, right)
// result auto-unwraps to Output; result.Output and result.OutputB
// are swapped if shouldSwap is true
Edge / Change Detectors
| Function | Signature | Description |
|---|---|---|
Edge(input) | (input: bool) -> {Rising, Falling: bool} | Bool pulses on boolean transitions |
EdgeExec(input) | (input: float) -> {Rising, Falling: exec} | Exec pulses when a value rises/falls |
Changed(input) | (input: any) -> bool | Bool pulse when the input changes |
Change(input) | (input: any) -> exec | Exec pulse when the input changes |
ContainerChange(container) | (container: T[] | Map<K, V>) -> exec | Exec pulse on the update after an array or map 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 fires an exec pulse 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!") }
Change and the other detectors watch a wire value, and an array or map’s
wire carries the same reference however its contents move (WS059).
ContainerChange watches the contents instead: it fires on the update after
the array or map changes in any way.
var scores: Map<string, int>
var shown: int = 0
on ContainerChange(scores) { shown = scores.length() }
Logical XOR (^^)
The ^^ operator is boolean XOR, which returns true if exactly one operand is true.
let either = a ^^ b // true if a or b but not both
The compiler fuses !(a && b) and !(a || b) into single NAND/NOR gates.
String Operations (Pure)
All string functions support receiver syntax on string:
| Function | Signature | Description |
|---|---|---|
s.Length() | (s: string) -> int | String length |
s.Contains(search, caseSensitive?) | (s: string, search: string, caseSensitive?: bool) -> bool | Check if string contains substring |
s.StartsWith(prefix, caseSensitive?) | (s: string, prefix: string, caseSensitive?: bool) -> bool | Check prefix |
s.EndsWith(suffix, caseSensitive?) | (s: string, suffix: string, caseSensitive?: bool) -> bool | Check suffix |
s.Find(search, caseSensitive?, start?) | (s: string, search: string, caseSensitive?: bool, start?: int) -> int | Find substring index (-1 if not found) |
s.Substring(start, length) | (s: string, start: int, length: int) -> string | Extract substring |
s.Replace(search, replacement, caseSensitive?, maxReplacements?, start?) | (s: string, search: string, replacement: string, caseSensitive?: bool, maxReplacements?: int, start?: int) -> string | Replace occurrences |
s.Split(delimiter, occurrence?, caseSensitive?) | (s: string, delimiter: string, occurrence?: int, caseSensitive?: bool) -> {Left, Right: string, Found: bool} | Split at the delimiter |
s.ToLower() | (s: string) -> string | Convert to lowercase |
s.ToUpper() | (s: string) -> string | Convert to uppercase |
s.Trim() | (s: string) -> string | Remove leading/trailing whitespace |
s.ParseInt() / ParseInt(s) | (s: string) -> {Value: int, Success: bool} | Parse an integer from text (auto-unwraps to Value) |
s.ParseNumber() / ParseNumber(s) | (s: string) -> {Value: float, Success: bool} | Parse a number from text (auto-unwraps to Value) |
let name = "Hello World"
let len = name.Length() // 11
let has = name.Contains("World") // true
let low = name.ToLower() // "hello world"
let sub = name.Substring(6, 5) // "World"
let parts = name.Split(" ") // parts.Left = "Hello", parts.Right = "World"
String Formatting (Pure)
| Function | Signature | Description |
|---|---|---|
Fmt(format, a?, b?, c?, d?, e?, f?, g?) | (format: string, a-g?: any) -> string | Format text with placeholders |
The Fmt function wraps the FormatText gate. The format string names inputs
a through g as {A} through {G}, the form the gate’s own tooltip uses.
The numbered form {0} through {6} also works, and it is what ${}
interpolation compiles to.
let label = Fmt("{A}: {B}", "Score", score)
let coords = Fmt("({0}, {1}, {2})", x, y, z)
// The format string can be built at runtime, which turns Fmt into a lookup table:
let col = Fmt('{' .. bucket .. '}', 'eee4da', 'f2b179', 'f65e3b')
An option after a colon changes how a value prints:
| Placeholder | Prints | Example |
|---|---|---|
{A:,} | Thousands grouped | 1,234,567 |
{A:.2} | Fixed decimals, up to 6 | 12.50 |
{A:,.2} | Both | 1,234.50 |
{A:On=Off} | The first word when true, the second when false | On |
A form after a bar picks a word by number. Each form names a plural category
(one, two, few, other), and a form’s text can insert inputs itself:
let apples = Fmt("{A} {A}|plural(one=apple,other=apples)", n) // 1 apple, 3 apples
let place = Fmt("{A}{A}|ordinal(one=st,two=nd,few=rd,other=th)", rank) // 1st, 2nd, 3rd, 4th
let basket = Fmt("{A}|plural(one=one apple,other={A} apples)", n) // one apple, 3 apples
The game applies these options. A Fmt that uses lettered inputs, options or
word forms is never folded at compile time, even when its inputs are
constants. Whether the options also apply to the numbered form ({0:.2}) is
unverified, so prefer the lettered form with them.
Array Methods (Exec)
Methods on an array variable. All run in exec context (they lower to ArrayVar
exec gates), so call them inside on handlers / mods. Declare arrays with
var name: T[] (see statements).
The element type can be a record (var pts: Point[]): the array is stored as
one parallel array per field and each method fans out. sort/shuffle, the
aggregates, and the dual-array ops have no per-field meaning there and are
rejected with WS050. See Records as storage.
| Method | Signature | Description |
|---|---|---|
arr.push(value) | (value: T) | Append an element |
arr.pop() | () -> {Value: T, IsEmpty: bool} | Remove and return the last element (auto-unwraps to Value) |
arr.get(index) | (index: int) -> {Value: T, OutOfBounds: bool} | Read the element at index (auto-unwraps to Value). .OutOfBounds flags a bad index. This is the explicit form of arr[i] |
arr.length() | () -> int | Number of elements |
arr.remove(index) | (index: int) -> bool | Remove the element at index; gives true if index was out of range |
arr.insert(index, value) | (index: int, value: T) -> bool | Insert before index; gives true if index was out of range |
arr.clear() | () | Remove all elements |
arr.find(value) | (value: T) -> {Index: int, Found: bool} | 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) -> bool | Sort in place, reordering at most 7 parallel arrays through the same permutation (more is WS011). A record array argument is carried along as one array per field, and each field counts toward the 7. Only a trailing bool is descending |
arr.reverse() | () | Reverse in place |
arr.shuffle() | () | Randomly reorder |
arr.swap(a, b) | (a: int, b: int) -> bool | Swap two elements; gives true if an index was out of range |
arr.fill(value) | (value: T) | Set every element to value |
arr.resize(size, value) | (size: int, value: T) | Grow/shrink, filling new slots with value |
arr.sum() | () -> T | Sum of elements |
arr.min() / arr.max() | () -> {Value: T, IsEmpty: bool} | Smallest / largest element (auto-unwraps to Value) |
arr.average() | () -> {Value: float, IsEmpty: bool} | Mean of elements (auto-unwraps to Value) |
arr.join(separator?) | (separator?: string) -> string | Join every element into one string, each converted as it would be to a string, with separator between them (none when omitted) |
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) -> bool | Copy source[start..start+count] into this array; gives true if the range was out of bounds |
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.
InputReader is not a gate: it compiles to the Seat Control Splitter
component (Component_Internal_InputSplitter), with the character wired into
its Character input. Its outputs are live: they change while the player
presses keys, with no Clock and no exec step, so on input.PressedQ { ... }
runs once per press (on the rising edge) and on input.Forward > 0.5 once
per push. Any character wire works, not only a Seat’s Sitting Character:
a Switch’s Character holds the last player who flipped it and keeps
holding them wherever they go, which hands a remote to a player who is not
seated. One splitter reads one player; to read every player at once, spawn a
small chip per player that wires its own splitter
(One Chip per Player).
Returns a record with fields:
Forward: float: forward/backward movement axis (-1 to 1)Right: float: left/right movement axis (-1 to 1)Up: float: up/down movement axis (-1 to 1)Pitch: float/Yaw: float/Roll: float: look axesMouseWheel: float: mouse wheel deltaPressedC/PressedE/PressedQ/PressedLeftMouse/PressedRightMouse: bool: key/button states
let input = char.InputReader()
let moving = input.Forward != 0.0 || input.Right != 0.0
let interacting = input.PressedE
GetInputs
character.GetInputs() -> { ...same fields as InputReader... }
GetInputs(player: character) -> { ...same fields... }
Sample the same twelve controls once, at the point the exec chain reaches this
call, rather than reading them continuously. The field names are identical to
InputReader’s, so only the context differs. Its operand also accepts a
persistent player, which wires straight into the character parameter.
It reads any player, wherever they are: they need not be seated or standing
in a zone, so a list of players from a zone fill, a stored reference or an
event’s character all work.
It is an exec gate, so it must sit on an exec chain. In pure position it
reports WS007. Use it for a one-time read in a handler that has a player
but no character wire, such as a zone event. To watch keys, wire the
character into InputReader instead: a GetInputs on a
Clock misses any press shorter than the interval.
on ZoneEntered(zone = hall) -> (character) {
if character.GetInputs().PressedQ { character.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. Uses the admin-only Get
Player (Persistent) gate.
A controller (player state) is the persistent player, not the body: it stays
the same through deaths and respawns, while each respawn makes a new
character. A character and its controller are not equal. A character wired
straight into a controller slot (a Map<controller, V> key, a
controller var) still carries the character, so as a map key it files
under that body and a respawn starts a new key. Custom properties
(SetCustomProperty) can be set on a controller, which makes it per-player
storage that survives respawns but not a reconnect.
CharacterOf
controller.CharacterOf() -> character
CharacterOf(controller: controller) -> character
Get character from controller. Receiver on controller.
on CharacterSpawned() -> (character) {
let ctrl = character.ControllerOf()
ctrl.DisplayText("Welcome!", fontSize = 24)
}
Camera / Aim (Exec)
GetAim
character.GetAim() -> { Origin: vector, Direction: vector }
GetAim(character: character) -> { Origin: vector, Direction: vector }
Reads the character’s camera/aim in a single gate. Returns a record:
Origin: vector: aim origin positionDirection: vector: aim direction vector
Receiver on character. Access the fields with .Origin / .Direction. Both
share one gate, so reading both costs a single GetAim.
on trigger {
let aim = char.GetAim()
let origin = aim.Origin
let dir = aim.Direction
}
Display (Exec)
DisplayText
target.DisplayText(text, ...) -> int
DisplayText(target: controller, text: string, ...) -> 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, and 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 typeface / font, which are constant-only data fields
(not wire inputs). justify and easing are wire inputs: a constant member
name bakes into the gate, and any other value wires in.
DisplayText Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
target | controller | Yes | Player to display to |
text | string | Yes | Text content (auto-converted to string) |
positionX / positionY | float | No | 2D screen position, per axis |
anchorX / anchorY | float | No | 2D anchor point, per axis |
scaleX / scaleY | float | No | 2D scale, per axis |
pivotX / pivotY | float | No | 2D pivot point, per axis |
shadowOffsetX / shadowOffsetY | float | No | 2D drop-shadow offset, per axis |
angle | float | No | Rotation angle |
outlineSize | int | No | Text outline size |
outlineColor | color | No | Outline color |
fontColor | color | No | Font color |
shadowColor | color | No | Drop-shadow color |
miteredOutline | bool | No | Sharp (mitered) outline corners |
letterSpacing | float | No | Extra spacing between letters |
lineHeight | float | No | Line-height multiplier |
wrapWidth | float | No | Wrap width (0 = no wrap) |
skew | float | No | Italic-style skew |
zOrder | int | No | Draw order |
lifetime | float | No | Display duration (seconds) |
transition | float | No | Seconds to interpolate to the new state when re-emitted with the same textId |
textId | int | No | Unique ID for updating text in-place |
fontSize | int | No | Font size |
justify | DisplayTextJustification | No | Justification: DisplayTextJustification.Left / .Center / .Right, or bare Left / Center / Right. Defaults to Center (the game’s default since its EA 3.2 update; it was Left before) |
easing | DisplayTextEasing | No | Transition curve: DisplayTextEasing.Linear / .EaseIn / .EaseOut / .EaseInOut, or bare Linear / EaseIn / EaseOut / EaseInOut |
typeface | TextTypeface | No | Typeface: TextTypeface.Regular / .Bold / .Italic / .BoldItalic, or bare Regular / Bold / Italic / BoldItalic (constant only) |
font | asset ref | No | Font asset reference (constant only) |
on RoundStart() {
ctrl.DisplayText("Round Start!", fontSize = 48, lifetime = 3.0)
}
// Update text in-place: capture the id, then re-display with the same textId.
on trigger {
let id = ctrl.DisplayText("Score: ${score}", fontSize = 24, lifetime = 10.0)
ctrl.DisplayText("Score: ${score}", textId = id, transition = 0.25)
}
Entity Getters (Exec)
All entity getter functions require exec context and support receiver syntax on entity.
GetLocation
entity.GetLocation() -> vector
GetLocation(entity: entity) -> vector
Get entity’s world position.
GetRotation
entity.GetRotation() -> rotator
GetRotation(entity: entity) -> rotator
Get entity’s world rotation.
GetLocationRotation
entity.GetLocationRotation() -> {Vector: vector, Rotation: rotator}
GetLocationRotation(entity: entity) -> {Vector: vector, Rotation: rotator}
Get both position and rotation at once.
GetLinearVelocity
entity.GetLinearVelocity() -> vector
GetLinearVelocity(entity: entity) -> vector
Get entity’s linear velocity.
GetAngularVelocity
entity.GetAngularVelocity() -> vector
GetAngularVelocity(entity: entity) -> vector
Get entity’s angular velocity.
GetVelocity
entity.GetVelocity() -> {Vector: vector, Rotation: rotator}
GetVelocity(entity: entity) -> {Vector: vector, Rotation: rotator}
Get both linear and angular velocity at once.
on trigger {
let pos = entity.GetLocation()
let rot = entity.GetRotation()
let vel = entity.GetLinearVelocity()
}
Entity Manipulation (Exec)
All entity manipulation functions require exec context and support receiver syntax on entity.
SetLocation
entity.SetLocation(pos: vector)
SetLocation(entity: entity, pos: vector)
Set entity position. Use this (not Teleport) to move an entity to world
coordinates. pos is a real vector port.
It moves a spawned prefab (the entity SpawnPrefab returns, or
ReadBrickGrid() inside the copy). It does not move a pasted chip’s own
grid: ReadBrickGrid().SetLocation(...) in a chip that was pasted, not
spawned, leaves it where it is.
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: teleport)
Teleport(entity: entity, dest: teleport)
Teleport entity to destination.
RelativeTeleport
entity.RelativeTeleport(source: teleport, dest: teleport)
RelativeTeleport(entity: entity, source: teleport, dest: teleport)
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, so 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.
SetVisible
entity.SetVisible(visible: bool)
SetVisible(entity: entity, visible: bool)
Show or hide a physics object or dynamic brick grid. A hidden one keeps its collision and renders only for players holding a tool that shows hidden bricks.
SetVisibleTo
entity.SetVisibleTo(visibleTo?: controller | character | entity)
SetVisibleTo(entity: entity, visibleTo?: controller | character | entity)
Show a physics object or dynamic brick grid to one player or one team only.
visibleTo takes a player (a controller or character) or a team (the
entity that GetTeam gives). Everyone else sees it as hidden, but its
collision stays on for everyone, and builder mode always shows it. Leave
visibleTo out to show it to everyone again. SetVisible(false) still hides
it from everyone, the chosen player or team included.
@left in reveal: exec
@left in hideAgain: exec
@left in crate: entity
@left in seer: controller
on reveal { crate.SetVisibleTo(seer) } // only `seer` sees the crate
on hideAgain { crate.SetVisibleTo() } // everyone sees it again
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: int)
SetLeaderboard(controller: controller, key: string, value: int)
Set a leaderboard value. Receiver on controller.
IncLeaderboard
controller.IncLeaderboard(key: string, value: int)
IncLeaderboard(controller: controller, key: string, value: int)
Increment a leaderboard value. Receiver on controller.
GetLeaderboard
controller.GetLeaderboard(key: string) -> int
GetLeaderboard(controller: controller, key: string) -> int
Get a leaderboard value. Receiver on controller.
GetTeam
character.GetTeam() -> entity
GetTeam(character: character) -> entity
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, and 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. title and
text are gate settings, so they must be constants: a variable or computed
string is WS028.
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?, projectileScale?)
character.SetInventoryItemAdv(item, slot?, damage?, speed?, scale?, itemName?, projectile?, projectileScale?)
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, a projectile override, and
a projectile size multiplier (projectileScale, default 1.0).
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: any)
ShowChatMessage(controller: controller, message: any)
Send a chat message that only this player sees (a whisper). Receiver on
controller.
Dialogs
controller.ShowMessageBox(message: any, title?: any) -> { ClickedBy, OkClicked, Declined }
controller.ShowOkCancelBox(message: any, title?: any) -> { ClickedBy, OkClicked, Declined }
controller.ShowYesNoBox(message: any, title?: any) -> { ClickedBy, YesClicked, Declined }
controller.ShowTextInputBox(message: any, title?: any) -> { ClickedBy, Text, OkClicked, Declined }
Pop up a dialog for this player. Receiver on controller. The call itself
continues at once, and the answer arrives later. When the player confirms, the
OkClicked (or YesClicked) exec field pulses, ClickedBy is the player who
answered (a controller), and ShowTextInputBox’s Text is the line they
typed. Every other outcome pulses Declined: Cancel or No, closing the
dialog, or a dialog the game could not show.
var ready: int = 0
on ControllerJoined() -> (c) {
let r = c.ShowYesNoBox("Ready to play?", title = "Start")
on r.YesClicked { ready = ready + 1 }
on r.Declined { c.ShowChatMessage("Type /ready when you want to join") }
}
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)
These read a player’s account name, persistent user id, or current display
name. They check whether the player is trusted by the brick owner, holds a
named permission, or has a named role (HasRole). They toggle the player’s
ability to respawn, respawn them immediately (ForceRespawn, also callable as
ForceRespawn(player)), or pin them to their team. All receive on controller.
Broadcast Messaging (Exec)
BroadcastChatMessage(message: any)
BroadcastStatusMessage(message: any, 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). PlayClientAudio receives on the player, which is a
character or persistent player reference, and is 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. This is handy
for marking players/entities with game state (team, slot index, role). Zone
components can also filter on tags. Receiver on entity.
Entity Custom Properties (Exec)
entity.SetCustomProperty(key: string, value)
entity.GetCustomProperty(key: string) -> { Value, Found: bool }
entity.RemoveCustomProperty(key: string) -> bool
entity.CopyCustomProperties(source: entity)
A key/value store on an entity, a player or a dynamic grid. On a controller
the values stay with the player through respawns and are lost when the player
leaves and reconnects. On a character they belong to that one body and are
lost when it respawns. SetCustomProperty
replaces any value already under the key. GetCustomProperty gives the value,
auto-unwrapped like a map read, and Found is false when the key is not set.
RemoveCustomProperty gives whether anything was removed.
CopyCustomProperties replaces the receiver’s properties with a copy of
source’s, for instance to give a spawned replacement the original’s data. A
holder keeps up to 256 properties, and a key is at most 64 characters: a longer
key is silently not stored. A dynamic grid such as a spawned prefab holds
properties; a pasted grid does not. The value can be any wire value, refs
included, so .Value is an any. Give it a type by storing it in a typed
var or an annotated let (let hp: int = player.GetCustomProperty("hp").Value),
or convert it with as. .Value.getType() says which kind it holds (see
GetValueType).
on CharacterDied() -> (character) {
let player = character.ControllerOf()
let lives = player.GetCustomProperty("lives")
let left: int = if lives.Found then lives.Value else 3
player.SetCustomProperty("lives", left - 1)
}
The count is kept on the controller, so it carries over to the player’s next
character. Kept on character, every respawn would start without it.
Bricks (Exec)
GetMicrochipBrick
GetMicrochipBrick() -> brick
The program’s own microchip brick: the one brick a paste places on the main
grid. It is a reference baked into the save, so it needs no wire or editor
setup, and works anywhere in the program, a fn body included. It does not
yet name the right brick inside a spawned prefab.
in go: exec
on go {
GetMicrochipBrick().SetBrickProperty(Color, Color(0.0, 1.0, 0.0))
}
GetBrickProperty / SetBrickProperty
brick.GetBrickProperty(property: BrickProperty) -> bool | color | any
brick.SetBrickProperty(property: BrickProperty, value)
Read or write one setting of a brick: recolor it, hide it, change what
collides with it. property is a constant BrickProperty member (Visible,
Color, Material, MaterialOpacity, AllCollision, PlayerCollision,
Player2Collision to Player4Collision, WeaponCollision,
InteractionCollision, PhysicsCollision), written bare or qualified
(BrickProperty.Color). The value is a bool for Visible and the collision
flags and a color for Color, both for what GetBrickProperty returns and
for what SetBrickProperty accepts. Any other type there is WS003.
Material and MaterialOpacity read and write any. Receiver on brick.
in go: exec
on go {
let r = SweepSimple(1000.0, detectBricks = true)
on r.Hit { r.HitBrick.SetBrickProperty(Color, Color(1.0, 0.0, 0.0)) }
}
Environment and Components (Exec)
Environment settings
Environment.<environment>.<property> // read
Environment.<environment>.<property> = value // write
Reads and writes a setting of the world’s environment: the time of day, the sky’s color, the water’s height. Both run on the exec chain. Every environment and property, with the type each reads and writes as, is listed in Environment Settings.
in go: exec
on go {
let t = Environment.Sky.TimeOfDay
Environment.Sky.TimeOfDay = t + 1.0
Environment.Sky.SkyColor = Color(1.0, 0.5, 0.0)
}
Components
brick.GetComponent($Component/<type>) -> Component<type>
brick.AddComponent($Component/<type>) -> Component<type>
brick.RemoveComponent($Component/<type>)
component.<property> // read
component.<property> = value // write
GetComponent gives a reference to the brick’s component of that type (an
empty reference if it has none). AddComponent adds one first if it has none.
The reference is typed by its component type, so its properties read and write
with their own types (light.Brightness is a float). The game converts a
written value to the property’s type and leaves a wired property alone.
RemoveComponent removes the brick’s component of that type after the current
update, unless an AddComponent of its type runs first. It takes the brick,
not a reference. No gate gives a reference’s brick, so component.Remove() is
WS110. The component types are the ones a
player can add to a brick (see Components), and
the editor completes a reference’s properties.
A reference can be passed to a mod taking Component<PointLight>, or
Component for a reference of any type. It can be
stored in a var, an array (Component<PointLight>[]) or a map value, but not
used as a map key (WS039). The property lists come from the
save schema, so they may name fields the game’s own picker does not offer
(unverified).
in go: exec
on go {
let r = SweepSimple(1000.0, detectBricks = true)
on r.Hit {
let light = r.HitBrick.AddComponent($Component/PointLight)
light.Brightness = light.Brightness * 2.0
light.Color = Color(1.0, 0.5, 0.0)
}
}
Misc (Pure / Exec)
| Function | Signature | Description |
|---|---|---|
FindPlayer(query) | (query: any) -> controller (exec) | Look up a player by name; emits the player |
PrintToConsole(text) | (text: string) -> () (exec) | Print a value to the game console (debugging) |
Opaque(value) | (value: any) -> any (pure) | Identity rerouter that acts as a permanent constant-fold barrier. The wrapped value always stays a real runtime wire and is never folded or seen through. Used in probe/test circuits (see Constant Folding) |
DeltaTime() | () -> float | Seconds elapsed since the previous tick |
ServerUptime() | () -> float | Seconds the server has been running |
ReadBrickGrid() | () -> entity | The brick grid this gate’s microchip is on, as an entity |
NearlyEqual(a, b, tolerance) | (a: float, b: float, tolerance: float) -> bool | Approximate float equality |
Dampen(target, smoothTime) | (target: T, smoothTime: float) -> T | Critically-damped smoothing toward a target; T is any math variant, as for lerp |
Easing(a, b, blend, function?, direction?) | (a: T, b: T, blend: float, function?: EasingFunction, direction?: EasingDirection) -> T | Ease from a to b by blend |
Tween(target, duration, function?, direction?) | (target: T, duration: float, function?: EasingFunction, direction?: EasingDirection) -> {Value: T, Arrived: exec} | Stateful eased value toward target (auto-unwraps to Value; Arrived pulses on arrival) |
Timer(limit, restart?, pause?, resume?) | (limit: float, restart?/pause?/resume?: exec) -> {Time: float, Expired: exec} | Stateful countdown timer |
Blend/lerp/Easing/Tween/Dampen 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 function and direction: 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()
It is a value, not an event: the game no longer runs on ReadBrickGrid() { ... }
when the chip is pasted or loaded. Use on Loaded()
to run something at load.
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, because 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, including fields that have no alias 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 aliases for the common gates.
Config attributes by gate:
| Gate | Config attributes |
|---|---|
Sweep | bodyPartsOnly |
SweepSimple | direction (EBrickDirection), spreadTowardCenter, detectBricks, detectPlayers1 to 4, bodyPartsOnly, detectPhysics, detectMap |
Blend | clampAlpha |
ColorBlend | blendSpace (EBRColorSpace), clampAlpha |
Slerp | shortestPath, clampAlpha |
Easing | function (EasingFunction, schema EBREasingFunction), direction (EasingDirection, schema EBREasingDirection) |
Remap | function (EasingFunction), direction (EasingDirection) |
ConvertColor | fromSpace, toSpace (EBRColorSpace) |
DisplayText | typeface (TextTypeface, schema EBRTextTypeface), font (a $Font/... asset ref). justify (DisplayTextJustification) and easing (DisplayTextEasing) are wire inputs that also take a bare member name |
GetAim | localAim |
AddInventoryItemAdv / SetInventoryItemAdv | overrideColors, meshColors (a color array), ammoOverride |
Enum member names: EBRColorSpace Linear Srgb Oklab Hsv; EBREasingFunction
Linear Sine Quad Cubic Quart Quint Expo Circ Back Elastic Bounce;
EBREasingDirection In Out InOut; EBRTextTypeface Regular Bold Italic BoldItalic;
EBRDisplayTextJustification Left Center Right; EBRDisplayTextEasing
Linear EaseIn EaseOut EaseInOut; EBrickDirection X_Positive X_Negative Y_Positive Y_Negative Z_Positive Z_Negative.
Each of these schema enum types is also usable directly, under its clean
Wirescript name, as a built-in game enum:
EBREasingFunction as EasingFunction, EBREasingDirection as
EasingDirection, EBRColorSpace as ColorSpace, EBRTextTypeface as
TextTypeface, EBRDisplayTextJustification as DisplayTextJustification,
EBRDisplayTextEasing as DisplayTextEasing, and EBrickDirection as
Direction.
Clock (Event)
The Clock gate emits a periodic execution pulse forever. Wirescript uses it as
an event.
It takes interval and enabled, both wire inputs: a constant bakes into
the gate and a variable wires in, so the rate and the on/off state can change at
runtime. The handler body runs on each pulse.
in running: bool
var ticks: int = 0
on Clock(interval = 2.0, enabled = running) {
ticks = ticks + 1
}
ChatCommand (Event)
Registers a chat command. The call parens hold only config args (the
command name and an optional description). The event’s data outputs are bound
by a trailing -> (…) tuple capture (or -> { field: local } record):
- String literals fill the config fields in order:
CommandName, thenHelpText. The description can also be given by name asDescription = "...". - The
->capture binds the event’s data outputs, in order:controller(the player who typed it), thenarguments(the command text as a string).
on ChatCommand("greet", "Greets the player") -> (controller, arguments) {
// CommandName = "greet", HelpText = "Greets the player"
// controller: the player who typed the command
// arguments: the command text as a string
controller.ShowStatusMessage("You said: ${arguments}")
}
The description is optional and can use the named form. Binding params are also optional, so 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 flavors: personal (same-owner) and global (ownership-agnostic), on
separate channel namespaces, so a personal "x" and a global "x" never mix.
SendCustomEvent (Exec)
SendCustomEvent(name, data1, … data8, target = …) takes the channel name as 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. It
fires all matching receivers.
A targeted send needs
isObject = trueon the receiver. Giving atarget(including the receiver spelling,ent.SendCustomEvent(...)) makes it an object event, and only an object-scoped receiver matches one. A plainon CustomEvent("x")is scoped grid-wide and silently never fires for it. There is no error or warning at check or compile. The two spellings pair up:ent.SendCustomEvent("ui.init", who) // targeted -> object event on CustomEvent("ui.init", isObject = true) -> (who: character) { } SendCustomEvent("ui.init", who) // untargeted -> grid-wide on CustomEvent("ui.init") -> (who: character) { }This is the usual way to talk to a spawned prefab: keep the entity
SpawnPrefabreturned and target it, withisObject = trueon the prefab’s receivers.
on hit {
SendCustomEvent("damage", 7, attacker) // send an int + a character
}
on CustomEvent (Event)
The receiver’s call parens hold only the channel name (positional) and any
config (isObject = true); the typed data outputs are bound by a trailing
-> (…) tuple capture. Each data slot’s type can be given explicitly
(amount: int) or inferred from a matching in-unit
SendCustomEvent on the same channel. When neither is available, the slot
defaults to float and a WS042 warning is emitted (the game stores each
data slot as a typed value, not any). Unused slots default to float.
isObject = true is constant config that scopes the receiver to a specific
grid/object (an object event) instead of firing grid-wide.
var lastDamage: int = 0 // a top-level var is already persistent, so it needs no `static`
on CustomEvent("damage") -> (amount: int, attacker: character) {
lastDamage = amount
attacker.ShowStatusMessage("You took ${amount} damage")
}
Global variants: SendGlobalCustomEvent / on GlobalCustomEvent
SendGlobalCustomEvent and on GlobalCustomEvent are the ownership-agnostic
counterparts: delivery ignores the owner, reaching every matching global receiver.
They have the same shape (constant channel name, up to 8 typed data values,
optional target entity, isObject config), on a channel namespace that is
separate from the personal one: SendGlobalCustomEvent("x") reaches
on GlobalCustomEvent("x") but never on CustomEvent("x"), and vice versa.
var total: int = 0
on GlobalCustomEvent("score") -> (points: int) { total = total + points }
on hit { SendGlobalCustomEvent("score", 10) }
One-tick delay. A
CustomEventreceiver fires on the tick after theSendCustomEventruns. The pulse is delivered on the next frame, not synchronously within the sender’s exec chain. Anything that must observe the event’s effect immediately has to account for that one-tick latency (and a send → receive → send round trip costs a tick each hop).
Signature checking (
WS030). When a send targets a constant channel name, the compiler compares each data value’s wire type against every matching receiver’s declared param types (and a typedlet v: T = await CustomEvent("c")capture’sT) and warns (WS030) on a mismatch, e.g. sending afloatwhere the receiver declaredint. A constant is sent as the type it has, so2.5to anintparam warns too. This runs for both the personal (SendCustomEvent/on CustomEvent) and global (SendGlobalCustomEvent/on GlobalCustomEvent) pairs, each within its own namespace. Types that share a wire variant are interchangeable and never flagged (any two entity kinds,character/entity/controller/…, are all the sameObjectvariant). The channel name must be a constant literal, so every send’s receiver set is known at compile time. In the editor, go-to-definition on a send’s channel-name string jumps to the receiver.A non-constant data value is still typed as
floaton the wire at emit rather than from the value’s real type. Full end-to-end typing waits on generics. For now the receiver’s annotations are the source of truth, and constant sends carry their type.
Prefab Spawning (Exec)
SpawnPrefab
| Parameter | Type | Required | Description |
|---|---|---|---|
prefab | prefab ref | No | The prefab to spawn: a $./file.brz archive, a $./file.ws source compiled on reference, or an inline $ triple-backtick block (prefab reference). Embedded into the bundle at compile. |
offset | vector | No | Spawn position, relative to the Spawn Prefab gate’s own position (local, not world space) |
rotation | rotator | No | Spawn rotation offset |
velocity | vector | No | Initial velocity of the spawned entity |
lifetime | float | No | Lifetime in seconds (0 = permanent) |
limit | int | No | Max concurrent instances |
destroyAll | exec | No | Wire an exec here; pulsing it destroys every entity this gate has already spawned. Independent of the spawn Exec, so one gate both spawns and clears. |
Returns: entity, the spawned entity. Give the prefab with a $…brz
reference; omit prefab to configure it on the placed gate in-game instead
(copy a prefab onto the Spawn Prefab brick).
offset is measured from the Spawn Prefab gate, so Vec(0.0, 0.0, 50.0) is
50 units above the spawner. To place a copy at a world position (a player’s
location, a stored spot), spawn it and then move the returned entity with
SetLocation:
in pad: zone
on ZoneEntered(zone = pad) -> (character) {
let crate = SpawnPrefab(prefab = $./crate.brz, limit = 3)
crate.SetLocation(character.GetLocation() + Vec(0.0, 0.0, 150.0))
}
on trigger {
let spawned = SpawnPrefab(
prefab = $./turret.brz,
offset = Vec(0.0, 0.0, 50.0),
lifetime = 10.0,
limit = 5
)
spawned.SetVelocity(linear = launchDir)
}
destroyAll is a secondary exec trigger (like Timer’s restart): wire a reset /
round-start signal into it to remove every entity this spawner has produced, without
re-spawning. The gate spawns when its own exec chain fires and clears when
destroyAll fires:
in reset: exec
on trigger {
let cube = SpawnPrefab(prefab = $./msg.brz, limit = 64, destroyAll = reset)
cube.SetTag(payload)
}
// pulsing `reset` destroys every cube this gate has spawned
A $./file.brz reference reads the .brz at compile and embeds it into the
output bundle (content-addressed at Prefabs/Uploads/<hash>.brz), so the
compiled program carries its prefab. A $./file.ws reference compiles that
source first and embeds the result, and a prefab can also be written inline as a
$ followed by a triple-backtick block. See
Prefab References and the per-entity
fan-out section in best practices.
SpawnExplosion
Spawns an explosion of a given projectile/explosion class.
| Parameter | Type | Required | Description |
|---|---|---|---|
projectileType | class ref | Yes | The explosion/projectile class: a $… asset reference (or a wired value) |
instigator | entity | No | The character/entity that caused it (kill credit, etc.) |
offset | vector | No | Spawn position offset |
scale | float | No | Explosion scale multiplier |
damage | float | No | Damage multiplier |
on hit {
SpawnExplosion($BRWeaponProjectile/Grenade, instigator = attacker, scale = 2.0, damage = 1.5)
}
SpawnExplosionAt
Like SpawnExplosion, but at an absolute world position instead of an offset
from the gate’s brick.
| Parameter | Type | Required | Description |
|---|---|---|---|
worldPosition | vector | Yes | Absolute world position to spawn the explosion at |
projectileType | class ref | Yes | The explosion/projectile class: a $… asset reference (or a wired value) |
instigator | entity | No | The character/entity that caused it (kill credit, etc.) |
scale | float | No | Explosion scale multiplier |
damage | float | No | Damage multiplier |
on hit {
SpawnExplosionAt(Vec(0.0, 0.0, 200.0), $BRWeaponProjectile/Grenade, instigator = attacker)
}
Raycasting (Exec)
Sweep
| Parameter | Type | Required | Description |
|---|---|---|---|
origin | vector | Yes | Ray start position |
direction | vector | Yes | Ray direction |
distance | float | Yes | Maximum ray distance |
radius | float | No | Sphere radius (0 = line trace) |
relative | bool | No | Interpret origin/direction in the owning grid’s local frame |
ignore | entity | No | A single entity to exclude from hits |
ignoreList | entity[] | No | An array var of additional entities to exclude (on top of ignore) |
detectBricks | bool | No | Detect brick grids, including spawned prefabs (default false) |
detectPlayers1 to detectPlayers4 | bool | No | Detect players on collision channels 1-4 (default false) |
detectPhysics | bool | No | Detect physics-simulating objects (default false) |
detectMap | bool | No | Detect the static world / environment (default false) |
ignoreOwningGrid | bool | No | Exclude the grid this gate sits on (prevents self-hits) |
collisionChannel | SweepCollisionChannel | No | Collision channel to sweep on (EBRSweepCollisionChannel: 0 Physics, 1 Weapon, 2 Interaction, 3 Tool, 4-7 Player1-4, 8 NoAdditionalRestriction) |
bodyPartsOnly | bool | No | Only hit player body parts (constant only) |
Detection is opt-in. Every
detect*flag defaults tofalse, so a Sweep with none set detects nothing and always firesMiss. Enable the channel you want:detectBricksfor brick grids / spawned prefabs,detectPlayers1for players,detectPhysicsfor loose physics objects.
Returns a record with fields:
HitDistance: float: Distance to hit pointHitEntity: entity: Entity that was hitHitLocation: vector: World position of hitHitNormal: vector: Surface normal at hitHitColor: color: Color of the surface hitHitBrick: brick: Brick that was hit (see Bricks)Hit: exec: Fires if something was hitMiss: exec: Fires if nothing was hit
on trigger {
let aim = char.GetAim()
// Run the Sweep inside the exec handler and handle it with nested Hit/Miss branches.
// A top-level `Sweep(..., exec = t)` does not fire.
let r = Sweep(aim.Origin, aim.Direction, 10000.0,
radius = 5.0, ignore = char, detectPlayers1 = true)
on r.Hit { r.HitEntity.ShowStatusMessage("hit!") }
on r.Miss { /* nothing in range */ }
// If should also work here
if r.Hit { r.HitEntity.ShowStatusMessage("hit!") }
if r.Miss { /* nothing in range */ }
}
Random (Exec)
| Function | Signature | Description |
|---|---|---|
Random(min, max) | (min: int, max: int) -> int | Random integer in [min, max] |
Random(min, max) | (min: T, max: T) -> T, T ∈ vector/rotator/quat/color | Per-component random of the same type |
on RoundStart() {
let r = Random(0, 15)
if r == 0 { specialEvent = true }
}
Random uses 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.
Random is an exec function because it requires sequential execution to produce a new random value each time.
Sleep / Delay (Pure)
Buffer gates that delay a value passing through. Most useful with await and the _ armed flag placeholder.
| Function | Signature | Description |
|---|---|---|
Sleep(input, delay?, hold?) | (input: T, delay?: float, hold?: float) -> T | Delay by seconds (BufferSeconds gate) |
SleepTicks(input, delay?, hold?) | (input: T, delay?: int, hold?: int) -> T | Delay by ticks (BufferTicks gate) |
input: the value to delay. Use_insideawaitto wire the armed flag.delay: seconds/ticks to wait before the output follows the input.hold: seconds/ticks to hold the output after the input drops to zero. Set to -1 to use delay instead.
// Sleep 2 seconds using await
on start {
await Sleep(_, delay = 2.0)
doAfterDelay()
}
// Sleep 60 ticks (~1 second at 60Hz)
on start {
await SleepTicks(_, delay = 60)
doAfterDelay()
}
// Pure usage: delay a signal by 5 ticks
let delayed = SleepTicks(rawSignal, delay = 5)
Exec Override
Exec functions that are called outside of an exec context can be given an explicit exec named argument to provide the execution trigger:
// Outside a handler, provide exec explicitly
let r = Random(0, 10, exec = someTrigger)
This wires someTrigger as the exec input of the gate, bypassing the requirement for an enclosing handler context.
Newer builtins
Player-reference gates (DisplayText, ShowChatMessage, HasRole, leaderboard and
team setters, the join/left/chat events, ControllerOf/CharacterOf) target the
persistent player-state on the current build. The controller type is unchanged and
still wires straight into them, so 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, weapon, weaponName) 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, function?, direction?) -> 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?: ColorSpace, toSpace?: ColorSpace) -> color
"A".ToCharCode() -> { Codepoint: int, Success: bool }
FromCharCode(codepoint: int) -> { Character: string, Success: bool }
GetValueType(value) -> Type // which kind of value is on the wire
value.getType() -> Type // the same, as a method of any value
Remap follows a straight line by default. Its function and direction
settings bend it along an easing curve, with the same values Easing takes: a
bare member name (Bounce, InOut), an EasingFunction / EasingDirection
value, or an int. They are settings-menu fields, so each must be a constant
(WS028 otherwise); the raw names Function and Direction set the same
fields.
in v: float
out r = Remap(v, 0.0, 1.0, 0.0, 100.0, function = Quad, direction = Out)
GetValueType(v), or v.getType(), reports which kind of value a wire carries,
as the built-in enum Type. Use it to branch on what an untyped value (a
custom property, an any) holds. In each arm of a match on it, the tested
value has that arm’s type, so it needs no as:
in e: entity
in go: exec
var total: int = 0
var log: string = ""
on go {
let p = e.GetCustomProperty("score")
match p.Value.getType() {
Int => { total += p.Value }
String => { log = p.Value }
_ => {}
}
}
An if narrows the same way, in its block or its then branch:
in v: any
in go: exec
var total: int = 0
on go {
if v.getType() is Type.Int {
total += v
}
total = if v.getType() is Type.Int then total + v else total
}
The tested value must be a name or fields read off one (v, p.Value). A
let of the same name inside the arm is a new value and is not narrowed.
An Object arm reads the value as an entity. Enum, Component and
Exec have no one Wirescript type, so their arms leave the value an any.
| Member | Number | Member | Number |
|---|---|---|---|
Type.Bool | 0 | Type.String | 7 |
Type.Int | 1 | Type.Enum | 8 |
Type.Float | 2 | Type.Object | 9 |
Type.Color | 3 | Type.Brick | 10 |
Type.Vector | 4 | Type.Component | 11 |
Type.Rotator | 5 | Type.Exec | 12 |
Type.Quat | 6 |
The numbers are the game’s own, checked in game for every kind except Enum,
Component and Exec. Object covers entities, characters and players. A
custom property that was never set reads as Float (its value is 0), and a
game enum value on a wire reads as Int. Outside a match arm, name a member
through the enum (Type.Int): the bare names would clash with type names
and constructors like Color(...).
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, and 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, ..., collisionChannel?: SweepCollisionChannel)
-> { HitDistance, HitEntity, HitLocation, HitNormal, HitColor, HitBrick, 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, an object reference
(entity/character/controller) or a brick. 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, includeDead?), values(destArray).
A map keyed by objects or bricks can outlive what a key names. keys skips a key
whose brick no longer exists unless includeDead = true.
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, with no runtime
gates, and 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, so 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, because
: 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
because there is no whole-map-copy gate. Use m.copyFrom(m2) instead.
Exec-flow gates (Union / Branch)
Two exec-signal combinators, callable like any other builtin:
Union(a: exec, b: exec) -> exec
Branch(cond: bool, exec: exec) -> (A: exec, B: exec)
-
Union(a, b)merges two exec signals into one: the result fires whenever either input fires. Handy for running one handler from several triggers:let go = Union(init, Change(team)) on go { rebuild() } -
Branch(cond, exec)routes an incoming exec to.Aor.Bdepending oncondat the instant it fires (a runtimeiffor exec flow). Bind or trigger on the named outputs:on Branch(isRed, tick).A { redTick() } on Branch(isRed, tick).B { blueTick() }
Callable gate builtins
Every variable / array / map wire gate is also exposed as a plain function named after the in-game gate, alongside its method / operator / assignment form. The two forms are identical (the call desugars to the method or assignment at parse time), so pick whichever reads better. The names match the game gates so they are easy to find (and complete).
Variables
| Builtin | Same as |
|---|---|
GetVariable(v) | v |
SetVariable(v, x) | v = x |
IncrementVariable(v, n) | v = v + n |
Arrays: the function name maps to the array method of the same operation; the receiver is the first argument:
| Builtin | Same as | Builtin | Same as | |
|---|---|---|---|---|
GetArrayElement(a, i) | a.get(i) | SortArray(a, desc?) | a.sort(desc?) | |
SetArrayElement(a, i, x) | a[i] = x | ReverseArray(a) | a.reverse() | |
PushToArray(a, x) | a.push(x) | ShuffleArray(a) | a.shuffle() | |
PopFromArray(a) | a.pop() | SwapArrayElements(a, i, j) | a.swap(i, j) | |
InsertArrayElement(a, i, x) | a.insert(i, x) | SliceArray(a, src, s, n) | a.slice(src, s, n) | |
RemoveArrayElement(a, i) | a.remove(i) | AppendArray(a, src) | a.append(src) | |
GetArrayLength(a) | a.length() | CopyArray(a, src) | a.copyFrom(src) | |
FindArrayElement(a, x) | a.find(x) | SumArray(a) | a.sum() | |
ClearArray(a) | a.clear() | AverageArray(a) | a.average() | |
FillArray(a, x) | a.fill(x) | ArrayMaximum(a) | a.max() | |
ResizeArray(a, n, x) | a.resize(n, x) | ArrayMinimum(a) | a.min() | |
JoinArray(a, sep?) | a.join(sep?) |
Array fills (Gamemode / Zone gates, the function-call twins of the
fillFrom* array methods):
| Builtin | Same as |
|---|---|
FillArrayFromPlayers(a) | a.fillFromPlayers() |
FillArrayFromTeamMembers(a, team) | a.fillFromTeam(team) |
GetPlayersInZone(a, zone, tagFilter?) | a.fillFromZonePlayers(zone, tagFilter?) |
GetEntitiesInZone(a, zone, tagFilter?) | a.fillFromZoneEntities(zone, tagFilter?) |
Maps: map to the map methods of the same name:
| Builtin | Same as | Builtin | Same as | |
|---|---|---|---|---|
GetMapElement(m, k) | m.get(k) | ClearMap(m) | m.clear() | |
SetMapElement(m, k, v) | m.set(k, v) | CopyMap(m, src) | m.copyFrom(src) | |
HasMapElement(m, k) | m.has(k) | GetMapLength(m) | m.length() | |
RemoveMapElement(m, k) | m.remove(k) | GetMapKeys(m, dest) | m.keys(dest) | |
GetMapValues(m, dest) | m.values(dest) |
All array/map operations remain exec-context only (they run on the exec chain), like their method forms.
Environment Settings
A program reads and writes the world’s environment settings, the ones the game’s environment dialog shows: the time of day, the sky’s color, the fog, the water’s height, the ambience.
Environment.<environment>.<property> // read
Environment.<environment>.<property> = value // write
in go: exec
on go {
let t = Environment.Sky.TimeOfDay
Environment.Sky.TimeOfDay = t + 1.0
Environment.Sky.SkyColor = Color(1.0, 0.5, 0.0)
Environment.Ambience.ReverbEffect = ReverbEffect.LongHallway
}
Contents
Reading and writing
- Exec only. A read runs the Get Environment Property gate and a write the
Set Environment Property gate, so both sit on an exec chain: a read outside
a handler is
WS007. - Names. An environment is named as the dialog labels it, without spaces
(
Environment.Ground), and a property by its name in the game. The editor completes both afterEnvironment.. - Types. Each property has its own type, listed below, and a write must
fit it (
WS003). Colors read and write ascolor. - What the game does with a write. The game converts and clamps a written value as the environment dialog would, and leaves a setting the dialog has locked alone.
- Enum properties. A property backed by a game enum reads as an
int(the member’s value) and takes either anintor a member of its enum when written. The members are listed at the end of this page. - Not every property is available. A property that holds an asset
reference, an array or a structure with no Wirescript type is listed with
the reason; reading or writing one is
WS003. - Errors. An environment or property that does not exist is
WS110, andEnvironment.Skywith no property is an error naming the properties it has. - Shadowing. A binding of the program’s own named
Environmenthides the builtin.
The list below is generated from the game build the compiler was built
against (just doc-environment), so it changes with the game.
Reference
Environment.Ambience
The game’s Ambience environment.
| Property | Reads and writes as |
|---|---|
SelectedAmbienceType | not available: an asset reference (class), which a property gate cannot carry yet |
AmbienceVolume | float |
ReverbEffect | int, or a ReverbEffect member |
Environment.Ground
The game’s Ground environment (stored as GroundPlate).
| Property | Reads and writes as |
|---|---|
Variance | float |
VarianceBrickSize | float |
GroundColor | color |
GroundAccentColor | color |
IsVisible | bool |
bUseStudTexture | bool |
Environment.Sky
The game’s Sky environment.
| Property | Reads and writes as |
|---|---|
TimeOfDay | float |
AnimateTimeOfDay | bool |
DayLength | float |
NightLength | float |
SunAngle | float |
SunScale | float |
SunHorizonScaleMultiplier | float |
SunlightColor | color |
SkyIntensity | float |
SkyColor | color |
MoonPhase | float |
MoonScale | float |
MoonlightIntensity | float |
MoonlightColor | color |
StarsIntensity | float |
StarsColor | color |
AuroraIntensity | float |
CloudCoverage | float |
Rain | float |
Snow | float |
Dust | float |
Thunder | float |
Wind | float |
WindDirection | float |
bCloseLightning | bool |
RainVolume | float |
CloseThunderVolume | float |
DistantThunderVolume | float |
WindVolume | float |
ClearFogDensity | float |
CloudyFogDensity | float |
ClearFogHeightFalloff | float |
CloudyFogHeightFalloff | float |
FogColor | color |
Environment.Lighting
The game’s Lighting environment (stored as Studio).
| Property | Reads and writes as |
|---|---|
Scenarios | not available: an array, which a property gate cannot carry |
Environment.Universe
The game’s Universe environment.
| Property | Reads and writes as |
|---|---|
UniverseRotation | rotator |
UniverseLightColor | color |
UniverseLightIntensity | float |
UniverseAmbientColor | color |
UniverseAmbientIntensity | float |
UniverseGravityScale | float |
bUniverseFloorEnabled | bool |
NebulaTexture | not available: an asset reference (object), which a property gate cannot carry yet |
NebulaColor | color |
NebulaRedBlueSwap | float |
NebulaSaturation | float |
NebulaPower | float |
NebulaBrightness | float |
NearStarsTexture | not available: an asset reference (object), which a property gate cannot carry yet |
NearStarsPower | float |
NearStarsBrightness | float |
FarStarsTexture | not available: an asset reference (object), which a property gate cannot carry yet |
FarStarsPower | float |
FarStarsBrightness | float |
Environment.Water
The game’s Water environment.
| Property | Reads and writes as |
|---|---|
WaterHeight | float |
WaterAbsorption | vector |
WaterScattering | vector |
WaterFogIntensity | float |
WaterFogAmbientColor | color |
WaterFogAmbientScale | float |
WaterFogScatteringColor | color |
WaterFogScatteringScale | float |
Enum members
ReverbEffect
Default (0), SmallRoom (1), LongHallway (2), ExteriorEcho (3)
Game Knowledge
Brickadia behavior 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
- Input Action Names
- DisplayText Screen Placement
- References in wire values
- Loop Ticks
- Players and Components
Rich Text Markup
Any string the game renders as text accepts markup: DisplayText,
BroadcastChatMessage, ShowStatusMessage, chat, and the text components a
@label writes. Markup is ordinary string content, so it composes with
interpolation and .. concatenation like any other text.
| Tag | Effect |
|---|---|
<b> | Bold |
<i> | Italic |
<br> | Line break. It is not a container, so it takes no close |
<color="RRGGBB"> | Text color, 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')
}
Colour tags need no close
<color> does not have to be closed. An open tag applies until the next
<color> replaces it, and it keeps applying across <br>. Emitting a tag only
when the color differs from the previous one is therefore correct.
That matters at scale. A tag is 16 characters against one for a block
character, so tagging every cell of a 60x40 grid costs about 40,000 characters
while tagging only the changes costs a few thousand. Run-length encoding by
color is what makes a full screen fit in one DisplayText.
in go: exec
var cells: string[]
on go {
var line: string = ""
var last: string = ""
for i in 0..cells.length() {
let hex = cells[i]
if hex != last {
line = line .. '<color="${hex}">'
last = hex
}
line = line .. "#"
}
BroadcastChatMessage(line)
}
One caveat if the text is split across several displays: color carries forward within one string only. A chunk that begins mid-run needs its own opening tag, so reset the tracked color at every boundary you might split on.
Prefer single-quoted strings for anything containing markup. Both quote styles interpolate, and single quotes let the attribute’s own double quotes stand without escaping.
Input Glyphs
<inputAction>Jump</> renders the key each player has bound to that action, so
a prompt reads correctly for someone on a remapped keyboard or a controller
rather than hardcoding a key name. Axes take <inputAxis> instead.
in go: exec
on go {
BroadcastChatMessage('press <inputAction>Interact</> to draw a card')
}
Fonts
The 25 typefaces the game ships. Anything else falls back.
Aaaiight BadComic BlackOpsOne
Bungee CherryBombOne Cinzel
EagleLake GlacialIndifference GlacialIndifferencePlus
Gotfridus IosevkaTerm IosevkaTermSlab
Kurland MonaspaceArgon MonaspaceKrypton
MonaspaceNeon MonaspaceRadon MonaspaceXenon
MostWasted NotoSans NotoSerif
Orbitron PirataOne Roboto
RobotoMono
IosevkaTerm, IosevkaTermSlab, and the five Monaspace faces are monospaced,
which is what makes grid and table rendering line up.
Input Action Names
Every name accepted by <inputAction> and <inputAxis>. A * marks an axis,
which takes <inputAxis>. Everything else takes <inputAction>.
Special Functions
OpenEscapeMenu SelfDestruct FreeMouse
PlayerList OpenEnvironmentDialog OpenAvatarCustomization
OpenOptions HideHUD ToggleSmoothCamera
ToggleFreezeCamera HoldToZoomCamera Teleport
TakeScreenshot TakeScreenshotNoUI
Movement
Turn * LookUp * TurnRate *
LookUpRate * MoveForward * MoveLeft *
Jump Sprint ToggleFlying
ToggleGhostFlying MoveUp * SwitchCameraMode
EmoteMenu ToggleFlashlight Duck
HoldToWalk Reload Inspect
Fire AltFire AltFire2
AltFire3
Chat
OpenChatBox ChatHistoryPageUp ChatHistoryPageDown
ChatHistoryLineUp ChatHistoryLineDown ChatHistoryStart
ChatHistoryEnd
Tools
OpenToolPieMenu UseBrickTool UseHammer
Paint UseResizeTool UseSelectionTool
UseApplicator UseManipulator Paint_PaintMaterial
Paint_FillPaint Paint_PickColor Hammer_CheckOwner
Selector_SplitGrid Selector_AddSelect Selector_ToggleSelect
Selector_SelectContraption Selector_SwitchMode Selector_SelectBox
Selector_DeselectBox Applicator_SwitchMode PasteSelectionWithOwnership
Resizer_ExtendToMax Resizer_ShrinkToMin Resizer_FindSize
ToolAlt Connector_BulkConnect Connector_SwitchMode
Connector_ReselectLastPort ManipulatorLaunch Manipulator_SoftPick
Manipulator_Detach Manipulator_HoldToRotate Manipulator_DoNotAttach
Building
ToggleBuilding BrickRotate OrbitMode
UndoAction RedoAction CopySelection
PasteSelection CutSelection DeleteSelection
FineSelection LockBrickAlignmentPlane BrickChangePlacementMode
BrickChangeAlignmentMode BrickSuperAlignmentMode BrickAlignToWorld
DetachedMode Placer_PastePhysics DeleteBrick
Builder_PickBrick Builder_PickColor Builder_PickBrickIntoQuickbar
Building (Keyboard)
ToggleDetachedMode BrickMoveAway * BrickMoveLeft *
BrickMoveUp * BrickDetachedTurn * BrickMoveUpPlate *
BrickPlant BrickDetachedReorient BrickDetachedRotate
Quickbar
Quickbar_Slot0 Quickbar_Slot1 Quickbar_Slot2
Quickbar_Slot3 Quickbar_Slot4 Quickbar_Slot5
Quickbar_Slot6 Quickbar_Slot7 Quickbar_Slot8
Quickbar_Slot9
Misc
Rename Find
Vehicles
LeaveSeat Vehicle_ZoomIn Vehicle_ZoomOut
DisplayText Screen Placement
DisplayText positions text with an anchor and a position offset, and
the two use different units. Getting this wrong puts the text off-screen, where
it looks identical to the text not drawing at all.
anchorX/anchorYare fractions of the screen.0is left / top,0.5is center,1is right / bottom. This is the only pair that takes a0..1value.positionX/positionYare an offset from that anchor in slate units, which are roughly pixels at 1080p. They are not a fraction. A realistic offset is in the tens or hundreds.positionX = 0.98does not mean “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)
}
Default values
Unset parameters bake the gate’s own defaults, which are reasonable. An invisible overlay is far more often a placement bug than a scale or color one:
| Field | Default | Note |
|---|---|---|
Scale | (1, 1) | not zero, so unset scale never hides text |
FontColor | opaque white | not transparent |
Anchor | (0.5, 0.5) | screen center |
Position | (0, 0) | no offset from the anchor |
Pivot | (-1, 0.5) | |
FontSize | 16 | |
OutlineSize | 2 | text has an outline unless you pass 0 |
Lifetime | 5.0 | seconds; 0 means infinite, not “vanish now” |
TextId | 0 | 0 uses the brick’s persistent handle. A non-zero id is shared by every gate using it, which is how several gates update one piece of text |
lifetime interacts with how often you redraw. Text redrawn every tick needs a
short lifetime so it disappears if the drawing chip stops. Text redrawn only on
a change needs 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.
References in wire values
Measured in game, with the probes that motivated ref values as a language feature (see Reference values):
- A component ref is stored in an ordinary var. A gate reading its
VarReffrom that var reads, writes and increments the target, and copying the ref var-to-var keeps it. - Handle containers are kind-strict: a
component_handlearray or map value keeps a component ref, abrick_handlearray keeps a brick ref, and each only keeps its own kind. - A number container (an
i64/f64array, or anf64map value) turns a pushed ref into the plain number1. The compiler’s storage rules exist to prevent this: a ref only ever picks a handle container. - A non-ref value used where the game expects a ref halts the exec chain silently, with nothing logged.
1 == <ref>compares true in the native gate, which is why the language rejects a ref/brick compared with a non-ref (WS084) instead of letting the comparison reach the gate.- A Select gate routes a ref through unchanged, and refs travel as custom-event data, both without special-casing.
- Arrays of arrays work: a
component_handlearray holding array refs, with an element read back out able to drivePush,GetandGetLengthon the inner array. - A brick ref baked into a var’s data survives paste and resolves under both addressing forms. The relative form (grid 0, chunk offset, index) and the absolute form (saved grid id, chunk, index) of the same brick compare equal, and refs to two different bricks compare unequal.
- No gate consumes or produces a brick:
GetLocationon one returns the origin, and nothing outputs one. A brick ref comes only from the editor or from save data. FormatTextprints a var ref asVar, an array ref asArray Var, and a brick asBrick.
Loop Ticks
A for loop runs its entire body, every iteration, to completion within the
tick that triggers it. An N-iteration loop costs one tick, not N. The
exception is a loop marked @sliced, whose chip lets the game spread
the iterations over several ticks.
The standing guidance to give each entity its own chip instance rather than
sweep a central one in a loop (see
per-entity fan-out)
exists because a back-edge loop, built from emit/await and a buffered
edge, costs a tick per iteration. That is no longer the only option: a bounded
sweep that must finish before the tick ends belongs in a for now. Keep the
back-edge form for work that should stay spread across ticks on purpose, such
as a slow reveal or a rate-limited rebuild.
Players and Components
Behavior checked in game:
- Button. Its outputs are Held, Character and Press Count;
it has no exec output, and nothing fires on release except Held and
Press Count changing. Press Count is the number of players holding
the button right now: it goes up when someone presses and down when they
let go. It is not a running total of presses. Held stays on while
anyone holds the button. Character is set on every press, including a
second player pressing while the button is already held. With Held
wired to an exec pin (
in held: exec),on heldfires when Held turns on, so it misses that second press. To count presses from one player,on held { count += 1 }. To catch every player’s press and who made it, wire Press Count to anintpinholdingand keep its last value in avar last: inon Change(holding), act onlyif holding > last, reading Character there, then setlast = holding.Changedoes not fire when the chip loads. - Switch. Its outputs are Enabled and Character (the Toggle
Switch has the same two). Character holds the last player who flipped
the switch, either way, and keeps holding them: it is a steady character
wire that changes only when someone else flips it. Wired into
InputReader(the Seat Control Splitter), it gives that player’s keys live, wherever they stand, with no seat and no Clock. - Get Inputs (
GetInputs) reads any player, wherever they are. They need not be seated or inside a zone. - Spawn Prefab’s
offsetis relative to the Spawn Prefab gate’s own position, not world space. For a world position, spawn the copy and thenSetLocationthe returned entity. SetLocationmoves a spawned prefab, from the spawner (the entitySpawnPrefabreturns) or from inside the copy (ReadBrickGrid()). It does not move a pasted chip’s own grid.- Controllers. A controller (player state) is the persistent player,
not the body. A character and its controller are not equal: a character
used as a
Map<controller, V>key files under that body, and a respawn starts a new key. Custom properties (SetCustomProperty) can be set on a controller, which keeps them through respawns (but not a reconnect; on a character they are lost at respawn). Getting a controller from a character takesControllerOf, an admin gate; without admin, key per-player state byGetUserId(). - A player who leaves. A stored reference to a player who has left the
server (in a
var, an array or a map) turns intonull, soowner == nulldetects it.GetUserId()on it reads back""too. - A player who dies. A stored character of a player who died also
turns into
null, but not at once: some time after the death, with no fixed delay. Anowner == nullcheck therefore also removes a dead player’s copy, a little later than the death. - Zone events.
ZoneEnteredandZoneLeftare fine for reacting to one player stepping in or out (a greeting, a chime, a door), but over a long session some are missed. A list of who is inside, or a count per team, kept up from them drifts and does not come back. Ask the zone instead:arr.fillFromZonePlayers(zone)(Get Players In Zone) gives everyone in it now. When that has to stay current, run the fill on a Clock; that is a proper use of one. - Clock. The first pulse comes one
intervalafter the Clock is enabled, not at the moment it turns on. - Server restarts.
vars, arrays and maps are saved with the bricks and come back. Buffers survive a restart too, so a pendingawait Sleep(...)or abuffer emitstill lands after it.ServerUptime()starts again from 0, so a deadline stored from it comes back unreachable;GetUnixTime()keeps counting. - Tagging a spawned copy. A spawned prefab’s entity holds custom
properties like any other. The spawner can tag a copy with its player’s
user id right after spawning it (
copy.SetCustomProperty("owner", id)), and the copy reads its own tag withReadBrickGrid().GetCustomProperty("owner"). Store the read in a typedvarbefore sending it in an event: an annotatedletstill sends as an untyped value (WS045).
Execution Context
Understanding exec vs pure context is fundamental to writing correct Wirescript. This distinction reflects how Brickadia’s logic gates execute: some gates define continuous signal relationships (pure), while others execute imperatively in response to events (exec).
Contents
- Two Contexts
- What Requires Exec Context
- How to Enter Exec Context
- Reading Variables: Exec vs Pure
- Handler Exec Chains
- Forking the Exec Chain
- Exec Context in Conditional Expressions vs Statements
- Summary of Context Rules
- Await
Two Contexts
Pure Context
Pure context is the default. Code in pure context defines continuous signal-flow relationships, like wiring gates together. Pure expressions re-evaluate whenever their inputs change.
What runs in pure context:
- Top-level
letbindings varinitializersoutbindingsbufferexpressions- Chip output expressions
// All of these are pure context:
var count: int = 0 // initializer is pure
let doubled = count.Value * 2 // let binding is pure
out result = doubled // out binding is pure
buffer prev = count.Value // buffer expression is pure
Exec Context
Exec context represents imperative, sequential execution triggered by an event. Code in exec context runs once per trigger, in order, along an exec chain.
What runs in exec context:
onhandler bodies- Code after
onhandlers at the same scope level (exec union) - Named chip bodies when the chip has
refparameters - Exec function calls (
DisplayText,Random,SetLocation, etc.)
// This is exec context:
on RoundStart() {
count = count + 1 // assignment requires exec
let r = Random(0, 10) // Random is an exec call
if r == 0 { // if statement requires exec
emit special // emit requires exec
}
DisplayText(ctrl, "Go!") // DisplayText is an exec call
}
What Requires Exec Context
The following operations are only valid in exec context:
| Operation | Why |
|---|---|
var = expr (assignment) | Writing to a variable gate requires an exec chain |
if { ... } (statement) | Conditional execution requires an exec branch gate |
emit event | Firing an event requires an exec signal |
| Exec function calls | Functions like Random, DisplayText, SetLocation have exec pins |
*var (explicit deref) | Reads the variable’s current-tick value via Var_Get; not allowed in pure context (WS006) |
The typechecker emits error WS007 when any of these appear outside exec context:
var n: int = 0
n = 1 // WS007 error: 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 gates work: 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, including array and map methods.
The trigger drives the gate instead of the surrounding chain, so 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, mods and fns:
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 { },
or write on f(args, exec = trigger) -> (results) { } directly. A fn call’s
completion is its Function Call’s Completed, so it is there even when the
fn returns nothing. See Exec Chips and
fn.
Reading Variables: Exec vs Pure
The behavior of a bare variable name depends on context:
In Exec Context
A var x: int has type ref int internally, but in exec context, the bare name x auto-dereferences to type int when used in expressions. When passed to a *T parameter (e.g., inc(x) where inc takes *int), it stays as a reference:
on RoundStart() {
// x auto-derefs: reads as int, writes as int
x = x + 1
let doubled = x * 2 // x is int here
}
Note: Even pure expressions like
x * 2use aVar_Getgate (exec) to readxwhen inside an exec context. This ensures the read is sequenced correctly in the exec chain: theVar_Getfires at the right point and its value output feeds the pure*gate. In pure context,xreads directly from the PseudoVar’sValueport (no exec gate).
You can also use *x as an explicit deref. It compiles to the same Var_Get gate and is equivalent to the bare name in exec context:
on tick {
let a = x // implicit deref via Var_Get
let b = *x // explicit deref, identical result
}
*x in pure context is an error (WS006): use .Value for pure reads.
In Pure Context
In pure context, a bare name x reads the var’s value directly from the
PseudoVar’s Value port. This is the same value .Value and .prev read
(see the note above). &x is x’s ref as a value. An unannotated out fed a bare
variable name is flagged (WS017): annotate it T for the value, or write
out h: *T = &x for the ref. A *T output carries a ref, so a bare x there
is a type mismatch (WS003). See Reference values.
var count: int = 0
// Pure context: a bare name reads the value
out current: int = count // same as count.Value
out previous = count.prev // .prev reads the previous tick's value
out sum = count + 1 // arithmetic reads the value too
// The ref, explicitly:
out h: *int = &count
// An unannotated bare name is ambiguous between value and ref (WS017):
// out either = count
.Value vs .prev vs *var
| Access | Context | Gate | Meaning |
|---|---|---|---|
x (bare) | Exec | Var_Get | Current tick’s value (auto-deref) |
*x | Exec | Var_Get | Current tick’s value (explicit deref, same as bare) |
*x | Pure | none | Error WS006: use .Value |
x (bare) | Pure | .Value port | The same read as x.Value; an unannotated out of it is WS017 |
&x | Either | VarRef port | x’s ref, as a value |
x.Value | Either | .Value port | Previous tick’s value (delayed read) |
x.prev | Either | .Value port | Previous tick’s value (same as .Value) |
.prev is essential for change detection:
// Detect when count changes
chip let changed = count != count.prev
on changed {
DisplayText(ctrl, "Count changed!", fontSize = 24)
}
Reading an Output Port
An output port reads as the value driving it, so one port can feed another:
in a: int
out y: int = a + 1
out z: int = y * 2
A port resolves only when no variable, binding, input, buffer or parameter owns
the name, so var count alongside out count = count reads the variable.
A port holds no state, so reading one inside its own value is a loop with no
tick barrier and reports WS005. Route it through a buffer or a variable.
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 gates execute these in sequence: event fires, then node 1, then node 2, then node 3, then the branch.
Forking the Exec Chain
A call in statement position joins the chain: the statement after it runs when
the call has finished. Reading .exec off the call instead binds the call’s own
exec output as an exec value and leaves the chain where it was, so the call
runs as a branch and the following statement runs right away.
emit in value position names the exec chain point the handler has reached,
as a value of type exec. A branch forks from that point.
var m: int = 0
var n: int = 0
in start: exec
chip Later() -> (count: int) {
m = m + 1
out count = m.Value
}
on start {
let d = Later().exec // forks: Later runs, and the chain carries on
n = 1 // runs alongside Later, not after it
await d // rejoins once Later has finished
n = 2
}
One exec output driving two exec inputs runs both, so the fork needs no gate of its own: the same chain point feeds the branch and the next statement.
emit is also what you hand to an exec = argument
to trigger a call from the current chain point. That call never joins the chain,
so it forks the same way, and its completion comes back on the result’s .exec
field:
var m: int = 0
var n: int = 0
in start: exec
chip Later() -> (count: int) {
m = m + 1
out count = m.Value
}
on start {
n = 1
let d = Later(exec = emit) // Later fires from this chain point
n = 2 // and so does this, in the same pass
await d.exec // rejoins once Later has run
}
Both spellings compile to the same fan-out. Prefer exec = emit when the call
site is choosing the trigger, and <call>.exec when an ordinary call that would
otherwise splice into the chain should run beside it instead.
let _ = <call>.exec fires and forgets: _ discards the binding only, and the
call still runs.
.exec needs a call that runs on an exec chain: a chip, a fn, or a mod
whose body runs exec statements. A mod whose body is all values has no
completion to name, and .exec on it is WS043.
emit as a value is meaningful only in exec context. Pure context has no chain
point to name, so it reports WS007 rather than resolving to nothing.
Exec Context in Conditional Expressions vs Statements
There are two different if constructs with different context requirements:
if-then-else Expression (Pure)
The if-then-else expression is pure: it selects between two values based on a condition and produces a value. No exec context needed.
// Pure, works anywhere
let abs = if x < 0 then -x else x
let label = if count == 1 then "item" else "items"
if { } Statement (Exec)
The if statement conditionally executes a block. It requires exec context.
// Exec: must be inside a handler or after one
on trigger {
if score > highScore {
highScore = score
}
}
Summary of Context Rules
| Construct | Context | Notes |
|---|---|---|
var x = expr initializer | Pure | Initializer is pure even though var is mutable |
let x = expr | Pure (at top level) | Inside handlers: shares handler’s exec context |
out x = expr | Pure | Always pure, because outputs are continuous signals |
buffer x = expr | Pure | Always pure |
on trigger { ... } body | Exec | Primary way to enter exec |
After on at same level | Exec | Exec union from all preceding handlers |
chip { ... } body (anon) | Inherits parent | Shares parent’s context |
chip Name(ref params) { ... } | Exec | Auto-exec from ref params |
chip Name(value params) { ... } | Pure (by default) | Unless handlers inside create exec |
mod Name(params) { ... } | Inherits call site | Inlined, takes caller’s context |
await expr | Exec | Rewires exec continuation to expr |
emit name | Exec | Bare emit requires exec |
emit name = expr | Either | Value emit works in pure or exec |
buffer(...) emit name | Exec | Buffered emit that delays delivery a tick+ and legalizes loops |
let name: exec | Either | Declares local exec signal |
Await
await suspends the current exec chain and resumes when the awaited expression fires. It rewires ctx.current_exec. There is no state machine, only 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
}
|| waits for whichever operand fires first. The compiler joins the operands with an OR gate.
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, with buffer emit (1 tick) or buffer(N) / buffer(0.5s), because
every cycle of gates 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 is carried on 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 emit that starts the loop must be buffered as well as the back edge.
emit fires immediately, and the await on the next line is what arms the
resume. A plain emit loop therefore fires 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 the program looks half-working instead of stopped. buffer emit
lands on the following tick, after the await has armed.
Sleep / SleepTicks
Sleep and SleepTicks are buffer gates that delay a value. Combined with await and _, they create timed delays:
on start {
await Sleep(_, delay = 2.0) // wait 2 seconds
doAfterDelay()
}
on start {
await SleepTicks(_, delay = 120) // wait 120 ticks (~2s at 60Hz)
doAfterDelay()
}
_ inside an await expression is the armed flag, a bool that becomes true when the await is armed. The buffer gate delays this bool, and the await resumes when the delayed output transitions to true.
Important Notes
awaitonly works in exec context, because it modifies the exec chain.awaitworks insideifblocks.- Each
awaitcreates an armed flag (static bool) that guards the continuation. The continuation fires exactly once per arming. _insideawaitis typed asbool(the armed flag). It is only valid insideawaitexpressions.let foo = await 1is valid but dangerous: the pure value pulses once, so the continuation runs immediately and never again.- Multiple sequential awaits chain: each one gets its own armed flag and redirects exec.
Best Practices: Gate Count & Scaling
Wirescript makes it easy to write logic that reads like a normal program but compiles into an enormous number of gates. The patterns below came out of shrinking a real game circuit from roughly 300,000 gates to about 8,000. The logic did not change, only its shape.
Most of that work was done before fn and for existed. Those two now handle the most
common cause of a large build, so they are covered first. The older patterns follow, and
most of them still apply.
Contents
- Every
modcall is a copy; afncall is not - The call-site multiplier
- When to use
fn, and its limits - When to use
for, and what it costs - 1. Funnel many producers through one dispatch site
- 2. Merge per-variant mods into one parameterized mod
- 3. Defer hot shared work behind a flag
- 4. Bitmasks instead of per-slot arrays
- 5. Resolve once, pass down
- 6. Prefer pure
letchains over exec ladders - 7. Reduce with a native array aggregate, not an unrolled fold
- 8. Keep a derived array to unlock an aggregate
- 9. Derive in pure output bindings, not on the exec chain
- 10. Per-entity work: a
forsweep, or one chip each - Measured costs
- Profiling: find the hot spots
- Common mistakes
- Checklist
- See also
Every mod call is a copy; a fn call is not
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 and do not
deduplicate anything.
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.
A fn is compiled once. Its body becomes one function definition plane, and every
call is a single Function Call gate wired to it. The call runs the body to the end, in
the same tick, and the caller’s chain continues with the results:
var total: int = 0
var last: int = 0
fn heavy(x: int) -> int {
total += x
return total * 2 + x
}
in a: exec
in b: exec
in c: exec
on a { last = heavy(1) }
on b { last = heavy(2) }
on c { last = heavy(3) }
// heavy is built once; three Function Call gates point at it
A for loop is compiled once too, and runs every iteration within the tick it
starts. Repeated steps that differ only by an index can be written once:
var fuse: int[]
in tick: exec
on tick {
for i in 0..fuse.length() {
if fuse[i] > 0 { fuse[i] -= 1 }
}
}
// one copy of the body, however many slots there are
mod | chip | fn | for | |
|---|---|---|---|---|
| Compiles to | Inline gates in the caller’s grid | The same gates, in a microchip instance | One function definition plane | One loop plane |
| N call sites / iterations | N copies of the whole subtree | N copies of the whole subtree | One copy, plus one Function Call gate per site | One copy, swept N times |
| Runs | As part of the caller | As part of the caller | When its call fires, to the end | Every iteration in one tick (@sliced: several) |
| Pure (no exec) | Yes | Yes | No: an exec chain or exec = trigger | No: exec only |
Reads outer in ports and lets | Yes | Yes | No (WS113) | Yes |
emit | Yes | Yes | No (WS113) | Yes |
Outer var, array, map | Wired directly | Wired directly | Through a handle var in the plane | Through the plane’s pins |
| Named multi-outputs | Via a returned record | Yes (-> (a: int, b: int)) | Yes (-> (a: int, b: int)) | Write a var before the loop |
ref/* params | Yes | Yes | Yes | n/a |
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. The mod version does the same. 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 and does not get a private copy per instance.
Choose between mod and chip on organization, never on gate count. A chip gives
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.
Use a fn for a large body called from many places, and a for for repeated steps
that differ only by an index. When neither works (the body has to emit, read a port,
or run in a pure position), reduce the number of call sites instead. The numbered
sections below show how.
The call-site multiplier
The cost multiplies. 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
This is the shape that produced the 300k-gate build. The simplest fix now is to make
onInput a fn: the thirty call sites then cost thirty Function Call gates instead of
thirty copies of the state machine. If the state machine has to emit or read ports,
see the workarounds in the next section. If those don’t work for it, use the numbered
fixes further down, which are listed in order of impact.
When to use fn, and its limits
A fn costs its body once, plus a Function Input per parameter slot, a Function Output
per result, a handle var per outer var, array or map its body touches, and one Function
Call gate at each call site. A tiny body (a compare, a lookup) called from two or three
places is cheaper as a mod, because inlining it costs about as much as the call. Use
a fn when the body is large or the call sites are many.
A fn’s local var is one gate however many sites call it, and it resets when a call
starts. A mod’s local var is one gate per call site.
A fn body receives only its parameters and sends back only its results. The one
exception is outer storage, which it reaches through handles. Each of the limits below
has a workaround.
Ports and computed lets do not reach a fn body (WS113). Pass the value as a
parameter, or sample the ports into storage at the top of the tick and read the storage
inside:
in sit0: bool
in sit1: bool
var seated: bool[]
var count: int = 0
fn seatedCount() -> int {
var n: int = 0
for o in seated { if o { n += 1 } }
return n
}
on Clock(interval = 0.1) {
seated = [sit0, sit1] // one sample, read by every fn this tick
count = seatedCount()
}
A fn cannot emit (WS113). Return a flag and let the caller fire the output:
@top out placed: exec
var bombs: int[]
fn tryPlace(c: int) -> bool {
if bombs.length() >= 4 { return false }
bombs.push(c)
return true
}
in press: exec
on press {
if tryPlace(3) { emit placed }
}
If one call can cause several events that each need their own pulse, count them in a
var inside the fn. The caller then fires the output that many times from a loop:
for n in 0..hits { emit hit }.
Signature limits. A call carries at most 8 values in and 8 out, and
a record parameter takes one slot per field (WS115). A fn cannot return a record,
tuple or enum, or take a type parameter, a const, exec or ...rest parameter
(WS114), and it cannot call itself, directly or through another fn (WS020). If a
helper needs any of these, keep it as a mod. A mod can still call fns.
Inside a spawned prefab, a fn can’t use outer storage. Measured in game: its reads
and writes don’t reach the prefab’s storage. Pass values in as parameters and return
them as results.
Planes also save runtime. Pure gates outside a plane are evaluated even when a
runtime if around them is not taken. Gates inside a plane are evaluated only when the
plane is entered. Measured in game with a loop plane: an expression behind a runtime
if cost 121 evaluations per iteration, and the same expression inside the plane cost
39, about 8 of them for entering it. So heavy pure math that is only needed sometimes
runs faster inside a fn or for body.
When to use for, and what it costs
Use a for for repeated steps that differ only by an index: per-slot ticks, per-seat
updates, a fixed number of steps along a line. To stop at the first match, use break.
A loop body can’t return, so store the result in a var declared before the loop:
var cell: int[]
var found: int = -1
in go: exec
on go {
found = -1
for i in 0..cell.length() {
if cell[i] < 0 {
found = i
break
}
}
}
When the test is plain equality, cell.find(-1) is one gate and needs no loop.
Chain reactions can finish in one tick. When one step can trigger others (a blast setting off nearby bombs, a flood fill), run passes in a loop. Each pass handles everything that is ready, the next pass catches whatever it triggered, and a pass that does nothing ends the loop. Limit the number of passes to the element count, because each pass that continues has used up at least one element:
var fuse: int[]
var fired: int = 0
fn detonate(i: int) {
fuse[i] = -1
// light the blast here; set the fuse of any bomb it reaches to 0
}
in tick: exec
on tick {
for pass in 0..fuse.length() {
fired = 0
for i in 0..fuse.length() {
if fuse[i] == 0 {
detonate(i)
fired += 1
}
}
if fired == 0 { break }
}
}
The runtime cost. A loop’s gates are placed once, but its body runs once per iteration:
- Hoist invariant work. Anything in the body that does not depend on the binding is recomputed every pass. Compute it above the loop and read it inside.
- A runtime bound costs a few always-live gates.
for i in 0..nemits the iteration-count arithmetic (about five gates) outside the plane, evaluated whether or not the loop runs. A constant bound folds away. - Entering the plane costs about 8 evaluations per iteration, measured in game. For a two-gate body, unrolling is faster at runtime, though it uses more gates.
- The frame budget runs out before the loop cap. The game aborts a tick’s loops past 100,000 invocations, but at a measured ~44.5 ns per gate evaluation a 60 Hz frame has room for about 375,000 evaluations. A 2,300-gate body gets roughly 160 iterations per frame.
Body restrictions. A loop body can’t use return, continue or await (WS078,
WS079). Set a var and break instead. A mod called inside the body can still
return, which leaves the mod, not the loop. A file with a for cannot be @flat (WS080), and an array of
records is iterated by index (WS082). See for Loops.
To spread work across ticks on purpose, mark the loop @sliced, which lets the game
split its iterations over several ticks, or use a back-edge loop, which takes one tick
per iteration.
1. Funnel many producers through one dispatch site
With fn, many call sites no longer multiply the gate count, so try that first. A
queue is still useful when events must be handled one at a time, tagged with the phase
they were raised in, or limited to one per tick, or when the handler can’t be a fn.
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 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
}
// ...
}
The same approach works for a fn, or for the body of a for.
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
}
}
With a fn, calling refresh() from all eighteen sites is cheap in gates, but each
call still runs it. The flag is what limits it to once per tick.
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 biggest single saving late in the work. Bitmasks also help in other ways:
- Derived sets are free:
BitCount(active & ~disabled)replaces a loop-and-count, including one written as afor. It is one gate, evaluated once, instead of a loop over every slot. - Two masks beat a tri-state array: store
votedMaskandyesMaskrather than an array of-1/0/1. “Voted no” is thenvotedMask & ~yesMask. - Pass masks (plain
int) to pure helpers instead of arrays, so the 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 is 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)
}
If the callee is a fn, the lookup is built once but still runs on every call, so
passing the result down still saves runtime.
A free-running counter (buffer tick) also makes a good round-robin cursor. With it,
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. A
for that folds into a var emits the body once but still evaluates it N times. The
aggregate is evaluated once.
// Before: an N-way unrolled scan
mod maxTotal(t: int[]) -> int {
var m = t[0]
if t[1] > m { m = t[1] } // ... repeated per slot
return m
}
// After: one gate
let over = totals.max().Value >= 100
- Gotcha:
.max()/.min()return a record{ IsEmpty: bool, Value: int }. Read.Value(and checkIsEmptywhen the array can be empty)..sum()/.average()return a bare scalar. - If the slots you want to exclude already hold the identity value (
0for a sum, or a value below every real one for a max), reduce the whole array. No masking is needed. - The other whole-array methods work the same way:
find,fill,copyFromandresizeeach do in one gate what would otherwise take a loop.
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. That is 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. Every reduction is cheaper in return, and reductions are usually called from many slots. Check that every write to the source has a paired write to the mirror, or the aggregate silently goes out of sync.
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, and 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. That mod
can’t be a fn, because a fn call needs an exec chain, or an explicit
exec = trigger.
10. Per-entity work: a for sweep, or one chip each
A back-edge loop advances one iteration per tick, so it can’t sweep a roster every
tick: N entities take N ticks per pass, and it gets slower as N grows. A for loop
can. The whole sweep finishes in the tick it starts, and its body is built once:
var hp: int[]
var regen: int = 1
on Clock(interval = 0.1) {
for i in 0..hp.length() {
if hp[i] < 100 { hp[i] = min(100, hp[i] + regen) }
}
}
So per-tick work over a roster can be a for, as long as N times the body fits in a
frame (see When to use for).
Give each entity its own chip instance when a sweep isn’t enough: when it needs its own event handlers, a lifetime tied to the thing it follows, or per-tick work too heavy to run N times in one frame. Each instance then does its own entity’s work in parallel, and the cost no longer depends 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)
e.SendCustomEvent("minion.init", who)
}
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", isObject = true) -> (who: character) {
owner = who
ready = true
on ServerUptime() {
if ready {
// per-tick work for this one entity only
if owner == null { ReadBrickGrid().DestroySpawnedPrefab() }
}
}
}
Four things to watch for:
- The nested handler is not registered by the outer one. The circuit 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 only scopes the reference for readability. It does not order the two handlers.
- Each instance should own only its own state. Shared state stays in the central chip, which stays event-driven. If an instance needs to read another instance’s data, you are back to a roster sweep, done through events.
- Instances must clean themselves up. A spawned chip whose subject is gone
keeps running. Check for that in the tick handler and call
DestroySpawnedPrefab(). - A
fninside the prefab can’t use the prefab’s storage. Pass values in as parameters and return them as results, or use amod.
Keep the bookkeeping central and event-driven, and push only the per-tick rendering or sampling out to the instances.
Measured costs
The numbers below were measured in game, not estimated. They matter because the usual intuitions about what to optimize are mostly wrong here.
Almost nothing costs what you think
At 512 operations per tick against a 4.2ms frame, only one thing moved the tick time at all:
| Operation | Cost per op |
|---|---|
Building a string ("a" .. pad(x) .. "b") | ~1us |
| Reading a var | free |
Map.get + Found check | free |
| Reading a pre-built string from a map | free |
DisplayText call | free |
512 DisplayText calls per tick cost 3us. If a HUD redraws twenty elements
at 10Hz, drawing is not your problem and rate-limiting it will not help.
Map reads are free and O(1). A 1024-entry map reads no slower than an
8-entry one. A permanent record store can grow forever without pruning, and
keying per-entity state by character or account id costs nothing at runtime.
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. Caching anything else makes no measurable difference, and a cache with one reader is a net loss.
Gates, ticks and evaluations
For N items:
| Construction | Bodies (gates) | Ticks for N items |
|---|---|---|
| Unrolled calls | N | 1 |
fn called N times | 1, plus N call gates | 1 |
for loop | 1 | 1 (@sliced: several) |
Back-edge loop (await) | 1 | N |
| Self-event fan-out | 1 | N |
| One send to N distinct receivers | N | 1 |
| N sends on N distinct channels | N | 1 |
With for and fn you no longer have to choose between gates and ticks: one body
handles N items in one tick. The cost moves to evaluations instead. The body runs N
times within the tick, and the frame budget limits how many that can be (see
When to use for).
Firing the same event N times gives one invocation per tick. Repeating one channel behaves like a loop and does not parallelize the work.
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 serializing 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. Each extra send adds cost. Each extra receiver adds none. So broadcast one packed payload and let each receiver filter, rather than tailoring a message per receiver.
Practical rule: use a for or fn instead of unrolling. Either gives one body
and one tick. Unroll only a few cheap steps, where entering a plane would cost more than
the body. Spread work across ticks (@sliced, a back-edge loop, a self-event) only when
nothing is waiting on it.
Where the gates go
- A
modinlines per call site, so its cost is roughlybody x call sites. That is an upper bound. The compiler shares loop-invariant subexpressions across the copies, so hoisting a repeated read out of an unrolled mod by hand usually saves very little. - A
fncosts its body once, plus one Function Call gate per call site, a Function Input per parameter slot, a Function Output per result and a handle var per outervar, array or map its body touches. - A
forcosts its body once, plus the loop chip and its entry, and about five always-live gates when its bound is a runtime value. - A
mod’s localvarbecomes a storage gate at every call site. Three locals in a helper called from a 30-wide unroll is ninety gates. Making the helper afncollapses that to three. Hoisting them to module scope does too, but only do it when every call site is alone in its statement, because module scratch is shared and two calls in one expression will race. - A
mod’s return path depends on its shape. A mod whose body is a single expression returns through a wire and costs about one gate per call site. A mod with an earlyreturnreturns through a storage gate, which is opaque to the constant folder, and costs several times more per site. Rewriting an early-return helper as one expression is usually the cheapest change available to it. - Unused storage is never removed. Guarding a feature behind a false constant
removes its logic but not its
vardeclarations. Nothing prunes unused state, and the compiler never warns about it.
Feature flags fold completely
A body guarded by a compile-time-false constant is deleted, not skipped:
const FEATURE = false
on tick { if FEATURE { expensiveThing() } } // compiles to nothing
Measured: a 1056-gate body behind const FEATURE = false compiled to 3 gates.
Shipping two builds of a chip (one with a feature, one without) is therefore
close to free, and cuts both gate count and artifact size for anyone who does
not need it.
Size is usually the real constraint
There is plenty of runtime headroom, but artifact size is limited.
- Every
.brzhas a ~25KB floor, whatever it contains. A 10-gate helper chip and a 500-gate chip cost about the same. Splitting work across many small chips pays that floor every time. - A
SpawnPrefabreference embeds the whole prefab into the spawning chip. Shrinking the prefab shrinks every chip that spawns it, and each spawner carries its own copy. - On-screen text is capped at roughly 32 elements per player, shared across every circuit that player has loaded, not counted per chip. Past the cap, one element silently never renders, and which one it is changes when you change unrelated things. Budget by the number of elements, not the number of draw calls.
Before optimising, check you have a problem
Compute what your circuit 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. Optimize for whichever of
these is your real limit. The exception is a for loop or a frequently called fn: its
body runs many times per tick, so the body’s size does affect per-tick CPU.
Profiling: find the hot spots
--dump-ir prints the compiled program to stdout as .wsir text, one gate per
line with its loc(line:col ...) source anchor (diagnostics stay on stderr). The
format is described in crates/wirescript-ir/FORMAT.md, and wirescript assemble
builds the same save back from it. Measure before you refactor, and target the dominant
gate kind or source line rather than a guess.
# the compiled program as text
cargo run -p wirescript-cli -- compile foo.ws --dump-ir > foo.wsir
# total gate count
grep -cE '^\s*%\S+ = ' foo.wsir
# which gate kinds dominate
grep -oE '^\s*%\S+ = [A-Za-z_]+' foo.wsir | awk '{print $3}' | sort | uniq -c | sort -rn | head
# which source lines emit the most gates (an imported file's lines carry its name)
grep -oE 'loc\(("[^"]*":)?[0-9]+' foo.wsir | sort | uniq -c | sort -rn | head
A dominant Expr_MathModulo / Expr_MathDivide count usually means a decode called
too often. A large Exec_ArrayVar_Get count means reads that could be cached or
aggregated. A single source line with a big share is your first refactor target.
A fn or for shows up as a chip line whose body is listed once inside its braces.
Each call is a FunctionCall gate whose Definition names the fn, so counting those
per name tells you how many sites reach each fn.
Common mistakes
- An expression-
ifis aSelectgate, so both arms evaluate. Guard possibly-out-of-bounds array reads with a statement-if, never a ternary. - Delete dead arithmetic. If a value’s range makes an op a no-op, drop it. For example,
(x / 16) % 4, wherex / 16is already proven<= 2, is the same asx / 16, and a mask that can never clear a set bit does nothing. Every op you remove is a gate you don’t build. - Don’t recompute inside one expression. Bind a repeated (possibly expensive) call to a
letonce and reuse it. Each call is its own gate subtree, sof(x) + f(x)buildsftwice. With afn, it builds two call gates and runs the body twice. - A
mod-localstatic varis per-copy, not shared. Each inlined instance gets its own. Hoist shared state to a rootvar. - A
fn-localvaris shared by every call. It resets when a call starts, so one call doesn’t see another’s value, but it is one gate, not one per site. - Hover a
mod,chip,on, oriffor its gate count. The estimate covers the whole construct (following its calls), and amodreports that it is inlined per call site. This shows what a refactor costs without compiling. - Don’t optimize prematurely. Gate count only matters once something is instantiated
many times. A leaf helper called twice is fine as a
mod.
Checklist
When a build is unexpectedly huge, walk this list:
- Profile with
--dump-irfirst, then refactor the dominant gate kind / source line instead of guessing. - How many call sites reach the biggest
mod? Its cost is its body times that count. Can it be afn? - Are repeated steps that differ only by an index written as one
for? - Can many producers be funneled through one queued dispatch site?
- Can near-identical entry points collapse into one parameterized
modorfn? - Can a hot shared routine be deferred behind a dirty flag to one call per tick?
- Is any per-slot boolean state an array that should be a bitmask?
- Is anything being re-derived inside a callee that could be passed in?
- Can an unrolled or looped max/sum/count/search be a one-gate
.max()/.sum()/.find()? - Would a derived parallel array turn a per-element decode-and-add into a
slice+sum? - Is display/derived logic running on the exec chain when it could be a pure output binding?
- Is a per-tick roster sweep a
for, or one chip per entity when each needs its own handlers?
See also
- Chips:
modvschipsemantics,ref/*params, nested chips forLoops andfn: syntax, bindings and limits- Execution Context: pure vs exec, and why array reads need exec
- Builtin Functions:
BitCountand the other cheap primitives
Testing
A Wirescript program can check itself. The pattern below compiles to a circuit that runs its own assertions when the grid loads, says nothing when everything passes, and prints what it needs to diagnose the failure when something does not.
Type checking cannot do this job. It proves a program is well formed, not that a gate computes what you expected, and most unexpected behavior comes from the gates.
Contents
- The shape
- Compare two paths, not one path against a constant
- Include a control
- What an in-game test cannot prove
The shape
var pass: int = 0
var total: int = 0
mod assert<T: int | float | string>(want: T, got: T, label: string) {
total = total + 1
let ok = want == got
pass = pass + ok
if !ok {
BroadcastChatMessage("FAIL: ${label} want ${want} got ${got}")
}
}
on Loaded() {
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 parts of the example matter.
on Loaded() as the trigger. The Load Event fires once when the chip is
placed, and again whenever the world loads with it, so the test runs by
pasting it in.
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 ${...}. Only those three types support that, so the bound names them.
Widen it to plain <T> for a check that never prints the value.
The failure line prints the values. 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: ... would have to be scanned. The checks print with
BroadcastChatMessage rather than PrintToConsole, so the output 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 Loaded() {
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 Loaded() {
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, which is what a test of a compiler feature needs to show. A compile-time evaluation that fell back to emitting gates without any diagnostic still computes the correct answer and still passes every check above.
So the in-game program is one half. The other half is a compiler-side test that asserts the structure: that the gate count is what it should be, that a particular gate class is absent, that a value was baked into a component rather than wired. Together they cover both questions, and neither covers both alone.
Constant Folding
The compiler constant-folds pure gates before layout, guarded by an in-game-certified semantics table so nothing folds on a guess. Folding sees through chip boundaries and iterates to a fixpoint: a fold can unlock another fold, so a chain of constant math or nested conditionals collapses in a single compile.
Contents
- Enabling folding
- What folds
- Constant inlining into gate data (always on)
- Strings and composite values (wave 2)
- What never folds (barriers)
- The certification story
- Disabling the pass
- Guarantee
- See also
Enabling folding
Folding is on by default: every compile folds unless you opt out. An unannotated program folds:
let x = 2 + 3 * 4 // folds to the literal 14
Turn it off for a whole program with a module-level @nofold: on the first line of the
entry file (after any module doc), separated from the first declaration by a blank line.
@nofold
let x = 2 + 3 * 4 // stays as gates; nothing folds or is elided
--no-foldon the CLI’scompilecommand disables folding for that compile, equivalent to a module-level@nofold.--fold, and a module-level@fold, still parse but are redundant now that folding is the default. They never override a@nofold.- If both a module-level
@foldand@nofoldare present,@nofoldwins and the parser warns that the annotations conflict. - A
@nofoldin an imported file has no effect, because 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,
taking 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, and || 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, with no separate carrier gate. This runs before the fold pass and only
on source literals (never fold-produced ones), so folding stays a structural
no-op.
- A constant gate setting bakes in:
on Clock(interval = 0.25)stores0.25in the Clock’sIntervalSecondsdata, and a constantSweepdistance,DisplayTextfontSize, etc. do the same. A variable value still wires (so the setting stays dynamic). - A matching-type constant operand inlines too: the
0inx | 0, afalseoperand of||, and string literals intostrfields (Contains’s search text, an interpolation template) all bake into the gate instead of spawning a throwaway carrier gate. - A type mismatch (e.g. a float constant into an
i64field) does not inline. It keeps a converting carrier gate. Wire-variant math inputs (+/-/*) andVector/Rotator/Quat/Colorfields inline as wire variants instead.
Strings and composite values (wave 2)
Folding now also covers string operators/methods and vector/rotator/color/quat
math and constructors, certified the same way as everything else: an in-game probe
records the real output, and only a (gate class, input-variant) pair the probe
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 is as follows. 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 uses.
- A string result longer than 8,192 characters.
- Any float operand whose magnitude exceeds
1e15in a string/interpolation context, because the game cannot print a float that large at all (the console line is silently dropped), so folding refuses past that bound rather than bake unreproducible text.1e15itself is certified to print fine. The bound is exclusive. - A handful of composite-constructor gates whose only table evidence is a blank
render and that have no transitive proof either (unlike
Quat(...)/3-argumentColor(...)above): a bare rotator constructor, the sRGB-byte and hex-string color constructors, and rotation inversion. These always refuse, however constant their inputs are, until a future probe wave chains them through a value-bearing gate. - A fixed list of gates the probe recorded but that are excluded from folding: vector magnitude/normalize/distance, spherical interpolation, axis-angle construction, angle- and rotation-between queries, direction/rotation conversions, and color blending. Their math is 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 go through the same probe/table/replay/verifier loop as everything else.
What never folds (barriers)
These are always treated as unknown, and folding never elides or sees through them:
Opaque(x): the permanent, explicit fold barrier.@nofold-annotated declarations, including a module-level@nofoldat the top of a file, which disables the whole fold pass for that compile.- Rerouters,
var/Var_Getreads, arrays, buffers, events,ReadBrickGrid(), and any wire carrying an object-typed value. - Any gate class or input-variant combination the probe never certified: absent from the table means permanently unknown, never folded.
- Certified signatures whose specific values the evaluator declines to compute, as a
safety net layered on top of coverage: math involving a
stringoperand (the recorded observations rule out every parsing model), integer overflow, mixed-sign division/modulo with a nonzero remainder (truncation direction was never probed), and any result that would be non-finite. These are refused rather than guessed at, even when every input is constant.
The certification story
Every fold decision traces back to data/gate_semantics.json, a table built by
probing each gate combination in-game and recording its real output. Two things hold
the compiler to that table:
- Replay gate: a build-time test feeds every table case through the compiler’s evaluator and asserts the recorded output. The table cannot drift from what the compiler does without breaking the build.
- Coverage gate: a gate only folds when its (gate class, input-variant signature) pair is present in the table. A combination the probe never ran stays unknown, even if the evaluator could compute an answer for it.
A companion probe invariant test compiles the certification circuits themselves with folding on and off and asserts identical gate and wire counts. This shows the pass cannot change the circuits that certify it.
When a game build changes gate behavior: re-run the probe, regenerate the table, and re-paste the generated verifier in-game. The replay gate then re-certifies (or fails to) the evaluator against the fresh table automatically, so a stale assumption fails the build instead of silently folding wrong.
Disabling the pass
- A module-level
@nofold, placed at the top of the entry file and separated from the first declaration by a blank line, turns folding off for the whole program and always wins over a module-level@fold. --no-foldon the CLI’scompilecommand has the same effect for that compile.- Folding is on by default everywhere it runs (CLI compile, the LSP, and the wasm
build), so all tools fold consistently unless a program opts out with
@nofold.
Guarantee
A gate is only value-folded when its class and input-variant signature are certified,
and only using a law the evaluator implements for it. Anything else is left as a real
gate. Opaque(...), @nofold, rerouters, variables, arrays, buffers, events,
ReadBrickGrid(), and object-carrying wires are never folded, elided, or seen
through. Nothing is removed that the certified table does not license.
See also
- Statements:
@nofoldsyntax and placement rules - Builtin Functions:
Opaque
Diagnostics
Every problem the compiler reports carries a stable WSxxx code. This page
lists every code the type-checker and lowering passes emit, grouped by the kind
of problem. Codes are errors (they stop compilation) unless the entry marks
them (warning). Warnings compile but flag something likely unintended.
Diagnostics run in a fixed order (parse, resolve/import, type-check, lower, circuit analysis), so an early error can suppress later ones on the same construct.
Some numbers in the
WS0xxrange are not used:WS009andWS018have no emit site.WS034was once a generic-chip cross-wiring guard and has since been removed.
Contents
- Execution context
- Names, declarations & imports
- Types & operators
- Calls & arguments
- Generics
- Ports, outputs & labels
- Collections & shapes
- Loops
- Functions (
fn) - Compile-time constants (
const) - Gate & event config
any- Internal errors
- Silencing a warning
Execution context
Wirescript has a pure context (continuous signal-flow) and an exec context
(imperative code inside on handlers). These codes fire when a construct is
used in the wrong one, or when a feedback loop has no tick barrier. See
Execution Context.
| Code | Meaning | Trigger |
|---|---|---|
WS005 | Gate cycle with no barrier: a feedback loop must cross a Buffer/Queue/EdgeDetector; break it with buffer emit. | a gate loop with no buffer on any edge |
WS006 | *x deref used in pure context. Use x.Value for a pure read. | out v = *count |
WS007 | Exec-only construct outside an exec context: assignment, emit, await, an array index read, an exec-returning call, or a return inside a branch of a mod or chip called with no enclosing exec chain. A named chip whose output writes must choose between paths (several returns, a return beside one in a nested on handler, or a return beside an emit/out of an output) needs that chain too. Also an emit of a local exec signal (let sig: exec) where no exec chain runs, such as the body of a named chip outside its handlers, so nothing would ever fire it. 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 |
WS101 | An initializer on let s: exec. That form declares a new signal, fired only by emit s, so the value would be dropped and an on s handler would never run. Use on go { .. } with the existing signal, or name it again with let s = go (no annotation). | let s: exec = go |
WS116 | (warning) An on trigger that joins an object (an entity, character or player) with ` |
Names, declarations & imports
| Code | Meaning | Trigger |
|---|---|---|
WS001 | Unknown event or trigger: the name after on isn’t a known event, input, let, buffer, var, or param. | on Nope { } |
WS002 | Unknown name or type: an undefined variable, an unknown type, an undefined namespace base, a namespace base shadowed by a local binding of the same name, or a misused generic alias (bare, wrong arity, or recursive). Also a call to a mod or chip the calling file neither declares nor imports, such as a library body calling its importer’s mod or an importer calling a library’s private helper (see Imports). Likewise a type alias is read in the file that writes it: a library’s body sees the aliases the library declares or imports, never one its importer happens to declare, so a library naming an alias it does not have is this error whatever the importer declares. Enums are read the same way: a library names the enums it declares or imports, its own alias wins over an importer’s enum of the same name, and a library naming an enum only its importer declares (in a type or in Dir.B) is this error. So are the top-level values: a library body reading a const, let, var or port only its importer declares (a value, an event name, a Sleep delay, a constant’s initializer) is this error. Also a var initializer that reads the name it declares when nothing before the var has that name (var y = y + 1): the initializer reads the names around the declaration, never the variable it declares, and the message says so, with the declaration to write instead when the var’s type is known. | let x = undefinedVar / var x: Widget / import * as u then mod g(u: int) { u.f() } |
WS012 | Import error: a circular import, an unresolvable file, or a named binding not found in the target module. | import { nope } from "utils" |
WS013 | Duplicate declaration, or an output that is never assigned. In one anonymous chip { } a name is declared once: a second const, let or storage of it is this error, as are two storage declarations (var, buffer, array, map) of one name at the top level of a file. A second top-level const or let of a name, or a let beside top-level storage of it, is read by position instead (WS108). Also an enum of an import * as module named like another enum of the program: a program has one enum of a name. A top-level anonymous chip { } may declare a name the top level does too (see Anonymous chips), and a let in a body may shadow one declared before it. Also two outputs of one mod or chip with one name, of which a read of the call could take only one. Also an output driven continuously by a non-constant out name = expr and also written in a handler or branch (by emit or another out): the two would fight over it, so give the continuous one a constant default or remove it. | two var x: int = 0 in one scope |
WS103 | An output no site gives a type (out z = ... in a handler or a mod body, or emit z = ...) is stored as one type that holds every value written to it: int and float give float (an integer constant written there is that float), and a bare pop, get, find, min, max or average counts as the value it reads. This error is for values no one type holds: a string, bool or enum beside a number, a record beside a single value, or two different records. The message names both writes. Annotate the output (out z: int = ...) or write values of one kind everywhere. | if c { out z = 5 } else { out z = "six" } |
WS105 | A record or tuple written to an output that no site gives a type (out z = rec in a handler or at the top level, emit z = rec, or an out in an anonymous chip). Such a port is one pin, and only an annotation gives each field its own, so nothing would drive it. Annotate the output with the record’s type: the message spells it, by its alias when there is one (the one declared first when several name it), and puts it on the output’s declaration when the record is written elsewhere. A gate with named outputs (Edge(b)) is a record of them here; written to an output with a single-value type, it drives the output with its first one when that output is of the output’s type (WS003 otherwise). | on go { out z = pv } with pv: P |
WS107 | A type or enum declared under the name of a builtin type (int, float, string, vector, any, …), or as Array or Ref with one type parameter (Array<T> and Ref<T> are how T[] and *T are written). The builtin is what that name means wherever a type is written, so the declaration would never be used. Give it another name. Any other builtin generic name, such as Map, or Array/Ref with another number of type parameters, is the program’s own type wherever the program declares one. | type float = int |
WS108 | (warning) A top-level declaration of a name the top level of the same file declares already: a second const or let of it, or a let beside a var, buffer, array or map of it. The file is read by position: a read names the latest declaration visible where it is written (a let or const from its end, so let k = k + 1 reads the earlier k; storage from its start, though a var initializer reads the declaration before the var, as let k = 5 then var k: int = k starts the var at 5, while a buffer initializer reads the buffer, its value the tick before), so the later declaration hides the earlier one from its position on, for values and constant-only settings (an event name, a Sleep delay, a folded if) alike. Storage is declared ahead of everything, so a read above every declaration of the name names the storage. The body of a mod, chip, generic or const mod reads as code written where that declaration is, wherever it is called from; above every declaration of the name (and with no storage of it) it names the first. A file imported flat or by name reads its own duplicates the same way, in the order it writes them, however the program reaches it (a named import beside a flat one, several named imports, several paths), and an importer reads the last; a named import of the name binds the last. A module imported import * as is the exception: its namespace holds the last declaration only, so every read there names the last one, and its reads through a flat import in the same program too; it gets one warning, which says so. The message names both lines. Rename one to read both. | buffer tick: int = tick + 1, later let tick = ServerUptime() |
WS109 | (warning) A conversion as cannot make yet: an array or map, an enum, a ref, exec, a zone/teleport/prefab, a brick to another type, a value type to an object, or a record or tuple whose fields differ from the target’s. The value keeps its own type, so a use that needs the target type reports there. | xs as float[] for var xs: int[] |
WS110 | A name the property gates don’t know: a $Component/<name> or Component<name> that is not a component type the component gates take, a property a component or environment does not have (light.Volume, Environment.Sky.Brightness), an environment that does not exist, an Environment.<environment> with no property, a GetComponent/AddComponent whose type is not a $Component/..., or Remove() on a component reference (the game removes by brick: brick.RemoveComponent($Component/<type>)). The component types are the components a player can add to a brick, spelled as the display name without spaces. | $Component/SetVariable, Environment.Moon.TimeOfDay |
WS014 | (warning) Unused import, or a named import that leaves behind the on handlers / anonymous chips the module installs (write import "mod" to install those too). | import { clamp } from "u", clamp never used |
WS021 | Use before declaration: a chip/mod is called above the point where it’s declared (declarations register in source order), or a mod or chip body reads a top-level let declared below the call (the body reads it where it is called). | helper() above mod helper() { } / out b = f(n) above let A = n * 2, with f reading A |
WS091 | (warning) A local shadows a var, array, map or buffer that a mod or chip called in the local’s scope uses, itself or through the mods it calls. The body resolves its names where it is declared, so the call uses the outer one, never the local. Reported at the local. Rename one of them, or pass the local to the call. | mod bump() { q = q + 1 } then on go { var q: int = 5 bump() } |
WS093 | A name that starts with __: a variable, let, parameter, mod/chip, type, enum variant, or record field (declared, written in a literal, or read). The prefix is reserved for names the compiler makes for its own storage, such as an enum’s tag slot, so a user name could stand in for one. A __ later in a name (a__b) is fine. | var __x: int = 0 / type T = { __tag: int } |
WS043 | An on <call> -> <pattern> general (non-event) trigger’s call has no exec-typed output for on to auto-extract. It needs an event, or a call whose result includes an exec field (e.g. via exec = ...). Also .exec on a call that runs no exec step, such as an inline mod whose body is all values. | on pair(5) -> (p, q) { } where pair has two plain (non-exec) outputs and no exec = arg; inc(1).exec for mod inc(x: int) -> int { return x + 1 } |
WS060 | Unknown variant on an enum path (Enum.Variant): the enum has no variant with that name. | enum Shape { Empty } then Shape.Nope |
WS064 | Duplicate discriminant value in an enum declaration. Each variant’s tag must be unique. | enum E { A = 1, B = 1 } |
WS065 | Wrong bracket form for an enum variant’s payload: a named-payload variant called with (...), or a positional-payload variant called with { ... }. | enum S { Box { w: float } } then S.Box(1.0) |
WS054 | Non-exhaustive match: the arms don’t cover every value of the scrutinee. Each uncovered pattern is named in the message. Add the missing arm(s) or a _ catch-all. A match on an int, float or string always needs a _ (or a name) last, as no list of literals covers every value; true and false cover a bool. An arm with an if guard covers nothing, since its guard may be false. | enum S { A, B, C } then match s { A => 1, B => 2 } / match cmd { "go" => 1 } |
WS061 | (warning) Unreachable match arm: an earlier arm already matches every value this one could, so it never runs. | match s { _ => 0, A => 1 } |
WS092 | A match used as a statement has an arm that is only a value, such as a record literal: its arms run for their effects, so the value does nothing. Write the arm as a block, or use the match as a value. | match e { A => { x: 1 }, B => { n = 1 } } |
WS095 | A match used as a value has a block arm (A => { ... }) that does not end in return on every path. A block has no value, and the match reads its value after the arms rejoin. Write the arm as a value (A => expr), or make the match a statement whose arms assign a var. Also a record, tuple or enum-valued match with a block arm, and any block arm of a value match outside an exec context (a top-level out or let), where its statements and its return never run. | n = match e { A => { k = 5 }, B => 2 } |
WS097 | An if or match expression whose value is a record, tuple or enum has an arm that does more than give its value: it writes a variable or an output, sends, mutates a container, runs an action builtin, or returns, directly or through a mod it calls. A scalar if or match runs such an arm only when it is taken, by storing the value; a record has no single store to choose through. Use an if or match statement whose arms assign a var. A field read of one ((if c then mk() else p).a) is reported in its own words: read the field in each arm instead (if c then mk().a else p.a), which is a single value. | p = if c then { x: bump(), y: 0 } else { x: 0, y: 0 } |
WS102 | (warning) An if or match expression arm on an exec chain calls a mod or chip whose body has its own on handler that acts (writes outside the handler, including a var or container the body keeps from one trigger to the next, sends, mutates a container). That handler runs whenever its trigger fires, whether or not the arm is taken: the condition only chooses which value is read. To run it only when taken, call it from an if statement whose branch fires its trigger. | n = if c then Cx(go, 3) else 0, where Cx’s on t handler writes a top-level cnt |
WS062 | A let <pattern> = ... else { } whose else block can fall through. It must diverge on every path (return/emit, or an if/match whose arms all diverge), since the binding is unavailable when the pattern doesn’t match. | let Some(x) = o else { let y = 1 } |
WS063 | A generic enum’s type parameter can’t be inferred: a variant with no payload to pin it (Option.None) is constructed with no annotation supplying it. Annotate the target (out n: Option<int> = Option.None) or use a variant whose payload determines it. | enum Option<T> { Some(T), None } then out n = Option.None |
Types & operators
WS003 is the general type-mismatch code. The others are narrower.
| Code | Meaning | Trigger |
|---|---|---|
WS104 | (warning) An integer constant in a place typed float (an integer literal, or a name or expression that is one) that a float cannot hold exactly: beyond 2^53 (9007199254740992) in magnitude floats skip integers, so it is stored as the nearest float. Keep it in an int to keep every digit. | var x: float = 9007199254740993 |
WS106 | (warning) A constant stored as a different value than the one written. A float constant with a fraction that is the whole value of a place typed int (an annotated let, const, var, static var or buffer, an assignment, a mod or chip argument, a return, an output, a field of a record or tuple literal wherever the literal goes (a var, a mod argument, an enum payload, an array push), an array or map element, an enum payload such as Opt<int>, an array or string index, a range bound or a count) is stored as the integer it truncates to, held as an integer wherever it then goes, for a const exactly as for a var. A float constant giving one branch of an if or match keeps the join’s type, and a write through a ref (*h = 2.5) stays WS003. A float constant beyond the range of an int is WS028. A constant given to a gate setting the gate keeps in its own data (a tick count, a font size, a justification, a flag) is stored as that setting’s field holds it: a dropped fraction, a number other than 0 or 1 made a bool, or a bool made a number for a setting that does not take a bool are each reported, naming the setting, the value written and the value stored. Write the stored value to silence it. A value the field cannot hold at all is WS028. | var n: int = 2.5 / SleepTicks(_, delay = 0.999) |
WS003 | Type mismatch: a value doesn’t coerce to the expected type (assignment, argument, output, array element, an annotated buffer’s initializer, if-branch join, event input). Also a return from a mod/chip with several outputs whose value does not line up with them: a single value, a tuple of the wrong length, or a record with a field naming no output or missing a value output. Also an any value assigned, returned or emitted as a record or enum, which is one wire with no fields to split. Also a record of the program’s own (a record literal, or a parameter, var, field, element or one-output mod call of a record type) where one value goes: a return, emit or out to a single-value output, an assignment, an argument, a record field, an array element, an operand, a comparison, a condition or an interpolated part (mk(a) + 1, "${pv}"). A multi-output read (x = Edge(b), n = fs.max(), a call of a mod or chip with several outputs, flat, namespaced or as a method), written inline or held in a let, a record field or a tuple, gives such a place its first output instead, converted as a value of that output’s type is; a multi-output read whose first output is itself such a record is the same error, as the first output is read once, never a field of it. Also a string method called on a value that is not a string (vv.Length() on a vector), which would read the text the value formats to: a vector’s length is Magnitude(vv), an array’s or map’s count is xs.length(), and Length(n) formats a value on purpose. In a generic body the value may be of an unbounded type parameter, which is checked as every type it may be (a bool among them); the message names the parameter and the bound to add (<T: string>), also for a field or element of a value declared with it (v[0] for v: T[]), a let or for name taken from one, and a call’s result of its type (id(v)). A record a bounded type parameter stands for (r in mod gf<T: R | int> reading m.get(k)) names the bound to narrow (<T: int>), as a field of it is no field of the bound’s other members. An if, match or Select of two multi-output reads joins them output by output, an int output widening to float, when they declare the same outputs in the same order, or by their first outputs otherwise (an argument of one value included); with no common type it is this error, naming both. A record a bounded type parameter stands for names the members of the bound whose check of the body found no error, a class in the bound (Scalar, Numeric) read as its members, so following it compiles: <T: R | Scalar> suggests <T: int | float>. Also a multi-output read returned whole to several outputs when some, not all, of the outputs are named like the read’s: an output named like one takes that one, so every output must be, and the message names the ones that match none; outputs named like none of the read’s take them in order. | var n: int = "hi" / vv.Length() / -> (Found: bool, Value: float) { return fs.min() } |
WS004 | No operator overload for the operand type(s) (arithmetic / comparison / logical), named as the program spells them. Also two records compared or combined with each other. | "a" + 1 |
WS008 | Taking &/ref of a non-reference, or passing one to a *T parameter: only a variable, a ref parameter or a record field over one can be referenced, not a temporary. An element of a value array has no ref of its own (&xs[i], &pts[i].x), and neither does a plain field of a record value (rr.n of a let literal, m.get(k).Value on a map of values). | &(a + b) / &xs[0] |
WS011 | No overload for a bitwise/shift operator (&, |, ^, ~, <<, >>), or a builtin call with the wrong number of positional arguments (including more than seven parallel arrays passed to sortMultiple, whose gate has seven array ports). Bitwise/shift accept int, and float/bool coerce to int; only non-numeric operands (string, vector, …) have no overload. | "a" & 2 |
WS016 | (warning) let / out annotation doesn’t match the inferred type (a checked assertion; string-format coercion is exempt). | let n: int = s where s: string |
WS025 | A type with no storable form used as storage: zone, teleport, prefab, a ref to one of those, a container of records inside another container, or a ref, array or map as a map key. Also a handler-local *T / brick var with no initializer. | var h: *zone / on go { var h: *int } |
WS031 | A reference-only value (zone, teleport, prefab) used in an if-then-else. A *T, an array ref and a brick pass through the Select gate and are fine. | if c then zoneA else zoneB |
WS066 | .Discriminant or .ToInt() targets a value that isn’t an enum, or a match, if let or let ... else tests a value that is not an enum, int, float, bool or string. | var x: int = 0 then x.Discriminant |
WS067 | A bare variant name for a variant that has a payload (Circle instead of Circle(_)) in a match arm binds the whole value like a catch-all, which can leave later arms unreachable. | match s { Circle => .., Empty => .. } |
WS120 | A pattern that cannot match the value it tests: a literal of another type (an int literal is fine for a float), a range on a value that is not a number, a .. string pattern on a value that is not a string, a variant on a value that is not an enum, or a literal on an enum. Also a bare capitalized name in a match on a single value, which reads as a variant but catches every value; a name meant to catch the rest starts lowercase. | match n { "a" => 1, _ => 2 } for n: int |
WS121 | An alternative of a ` | pattern binds a name. Only one alternative matches, so the others would leave the name unset. Match that part with_`, or give each alternative an arm of its own. |
WS122 | A string pattern with two holes side by side (a .. b, "${a}${b}": nothing says where one ends and the next begins), or a part that is not a string literal or a hole, such as an expression inside ${}. | match s { a .. b => 1, _ => 2 } |
WS123 | (warning) A ${name} hole in a string pattern ("hi ${name}") named like a value already in scope. In a pattern, ${name} binds a new name to the text it matches; it never reads the existing one, so the arm matches any text there. Bind another name and compare in a guard: "hi ${n}" if n == name. | in player: string then match s { "hi ${player}" => 1, _ => 2 } |
WS068 | A custom-event data param is a record or an enum. A data slot is one wire and those span several, so the value cannot travel through it. Pass the fields as separate params, or send a key and read the value from shared storage on the other side. | on CustomEvent("c") -> (p: Point) { } |
WS069 | An enum payload field would need container storage. A payload slot is filled by constructing the variant, and a container cannot be constructed into one, so the value would read back empty. Fires on the declaration, or on a generic instantiation that binds a stored parameter to one (Option<int[]>). | enum E { B { xs: int[] } } |
WS070 | An unsafe payload access does not name both a variant and a field. The form is unsafe <value>.<Variant>.<field>; the variant is what selects the slot. | unsafe e.B = x |
WS071 | A record value used where a single value is expected that the type check could not see. A record is several wires, so it cannot drive one port. Read a field, or concatenate the fields individually. Also reported for an if/match choosing between records that hold an array or map of records, tuples or enums, since that field is several containers with no single reference to choose. | let w = if c then h else h2 for records h, h2 holding a P[] |
WS072 | An expression that produces no value read as one: a void container mutation (a.push(x), m.set(k, v)) or a mod/chip with no output. There is no wire to pass, so the consumer would read its port default. Use the call as a statement, or give the mod an output. Also reported for a named output of a mod or chip call (f(c).r) that nothing in the callee sets. | mod f(x: int) { } then o = f(1) |
WS084 | A ref or brick compared with something that is not a ref of the same kind. The game treats 1 == <ref> as true, so the comparison would mean nothing. Compare two refs, or read through one with *. | h == 1 where h: *int |
WS085 | * applied to an array ref, a map ref or a brick. An array or map ref is used directly (rows[y].push(v)), and nothing reads through a brick. | *rows[0] / *anchor |
WS086 | A ref to a ref: a **T type, or & of a value that already holds a ref. The game has no storage for one. | var g: **int / &h where h: *int |
WS100 | A type name (a record or other alias, not an enum) read as a value. A record type has no constructor call: Box { v: 1 } reads as Box followed by a separate { v: 1 }. Give the record literal its type instead: let c: Box = { v: 1 }. An alias of another type (type Score = int) is worded as one: write a value and give it the alias as its type. | type Box = { v: int } then let c = Box { v: n } |
WS117 | (warning) A comparison used directly as an operand of &, ` | or^. Those bind looser than ==, !=, <, <=, >, >=andis`, so the comparison would run first and the bitwise operator would get its true or false. Parenthesize the part you meant. |
Calls & arguments
| Code | Meaning | Trigger |
|---|---|---|
WS020 | Recursive chip/mod/fn call. Chips and mods inline at compile time, and the game does not run a recursive Function Call, so none can call itself, directly or mutually. | mod f() { f() }, fn f(n: int) { f(n) } |
WS022 | Wrong argument count in a user mod/chip call. | mod f(a: int) {} then f(1, 2) |
WS035 | A self-receiver mod shadows a builtin receiver-method of the same name/receiver, so it could never be reached as a method. Rename it. | mod Dot(self: vector, o: vector) -> float { ... } |
WS036 | A non-self mod called with method syntax x.f(…). Call f(x, …) directly, or rename its first param to self. Also a builtin that takes no receiver called as x.f(…), and a self mod the calling file neither declares nor imports: a module’s private self mod answers only its own module’s calls, even when a named import brings in a body that calls it, and an importer’s self mod is not visible in the bodies it imports. Import it by name or import the whole module, or, from a library, move it into the library (see Imports). | mod f(a: int) {} then x.f() |
WS038 | The callee isn’t callable: it’s a var/let/array/param, not a mod/chip/fn (often an index typo). | xs(i) for xs[i] |
WS041 | Unknown named argument: it matches no parameter and no settings-menu config field, so it does nothing (exec = and a variadic call’s trailing options are exempt). | p.DisplayText(t, positionX = 0.0) |
Generics
See Generics.
| Code | Meaning | Trigger |
|---|---|---|
WS033 | Generic inference failure: T can’t be inferred (conflicting args, unpinnable, or out of its bound), an explicit type-arg count/bound is wrong, or type args were given to a non-generic function. | pick(true, anInt, aVector) |
WS037 | (warning) Explicit type arguments on a builtin are ignored, because a builtin’s result type comes from its arguments. | Random<int>(0, 5) |
WS096 | Too many distinct instantiations. A generic body is checked for every combination of its type parameters’ bound members, or, past 64 combinations, for a sample of them plus each combination a call uses. Calls using more than 1024 combinations beyond those, counting the generics their bodies call, stop being checked. The first call left out is reported, with the number of combinations the calls demand in all and the generics that demand the most. Give the calls fewer distinct types, or narrow the bounds. | one generic called with 1025 different combinations of type arguments |
Ports, outputs & labels
| Code | Meaning | Trigger |
|---|---|---|
WS017 | (warning) Ambiguous variable output type: out foo = someVar with an untyped var. Annotate out foo: T = someVar (value) or out foo: *T = &someVar (ref). A var that holds a ref offers only out foo: *T = someVar, since a ref to it would be a **T (WS086). | out o = myVar |
WS019 | A prefab reference must end in .brz. | $./level |
WS023 | A side annotation (@left/@right/@top/@bottom) is only valid on a top-level port of the compiled file, not inside a chip/mod body. | chip { @left in go: exec } |
WS040 | A @label(<expr>) isn’t a compile-time constant, in a position that requires a baked label (a port, chip, or nested var). The same holds for a @color or @material value on a chip or a nested var; only the module and a top-level var take a runtime one. | @label(hp) in x: int |
WS073 | An out binding has no port to bind to, so its value is dropped. Declare the port at the top level of the file, in a chip body, or in the chip’s signature. | chip { on Clock { out x = Toggle() } } |
WS090 | A mod or chip with a value output uses return, and some path ends (at a bare return, or at the end of the body) before every value output has a value on it. That path would output a value from another path (the only return’s, or the previous call’s). An output gets a value on a path from return <value> or emit r = ...; a continuous out r = ... counts for every path, and an exec output never stands in for a value one. Return a value on that path, or give the if an else that returns. | mod f(c: bool, q: int) -> int { if c { return q } } |
WS099 | return <value> in a top-level on handler or anonymous chip, outside every mod and chip. Only a mod or chip has outputs a return gives a value to; the file’s own out ports are written by name. Write emit o = value (or out o = value), then a bare return. | out o: int then on go { return 1 } |
Collections & shapes
| Code | Meaning | Trigger |
|---|---|---|
WS010 | Destructure / field-access shape mismatch: wrong destructure arity, no such record field, or a tuple index out of range. | rec.missingField |
WS024 | (warning) An asset/prefab reference inlined into a constant array initializer is silently dropped. Build the array with .push(...) in an exec handler. | var s: entity[] = [$Type/Name] |
WS026 | A map literal used somewhere other than initializing or assigning a Map variable, or filling a Map field of a record literal that initializes or is assigned to a record variable. {} where a map is expected is the empty map literal, valid in those places and as a map argument. | foo({ "a": 1 }) |
WS027 | Assigning a whole array or map from a non-literal is unsupported; copy it with .copyFrom(src). This covers a record’s container field (rec.xs = ys, rec = { xs: ys, n: 1 }) and a handler-local record var initialized that way. A record array or map field copied from another record copies column by column instead. | m = otherMap |
WS039 | Invalid map key type: a Map<K, V> key must be int, string, an object (entity/character/controller) or a brick. | var m: Map<float, int> |
WS044 | An array/map method (.push, .set, .remove, …) called on something that isn’t an array or map. The receiver didn’t resolve to a container, so the operation would otherwise be silently dropped. Also reported for an array/map argument (append, copyFrom, slice, keys/values, sortMultiple) that does not resolve to a container; for a mutating operation on a constant array or map (a const, a let bound to one, or a literal argument), including through a mod or chip parameter (T[] or *T[]), an if choosing it, or as a sortMultiple parallel array; and for storing a constant container’s reference where it could be read back and changed (an array or map element, a *T[] var, an output port, event data, a chip that changes a parameter it was passed), for an array literal with a non-constant element used as a value, which names no storage; and for an index (r[0]) on a record, tuple or enum. A constant container is immutable, so its compile-time value and its runtime contents can never disagree; copy it into a var to store or change it. | let x = 5 then, in an exec handler, x.push(1); or const t = [1, 2] then t.push(3) |
WS087 | A whole-record assignment whose container fields each copy from another container field the same assignment replaces, in a cycle. Scalar fields are read before any field is written, but a container is copied when its write runs, so one of them would copy contents already overwritten. Copy one side into a separate variable first. | h = { pts: h.qs, qs: h.pts } |
WS088 | An array literal assigned to an array spreads that same array anywhere but first (xs = [0, ...xs]). By then the rebuild has cleared or added to the array, so the spread would not add the old contents. A leading ...xs keeps them and appends the rest; to put values in front, use xs.insert(0, v). | xs = [1, ...xs] |
WS089 | A whole-record assignment copies (or spreads) a container into a field when that field is written, after every source is evaluated, and a later field’s code writes that container first, or writes through a ref, an array element or a parameter pin, which may reach any container of its kind. A call that writes nothing the literal copies is fine. Put the field with the call first, or run the call in a let before the assignment. | h = { ps: src, n: popper() } |
WS098 | A var declared with an empty [] or {} and no type, or with an array literal whose only elements are spreads of empty literals ([...[]]). An empty literal says nothing about what the container holds, and its gate stores one element type, so annotate the declaration (var x: int[] = [], var m: Map<string, int> = {}). An array literal with elements needs no annotation: it takes the join of its elements. An error that follows from the unknown element type is not reported again; any other error, in the same statement too, is. | var x = [] |
Loops
for iterates a range or an array/map variable, but only on an exec chain.
See Statements.
| Code | Meaning | Trigger |
|---|---|---|
WS074 | A for loop outside an exec context. The loop shell has an Exec input, so it must sit inside an on handler or an exec mod/chip body. | if true { for v in arr { } } at the top level |
WS075 | A for loop’s collection source is not an array/map variable. It binds by reference, and a computed value (a call, an expression) has no storage to bind. Take the collection as a T[]/Map<K, V> parameter, or fill a declared var array/map and iterate that. | for v in make() { } where make() returns int[] by value |
WS076 | A for loop binding that doesn’t fit its loop: more names than the loop has slots, a { .. } pattern on an array or map (whose slots are never records, so there is nothing to destructure), or, on a range where { .. } names the loop’s own slots, a name other than index. | for a, b, c in arr { } |
WS077 | A loop’s .step(...) (or count(..., step = ...)) stride is a constant that is not greater than zero: an integer or a float (a float stride steps by its value, .step(0.5)), written or computed from constants (0.0, -1.5, a const of one, S - 0.5, or 0.0 / 0.0, which evaluates to 0 as every constant division by zero does). The message quotes a literal as written. Use .rev() to count down instead. | for i in (0..10).step(0) { } |
WS078 | Control flow that cannot cross a loop plane: break outside a loop, return inside one, or continue, which has no gate to lower to. | for i in 0..3 { return } |
WS079 | An await inside a loop body, including await Sleep(...)/await SleepTicks(...). A body is one synchronous sweep within a single tick, so it cannot suspend. Use a back-edge loop (emit + await) for work that needs to spread across ticks. | for i in 0..3 { await Sleep(_, delay = 1.0) } |
WS119 | (warning) @sliced on a loop that runs inside another loop’s body, written there or brought in by a mod. Only the outermost loop can spread its iterations across ticks. Put @sliced on the outer loop, or remove it. | for i in 0..3 { @sliced for j in 0..3 { } } |
WS080 | @flat over a program containing a for. A loop’s plane is an execution boundary, not a visual grouping, so flattening it would compile a different program. | @flat with any for in the file |
WS081 (warning) | A statement after break in the same body never runs, because the break already ended the block’s exec chain. Reported at the dead statement, not the break, and in place of the generic WS058 that construct would otherwise get blamed with. | for i in 0..3 { break\n t = t + 1 } |
WS082 | A for loop’s array/map source has a record-typed element, key or value. A record is stored as one container per field, so there is no single reference for the loop’s driver pin, in either binding form. Iterate the index instead (for i in 0..arr.length()) and read fields in the body. | for p in pts or for { x, y } in pts where pts: Point[] |
WS083 | A range method used where it cannot apply: .step(...)/.rev() on an array or map source, or a second .step(...)/.rev() on one range (the outer one replaces the inner rather than combining with it). Reported at the method itself. | for v in xs.rev() { } / for i in (0..10).step(2).step(3) { } |
Functions (fn)
A fn compiles once to a function definition plane, run by a Function Call
gate at each call site. See fn.
| Code | Meaning | Trigger |
|---|---|---|
WS111 | A fn called outside an exec context. A Function Call fires on its Exec input, so the call must sit in a handler or a mod/fn body, or pass exec = trigger. | out o = f() |
WS112 | A record parameter of a fn given an argument with no record fields to pass. | f(5) for fn f(p: { a: int }) |
WS113 | A fn body that reaches across its plane’s wall with something other than outer storage or another fn: a computed value (an in port, a let), or a value or exec leaving the plane. Storage (var, array, map) goes through handles inside the plane, and a call reaches its fn by a wire from the definition; anything else has no way in or out. Pass it as a parameter, or return it. | in k: int then fn f() -> int { return k } |
WS114 | A declaration one function plane cannot hold, or a misplaced default value: a fn with type parameters, ...rest, a const parameter, an exec parameter, a tuple or enum parameter, a record parameter holding a record, or a record, tuple, enum or exec result; a default value on a chip parameter, or a parameter without a default after one with a default. | fn g<T>(x: T) -> T, fn h(a: int = 1, b: int) |
WS115 | A fn with more than 8 inputs or 8 results. A Function Call has eight of each, and a record parameter takes one per field. | a fn with nine int parameters |
Compile-time constants (const)
See const – Compile-Time Binding.
| Code | Meaning | Trigger |
|---|---|---|
WS046 | Not a compile-time constant: the value names a runtime value, a call to a mod that isn’t const mod, an unsupported syntactic form, an out-of-range compile-time array index, or a missing compile-time map key/record field. The message names the actual offender. A const mod the file does not declare or import is the call’s WS002 alone. | in live: int then const n = live + 1 |
WS047 | The certified evaluator refuses to compute the value even though every operand is constant: integer overflow, a non-ASCII string operand, or an uncertified gate/operand combination. The value is computable in principle, but the compiler will not guess it. | const s = "café".ToUpper() |
WS048 | Const evaluation gave up: the call chain is too deep or took too many steps (guards a runaway or self-referential const mod chain against a stack overflow, since a const mod calling itself type-checks fine). | a const mod that calls itself, reached from a constant-only position such as a custom-event channel name |
WS049 | A record or enum can’t be stored or passed in this shape. A stored record decomposes into one backing gate per field, so four shapes are rejected. A field whose type has no storable value: a reference (*T), zone, teleport, prefab reference, or exec. A record or tuple stored in an array or map (var hs: H[], Map<K, H>, (int, int[])[], or a record variable’s H[] field) that contains an array or map at any depth, since each field becomes one column and a column can’t hold a container. An array or map of records (or tuples, or enums with payloads), or a record holding an array or map of records, tuples or enums at any depth, crossing a port (an in/out port or a chip parameter or output), since it is several columns and a pin carries one reference; a mod parameter is fine. An enum crossing a port where it would need more than one wire: an enum with a payload at any port, or any enum inside a record or tuple port or at a chip parameter or output (only a payload-free enum, a built-in game enum included, crosses a top-level in/out port, as its tag, alone or as an array or map element); a *E chip parameter and a mod parameter are fine. | type T = { z: zone } then var t: T; type H = { xs: int[] } then var hs: H[]; in ps: P[]; in e: E for an enum E { A, B(int) } |
WS050 | A method with no per-field meaning was called on a record array or map. A record container is stored as parallel per-field gates, so sort/shuffle (would desync the fields), the aggregates (sum/min/max/average), find, and the dual-container ops (append/copyFrom/slice/values) are not available. Use pts[i] / m[k] for element access. | var pts: Point[] then pts.sort() |
WS051 | null was used for a type that has no null value. null adopts its target type and produces that type’s zero / unset, which is valid for a number, bool, string, vector/rotator/quat/color, or an entity/character/controller, but not for a container, record, or reference-only type (which have their own empty forms). | var a: int[] = null |
WS052 | A ...rest variadic parameter was declared on a chip. Only a mod may be variadic: a mod inlines per call site (so the trailing args are captured into a compile-time tuple), while a physical microchip instantiates once and cannot vary its pin count per call. Change the chip to a mod. | chip C(a: int, ...rest) |
WS053 | A plain emit X is followed, in the same chain, by an await X on the same signal. The emit fires in the current tick, but the await is what arms the resume and only arms afterward, so the await never catches that kick and the chain parks forever. Use buffer emit X, which lands the next tick after the await is armed. | emit go then await go |
WS055 (warning) | let x = await CustomEvent(...) captures the event’s data but its type could not be determined, so the wire defaults to a float and mis-delivers non-float data. Annotate the binding (let x: int = await CustomEvent("c")). A tuple let (p, t) = ... positional capture has no annotation surface, so capture typed values one at a time or receive them with a handler (on CustomEvent("c") -> (p: T, ...)). | let foo = await CustomEvent("c") |
WS056 | let x = await sig binds a value from a signal that carries no payload. A bare emit sig fires the signal without a value, so there is nothing to capture and the binding would read the signal’s exec pin as garbage. Emit a value (emit sig = ...), capture a live value at resume (let x = await expr on sig), or drop the let. | let sig: exec then let v = await sig |
WS057 | emit X targets something that is not an out port or a let ...: exec signal reachable here, so it would compile to nothing. An input port, a var, or an out/signal declared outside an enclosing named chip (whose fresh scope can’t see it) is not a valid target, and neither is a bare emit of a value output (any out or output that is not exec or any, at the top level, in a chip or in a mod), which carries only its value: write emit X = value. | 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. So is one after a buffer emit on its chain (earlier in the block, ending both branches of an if, or ending a mod it calls), since the emit closes the chain; an await right after it still arms. 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() |
WS118 (warning) | An await inside an if whose signal is declared outside that branch, with an emit of the signal after it on the same chain. Only an emit of a signal declared in the await’s own branch re-arms the await, so that back-edge finds it unarmed and the loop runs once. Declare the signal inside the branch, or move the loop to a handler of its own. | let s: exec then on go { if c { buffer emit s\n await s\n buffer emit s } } |
WS059 | Change/Changed (or an Edge detector) is watching a value it can’t observe. A change/edge detector watches a single wire value. A reference or container (a Map, an array, a *T ref, a zone/teleport) has none, so Change(m) compiled to a dead gate. Watch a scalar the container produces instead. | in m: Map<int,int> then on Change(m) {} |
Gate & event config
Some gates and events take config args that bake into gate data rather than wiring in as ports (see Built-in Events and Custom Events).
| Code | Meaning | Trigger |
|---|---|---|
WS028 | Invalid gate/event config: an unknown enum member, an out-of-range int, a missing required config field, or a non-constant value for constant-only config. Also a constant gate setting the gate’s data field cannot hold, however it is spelled: a number outside the field’s range (SleepTicks(_, delay = 1e30), fontSize = 5000000000) or an enum index the field has no value for (justify = 3.0 as justify = 3); the message names the setting and what its field holds. Likewise a float constant that is the whole value of a place typed int (see WS106) but lies beyond the range of an int (n = 1e30, Opt<int> given -1e30, or 9223372036854775807.0, which as a float is 9223372036854775808): no int holds it. Likewise the negation of the least int (-(-9223372036854775808), -K of a constant holding it), which makes 9223372036854775808. 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 type on the same channel. A receiver type written through an alias is the type the alias names. A constant is sent as the type it has, so a number constant of the other number type is a mismatch too (SendCustomEvent("c", 2.5) or if c then 2.5 else 1.5 to amount: int). The send is checked against every receiver of the channel, a typed let v: T = await CustomEvent("c") capture included (it takes the first data value as T). A receiver param with no annotation takes its type from the first send on the channel in source order, so two sends of different types disagree with each other; the message names that first send. | sender sends float, receiver declares amount: int; or SendCustomEvent("c", 1) then SendCustomEvent("c", 2.5) to an unannotated receiver |
WS042 | (warning) A CustomEvent/GlobalCustomEvent handler param has no type annotation and no in-unit sender to infer its type from, so its data defaults to float. Annotate the param, or add a matching SendCustomEvent/SendGlobalCustomEvent in the same unit, to silence. | on CustomEvent("dmg") -> (amount) { } with no in-unit SendCustomEvent("dmg", …) |
WS045 | (warning) A custom-event send’s data argument has no concrete type (any, or an Opaque(...) that erased its input’s type). The send emits the float variant regardless, so it won’t match a receiver that declares a real type. Give the value a typed binding (e.g. a var of that type) instead of any/Opaque(...). Unlike WS030 this needs no in-unit receiver to visit. | SendGlobalCustomEvent("a", who, Opaque(true)) |
any
| Code | Meaning | Trigger |
|---|---|---|
WS032 | (warning) An any annotation on a non-storage position (port, param, let, output). Prefer a generic type parameter, which keeps the type. | mod f(a: any) -> any { return a } |
Internal errors
| Code | Meaning | Trigger |
|---|---|---|
WS094 | Internal compiler error: after lowering, a wire names a node that no longer exists, or reaches a node in another chip without going through a boundary pin (a constant read from another chip included). The message names the compiler pass that left it. Also an exec pin (an ExecOut, an exec input or pin) wired straight into an input that takes a value other than a bool: the message names both gates and the line, as the gate would read 0. An input whose type is bool (a branch or select condition, a logical gate’s input, the value written to a bool variable, array or map) reads an exec as true for the tick it fires, as Logical Operators documents (type checking still rejects storing an exec, or passing it as a bool, written directly: WS003), and a custom-event data pin takes one with WS045. Also a write the checker accepted that lowered to nothing, or to fields its target does not have, such as a returned or assigned record or enum value, and a call that instantiates a generic with type arguments the checker never checked its body with. These are compiler bugs, not the program’s fault: please report them with the program that caused them. Compiling would otherwise ship a gate that reads 0 or fail at emit with dropped wire. One case is the program’s own error instead: a rerouter pin of no type passes an exec on, and the check follows it to the input it reaches; where that is a value input other than a bool one (out o: any emitted as an exec, then read as a number, text or color), the program reads an exec as a value, and the message says so rather than calling it a compiler bug. Give the pin a type, or test the exec with if. Every such wire is reported. Every WS094, the program’s case included, is dropped when the compile reports any other error. | Compiler bug: none, no program should reach it. The program’s error: chip C() { out o: any with on go { emit o } }, then let k = C() and n = k.o |
Silencing a warning
Sometimes a warning reports something the program does on purpose. Two comment directives turn one off by code:
import { helper } from "utils" // ws-ignore-line:WS014
ws-ignore-line covers the line the comment sits on. Written on its own line it
covers the line below instead, so a long statement can carry the note above it
rather than past its right edge:
// ws-ignore-line:WS014
import { helper } from "utils"
ws-ignore-file covers the whole file no matter where it sits, and by
convention goes at the top:
// ws-ignore-file:WS014
import { helper } from "utils"
Both forms take a comma-separated list (ws-ignore-line:WS014,WS032), and both
silence every warning on their line or in their file when written bare
(// ws-ignore-file). The directive is recognized as any whitespace-separated
word of the comment, so prose saying why it is there can sit on either side of
it, and appending one to a line that already ends in a comment works. A trailing
: with no code after it is read as a typo, not as the bare form.
A directive travels with the file it is written in. A warning reported against an imported module is silenced by a directive in that module, not by one in the file that imported it.
Errors are never silenced. A directive naming one has no effect. The program still does not compile, and hiding the reason would only make the failure harder to find. The same goes for the editor, whose “Ignore WSxxx on this line” and “Ignore WSxxx in this file” quick fixes write these directives for you, and are offered on warnings only.
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.
An expression the type check accepts but lowering cannot build yet would
compile to a placeholder gate that computes nothing. In a program with no other
error that placeholder is a WSP001 error naming the expression (“this
expression is not supported by the compiler yet: the method call .twice()”),
so nothing is emitted, even when only a variable nothing reads holds it. The
message says how to rewrite it: a method the value’s type does not list in this
reference is replaced by one it does. Beside another error it is a warning,
since the placeholder is most likely that error’s consequence. Neither
ws-ignore directive silences it.
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.
Contents
2.0.0
DisplayText centers text unless justify says otherwise
The game’s EA 3.2 update changed the default justification of a Display Text
gate from Left to Center, and an unset justify takes the game’s default.
Text laid out to start at its position now centers on it instead, moving left
by half its width. Say justify = Left wherever the layout expects that:
ctrl.DisplayText("Score", positionX = -300.0, positionY = 40.0) // before: left-aligned
ctrl.DisplayText("Score", positionX = -300.0, positionY = 40.0, justify = Left) // after
An unset pivotX follows the justification (Left places the text’s start at the
position, Center its middle, Right its end), so only justify needs to change.
An empty [] or {} needs a type
A var initialized with an empty literal and no annotation is WS098: the
literal says nothing about what the container holds. Annotate the declaration.
A literal with elements still takes its type from them.
var hist = [] // before: an array of floats, whatever was pushed
var hist: float[] = [] // after
var names = ["a", "b"] // unchanged: a string[]
A string method on an unbounded type parameter
A generic body is checked for every type its parameter may be, and an
unbounded T may be a bool, so v.Length() on a v: T is WS003 even
when every call passes a string. It compiled before. Bound the parameter:
mod size<T>(v: T) -> int { return v.Length() } // before
mod size<T: string>(v: T) -> int { return v.Length() } // after
A file calls only the mods it declares or imports
Imports merge every module into one program, and a call used to reach any
mod or chip in it by name. Now a call, f(x) or x.f(), reaches only
the mods its own file declares or imports. Two programs that compiled before
are errors:
- an importer calling a library’s private helper, present only because an
imported body calls it:
WS002forf(x),WS036forx.f(). This includes a helper of a named or aliased import (import { g as gg }thenf(k), wheregcallsf). Import the helper by name or import the whole module. - a library body calling a mod its importer declares:
WS002orWS036, located in the library. A module can’t import the file that imports it, so move the mod into the library, or into a module both files import. - a module calling a mod of another module that only the importer imports
(
main.wsimportsa.wsandb.ws, anda.wscallsb.ws’sbf):WS002, located ina.ws. Importb.wsintoa.ws.
// lib.ws
mod scale(v: float) -> float { return v.hook() } // before: the importer's hook
mod hook(self: float) -> float { return self * 9.0 }
mod scale(v: float) -> float { return v.hook() } // after: the library's own
A record or tuple written to an output with no type
An output with no type is one pin, so a record or tuple written to it drove
nothing. It is now WS105. Annotate the output with the record’s type, which
the message spells out:
on go { out z = pv } // before: an undriven pin
on go { out z: P = pv } // after: a pin per field
An expression the compiler cannot build is an error
An expression the type check accepts but lowering cannot build compiled to a
gate that computes nothing, with a WSP001 warning. In a program with no other
error it is now a WSP001 error that names the expression and how to rewrite
it. This applies even when the only thing holding it is a variable nothing
reads, and neither // ws-ignore-line nor // ws-ignore-file silences it. For
a method the value’s type does not list (k.nosuch(), a.ToString()), the
message suggests one this reference lists for the type (a number becomes text
through an interpolation, "${a}").
A record or tuple held by a var with no type (var v = (a, 1),
var r = m.get(k) on a map of records) is held as its type, field by field,
the same as in an annotated var. Before, it was a placeholder or read its
fields as a vector’s.
A name declared twice at the top level is read by position
A file’s top level may declare a name again (const ch = "a", later
const ch = "b", or a let beside a var of the name), and WS108 warns at
the later one. Every read now names the declaration written before it, for
values and for constant-only settings alike. This covers a mod, chip or
const mod body (read as if where it is declared, so it gets the first
declaration when it is above all of them) and storage (declared ahead of
everything, so read as if above every declaration). A library imported flat or
by name reads its own duplicates the same way, however the program reaches it.
A library imported with import * as reads its last declaration everywhere
(WS108 says so). A var initializer reads the declaration before the var,
so let k = 5 then var k: int = k starts the var at 5 again. Before, a
constant-only setting (an event name, a Sleep delay, a folded if) and
another constant’s initializer read the first declaration, while a value read
the latest, so one handler could store "b" and send on "a".
const ch = "a"
const ch = "b" // WS108
on go { s = ch
SendCustomEvent(ch, 1) } // before: s = "b", sent on "a"; after: both "b"
Constants that read as integers
A negated integer constant (--2, -(-K), -K) is an integer in both fold
modes. With folding on it was stored as a float
(MathNegate gives a float). Negating the least int is WS028.
A float constant used as an array or string index, a range bound or a count
is the integer it truncates to (WS106, or WS028 when no int holds it), as
in every other int place. Before, 0..2.5 ran three times. A .step stride keeps a
float value, and one that is not greater than zero, integer or float, is
WS077. An integer literal given to a bool parameter is the bool it names,
and a bool literal given to an int parameter the integer.
const of a name that hides a constant
A parameter, local or loop variable named like an outer constant hides it, so
const q = K of such a name is WS046: the name is not a constant there. It
compiled with the outer constant’s value. Use let, or rename the binding.
const K = 4
mod g(K: int) -> int { const q = K return q } // before: q was 4
mod g(K: int) -> int { let q = K return q } // after: q is the argument
A signal is declared with let
A signal is declared with let s: exec, which also accepts an alias of exec
(let s: Signal for type Signal = exec). An initializer on it is WS101:
it was dropped, so s fired only on emit s and never when the signal it
named did. To run code when another signal fires, write on <signal> { .. }.
const s: exec is a single WSP001 asking for a value (it was WS046). A
const is a value known while compiling, never a signal.
let done: exec = go2 // before: `= go2` was dropped
let done: exec // after
A var initializer reads the names around it
A var initializer is evaluated before the variable exists, as a let’s is.
var K = K + 1 reads the outer K. It used to read the variable being
declared, and the reads after it saw the value from before the initializer ran.
With no outer name of its own, var x = x + 1 is WS002, which says the
initializer cannot read the variable it declares. Declare it without one
(var x: int), or initialize it from another value. In a mod or chip body,
a read above a var of the same name, and that var’s own initializer, read
what the name means there (an outer binding, a constant, an enum or a bare
variant), as the type check always did. They used to read the body’s own
var. For example, var Blue: int = EnumToInt(Blue) + p reads the variant
Blue. The idiom static var z: int = z, whose initializer read the variable
being declared, is WS002 the same way. Write static var z: int instead,
which keeps its value across calls with no initializer.
const K = 4
on go { var K: int = K + 1 } // before: 0 + 1 / after: 4 + 1
static var z: int = z // before: read z itself / after: WS002
static var z: int // after
A record where one value goes
A record of the program’s own (a record literal, or a parameter, var, field,
element or call of a record type) written where one value goes is WS003,
and the message names a field to read. This covers a return, emit or out
of one value, an assignment, an argument, an operand, a comparison, a
condition or an interpolated part. It used to compile. A return or emit to
a mod’s int output stored the exec wire, mk(a) + 1 and "${mk(a)}" read
the exec wire too, m.get(k) + 1 on a map of records read one field, and
other places were placeholders. A multi-output read (x = Edge(b),
n = fs.max(), a call of a mod or chip with several outputs, flat,
through import * as or as a method) is not a record of the program’s own.
Where one value goes, it gives its first output, converted as a value of that
output’s type is. This holds whether it is written inline or held in a let,
a record field or a tuple (let t = { m: fs.max() } then f = t.m),
returned, sent or chosen. One whose first output is itself a record
(-> (p: R, q: int)) is WS003 there too, because the first output is read
once, never a field of it. A generic body is checked for every member of its
bound, so one that reads such a value as an operand (m.get(k) + 1 in
mod f<T: R | int>(m: Map<string, T>)) is WS003 at its definition even if
no call uses the record member. Narrow the bound. A tuple pattern takes a
multi-output read’s outputs in the order they are declared
(let (v, e) = fs.pop() binds Value then IsEmpty).
mod C(q: Q) -> int { return q } // before: the caller stored the exec wire
mod C(q: Q) -> int { return q.a } // after
New errors for programs that compiled
Each of these compiled before, to something other than what the source says:
WS097: aniformatchexpression of a record, tuple or enum with an arm that writes, sends or returns. Use aniformatchstatement whose arms assign avar.WS099:return <value>in a top-level handler, which drove the file’s oneoutfrom load time. Writeemit o = value, then a barereturn.WS100: a record type read as a value (let c = Box { v: n }). Give the record literal its type instead:let c: Box = { v: n }.WS103: values no one type holds written to an output with no type (out z = 5andout z = "six"). Annotate the output.WS003: a string method on a value that is not a string (vv.Length()on a vector), which read the text the value formats to. A vector’s length isMagnitude(vv), an array’s countxs.length().WS088: an array assigned a literal that spreads the same array anywhere but first (xs = [0, ...xs]), which emptied it. Usexs.insert(0, v), or put...xsfirst.WS095: a block arm (A => { k = 5 }) of amatchused as a value, whose value was the exec pin. Write the arm as a value, end it inreturn, or make thematcha statement whose arms assign avar.WS057: a bareemit oof a value output, at the top level or in a chip, which wired the exec into the value. Writeemit o = value.WS021: amodorchipbody reading a top-levelletdeclared below the call, which read 0. Declare theletabove the first call.WS044: changing a constant array or map (aconst, aletbound to one, or an array or map literal passed as an argument), also through a parameter, or storing its reference where it could be changed. Hold the values in avarand change that.WS049: an enum at achipparameter or output, or nested in a record or tuple at a port, which was an unwired pin. Only a top-levelin/outof a payload-free enum carries one, as its tag. Pass it to amod, pass a chip a*E, or pass the tag and payload as plain values.WS004: arithmetic on a*Tfield of a call’s result or of a record anif/matchchooses (mk().x + 1), which read the var the field names. The field is a ref there, as it is everywhere. Read through it with*mk().x.WS090: amodorchipwith a value output and a path that ends before the output has a value (if c { return a }with nothing after), which gave the value another path or the previous call set. Return a value on every path, or give theifanelsethat returns.WS003: an annotatedbufferwhose initializer is not of its type (buffer b: int = "abc"), which buffered the value as it was. Initialize it with a value of its type.WS028: a constant gate setting its field cannot hold, however it is written: a number outside the field’s range (SleepTicks(_, delay = 1e30),fontSize = 5000000000), which wrapped, or an enum index the field has no value for (justify = 3.0), which was stored as it was. Write a value the setting holds.WS002: a library body naming a type alias that only the file importing it declares, which read the importer’s alias. Declare the alias in the library, or import it there.WS107: atypeorenumnamed like a builtin type (type float = int), or namedArrayorRefwith one type parameter (type Ref<V> = V[]), which was ignored. Give it another name.WS002: a library body naming an enum that only the file importing it declares (var e: Dir,Dir.B), which read the importer’s enum. Declare the enum in the library, or import it there. A library’s own alias now also wins over an importer’s enum of the same name, which it lost to before.WS028: a float constant beyond the range of anintin a place typedint(n = 1e30), which was stored as the largestint.WS002: a library body reading a top-level value (aconst,let,varor port) that only the file importing it declares, which read the importer’s value. Declare the value in the library, or import it there.WS013: two declarations of one name in one anonymouschip { }. Rename one. A top-level anonymouschip { }may still declare a name the top level does (see Anonymous chips).WS013: two outputs of onemodorchipwith one name. Rename one.WS003: a multi-output read returned whole to several outputs, some but not all named like the read’s outputs (-> (Found: bool, Value: float)returningfs.min()), which filled them by position, soValuecarriedIsEmpty. Name every output like one of the read’s, or none of them to fill them in order.
A float constant that is the whole value of an int place is the integer it
truncates to, in a const and a let as in a var, with a WS106 warning.
const K: int = 2.5 reads 2, and n = 2.5 for an int var writes 2. Both
kept 2.5 before. The constant is an integer wherever it then goes (var u = K
is an int), and a chip argument and a field of a record or tuple literal
truncate the same way (a record or tuple
literal with such a field was WS003). The data of a custom event is not such
a place. A value is sent as the type it has, and a receiver that declares the
other number type still warns WS030. A constant gate setting stored as a
different value than the one written (SleepTicks(_, delay = 0.999) waits 0
ticks) now warns WS106 too. Write the stored value to silence either.
A line that starts with * begins a new statement (a deref). Before, n = a
followed by a line *h was n = a * h. A deref nothing uses warns (WSP001). To
multiply across lines, end the first line with *.
A mod or chip body uses the names where it is declared: a caller’s own
local named like a var the body uses no longer stands in for it. The call
uses the outer var, and the local gets a WS091 warning. Rename the local,
or pass it to the call.
A failed compile on the command line now ends each error with where it is,
(file.ws:line:col). A script reading the output sees that suffix.
1.1.0
Event handlers bind outputs with ->
An event’s data outputs are captured in a trailing -> (…) (tuple, positional;
the cleanest form) or -> { … } (record, by field name), not inside the
event call. The event call’s parens now hold config/inputs only (a
custom-event channel, isObject, zone, interval, a ChatCommand name/help).
Binding outputs inside the call is an error.
// before
on CharacterDied(character, killer) { … }
on CustomEvent("dmg", amount: int) { … }
// after
on CharacterDied() -> (character, killer) { … }
on CustomEvent("dmg") -> (amount: int) { … }
Config stays in the parens; only data moves to ->:
on ChatCommand("greet", "Greets you") -> (controller, args) { … }
on ZoneEntered(zone = z) -> (character) { … }
Event triggers are calls: on RoundStart()
An event trigger is written with (), uniform with the on <call> -> (…)
model. The no-parens form, a handler head (on RoundStart { }) or a
captured-event alias (let x = on RoundStart), is now an error. The () makes
it clear the event is a gate/call.
on RoundStart { … } // before -> on RoundStart() { … } // after
let x = on RoundStart // before -> let x = on RoundStart() // after
Custom-event data types move to the capture (and are inferred)
Write a slot’s type in the tuple capture, or omit it and let the compiler infer
it from the matching in-unit SendCustomEvent. When no sender supplies a type,
the slot defaults to float and warns WS042 (replacing the old WS029
“annotate this param” lint).
on CustomEvent("dmg") -> (amount: int, source) { … }
// ^annotated ^inferred from the sender
on CharacterSpawned() -> (ch) { SendCustomEvent("dmg", 5, ch) }
emit is for exec signals, not data
Set a data output with out name = value (in a handler or a chip/mod body)
or return value (a single-output mod). Reserve emit for firing an exec
signal (emit done) or ferrying a payload with a local signal you await
(emit loop = value … await loop).
General-call triggers
on <call> -> (…) works for any exec-producing call: a mod/chip call, or a
gate driven by an exec = input (on Foo(exec = go) -> (out)). on
auto-extracts the call’s exec output; a general call with no exec output (or an
exec = with nowhere to attach) is WS043.
New whole-grid events
on WholeGridInteracted() -> (character, held) fires when the grid is interacted
with; on WholeGridTargeted() -> (character, damage, weapon, weaponName) fires
when it is hit.
For the full, per-version history (features, fixes, and migration notes), see
CHANGELOG.md at the repository root.
Wirescript Changelog
2.2.0
matchtests anint,float,boolorstringtoo, with literal arms ("+",3,true,:red), ranges (1..10) and string patterns ("say " .. rest,name .. ".ws",key .. "=" .. value) that bind the text their names stand for.if letandlet ... elsetake the same patterns, and so does an enum payload (Say("/" .. cmd)).- A string pattern can also be written as one string with
${}holes:"give ${item} to ${who}"is"give " .. item .. " to " .. who. A hole named like a value already in scope is a warning (WS123), as it binds a new name rather than comparing. matcharms take alternatives (N | S => ...), built once, and guards (Some(n) if n > 10 => ...), which fall through to the next arm when false.@slicedon a loop inside another loop’s body is a warning (WS119): only the outermost loop can be sliced.
2.1.0
v.getType()andGetValueType(v)give the built-in enumType(Type.Int,Type.String,Type.Object, …), somatch v.getType() { Int => ... }branches on what ananyholds, and inside theIntarm (or anif v.getType() is Type.Intblock)vis anint.GetValueTypegave anintbefore; test withis Type.Intinstead of== 1.anycan be stored:var x: any,any[]andMap<K, any>hold values of every kind, each keeping its own (the game’s Any Value). A map key still cannot beany.GetCustomProperty(...).Valueis anany: an annotatedletgives it a type, and operators take it directly.v as entity.GetLocation()is one clear error saying to write(v as entity).GetLocation().- Hovering a builtin shows what its gate does, from the game’s gate list.
- Hovering a narrowed
anyshows the type it has there and the test that narrowed it. ishas a hover, and colors as a keyword in VS Code.- Fixed: the language server crashed as you typed into an empty file.
- Fixed: the VS Code extension’s built-in server crashed on completion, and stayed stopped until the window was reloaded. Completion now reads the folder only for a
$./path, and a crashed server restarts.
2.0.4
on Loaded() { ... }runs once when the chip is placed, or loaded with a world or prefab (the Load Event gate); use it in place ofon ReadBrickGrid(), which the game no longer runs at load.entity.SetVisibleTo(visibleTo?)shows a physics object or dynamic brick grid to one player or team only; leavevisibleToout to show it to everyone again.
2.0.3
arr.join(separator?)joins every element of an array into onestring, withseparatorbetween them (the Join Array gate, also callable asJoinArray(a, sep?))BrickChangedandBrickRemovednow bindbrick(the added, changed or deleted brick) andcharacter(the player who did it):on BrickChanged(zone = z) -> (brick, character) { ... }.
2.0.2
- Fixed: in the playground, a layout argument that set one
Vector2Daxis (anchorY = 0.25alone) wrote 0 to the other axis instead of the gate’s default, and hover showed no gate defaults. - Fixed: the language server crashed on a deeply nested program (around 300 nested
ifblocks) instead of reportingWSP001. - The playground now builds the same save as the CLI. Two layout and lowering steps depended on hash-map order, which differs on 32-bit targets.
wirescript fmtformats.wsfiles in place (--checklists the unformatted ones), following.editorconfig: indentation,max_line_length,end_of_lineandinsert_final_newline. The playground and the language server use the same formatter, which now splits over-wide lines and honours// fmt-ignore.- Fixed: the formatter counted brackets inside block comments and inside
${...}with quotes, indented a}/else {pair and a statementelse ifone level too deep, and rewrote nested-prefab blocks. - Hovers on builtins, events, keywords, annotations and types link to their docs section, and each diagnostic code links to its entry in Diagnostics. In VS Code the links open the docs bundled with the extension.
- Fixed: go to definition on an annotation (
@left) jumped to a variable with the same name. - Fixed: the language server kept running after the editor shut it down.
- Hover on
Environment, an environment group or a setting (Environment.Sky.TimeOfDay) shows what it is and its type, also inside${...}. An enum setting names the enum as programs write it (ReverbEffect, notEBRReverbEffect), in completion too. - Fixed: words in comments and in a string’s text got hovers when spelled like a name.
${...}still hovers.
2.0.1
- Ref initializers are baked into the save:
var h: *int = &a,var refs: (*int)[] = [&a, &b],var rows: int[][] = [row0, row1]andvar m: Map<string, int[]> = { "x" => row0 }start out holding those refs, with no gates. Each must name a var, array or map declared above it (WS003). Requires brdb 1.0.1. - Fixed: a ref sent as custom-event data (
SendCustomEvent("ch", &x), or a var holding a ref) typed its data port as the target’s type, a numeric variant that can turn the ref into the number 1.SetCustomProperty’s value port now takes its type from the value wired in, as event data does, instead of the float variant. - Fixed: a
fnreading a named constant that stays a gate (a stringletinterpolated beside a runtime value) wasWS113. The function’s plane now gets its own copy.
2.0.0
Functions, loops and references as values lead this release, with components, bricks and player dialogs from the latest game build.
Functions
A fn is written like a mod but compiles once: its body becomes one function definition plane, and each call is a single Function Call gate, so ten calls cost ten small gates rather than ten copies of the body. The body reaches outer vars, arrays and maps through handles baked into the plane, and trailing parameters take default values (mod parameters do too).
var total: int = 0
fn add(x: int, y: int = 1) -> int {
total += x
return x + y
}
in go: exec
on go {
let a = add(3) // y defaults to 1
BroadcastChatMessage("${a} ${total}")
}
Outside a handler, a call fires on exec = trigger, and on f(..., exec = t) -> (r) { } runs when it completes. A fn cannot call itself (WS020); WS111-WS115 cover its other limits.
Loops
for iterates a range, an array or a map. Each loop is a callable loop microchip that finishes within the tick, or spreads across ticks with @sliced; let run = for reads run.aborted, run.iterations and run.running afterwards.
in go: exec
var total: int = 0
on go {
for i in (0..100).step(2) {
if i > 50 { break }
total = total + i
}
}
References as values
A *T ref is a value: &x makes one, *h reads or writes through it, and vars, arrays ((*int)[]) and maps hold refs, which also travel as custom-event data. Arrays nest (int[][]). A brick holds a brick set in the editor, and GetMicrochipBrick() is the program’s own microchip brick.
var a: int = 0
var h: *int
on ReadBrickGrid() {
h = &a
*h = 5 // a is now 5
}
Comparing a ref or brick with a non-ref is WS084, * on an array ref, map ref or brick is WS085, and a ref to a ref is WS086; an if-expression may choose between refs and bricks.
Components and bricks
brick.GetComponent($Component/PointLight) and AddComponent give a Component<PointLight> whose properties read and write as fields, and brick.RemoveComponent(...) removes one. A component reference can be stored in a var, array or map. $Component/<name>/<property> names a property, and the editor completes both; an unknown name is WS110.
in go: exec
on go {
let r = SweepSimple(1000.0, detectBricks = true)
on r.Hit {
let light = r.HitBrick.AddComponent($Component/PointLight)
light.Brightness = light.Brightness * 2.0
}
}
GetBrickProperty/SetBrickProperty read and write a brick’s color, visibility and collision, Sweep/SweepSimple give the HitBrick, maps take brick keys, and keys(dest, includeDead) lists keys whose brick is gone.
Also new
- Player dialogs:
ShowOkCancelBox,ShowYesNoBoxandShowTextInputBox, andShowMessageBoxnow answers too, each withClickedBy,OkClicked/YesClickedandDeclined. value as Tconverts with the game’s Convert gate, one gate per value that changes type (WS109for a conversion it cannot make yet).- Entity custom properties:
SetCustomProperty,GetCustomProperty,RemoveCustomProperty,CopyCustomProperties, andGetValueType(x). ContainerChange(c)fires when an array or map changes,entity.SetVisible(visible)shows or hides a physics object or dynamic grid, andEnvironment.Sky.TimeOfDayreads and writes an environment setting.overrideColorson the advanced inventory calls takes a runtimebool.projectileScaleonAddInventoryItemAdvandSetInventoryItemAdvscales the size of the weapon’s projectiles.@color(c)and@material(m)set the color and material of a chip’s, avar’s or the root chip’s brick: a constant on the brick itself, a runtime value wired live through a Brick Property Changer;@materialtakes the new built-in enumBrickMaterial.compile --dump-irprints the compiled program as.wsirtext (--emit-ir <path>writes it to a file), andwirescript assemblebuilds the same save from it.
Changes
These are breaking:
ControllerLeftno longer bindscontroller; useuserIdanduserName(WS010).- A
varinitializer reads the names around it, as alet’s does, and a file uses only what it declares or imports (WS002,WS036forx.f()). WS003for a read returned whole to outputs named partly like its own, a record where one value goes, an annotatedbuffer’s initializer, and a string method on a non-string.WS090for a value-returningmodorchipwith a path that ends before its outputs are set.WS004formk().x + 1on a*Tfield of a call’s result (write*mk().x).WS107for atypeorenumnamed like a builtin type, orArray/Refwith one type parameter.WS028for constants anintor a gate setting cannot hold.- An expression the compiler cannot build is a
WSP001error thatws-ignoredoes not silence. openis no longer a keyword;open chipis now justchip.
Fixes
- Evaluation: expression parts run in source order, enum payloads and record-literal calls run once, a whole-record assignment is parallel, and
xs = [...xs, 1]appends (WS087-WS089). An output set on several paths takes the value from the path that ran, and one written in several places no longer fails to load. Aniformatcharm that writes, sends or returns runs only when taken (WS097,WS102). Misusedemitandreturn, and an exec pin wired into a value, are errors (WS057,WS007,WS013,WS094,WS095). A var also held in a record keeps its read across anifthat does not write it. - Control flow: an
_arm catches a tag that names no variant instead of being dropped (WS061), a statement after abuffer emiton its chain is aWS058warning and is not compiled, a back-edge loop inside anifre-arms when its signal is declared in that branch (WS118otherwise), and an enum or record returned from amodbranch outside a handler isWS007. - Names and imports: a
modorchipbody resolves names where it is declared, and a local named like an outer constant, alias or variant is its own value (WS091,WS021,WS046). A name declared twice at the top level is read by position (WS108). A named argument to amodbinds its parameter instead of being dropped, and a namespaced call takes defaults and named arguments. Importing a declaration brings the names its handler settings andbufferinitializers read,selfmods travel with a named import, and an unknown handler trigger is reported once. - Types and values: multi-output reads, enums, generics (
WS096) and type aliases keep their types through outputs, returns, joins, destructuring, imports and spreads. Integer constants are floats infloatplaces (WS104beyond 2^53), float constants inintplaces truncate (WS106), and unannotated values take their joined type (WS103,WS105). Forms that compiled to placeholders are errors (WS098-WS101). A nonzero float in aboolarray or map saves astrue,average()always gives afloat, and aconst modcall takes parameter defaults and named arguments.x == nullandx != nulltest for an unset object, brick or ref. - Math:
abs,min,max,clampandDampenkeep their argument’s type (max(3, 7)is theint7, aboolreads as anint, vectors, rotators, quats and colors work per component),Remaptakes lowercasefunction =anddirection =asEasingdoes, andf -= STEPsteps anintby an integer. - Containers: arrays and maps behind fields, calls, namespaces, constants and literals act as on a plain
var(WS044,WS049,WS027), their methods take named arguments, lost initializers are stored, and a handler-local recordvarresets every run. - Custom events: every receiver of a channel and typed
await CustomEventcaptures are checked (WS030). - Captured events:
let x = on <trigger>names any trigger, its config andUnion(...)included. A captured event runs its body, applies its trigger’s config and binds outputs with->, and a capturedCustomEventgets a handler’s checks (WS030). - Output:
n -= dcompiles to a single increment. AFmtwith constant inputs that uses{A}-style inputs,:options or|plural/|ordinalstays a real gate instead of compiling to the wrong text. A number, bool, vector, color or player wired into astringpin, var or parameter goes through a Format Text gate, as the string coercion rule says. A chip’s input and output gates stack in declaration order, matching its side pins. - Diagnostics: new warnings for
on a || bwith an object operand, which fires once and never again (WS116, useon Union(a, b)), and for a comparison used directly as an operand of&,|or^(code & 4 == 4,WS117);anyon anin/outport no longer warnsWS032, since a port cannot take a type parameter. - Editor and tooling: rename, references and hover follow the compiler’s scope rules; hover, completion, inlay hints and gate estimates cover
rotator/quatcomponents,eventName, andif let,let elseandmatcharms; the playground completes and inserts like the language server, and highlighting knows every keyword, type and event; an edit is no longer skipped when another file is edited right after;>>and>=close nested type arguments; the VS Code extension removes its temporary server copy and Compile and Copy handles quotes on macOS; the SDK compiles prefab references and subfolder imports; the tree-sitter grammar reads any expression as an annotation argument, so@label(name),@color(...)and@material(...)parse.
Performance
- Compile and editor time no longer grow quadratically with program size, in lowering, layout, emit, type checking and editor requests (a 36x speedup for rename on an 8k-line file).
just scale-benchandjust editor-scalefail a stage or request that grows faster than linear.
1.11.1
// ws-ignore-line:WS014silences that warning on its own line, or on the line below when the comment stands alone;// ws-ignore-file:WS014silences it for the whole file. Both take a comma-separated list of codes, both cover every warning when written bare, and neither silences an error.
Fixes
var a: int[] = []andvar a: float[] = [1, 2]keep their declared element type inside a handler, as they already did at top level. A local array literal fell to a whole-array coerce that refusedany[]intoint[].wirescript-checkreports diagnostics from imported modules. It filtered them to the file named on the command line, so a rejected program printedno errorsand exited 0.- A program nested more than 400 levels deep is a
WSP001error. Every later pass recurses over the tree, and a stack overflow aborts rather than panicking, so the editor died silently. cargo clippypasses on the workspace. A loop in import resolution that could only run once and four quaternion test constants were hard errors.import { x } from "lib"warns (WS014) when it leaveslib’sonhandlers behind. A handler binds no name to select, so the named form never brought one in and nothing said so.- A module reached by two import paths installs its handlers once. The diamond check is keyed on the names a declaration binds, and a handler binds none.
- An
enumcan be imported. Listed as neither importable nor named, a named import answered “not found” and a whole-module import left the type unknown. - Two modules that each declare the same top-level
letis aWS013, as it already was for amod/chip. Shadowing within one file stays legal. - A record field or container element written as an assignment target is type-checked.
p.x = "wrong"on anx: intanda["str"] = 1on anint[]compiled clean. - An array literal’s element type is the widening join of its elements, not element 0’s.
[1, 2.5]typedint[]and rode the float into an int-variant gate. var c: exec,var c: never,int[][]andMap<int, int[]>areWS025errors. A storage gate holds one wire variant; the check listed onlyzone/teleport/prefab/any.- Both quote styles read the escape table the syntax reference publishes. The single-quoted reader had no
\ror\0. - A doc comment stays with the declaration it was written on. The table was keyed by byte offset alone across files, so a chip’s header text could come from another module.
- A
matcharm orif letpattern acceptsShape.Circle(r), the spelling construction andisboth require. It was a parse error that cascaded through the rest of the match. - A multi-output
mod’sreturnis checked per port.-> (a: int, b: string)tookreturn { a: someString, b: someInt }and wired the string into theintgate. - A container method given
exec = triggerno longer captures the statements after it. The saved exec context was restored past fourteen early returns. on x/on !xover avarcompiles, as the statement reference documents. The check matched the var’s symbol type, which is a reference to what it stores.*xin exec context reads the variable, as the expression reference documents. The bare name auto-derefs first, so*xsaw a value where it wanted a reference.out y: *int = &xand= ref xbind the same storage asout y: *int = x. Only the expected side of the check unwrapped the reference."x ${n GARBAGE} y"is aWSP001error. The slot’s sub-parser read one expression and discarded whatever followed it.- An error inside
${...}reports at its own position. The slot is lexed where it sits, rather than shifted afterwards by a walker that missed several expression shapes. @layout("cube")no longer hangs a tall brick down through the layer below it. A layer shared one z while the step was measured from the layer beneath.- A name used only in a
matcharm, anif letor alet ... elsecounts as used. The import walkers reachedifalone, soWS014called it unused and Organize Imports deleted it. WS053reaches every branch form. The walker descended intoifalone, so the same parkedemit/awaitpair inside anif letor amatcharm went unreported.- A dangling operator before a
}no longer swallows the rest of the file. Error recovery consumed the token it failed on, brace included, so nothing after the block was parsed. Timer(10.0)and the handler it drives survive folding. The dead-chain sweep seeded every gate with an unwired exec input as untriggered, but aTimerfires on its own.- A
modreturning from inside a branch, called purely, reports at the call site. It handed back the never-allocated node as a port, so the program lowered clean and failed at emit namingn0. - A doc comment in a CRLF file does not keep its
\r. The text ran one byte past its end, and a doc comment is baked into the world as a chip’s header. - Two sources into one brick port is caught wherever the wire is drawn. The check was keyed on the IR node and local to one module, so the bus lanes,
@sidererouters and@labelwires bypassed it. - A file starting with a stray
),},]or,is a parse error rather than a hang. Recovery leaves a closing token in place, and the top-level loop had no no-progress backstop, so the compiler spun until it ran out of memory. -1_000.5reads as-1000.5. The negative path skipped the digit-separator strip and swallowed the failure as0.0, and1eand1e999baked0.0andinfin silence.- A parse error at the end of a
${...}slot reports where it sits. TheEoftoken bypassed the offset shift, so a line-6 error landed at1:6. - A
matchorif letpattern naming no variant of the scrutinee is aWS067error. It read as an irrefutable binding, so a typo swallowed every later arm andif letdropped the test entirely. var s: string[] = [1, 2]is aWS003. An array element is baked as a constant with noFormatTextgate to run through, so a string coercion shipped empty strings; the map path already refused this.- Two modules with a same-named private helper stay apart. The named-import closure matched on the name alone, so an importer silently called the other module’s
helperwith no diagnostic. - Two modules that each privately write
import * as Uis aWS012. Both flattened to one program-wide binding, so they type-checked clean and then both called whichever registered last. import { addK, K as KK }leaves the library’s ownKbound. The closure was keyed on the pre-alias name, so the module’s ownx + Kbound to whatever the importer calledK.arr[i].Valuereads the element, asarr[i].valuealready did. The field list was case-sensitive and omitted the capitalisation the docs teach, so it lowered to an_Unsupportedplaceholder.xs.sort(descending = true)sorts descending. Only the first positional argument was read, so the named form was dropped and the sort silently ran ascending.let r = ref someVarbinds the variable, as the expression reference documents. Arefin a value position fell to the_Unsupportedcatch-all and the binding was dropped.- A chip’s array, map or
refparameter wires the rerouter’s own port. Its pin was bound as storage, so every read namedArrayVarRefon aMicrochipInput, a port the game does not have. - An anonymous
chip { }inside a named chip keeps its body. The chip-body pre-declare pass never descended into it, so its vars, handlers andoutbindings were dropped and the chip emitted no gates. - A handler whose trigger cannot resolve is a
WS001and takes nothing with it. It was deleted in silence, and the exec exits it dropped left the following statement with no chain to run on. on go { let y = { 1, 2 } }is a parse error rather than a hang. Braces where the language wants brackets allocated until memory ran out, taking the language server with it.m[k].Foundreads the map lookup’s found flag, asm[k].bFoundalready did. That spelling compiled clean and produced a dead circuit.- An anonymous
chip { }inside a named chip emits onto its own sub-grid. The labelled box was dropped and its gates went flat onto the enclosing chip’s grid. static var x: int = 1 + truebakes the value the gate returns. Mixed-operand arithmetic dropped the initializer and the var started at its type default.- Two modules that each contain an
on <call>() { }handler can be imported together. The duplicate-declaration check fired on a name the compiler generated, and told the user to rename it. on v.Prev { ... }runs, ason v.prevalready did. That capitalisation dropped the whole handler with no diagnostic.- A void builtin used as a value is a
WS072, as a no-outputmodalready was.v = PrintToConsole("x")compiled clean and produced a dead circuit. - Two always-live
outsites that each carry a value are aWS013. Both drive the port at once, so the value it reads is undefined. GlobalCustomEvent(CH)inside a larger expression bakes its channel whenCHis aconst, as it already did as a trigger. That path folded no constants, so the receiver listened on no channel.a.get(exec = t, 0)reads its positional argument, asa.get(0, exec = t)already did. Eleven array methods indexed the raw argument list, so a leading named argument shifted every position.mod m(p: vector) { on p { } }compiles, asin p: vectorandvar p: vectoralready did. The handler check spelled the trigger-type list by hand and one copy omittedvector.- The language server reports an import error against the buffer being edited. Diagnostics re-read the file from disk while the rest of the analysis used the open document, so a fixed import still showed
WS002. - Two bricks that would overlap in the emitted world, or a wire naming a port its component lacks, are errors. The game drops an overlapping brick at load, orphaning its components and dangling its wires.
Migration
Two new errors reject code that used to compile. Neither fires on any program
in wirescript-misc/projects.
WS072: a void call used as a value.v = PrintToConsole("x"),v = arr.push(1),v = Ns.voidMod(1). Call it as a statement, or give themodan output (-> (r: int)).WS013: two always-liveoutsites that both carry a value.out o = x + 1thenout o = x + 2. Give the port one value, or write it withemit o = ...from a handler. Handler-body sites are unaffected.
Editor
- A warning offers “Ignore WSxxx on this line” and “Ignore WSxxx in this file” quick fixes, which write the matching
ws-ignorecomment. - The playground reports
WSP001placeholder warnings. A program that lowers to a dead gate looked clean and still offered its.brzfor download. - The playground compiles the same source to the same bytes every time. Every compile after the first in a page session produced a different circuit.
- An unbarriered wire-graph cycle is a
WS005in the playground, as it already was everywhere else. - Top-of-file
@invisibleand@layout("cube")apply in the playground, and a chip keeps its header text. - Semantic highlighting on a large file is 26x faster: 1,257 ms to 48 ms on a 257 KB program, with identical output.
- A cursor past a non-ASCII character no longer kills the language server or traps the playground. Nothing converted between byte, char and UTF-16 columns at the protocol boundary.
- Hover, completion and go-to-definition work in a CRLF file. Line offsets summed
len + 1, which drops the\r, so every position past line 1 drifted. - The language server runs parse, resolve, typecheck and hover on the big stack
compilereserves rather than the ~2 MiB editor worker thread. - Closing a file clears its diagnostics. They belong to the server until it says otherwise, so every marker stayed on screen for the rest of the session.
- Rename and find-references resolve the symbol under the cursor on a line holding non-ASCII text. They took a char column and compared it against byte columns, so the cursor shifted left and rewrote a different identifier.
- Diagnostics from an imported file publish to that file. The server kept only the entry file’s share, so a marker for
util.wsreached no buffer and the editor showed a clean file for a rejected program. - The Compile command’s diagnostics land on the right column. It handed the editor raw byte columns, which put the squiggle right of the token on any line holding non-ASCII text.
- Completion inside a
matcharm no longer panics with the cursor just past an accented character, and a whole-document format edit no longer names a position one line past the end.
Performance
- Hover answers without re-parsing the file.
hover_atre-parsed its whole source at eight sites in one request. - The language server waits 150ms before analysing an edit, and analyses only the newest one. Typing outran the front end, so intermediate states were analysed and thrown away.
- Lowering reads a wire port’s interned name from the port’s own cache. Around thirty sites re-interned a name the port already had.
- Lowering allocates 24% less on a large const-heavy program. Its constant lookup rebuilt the merged scope table per expression rather than per scope change.
- An import borrows the module it reads rather than deep-cloning every declaration first. A named import that takes three names out of a hundred cloned all hundred.
- Code layout sorts its adjacency once when building it rather than per node visited, so adopting unplaced nodes no longer re-sorts the same neighbour lists thousands of times.
- Constant-literal inlining indexes the surviving wire sources once instead of rescanning every wire per candidate, and interns its port names once rather than per wire.
- The type checker shares the module constant table rather than deep-copying it on every constant evaluation.
- One hover allocates 89 times rather than 54,248 on a 257 KB program.
atom_atre-lexed the whole file at every cursor position, including inside comments. - The constant-environment fixpoint shares its table rather than deep-copying it twice per binding per pass, cutting typecheck allocations 31% on a large const-heavy program.
- Debug and test builds compile with
opt-level = 1, so the integration suite exercises an optimized compiler:cargo test -p wirescriptruns in 4.4s where it took 17.1s.
1.11.0
outports are readable by name, and a top-levelonhandler can declare its own port:on Clock { @top out flash: bool = Toggle() }.- An
outwritten directly in an anonymouschip { }is a module-boundary port too, readable by name from anywhere in the file. - An
outwith no port to bind to is aWS073error rather than a silent drop of the binding, its driver and its value. s[i]reads one Unicode character from a string, in pure context as well as exec, and folds when both sides are constant.<call>.execbinds a call’s exec output without joining the current chain, so the call runs as a branch while the chain carries on, andemitin value position names the chain point it forks from.
Fixes
- A constant string operand bakes into the concatenation that consumes it instead of carrying a gate of its own, so
"n=" .. nemits one gate rather than two. on WholeGridTargeted()compiles. Its trigger port was missing from the wire-port table, so the event panicked the compiler instead of lowering.- A self-referential record alias (
type L = { tail: L }) used as a variable or a port reports its error instead of exhausting the stack. .length()on aconstarray or map folds to its value in pure context. The map form wired the container reference in place of the value with no diagnostic.- A
*Tparameter is invariant, since it aliases the caller’s storage rather than riding a wire: binding one to a differently-typed variable is aWS003.
1.10.1
Fixes
- A
matchexpression whose arms are records or enum values compiles, choosing per leaf field. The record spelling dropped the statement silently; the enum spelling was aWS071per arm. let m = if c then a else b/let m = match s { .. }bind a record per field, calls in an arm included.- A multi-output result chosen by an
if/matchpicks each port separately. One Select over port 0 meantc.Foundread the value andbFoundwas wired nowhere. - A
mod/chipwith one record-typed output hands back the record itself, as typecheck already reported. Keyed by the output name,r = mk(1)wrote no field. (match s { .. }).fieldreads that field, distributing over the arms likeifdoes.- A
modrecord argument accepts a container element or a record-valuedif/match, as itschiptwin already did. - An expression with no value read as one is a
WS072error. A no-outputmod/chiptyped asany, soo = noret(1)wired the caller’s exec continuation in as data. - A namespace reached two levels deep resolves. The seal over a namespaced body also hid the alias that module imported for itself.
- A namespaced
letinitialized through such an alias carries its type, so reading it is no longer aWS002. - An
import * asalias is file-local, so an importer and the module it imports may both use the same one. Both tables were keyed by name alone, so the inner namespace was dropped and every reference through it was an unknown identifier. - A namespaced record type (
Ns.Point) resolves to its fields, so a var or array declared with one decomposes and keeps its element type. It typed asanyand loaded empty. - Two
import * asin one file under the same alias is aWS012; unreported, the second shadowed the first and every reference read whichever module came last.
Editor
- The LSP watches the workspace’s
.wsfiles, so a module changed or created on disk while closed refreshes the files importing it. Only edits to OPEN documents did, leaving stale diagnostics about a version that was gone. - A record-literal key hovers wherever the literal is written, including a
var, an array initializer, and a literal nested in another; onlylet x: T = {resolved before.
1.10.0
value is Enum.Varianttests which variant an enum value holds, the shorthand for comparing discriminants. It binds like==, and a test whose two sides are both constant folds away.
Fixes
on Clocktakes onlyintervalandenabled.pulseOn,onTimeandoffTimeare inert fields of the gate’s data struct, so passing them baked config the gate never reads; naming one is now aWS041error.
1.9.0
- Const-heavy programs compile about a third faster. Lowering deep-copied the whole constant table on every expression it lowered.
- A program with many enum variants and match arms compiles dramatically faster. Exhaustiveness checking and match lowering were each cubic in the arm count.
- Large programs compile 10% to 15% faster, from fewer allocations in the fold and CSE passes and faster hashing in emit and layout.
- A source prefab referenced from several sites is compiled once and embedded once, so a program that spawns the same
$./file.wsrepeatedly produces a much smaller bundle. - Deeply nested chips, deeply nested record types, and files with many imports no longer slow down faster than they grow.
brdb
- Updated to brdb 0.11.0, which writes a save roughly three times faster.
- Each schema struct compiles into a write plan once and replays per instance, instead of re-resolving every field’s type for every component.
- Brick and wire chunks use specialized encoders when the loaded schema matches the expected shape, and fall back to the generic writer when it does not.
- Archive blobs are compressed in parallel.
Fixes
- A union-trigger handler (
on a | b { emit r = v }) compiles. Its body lowers once per part, so one emit became two drivers on the output and failed at emit with no earlier diagnostic.
1.8.1
- Refreshed the baked gate inventory from the current game build: the string gates carry their clearer display names (
Containsis nowString Contains,Format Textis nowFormat String,Lengthis nowGet String Length). No gates, ports, or behavior changed. - Added an external-asset catalog (
data/asset_inventory.simple.json) covering 832 assets across 8 descriptor types, generated byscripts/gen_assets.mjsfrom the in-game dump. Weapon one-shot audio is renamedBOSA_*toOSA_*, the smoke grenade is gone, and aircraft wheel-engine audio plus an electrical-arc particle system are new.
Fixes
- Reading a reference-typed record field yields its value, like reading a
var.var f: float = state.aon a*Tparameter reportedexpected float, got *float, and an if-then-else over two such fields reported a branch mismatch. rec.field.Valuereads the field’s backing variable. Onlyx.Valueon a plain identifier resolved, so the field spelling lowered to a placeholder and left its consumer unwired.- A record literal passed to a
*Tparameter is aWS008error when a reference field has no variable behind it. Aninport or expression has no storage to write back to, and the call previously compiled with the caller wiring a value where the body read a reference. - A
chipwith a*Tparameter binds each call site to its own argument. Every call after the first reused the first instance’s captured variables, so writes through the reference landed on the first argument and the later arguments’ variables were left unwired. &xandref xbind achip’s reference, array, record, or map parameter to the caller’s storage. Only themodform stripped the sigil, so the chip form left the parameter’s pin unfed and silently dropped every write through it.
1.8.0
*Ton a record, tuple, or enum parameter distributes over its fields, so a chip or mod can write through to the caller’s storage (chip M(s: *T) { s.a = x }). None of these has a single wire to reference, so the ref now names each backing gate: a record’s fields, a tuple’s elements, or an enum’s discriminant and payload slots.- Tuple variables get one backing gate per element, so
g.0 = vwrites. A tuplevarcollapsed to a single gate and element writes emitted nothing.
Fixes
- A field read directly on a multi-output chip call (
Chip(x).field) wires the named output. Only the two-steplet r = Chip(x)/r.fieldspelling resolved it; the inline form compiled to a placeholder wired to nothing. - A record used where one value is expected is a
WS071error instead of a silent placeholder. A record is several wires, so"x=" .. rechad no value to concatenate and dropped the operand.
1.7.4
unsafe <value>.<Variant>.<field>reads or writes one enum payload slot directly, without testing the tag. Reading a variant the value is not returns stale contents, and writing leaves the tag alone. Contextual keyword, so the name stays usable.
Fixes
- A declaration inside a
chip { }nested in a handler initializes on that handler’s chain when its initializer calls something. It was held off the chain like a top-level chip’s, which dropped the call and then reported a missing exec context.
1.7.3
Fixes
- Assigning a
match/if let/let elsecapture writes the matched enum’s payload slot in place, when the scrutinee is storage. A container element is read by value, so its captures stay read-only. - Assigning a field with no storage behind it (an enum payload field, a misspelled record field, a field of a scalar) is a
WS007error instead of compiling to nothing. - An enum payload field that needs container storage is a
WS069error, at the declaration or at a generic instantiation that picks one (Option<int[]>). The slot could never be filled, so the value read back empty.
1.7.2
Fixes
- A record or enum value spans several wires, and every position that collapsed one to a single wire is fixed. Most of these compiled clean and emitted nothing at all.
- Producing one: a record-valued
if/then/elseselects per leaf, in assignment,outand chip-argument position alike; a record-returning call used as a nested record-literal field writes every leaf, where the array and map forms had left the columns at different lengths; an enum payload that is itself a record or another enum is constructed field by field; a declaration initializer bakes a record payload instead of zeroing it. - Storing one: an enum-element array or map decomposes into parallel columns, one per tag and payload slot, so pushes and element reads carry the value; an enum-typed record field gets its own tag and payload storage rather than collapsing to one gate.
- Crossing a boundary: a nested record
in/outport creates a pin per leaf instead of stopping one level down; a record-typed chip signature output gets one pin per field, so the body emits and the caller’s pins are wired;&pon a record variable passed to a*Pparameter binds the caller’s record instead of dropping the whole body. - Copying one: a whole-record copy carries array and map fields instead of skipping them.
- Matching on one: a
matchorif letaccepts a container element (match m[k],match arr[i]) or a.Valueprojection off a mapgetas its scrutinee. Only a named binding of the same read resolved before, so the inline form hit a placeholder. - Referencing one:
&p.aon a record field is accepted, since each field is its own storage gate. It lowered correctly and was rejected byWS008anyway. - A record or enum as a custom-event data param is now
WS068, since a data slot is one wire and cannot carry one.
- Producing one: a record-valued
- A scalar initializer is baked in the declared type, so
var x: float = 0builds a float Variable gate instead of an integer one. Applies to every bool/int/float pair, to record and enum-payload fields, and to a var-backed output’s default (out y: float = 0). - A component read straight off a call (
d.ToRotation().ToEuler().Yaw,v.SplitVec().y) reads that call’s own port; it used to emit a second Split gate fed by the first one’s primary output, yielding the wrong component. - A local
execsignal consumed inside achipcompiles. The union cleanup redirected and pruned using one chip’s wires at a time, deleting a node the chip’s own wire still named, which failed the compile with a dropped-wire error. .Value/.prevopens a variable that spans several storage gates (a record, an enum); it used to emit a placeholder.- A
varinitializer naming a constant (var x: float = K) bakes that constant.
1.7.1
enumis a contextual keyword: it opens a declaration only when a type name follows, so it stays usable as a variable, parameter, or field name.- A
rotator’s.pitch/.yaw/.rolland aquat’s.x/.y/.z/.wnow read their components, lowering to a Split gate the way avector’s.xalready did; several components of one value share a single gate. - Each language-reference page opens with a Contents list of its sections, regenerated by
just doc-tocand gated byjust doc-check.
Fixes
- A constant
Rotation(...)passed to a quaternion port (v.Rotate(Rotation(0, -90, 0))) is converted and baked into the gate, instead of panicking the compiler.
1.7.0
enumtypes (tagged unions):enum Shape { Empty, Circle(float), Box { w: float, h: float } }supports C-like members, positional and named payloads, explicit= Ndiscriminants (later members auto-number from there; a collision isWS064), and generics (enum Box<T> { Value(T), Empty }). Enums are nominal, so two identically-shaped ones are different types..Discriminantreads a value’s tag as anint, and on a variant path (Shape.Circle.Discriminant) it folds to a compile-time constant.matchbranches on a variant and binds its payload, both as an expression (comma-separated value arms, lowering to aSelecttree) and as a statement (block arms, lowering toBranch/Union). Coverage is checked: an uncovered variant isWS054and names the missing patterns, an arm that can never run isWS061, and patterns nest into payloads including other enums. A wrong bracket form for a variant’s shape isWS065, and.Discriminantormatchon a non-enum isWS066.if letandlet elseare single-variant refutable binds:if let Some(x) = o { ... } else { ... }runs the block only for that variant, andlet Some(x) = o else { return }binds into the surrounding scope with a required divergingelse(WS062).- Built-in
Option<T>andResult<T, E>prelude enums, with their variants usable bare (Some/None/Ok/Err); a payload-less bare variant needs a type annotation to pin its parameters (WS063). - Built-in game enums for the game’s own config enums (easing function and direction, brick direction, color space, text justification and typeface): they need no declaration,
.Discriminantgives their real integer value, and a variant such asEasingFunction.Bouncepasses directly as the matching gate config argument alongside the older bare-name form. - Enum and int conversion:
value.ToInt()is an alias for.Discriminant, andEnum.FromInt(n)builds a value from a (possibly runtime) int tag with payloads defaulted to zero. TheEnumToInt/IntToEnumbuiltins (renamed fromEnumToInteger/IntegerToEnum) are the gate-backed twins: they now require an enum-typed value instead of acceptingany, fold a compile-time-known value to its tag, and use the game gate at runtime.IntToEnum’s result enum type is pinned by the target (WS063when it can’t be). - Editor support for enums: completion of variants, enum type names, and
.Discriminant; a fill-missing-arms code action; and hover and go-to-definition on enum types, variants, and named payload fields.
Fixes
- Calling a
modthat reads or writes a container in its body (m.get(k),arr[i],m.set(...)) from a pure position now reportsWS007, instead of silently wiring the container reference into the caller. This now also covers a container reached through themod’s own parameter (mod f(m: Map<int, int>) { m.get(0) }) or a body-local, not just a top-level one. - A multi-return
modwhose single output is a record now yields the branch actually taken at runtime; previously one return’s record literal leaked to the caller and folded the result to a fixed, often wrong, value. - A reference inside an
emit,await, orbufferstatement now counts as a use, so a genuinely-used import is no longer falsely reported unused (WS014) or dropped by Organize Imports.
1.6.2
- Raise microchips so nested ones are not underground
Fixes
- Reading a namespaced
outorinmember (L.count,L.level) now resolves to the port’s value instead of reporting “not found in namespace” and lowering to a placeholder. - A namespaced defaulted output with no
emit(out count = counter) is a single direct drive again, instead of also getting a backing variable that fanned two wires into its port (which failed to load). - Indexing a record-array or record-map column (
pts.x[i],m.x[k]) now reads the field’s value instead of typinganyand dropping to a placeholder; it works in expressions (a comparison, arithmetic), not just a barelet.
1.6.1
.execnames an event’s exec output, so a data-carrying event composes intoUnion(...);Unionalso takes an exec receiver, soa.Union(b)chains left-associatively.
Fixes
- An inline record-returning
modcall read by field (f(x).sum) now projects the field instead of an_Unsupportedplaceholder. - A record-typed module output (
out p: Point = { .. }) now dissolves into per-field boundary pins instead of dangling. - A whole-array assignment from a record-array literal (
arr = [rec, rec]) now rebuilds the array instead of being silently dropped. - A constant read through an
import * as nsnamespace (ns.NAME) now folds, so it works in constant-only positions (array initializer, channel name, label). - Spreading a tuple that came from a multi-output
modcall (f(n, ...g()), or a boundlet t = g()thenf(n, ...t)) now expands its elements instead of dropping them. - A field or index access on an aggregate-typed
ifexpression ((if c then a else b).x,(if c then a else b)[i]) now distributes over the branches instead of dropping to a placeholder. - A nested record passed as a chip parameter now wires every field, instead of collapsing the inner record to one unwired input.
- A tuple parameter on a chip (
chip f((a, b): (int, int)) { }) now binds its elements, instead of leaving the body reading unresolved values. - LSP: hovering a record field written through an array index (
arr[i].field = v) now shows the field’s type. - An
onhandler in animport * asmodule no longer resolves a free name against the importing file’s state; an undefined name there is now reported instead of silently wiring to a same-named importer variable. - State imported from one file both plainly and through
import * as(or viaimport { g }plusimport { g as h }) now shares one storage gate instead of duplicating into two that drift apart. - A namespace a module imports privately (
import * as B) no longer leaks to files that import that module; naming it from another file is an undefined identifier. - A namespaced output written by more than one
emit, or by a conditional or defaulted one, now routes through a backing variable instead of fanning two wires into its port (which failed to load).
1.6.0
- A
...tuplespread now expands into a call’s positional arguments.SendGlobalCustomEvent(name, ...t)splats a tuple across the event’s data slots, andf(...t)/f(a, ...rest)binds each element to consecutive parameters (a tuple literal...(a, b), a bound tuple, or a field chain reaching one). Over-filling a fixed-arity callee reports the usual arity error, and spreading a non-tuple is aWS003. - A
modcan take a trailing...restvariadic parameter that captures every argument past its fixed params. Each call site bindsrestto a compile-time tuple of the extra args, and a...restin the body splats them onward, somod broadcast(name: const string, ...rest) { SendGlobalCustomEvent(name, ...rest) }forwards any number of values. A call must still supply the fixed params (fewer isWS022). Onlymods may be variadic; a...reston achipisWS052. await CustomEvent("c")can now capture the event’s data inline:let n: int = await CustomEvent("c")suspends until the event fires and binds the first data output (typed by the annotation). A tuplelet (p, t) = ...captures data outputs positionally. Annotate the type or the wire defaults to a float (WS055warns when it can’t be determined). Bareawait CustomEvent("c")(no binding) just waits.- A tuple return’s parentheses are optional:
return a, b, creturns the same tuple asreturn (a, b, c), and.0/.1access the elements.
Fixes
- Compile progress now accounts for embedded prefabs: the reported step total grows by one per
$./filereference and inline$```...```block, so the editor indicator advances through them as it compiles. - The editor’s compile status indicator shows only while a compile is actually running.
SpawnPrefab(..., destroyAll = sig)fed a locallet sig: execsignal wires the signal into the spawner’s destroy pin, so those prefabs are destroyed (a localexecletfolds to a placeholder0, so it needs this dedicated wiring).wirescript-checkruns through lowering, so it surfaces the same_Unsupported-placeholder warningscompileemits.- New
WS053: a plainemit Xin the same chain as a followingawait Xwarns that the kick fires before the await arms (parking the chain forever); usebuffer emit X. - A record passed to a
chipwires its fields: a record literal argument (Foo({ x: a, y: 2 })), a field whose value is avar(read by value, not through the reference port), and a destructured chip parameter (chip f({ x, y }: P)) all bind correctly. - Assigning a field of a
let, input-port, or literal record (p.x = 5wherepis not avar) reportsWS007. Avar-backed record field stays assignable. ==and!=between two whole record values reportWS004. Comparing a multi-output result to a scalar (arr.pop() == 5) still works.- A
...restparameter in a destructuredmodparameter types as the record of the remaining fields, and a tuple-typed field inside a record annotation (type T = { pair: (int, int) }) type-checks correctly. - A record literal used as a spread source (
{ ...{ ...p, y: 2 }, x: 9 }) or read directly (({ x: 1, y: 2 }).x) resolves its fields. - A nested record inside a record array or map decomposes to leaf columns like a flat field:
arr[i].inner.a/m[k].inner.aread and write a single leaf,arr[i].inner/m[k].innerread and write the whole sub-record, andlet row = arr[i]binds the element as a record value. - Namespaced member access is stricter. Reading a missing or non-value member (
L.nope, or a namespacedoutport) and a nested path (A.B.bar()) reportWS002; a named-argument call through a namespace reportsWS022when the positional count is wrong. A namespaced container read (S.scores.get(k)),&S.g, and an import used only as a handler trigger type-check cleanly. - An
ifexpression written directly inside a string interpolation ("${if n < 10 then "0" else ""}${n}") emits its comparison and select. - Passing a non-lvalue (a literal, expression,
let,arr[i], or call result) to a*T/refparameter reportsWS008. - A statement written after a nested
onhandler inside a handler body stays on the outer exec chain rather than binding to the nested handler’s trigger. - New
WS057:emit Xis an error whenXis not anoutport or alet ...: execsignal (an input port, avar, or anout/signal declared outside an enclosing namedchip). - New
WS056:let v = await sigon a signal that carries no payload is an error. Emit a value (emit sig = ...) or capture one withawait <expr> on sig. - New
WS058(warning): an exec statement that never runs is flagged - A tuple
return (a, b)to amodwith named multi-outputs wires each element to the matching output in declaration order, solet (a, b) = f()reads the returned values. - Three constructs that type-checked but built no working circuit now report an error instead of compiling to a silent miscompile: a component field that isn’t on its type (
v.ron a vector,color.x) reportsWS010rather than emitting a wrong-typedSplitColor/SplitVector; a negated-union or double-negation handler trigger (on !(a | b),on !!x) reportsWS001rather than dropping the whole handler; and assigning to a non-lvalue (f() = 5) reportsWS007rather than dropping the assignment. - Emit rejects a wire fan-in (two sources driving one input port, such as a duplicate
out o) with a compile error, rather than writing a format-valid save the game refuses to load. - Unary negate (
-x) resolves correctly during generic-modmonomorphization, so a generic-xemits a negate gate with the right numeric type. - A constant
Substringwith a very large length clamps to the string end instead of overflowing into a panic. - New
WS059:Change/Changed(and theEdgedetectors) on a reference or container (aMap, an array, a*Tref, azone, or ateleport) is an error, since a change/edge detector watches a single wire value and those carry none. Watch a scalar the container produces instead. - A map access gate (
m.get(k)and friends) is colored by its value type, like the var and array access gates, rather than a neutral grey. - A get/set on a reference-passed container input (
in m: Map<K,V>/in xs: T[]) is tagged with the input port’s name, matching how an access on a storedvaris tagged.
1.5.0
- Added a
nullliteral. It adopts its target type and produces that type’s zero/unset value (entity/character/controller-> an unset object, a number ->0,bool->false,string->"", vector/rotation/color -> zero) in any typed position: avar/outinitializer, an assignment, a call argument, or a record field.nullfor a container, record, or reference-only type has no value and reportsWS051; a barelet x = nullwith no target types asany. - Records can now be stored. A record variable, array, or map (
var p: Point,var pts: Point[],Map<K, Point>) decomposes into one backing gate per field, so field reads/writes, whole-record assignment (p = { x, y },p = q), and the container methods all lower to real gates: arrays getpush/pop/insert/remove/fill/resize/swap/reverse/clear/lengthplus element access (pts[i],pts[i].x,pts[i] = rec,p = pts[i]); maps getset/get/has/remove/clear/length/keysplusm[k]access. A record variable used to collapse to a single gate whose fields read a bogus vector swizzle, and a record pushed into an array lowered to a placeholder that did nothing. - A stored record with a field that has no storable value (a
ref,zone,teleport, prefab reference, orexec) now reportsWS049instead of silently backing it with an unusable gate. - A record-container method with no per-field meaning (
sort/shuffle, the aggregatessum/min/max/average,find, and the dual-containerappend/copyFrom/slice/values) now reportsWS050instead of silently lowering to a no-op placeholder. - A constant record-array/map initializer (
var pts: Point[] = [{ x: 1, y: 2 }]) now bakes each field’s column into its backing container; it used to compile clean but silently start empty. A record array’s per-field arrays are also reachable directly aspts.field(struct-of-arrays access) — index, read, and aggregate a single column (pts.x[i],pts.x.sum(),pts.x.min()).pts.field.sort(descending?)sorts the whole record by that field, keeping rows intact.
1.4.5
- An operator or sibling call inside a namespaced module’s
onhandler (import * as L from "lib"where lib hason ReadBrickGrid() { arr.push(n << 10) }) is now type-checked, so it lowers to a real gate instead of_Unsupported. Namespaced handlers began lowering in 1.4.4 but typecheck never descended into their bodies, so operators got no resolution. --dump-irnode locations now include the source file (@ lib.ws:3:1) and render each node’s snippet from that file. Imported nodes used to show the entry file’s text at their offset and an ambiguous@ line:col; the IR ranges themselves were already correct, only the dump’s preview read the wrong source.
1.4.4
- A module imported as a namespace (
import * as L from "lib") now runs its top-levelonhandlers. - Two
import * asnamespaces (or a local declaration plus an imported one) that share a state name now get distinct storage gates. - A
var/array/map/bufferdeclared inside a handler,if, or block now gets its own storage gate instead of silently reusing an outer same-named one. &xpassed to a*T/ref parameter now binds the caller’s var, so writes through it land.- An or-triggered handler (
on a | b { ... }) now runs its body when either trigger fires instead of silently dropping the whole handler. - Two different modules declaring the same top-level name, merged via
import "m"orimport { x }, now reportWS013instead of silently collapsing both onto one storage gate. - A container mutation (
arr.push(x),m.set(k, v)) outside an exec context now reportsWS007instead of silently lowering to a placeholder that does nothing. - A parse error in an imported file now surfaces instead of being silently swallowed; the identical source only errored when compiled as the entry file.
- An anonymous chip (
chip { … }/chip on t { … }) in an imported module now runs; it was filtered out before the merge and silently dropped along with its writes. - A single
emit out = <expr>inside anifis now gated by the branch instead of wired unconditionally to the output; a guarded write no longer becomes permanent. - A
letthat shadows an in/out port of the same name now reportsWS013instead of silently hijacking it (let gobeforeon go, within go: exec, left the exec input dead). Ordinary value shadowing (let a = 1; let a = 2) is unaffected. - An output reached by an
emitplus a default initializer (out r = 0thenemit r = …), or by emits split across a handler and an anonymouschip { … }, is now var-backed instead of driven by two wires - a load-breaking fan-in on the output rerouter. - A
letaliasing an input-port array or map (in a: int[]thenlet x = a) now resolves forx[i]and container methods, like avaralias; it used to lower the index to a placeholder. - A namespaced mod’s body now resolves its OWN module’s siblings, vars, and constants. Two imported modules with a same-named private
helperno longer make one module’s public functions run the other’s code, and a namespaced mod mutatinggnow writes its own module’s storage gate instead of the last-imported namespace’s. - A container method READ outside an exec context (
out r = arr.length()) now reportsWS007instead of silently lowering to a placeholder, matching the pure index-read rule. Aconstreceiver or an explicitexec = <trigger>arg is exempt. - A captured handler with a non-Event trigger (
let e = on go { … }wheregois an exec input,var, orlet) now runs its body and captures its exit ase; the body used to be dropped entirely unless the trigger was a built-in event.
1.4.3
- An imported
letno longer clobbers a same-named declaration in the importing file. An importedlet startoverwrote the file’s ownin start: exec, soon startbound the imported value instead of the input and silently dropped the handler body. - Void container operations (
push/clear/set, thekeys/valuesfills) now type asnever, so using their result as a value (let r = a.push(x)) is a type error instead of silently accepted. - Top-level
onhandlers in an imported file now run; they were silently dropped, and anon <expr>handler additionally left a dangling trigger gate with no body. - A
letaliasing an array or map (let ar = a) now resolves for indexing, writes, and methods (ar[0],ar[0] = x,ar.push(...)) instead of lowering to a placeholder or silently dropping the write. - A namespaced member (
ns.name) whose bare name the importing file also owns is now reachable asns.name, sons.tuple.0resolves instead of falling through to the local binding.
1.4.2
- Folding enabled by default now that it’s stable
- Inline nested-prefab blocks now compile in the browser build. An inline nested-prefab block passed to
SpawnPrefab(the$-fenced source form) is compiled to its own prefab and embedded, matching the native CLI. The browser previously rejected any inline nested block with “no nested compiler configured for this compile”; dragged-in$./file.brzprefab references already worked. Blocks nested past a fixed depth fail with a clear error instead of hanging. - An import read only in the config of an event handler inside a chip body is no longer reported as unused. The 1.4.1 fix covered handlers at module level but not the statement path a chip body takes, so
on CustomEvent(CH)inside a chip warnedWS014while the identical handler outside one did not, and Organize Imports would then delete the import and leave the handler naming nothing.
1.4.1
- A record assigned to a mod output (
mod f() -> (o: Rec) { out o = rec }) now reaches the caller. A record has no single value port, so the output silently carried a placeholder that read a default, and everything downstream of it did too, including a record result passed straight to another mod (Take(Make())). The warning only appeared when the result went unused, so the broken cases were the quiet ones. - An imported module’s root-level
inandoutports are now declared and reachable asns.name. They were dropped entirely, soon ns.trigger { ... }matched nothing and silently discarded the whole handler body while both stages reported the file clean. Root-levelvar,array,mapandbuffermembers already worked. - An import read only in an event handler’s config (
on Clock(interval = TICK)) is no longer reported as unused. The scan walked the handler body but not its config args, so a constant used only to configure the gate warnedWS014, and Organize Imports would then delete it. - Two
import * asnamespaces that export the same member name now stay distinct.A.fooandB.foohad both resolved to whichever module was imported last, so a field access on the other read the wrong value and lowered to a placeholder while type-checking reported the file clean.
1.4.0
constcompile-time evaluation:constbindings,constparameters (f(name: const string, v: int)) andconst moddeclarations are evaluated at compile time and can be used anywhere a literal is required, such as gate config and custom-event channel names. Const expressions compose freely (operators, constructor arguments, destructuring, indexing, collection assembly) and emit no gates. Anifon a const condition drops its untaken branch, and aconstthat fails to be compile-time is reported at the binding.@layout("cube")no longer emits per-gate name labels, var tags, or chip-brick labels. Plane headers, the shell label, and a runtime@label(expr)are kept.GetInputs: samples a player’s twelve controls once when the exec chain reaches it, the exec-form counterpart ofInputReader. Same field names, sochar.GetInputs().PressedQreads like the splitter, and its operand also accepts a persistent player.- An exec builtin called with receiver syntax in pure position now reports
WS007. The check only covered the plain spelling, soGetLocation(e)was an error whilee.GetLocation()compiled to a placeholder that read a default. - A local binding that shadows an
import * as nsalias now reportsWS002at the call. A parameter orletnamednsmade everyns.f(...)in that scope resolve against the local value, which has no such member, so the call typed asanyand compiled to a placeholder that did nothing while type-checking reported the file clean. The failure then surfaced wherever theanywas finally consumed, on a line that was not the mistake. - LSP: Hover gate estimates now refresh as you type, so a mod added or renamed since the last save shows a count instead of none.
- LSP: Hovering a namespaced call (
ns.f()) now shows its gate estimate.
1.3.0
- An array/map method on a record field (
g.ready.sum()) now types its result instead ofany, so arithmetic on it works. Args are checked through the chain too. - A type alias whose body names another alias (
type Rect = { a: Point, ... }) now expands all the way down when imported, sons.rect.a.xkeeps its type instead of reading asany. - Namespace members (
import * as ns) written without an annotation, or bound by a destructuringlet, now carry their record type across the import too. - Compiler source reorganised
1.2.0
map[key]subscript syntax -m[k]andm[k] = vnow work on aMap<K, V>, desugaring to the same get/set them.get(k)/m.set(k, v)methods use (m[k]reads the value, auto-unwrapping the found flag;m[k] = vwrites it). The read types as the value typeVand, like array indexing, only works in an exec context. Previously this parsed and type-checked but silently did nothing (the read produced 0, the write was dropped).- Fill record fields (editor code action) - inside a record literal whose expected type is a record (
let x: Card = { … }), the lightbulb /Ctrl+.offers Fill record fields, inserting every missing field with a type-appropriate default and recursing into nested records — e.g.{ foo: "", bar: { baz: 0 } }. Present fields are kept, so a partial literal completes; nested, aliased, and imported record types are resolved server-side.
Fixes
- Output values (
return,emit out = …, statement-levelout name = …) are now checked against the declared output type (WS003) instead of baking a mismatch into the wire. - Conflicting generic-builtin arguments (
Select(c, 5, "hello"),Swap) now error (WS033) instead of widening toany. let t: (A, B) = …andlet x: R = { … }now check the value against the annotation (WS003), not just field names.- A multi-output result used as a scalar unwraps only through its first field — a later-field match wired the wrong port (now
WS003). - A tuple literal no longer collapses to a scalar (
let x: int = (1, "abc")isWS003). - A heterogeneous array literal in a handler (
xs = [1, "hello", 2]) reportsWS003on the odd element. - An
any[]/Map<any, …>parameter now accepts a concrete array/map argument. ==/!=on twovector/rotator/quat/colorvalues is now accepted (ordering stays scalar-only).- Assigning to a scalar
letreportsWS007instead of silently emitting no gate. - An unknown named argument on a
mod/chipcall reportsWS041instead of being dropped. - A typo’d event config/input name (
on Clock(intreval = …)) reportsWS041instead of no-opping. - An out-of-range integer literal reports a parse error instead of compiling to
0. - A captured event inside a handler (
let x = on Clock(1.0) { … }) reports one clear “top level only” error instead of misleading tail-parse errors. - A
varread afterawaitre-reads fresh, so a value changed during the wait is visible. ns.myValueon animport * as nsnow reads the real value and type — it typed asanyand compiled to a placeholder reading 0, while onlyns.f(...)calls worked.- A chip that writes a global no longer leaves a stale read after the call; the next read is fresh. (Inline
mods were already correct.) - A chip called in two contexts (pure-then-exec, captured-vs-passed arg, or same name in two modules) compiles a separate body per context instead of reusing the first’s mis-wired one.
- A chip with multiple
returns, and an output emitted from multiple sites, each route through one holder variable instead of fanning two wires into one pin (a load failure). - A
chip … -> (sig: exec)that doesemit signow wires the emit to the output (was silently dropped). - A wire whose endpoint can’t be resolved is now a compile error, not a silently dropped wire in a shipped save.
- The formatter no longer splits a
:kebab-caseatom literal at its hyphen. - A field access on a scalar (
x.whateveron anint) reportsWS010instead of silently reading the whole value. Projecting a single-output result by its output name (f.result) still works; a mis-typed one is caught.
Performance
- Repeated calculations build one gate and share it. Write
x + 1in three outputs, or call the samemodtwice, and you get one gate instead of a copy per use. (State-holding and@nofoldgates are left alone.) - A variable read is reused across an
ifwhen the branch doesn’t touch it, instead of being re-read afterward. A variable a branch writes still re-reads fresh, so it’s never stale. - The editor keeps up better while you type — ~20% less work per keystroke, and no longer multiplied by your open tab count. It parses the file once instead of twice, leaves hover’s gate-count estimates to open/save, and re-analyzes only the open files that import what you changed.
- Imports resolve against unsaved edits. A file importing something you’re editing was analyzed against the last saved bytes on disk until you hit save.
1.1.1
- Scope-aware rename & find-references - rename/
textDocument/referencesnow resolve the identifier under the cursor to its exact binding instead of matching the name as text, so they never touch comments, strings, a same-named type, another scope’s binding, or an unrelated file; renaming an exported symbol updates its importers (and a local alias stays local). (This replaced the old textual scan.) - Semantic type highlighting - the LSP emits semantic tokens so a name in type position highlights as a type (including user
typealiases the grammar’s builtin list can’t know), while every value identifier is highlighted uniformly - so a binding whose name collides with a type keyword (acharactercapture) or a builtin function (a value namedround) reads as a plain identifier, not a type or a call. Genuine builtin calls are unaffected. Atom literals (:name, kebab-case allowed like:kebab-casea, including a:name:map key) highlight as the integer constants they compile to; hovering an atom shows the compile-time xxHash64 value it resolves to, and find-references gathers every use of that atom across the workspace. .wssource prefabs - a prefab reference may now point at a.wssource file,SpawnPrefab(prefab = $./control.ws, …), which compiles that file into a prefab and embeds it on the spot - the file form of an inline$```…```block. Errors in the referenced file surface on the$./control.wsreference itself, so a broken prefab underlines where it’s used..brzarchives work as before; any other extension isWS019.- Fixed: a no-receiver gate called with a receiver -
x.SweepSimple(…)/x.Sweep(…)(the sweep gates act on their own brick and take no receiver) now reports a clearWS036error instead of silently type-checking asanyand lowering to a do-nothing placeholder. - Fixed:
@folddropping a runtime@label- a runtime@label(<expr>)(on avaror, blank-line-separated, on the root microchip) is wired into its text at emit time, so a folded module’s dead-code sweep no longer prunes the label’s source as “unused” and silently drops the label.
1.1.0
- Events as expressions - an event called as an expression emits its gate and yields its exec, so an event composes in expressions like
Union(RoundStart(), other). It takes the same inputs as theonform (Clock(interval = 2.0)), and a data-carrying event exposes its outputs by field access (CharacterSpawned().character); a bare call auto-unwraps to the exec. Theon E { ... }trigger form is unchanged. - LSP: named-argument values complete in-scope identifiers - typing the value of a named argument (
itemName = <here>) now offers the in-scope identifiers, not the call’s other argument names (those are only offered when completing an argument name). Enum/asset value slots still complete their specific members. - Event output capture (
on ... -> ...) - an event handler binds its data outputs in a trailing capture instead of inside the event call:on Foo() -> (a, b)(tuple, positional - the cleanest form, for any event) oron Foo() -> { field: local }(record, by field name - named events, for subset/rename).on <call> -> ...also triggers on any exec-producing call - amod/chipcall, or a gate driven byon Foo(exec = x) -> (...)- auto-extracting the call’s exec output; a general call that exposes no exec (or anexec =with nowhere to attach) is aWS043error. A single untyped output may drop the parens:-> whois shorthand for-> (who). - Custom-event data-type inference - an unannotated custom-event receiver slot takes its type from the matching in-unit
SendCustomEventon that channel; when none is inferable the slot defaults tofloatand warnsWS042(which replaces the oldWS029“annotate this param” lint). A written type goes in the tuple capture:on CustomEvent("ch") -> (x: int). - Whole-grid interaction events -
on WholeGridInteracted() -> (character, held)fires when the grid is interacted with;on WholeGridTargeted() -> (character, damage, weapon, weaponName)fires when it is hit. - Maps work wherever arrays do - a
Map<K, V>can now be amod/chipparameter, anin/outport, or a record field (not just a file-scopevar). A container method whose receiver isn’t a container isWS044, not a silent no-op. - Generic value builtins -
Select,Swap,Sleep,SleepTicks, andTweencarry their argument’s type instead ofany, soSelect(c, 1, 2)is anint. - Tuple arguments match tuple parameters - passing a tuple value or literal to a
(A, B)-typed parameter type-checks. - Ref-insensitive record fields - a record value matches a record type when fields differ only in ref/array exposure (a
*Tfield and a plainTare interchangeable at a call boundary, as parameters already are). - Tighter builtin argument types -
DisplayText/PrintToConsole/Fmt/SetTagtakestring,SetLeaderboard/IncLeaderboardtakeint, andSpawnPrefabtakes aprefabreference. - Fixed: exec after an emitting mod - a statement following a
modwhose body ends inemitno longer drops to an unsupported placeholder.
Migrating to 1.1.0
The on handler form now binds event outputs with ->, and no longer accepts data params (or their types) inside the event call.
- Move data outputs to
->-on CharacterDied(character, killer) { ... }becomeson CharacterDied() -> (character, killer) { ... }. Config and inputs stay inside the parens:on ChatCommand("greet", "help") -> (controller, args),on ZoneEntered(zone = z) -> (character). Config-only handlers are unchanged (on Clock(interval = 2.0) { ... },on RoundStart() { ... }). - Custom-event types move to the capture -
on CustomEvent("dmg", amount: int) { ... }becomeson CustomEvent("dmg") -> (amount: int) { ... }; an omitted type is inferred from the sender, or warnsWS042. - Event triggers are calls - an event trigger is written with
():on RoundStart(), noton RoundStart. The no-parens form - a handler head (on RoundStart { }) or a captured-event alias (let x = on RoundStart) - is now an error. The()marks the event as a gate/call, uniform with theon <call> -> (...)model. WS029is removed - the “annotate your custom-event param” lint is replaced by inference plusWS042.
1.0.0
- Maps (
Map<K, V>) - a keyed variable collection paralleling arrays:var scores: Map<string, int>, keyed byint/string/object reference and holding any wire-storable value, with exec-context methodsset/get/has/remove/clear/copyFrom/length/keys/values(getgives{ Value, Found }, auto-unwrapping toValue). A non-int/string/object key type is aWS039error. - Map literals -
{ k => v }keys by any expression,"s": v/:atom: vby a string/atom/int literal, and[expr] => vby a computed key; a fully-constant literal bakes the map pre-populated at rest,{}is an empty map, andm = { ... }in a handler desugars toclear()plus onesetper entry in source order. - Atom literals (
:name) - a compile-timeintconstant (the deterministic xxHash64 hash of the name), a readable stand-in for a magic number as anint-map key or enum-like tag; it only ever resolves at compile time, never from a runtime string. - Generic type syntax -
Array<V>andRef<V>are exact aliases ofV[]and*V. - Gate config properties - a gate’s non-wire settings-menu fields (checkboxes, dropdowns, values) are now settable as optional, constant-only call args, both by friendly alias and by raw game name (
SweepSimple(Direction = X_Negative, ...),p.DisplayText("hi", typeface = Bold)); enum args take bare member names validated against the game’s enum list, and an unknown name or non-constant value is aWS028error. - Custom events -
SendCustomEvent(name, data...)pulses everyon CustomEvent("name", a: int, b: float, ...)receiver on that channel with up to 8 typed data values; a receiver fires the tick after the send, and an untyped receiver param warns (WS029). - Custom-event signature check and navigation - a
SendCustomEventon a constant channel whose data types disagree with the matching receiver’s declared params warns (WS030), and go-to-definition on a send-site channel-name string jumps to the receiver. zone&teleportreference types - rerouter-only component references (like a var ref): passable through ports and parameters but not storable in a var/array/buffer (WS025) or selected with if-then-else (WS031). Zone events’zoneinput is nowzone-typed, andTeleport/RelativeTeleportdest/sourceare nowteleport-typed - teleporting to a raw position usesSetLocation.Clockreads as an event -on Clock(interval = 2.0, enabled = running) { ... }runs its body on each pulse;intervalandenabledare wire inputs (constant or dynamic, so the clock toggles at runtime) andpulseOn/onTime/offTimeare constant-only config.- New entity/character builtins -
GetSpeed,GetVelocityAtPoint,GetEntityTeam/SetEntityTeam,IsFrozen,DestroySpawned/DestroySpawnedPrefab, a character ammo family (GetAmmo/GrantAmmo/SetAmmo, weapon-chamber ammo,GetInventoryEntry,GetCurrentInventorySlot),GetOwnTransform, andForceRespawn(player)(alsoplayer.ForceRespawn()). - New date/time and conversion builtins -
GetUnixTime,FormatDate,Remap,LogicalShiftRight,EnumToInteger/IntegerToEnum,ItemToPickup,ConvertColor, andToCharCode/FromCharCode;ParseInt/ParseNumbergained a.Successflag and auto-unwrap to their parsed value. - Zone array fills -
arr.fillFromZoneEntities(zone, tagFilter?)/fillFromZonePlayers(...)populate an array from a zone, andarr.sortMultiple(other, ...)sorts a value array plus up to seven parallel arrays together. SpawnExplosion- an exec gate spawning an explosion of a given projectile/explosion class, with optional instigator, offset, scale, and damage.PlayClientAudio-player.PlayClientAudio(audio, volume?, pitch?)plays a non-spatial one-shot for a single player (alsoPlayClientAudio(player, audio, ...)); the descriptor is an inlined$BrickOneShotAudioDescriptor/...asset reference, like the other Play* audio gates.- Expanded
InputReader- reads look axes and key/button states (Up,Pitch/Yaw/Roll,MouseWheel,PressedC/E/Q/LeftMouse/RightMouse) alongsideForward/Right. DisplayTextreturns atextId- capture the returnedintto update or clear the same on-screen text later, with new color/outline/shadow/spacing/wrap styling params;position/anchor/scale/pivot/shadowOffsetareVector2Dlayout properties on the reworked gate.GetDamagegives{ Damage, DamageLimit }- auto-unwraps toDamagewhere a float is expected, and.DamageLimitreads the death threshold.- Richer event outputs - the fired-weapon event exposes the
weaponand its name,CharacterDiedthe killer’s weapon and name, andControllerJoined/ControllerLeftthe player’s user name;Sweep/SweepSimpleresults carry aHitColor. - Player-reference gates target persistent player-state -
DisplayText, chat/leaderboard/team setters, and the join/left/chat events resolve the current build’s persistent player-state; existingcontroller-typed scripts keep working unchanged. - Fixed fixed-size component arrays failing to load - weapon ammo resources and mesh colors are native fixed-length arrays; emit now pads them to their full length so a save that sets one loads instead of rejecting on size.
- LSP: config-aware completions and hovers - enum sibling completion, hovers for events, enum values,
Clock, and settings-menu config fields, and asset-type dropdown completions for$Type/Nameconfig refs. @label(<expr>)labels -@labelaccepts an expression, not just a string literal. A constant folds to baked text; on a top-levelvara runtime value becomes a dynamic label, wired live into the variable’s floating text - including a variable labelling itself with its own value (@label(x) var x). A blank-line-separated@label(<expr>)at the very top of the file labels the root microchip rather than a declaration, and may forward-reference declarations below it. A runtime@labelon a port/chip (which has no wireable text) is aWS040error.@invisible- hide a port’s rerouter brick and label, or - as a top-of-file annotation - the whole microchip shell (hidden, non-colliding, no labels), for microchips that spawn other microchips.- Inline nested prefabs - a triple-backtick block prefixed with
$compiles an isolated inner program and embeds it as a prefab, usable directly as aSpawnPrefabargument. The inner block canimportbut shares no wires with the outer file (it is a separate prefab); its diagnostics, highlighting, and completions all resolve against the inner program. on <expr>triggers - anonhandler can fire on an arbitrary boolean expression - a comparison (on hp <= 0), a method/index result (on a.Dot(b) > 0.0,on arr[i] > 0), a negation (on !flag), or a bare variable - not only a named event or input.- Chained method calls across lines - a call chain may continue with a leading
.on the next line (f(...)then.g(...)below it); the formatter indents the continuation. - Fixed a
varinitializer missing its=-var x: int 5silently dropped the value; it now reports amissing =error (and still recovers by taking the expression as the value). - Callable gate builtins - every variable/array/map wire gate is now callable as a function named after the in-game gate; this now includes the array-fill gates (
FillArrayFromPlayers(arr),FillArrayFromTeamMembers(arr, team),GetPlayersInZone(arr, zone),GetEntitiesInZone(arr, zone)), the function-call twins of thearr.fillFrom*methods. - Callable exec-flow gates -
Union(a, b)merges two exec signals into one (fires when either fires);Branch(cond, exec)routes an exec to.Aor.Bon a condition. - Fixed multi-output builtin field names - a gate returning several outputs (
Edge,Branch, …) now exposes them under their friendly names (Edge(b).Rising/.Falling,Branch(c, e).A/.B) as intended, instead of only the raw port names. on <call>()triggers - a builtin call that returns an exec (on ServerUptime(),on Change(v)) fires the handler on that exec, desugaring like any otheron <expr>trigger - distinct from the event-with-args form (on Clock(...)).- LSP: scope-aware completion & hover - the editor now resolves a name to its in-scope declaration, so a name reused across scopes (a
stringat file scope, acharacter[]inside a handler) is read as the one actually visible at the cursor rather than the first declaration found. This applies toreceiver.member completion (the correct method table), the bare-identifier list (a single entry with the in-scope type, no leak of a handler-local into file scope), and hover (the declaration under the cursor shows its own type). - LSP: default values in gate hovers - hovering a gate/builtin (e.g.
SpawnPrefab,Sweep,DisplayText) now shows a table of its parameter and settings-menu defaults (limit = 5,distance = 100,fontSize = 16, …), read from the same source the emitter uses; enum defaults show the member name (direction = X_Positive). Hovering a named argument (fontSize =) shows that field’s default too. DisplayTextlayout is per-axis - theVector2Dlayout ports (position/anchor/scale/pivot/shadowOffset) are set per axis:positionX/positionY,anchorX/anchorY,scaleX/scaleY,pivotX/pivotY,shadowOffsetX/shadowOffsetY. A constant axis bakes the property (an unset axis keeps its default); a runtime value wires itsX/Ysub-port.- LSP: gate hovers render composite defaults - composite/color parameter defaults show as their constructor or sRGB hex (
outlineColor = #181425), and aVector2Dsub-port axis shows that axis of its parent default (anchorY =→0.5). - Unknown call arguments now error - a named argument that matches no parameter and no settings-menu config field is a
WS041error instead of being silently dropped (a typo’d argument name that previously did nothing). The universalexec =override and a variadic call’s trailing options are exempt.
Migrating from 0.x
1.0.0 tightens several call signatures and reworks a few gates, so some .ws that compiled under 0.x needs edits.
- Teleporting to a position -
Teleport/RelativeTeleportdest/sourceare now theteleportreference type, soe.Teleport(Vec(x, y, z))ande.Teleport(other)no longer typecheck. Usee.SetLocation(Vec(x, y, z))to move to a raw position, or wire a teleport point into anin p: teleportport ande.Teleport(p). - Zone events take a
zonereference - the events’zoneinput is nowzone-typed (andtagFilterisstring), sozone = ewith an entity/brick value errors. Feed anin z: zoneport from a Zone brick:on ZoneEntered(character, zone = z) { ... }. zone/teleportare reference-only - like a var ref, they can’t be stored in avar/array/buffer(WS025) or picked with an if-then-else (WS031); pass them straight through ports and parameters.DisplayText.outlineSize/fontSizeare nowint- pass an integer. The per-axis layout args (positionX/positionY/anchorX/anchorY/scaleX/scaleY) are unchanged and keep working;pivotX/pivotY/shadowOffsetX/shadowOffsetYare new axes on the reworked gate.Swapresult fields renamed -Swap(cond, a, b)returns{ Output, OutputB }(was{ a, b }); readr.Output/r.OutputB(a barerstill auto-unwraps to the first value).InputReader().Jumpremoved - the movement record droppedJump; read the new axis/button fields instead (Up,Pitch/Yaw/Roll,MouseWheel,PressedC/E/Q/LeftMouse/RightMouse).BrickChanged/BrickRemovedlost their brick output - these events no longer carry abrickvalue, soon BrickChanged(brick) { ... }won’t bind; drop the parameter (on BrickChanged { ... }).RotToDirremoved - its gate no longer exists in the build; useq.ToDirection()(takes aquat) to turn a rotation into a forward direction.arraydeclaration keyword removed - declare container variables withvarinstead:var scores: int[](wasarray scores: int[]). Storage and behavior is identical - only the keyword changed - so the fix is a mechanical rename. Usingarrayas a declaration keyword is now a parse error that points at thevarform.
0.20.0
@layout("code")– source-shaped gate layout - a row per source line, expressions left to right, widely-read values down reusable gutter lanes, and own-line//comments rendered onto the plane.@layout("cube")– compact 3D packing - stacks gates into brick layers without analysing the wire graph. Minimal brick mass, no visible dataflow.@flat– inline every chip onto one grid - no microchip bricks or nested planes; chip-wall crossings become ordinary wires. Behavior unchanged.- Every chip-wall crossing now routes through a labeled boundary pin - crossings get their own
MicrochipInput/MicrochipOutputrerouter, even when not a declared parameter. Constant arguments still inline.
0.19.0
- Constant expressions in
var/arrayinitializers - an initializer may name a top-levelletconstant and compute with it:array mask: int[] = [1 << C_FLAG, WIDTH * HEIGHT]. - A named import now carries the constants an imported array is built from - the dependency closure skipped array initializers, so the import failed with “unknown identifier”.
- Fixed a spurious “unused import” on a constant used only by an array initializer - WS014 fired and Organize Imports would delete it, breaking the table it fed.
- Fixed a playground rename clobbering another file - the active-file pointer stayed on another file, so the next autosave overwrote it.
0.18.0
exec =on any exec call - an exec-gate call (array method, builtin, mod/chip) can run off an explicitexec = <trigger>, e.g. an array read in a pure binding:lut.get(i, exec = i + 1).- Fixed
emit output = valuefan-in - it wired both the value and the exec to the output’s input pin, failing to load; now just the value. - Certified constant folding - pure gates whose inputs are known constants are
evaluated at compile time against the in-game-certified semantics table,
constant-selector
Selects short-circuit, constant-condition branches truncate their dead side (including across chip boundaries), all before layout.Opaque(...)and@nofoldexempt code. The pass is opt-in while it stabilizes: enable it with a module-level@fold(or--fold);--no-fold(or a module-level@nofold) disables it and always wins over@fold. String concatenation, string methods, and${...}interpolation now fold too, byte-exact against the game’s own text rendering, alongside vector, rotation, color, and quaternion constructors and their certified math operations. - Fixed returning a record through another
mod-return f(x)orreturn rwith a record value wired the caller to a phantom node whose wires were silently dropped at emit. The record’s fields now forward to the caller. anytype - anin/let/mod-or-chip-param/-output can now be annotatedany, an operator-wildcard type that resolves real overloads (x & 1,x == "y", …) instead of erroring on an unknown type. It works anywhere, but the side effects of whichever overload gets picked are on you. Avar/static var/array/buffercan’t store one: a variable gate needs one concrete wire type to hold, so an explicitanythere is now a compile error.- String truthiness - a
stringnow coerces toboolwherever a bool is expected (anifcondition, a bool-typedlet/var, a bool port or chip param). The coercion compiles to an inserted!= ""compare gate, so the semantics are deterministic: empty is false, everything else — including"0"and"false"— is true. (Strings wired into bool ports manually viaanystill get the gates’ native content-aware truthiness, where"0"/"false"are also falsy.)
0.17.1
- Fixed a constant on a data-only port failing the build -
DisplayText.fontSizeand 13 other params name settable fields with no wire input; binding one emitted a wire emit rejects. Now written as data, with a test pinning the list. - Fixed destructuring a builtin multi-output call binding nothing -
let { Forward, Right } = c.InputReader()left every name an unwired placeholder. Fields now bind to the gate’s ports, and an unknown field errors with a suggestion instead of binding silently. - LSP reports lowering and emit errors on save - Live analysis stays typecheck-only, so lowering problems only surfaced on an explicit Compile. Saving now runs the full pipeline and publishes its diagnostics.
- New
arr.get(index)- A checked read giving{ Value, OutOfBounds }; used bare it is the element. Completion now offers those fields onarr[i].too. Blendis the math blend gate - An alias forlerp, accepting any math variant (float/int/vector/rotator/quat/color), as dolerp,Easing, andTween. The colour-space gate is nowColorBlend.Opaquehovers with its own docs - It showed the Rerouter gate’s blurb, which says nothing about the fold-hiding and type-erasing behaviour it exists for.- Fixed
.Valueon a multi-output result -a.pop().Valuetyped as the whole record, so every use of it mismatched. - Fixed a type alias not resolving through a namespace import -
import * as Twithmod f() -> MyTypefailed with “unknown type”. Aliases now inline as they do for a named import, andT.MyTypeparses as a qualified type. GetLeaderboardreturnsint- It was typedany, so arithmetic on its result had no operator overload.boolarithmetic with two bools -bool + bool(and- * / %) now promotes toint, matchingbool/intmixes and the bitwise ops;(a && b) + (c && d)compiles.
0.17.0
Opaque(x)builtin +@nofoldannotation -Opaquepasses a value through a rerouter and hides it from constant folding;@nofoldsuppresses folding for a declaration, or for the whole file when placed at the top separated by a blank line. No-op placements warn. Groundwork for gate-semantics verification circuits.- Gate semantics probe -
probes/gate_semantics.wsprints every probed gate interaction to the console on paste;scripts/gen_semantics.mjsturns the dump intodata/gate_semantics.json, andscripts/gen_verifier.mjsgeneratesprobes/verify_semantics.ws, which re-asserts every recorded case in-game. - Fixed a chip output named
x/y/z/r/g/b/areading garbage - Those names collide with vector/color component access, so reading one split the scalar and returned a component instead of the output. Field access now splits only when the value really is a vector or color. - Fixed a
chipcalled from inside a nested anon chip never firing - Its exec trigger stayed at the root, and an exec pulse cannot cross into an instance grid nested inside another anon chip. Partition now routes boundary-pin wires into the module that directly contains the instance. - A constant argument to a
chipno longer costs a gate per instance -F(1)materialized a_Varin the caller and wired it across the boundary. The constant now folds into the instance itself and its input pin is dropped, matching what the equivalentmodemits. - Fixed a tuple-destructured
modparameter binding nothing -mod f((a, b): (int, int))left every name unbound, so the body silently computed on zeros. - Fixed
let (a, b) = ton a tuple value - Both names bound nothing, and the shape was rejected as a non-tuple (WS010). out f(x)is now a parse error - The trailing call was dropped and re-parsed as a separate declaration, leaving a bare port.- Fixed a namespace import lost through a re-export -
Nsdidn’t travel with the imported declarations calling through it, so everyNs.f(...)silently did nothing at runtime. - Fixed a namespaced call losing its return type -
Ns.f(x)typed asany, soNs.f(x) + 1failed operator resolution (WS004) and dropped the expression. - New tree-sitter grammar -
editors/tree-sitter-wirescript/, with highlight/locals/indent queries. - Docs: dropped
matchexpressions - Reserved keyword, but the parser has no expression form for it. - New docs page: Best Practices - Gate count and scaling: why every call site is a copy (for
modandchipalike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state.
0.16.4 - 2026-07-17
- Fixed a constant shared across two chips reading 0 in one of them - A literal used as a wired operand (e.g.
x * 4) inside two separatechip { ... }blocks was merged by constant-deduplication into a single gate before anon-chip partitioning, leaving the second chip’s operand wired across the chip boundary — where emit’s per-module literal inlining can’t reach it, so the operand silently read its port default (0). Deduplication now groups by owning chip, keeping a shared constant once per chip. - Fixed
onhandlers bound toChange(x)-Change’sOnChangedoutput is now typedexec(wasany), solet c = Change(x)+on c { ... }fires on the change pulse. thenmay start its own line in an if expression -let x = if condfollowed by indentedthen .../else ...lines now parses; the formatter indents both keywords one level as expression continuations.- Fixed transitive imports resolving in the wrong order - When an imported file had imports of its own, its declarations were placed before the ones it imported, so any call into a deeper module was a use-before-declaration (WS021) or lowered against a missing declaration. This surfaced through a file that only re-exports another (
import "b"alone ina.ws). Nested imports now resolve ahead of the importing file’s own declarations, matching how the entry file already behaved.
0.16.3 - 2026-07-16
- Compiler is ~2x faster on large projects - mimalloc in the native binaries, thin LTO, a single-pass anon-chip partition, a quadratic wire-scan fix in inline chip calls, and Arc-shared ports/templates: lowering −69%, end-to-end −42%, lowering allocations −46%.
- Compiled output is deterministic - Anon-chip partitioning iterated a randomly-ordered set, so emitted gate/wire structure varied run to run; chips now partition in sorted order and repeated compiles produce identical graphs.
- New
fuzz_programsexample - Seeded grammar fuzzer that hunts silent miscompiles: programs with no error diagnostics whose output has_Unsupportedgates, duplicate/fan-in wires, or dangling endpoints. Findings write to a gitignoredfuzz_findings/.
0.16.2 - 2026-07-15
- Fixed LSP crash (stack overflow) on large programs - The cycle-analysis SCC walk is now iterative, and every
compile*entry point runs on a worker thread with a 256 MiB reserved stack. - Fixed LSP crash on multi-byte text - Hover/completion word scanners now step past characters by their real width, and member-receiver lookup converts the cursor column from chars to bytes.
- Stale compile-command diagnostics clear on edit - Editing or saving any
.wsfile clears the previous Compile command’s diagnostics; the next explicit compile repopulates them. - Rename applies to every reference find-references sees - Three
textDocument/renamefixes:- Open files match by canonical path and references are deduplicated, so edits are no longer doubled and rejected.
import { foo }rewrites toimport { bar }; shorthand expands to{ foo: bar }, and value-position names are untouched.- Rename works from any reference site (
u.foo, record fields); built-in event names refuse rename.
- Compiler is ~15% faster end-to-end - Internal tables use the Fx hasher (
crate::collections) instead of SipHash: lowering −15%/−26%, cycle analysis −45%/−55%, layout −36%, world building −21%/−33%. Map iteration is now deterministic, so output is more stable run-to-run. - ~30% fewer allocations during lowering - Chip declarations are shared via
Arcinstead of deep-cloned per call, and scope keys ride the interner; mod-heavy programs lower ~14% faster. Newcount_allocsexample reports per-stage allocation counts/bytes.
0.16.1 - 2026-07-15
chip letlabels the chip with its binding name -chip let x = ...now shows the binding name(s) as its display label; an explicit@label(...)still overrides.- Wider vertical gap between chip-pane rows - The wall layout’s row-to-row gutter was widened so stacked chip planes read as separated.
- Fixed repeated chip calls sharing wire endpoints - Later instances of the same
chip(foo(0),foo(1)) wired their boundaries to the first instance and failed to load (“Failed to connect wire”). Boundary wires now remap to each instance’s own nodes. - Hover on a namespace member shows its signature -
u.foo(viaimport * as u) now shows the full signature and exec-ness, matching a direct call’s hover. - Unresolved namespace/method call is now a hard error (WS002) -
ns.foo(...)whose base is not in scope errors at the dangling identifier instead of lowering to a silent_Unsupportedgate. - Organize Imports preserves namespace, bare, and multi-line imports - Alt+Shift+O now keeps every import form (namespace/bare imports are never pruned; unused named imports still are) and sorts a namespace import before a named import from the same module.
0.16.0 - 2026-07-14
- Fixed field triggers on a local in handlers -
on x.field(and negatedon !x.field) now fires the matching output port instead of the local’s default port. - Duplicate constant gates merged per chip - A repeated constant is emitted once and fanned out. Pure gates with no wired input only;
Random/stateful detectors are never merged; cut ~1200 gates on a large project. - LSP: member completion after
receiver.wins inside a call arg -Call(arg = recv.<here>completesrecv’s members (records complete their fields, including inonhandlers);Call(<here>still completes params. - LSP: more completion contexts -
import * as uthenu.<here>lists the module’s members.pos.<here>on avar pos: vectoroffers type methods + swizzle (x/y/z,r/g/b/a) alongside.Value/.prev;static vargets.Value/.prev.- Values typed by a
type Foo = { ... }alias completeFoo’s fields. - User
mod/chip/fncalls complete their param names instead of the global list. - All-required calls (
Vec(<here>)) offer their params; method calls drop the bound receiver param. @-annotation list adds@labeland@closed.- Native LSP and web playground share these paths.
- Doc comments on record-type fields - A
///on a field insidetype T = { ... }now parses (was a parse error) and shows on hover of that field. - Fixed hover on a namespace alias - Hovering
uinimport * as ushowsnamespace uand lists its members (wasnamespace u: unknown). - VS Code formatter (Prettier plugin) - Adds a space after commas; splits long braced imports (fill) and binary-op statements (one operator per line, lowest precedence first) at 100 cols; joins
} else {; honors// fmt-ignore(standalone guards the next line, trailing its own).///doc comments auto-continue on Enter. - Opened-plane headers space the doc off the title - A blank line now separates the size-96 title from the chip/module doc comment.
- Warn on asset/reference values in an array initializer (WS024) - Assets (
$Type/Name) and prefab refs can’t bake into a constantarray/varinitializer; build with.push(...)in an exec handler. All reference types (entity/character/controller/brick/prefab/assets) share the object wire and can’t be inlined. - Module doc comments stay separate from the first declaration - A top-of-file
///block followed by a blank line (or//comment) is the module doc (root plane header); a block directly above a declaration still documents it.
0.15.0 - 2026-07-13
- Color arithmetic -
+ - * / %operate RGBA channel-wise on twocoloroperands; a scalar broadcasts across channels (tint * 0.5). Same PrimMath gate as vectors/rotations. Randomis polymorphic -min/maxmay bevector,rotator,quat, orcolor; each component rolls independently and the same type is returned (Random(Vec(0,0,0), Vec(1,1,1))→ point in the unit cube). Scalarintform unchanged.- Fixed anonymous-record mod returns - A
modreturning a record literal (return { head: ..., rest: ... }) now destructures into per-field sources, so each field wires to its own value (was one_Unsupportedgate). - Non-root chips compile open by default - Opened planes stack as a wall above the compiled microchip (root at bottom, deeper nesting higher). New
@closedcollapses a chip but keeps its wall slot;open chipis now a no-op. - New
@label("text")annotation - Display-text override for chip labels/headers andin/outport labels (stacks with@sidein any order); the wiring-UI port name is unchanged. - Opened planes render a header - A size-96 title (the
@labeltext, else the chip name) plus the chip’s///doc comment, on an invisible brick at the plane’s top edge.
0.14.1 - 2026-07-13
- LSP: fixed a
return <expr>mod mislabeledexecon hover -returnalone no longer forces the exec label; only an exec op in the returned expression (e.g. an array read) does. - Pruned duplicate constants from dual imports - A module imported via both
import * as xand a named import no longer ships its top-levelletconstants twice; fully-disconnected pure gates and orphan literals are pruned.
0.14.0 - 2026-07-12
- Port-side rerouter pins -
@left/@right/@top/@bottomon a top-level port (same line or the line above) emits a pre-wired rerouter brick flush against that side of the microchip. Ports keep declaration order per side (ins/outs interleave), each pin is labeled with its port name; annotations insidechip {}/modbodies error (WS023).
0.13.1 - 2026-07-11
- Fixed
array.pop()returning0- Both gate outputs are now declared:.Valuereads the popped element and.IsEmptyreadsbIsEmpty(true once the array is empty after the pop). - Fixed
bufferinitializers inside chip/mod/handler bodies never wiring - The initializer expression was silently dropped, leaving the buffer’s input dangling. - Silently-dropped
varinitializers now warn (WSP001) - Warns on a non-constant init in pure position, any non-constantstatic varinit, and an exec-context array-var init that isn’t an array literal. Use aletfor a pure computed binding, or assign inside an exec handler.
0.13.0 - 2026-07-10
- ~4x faster compiles on large projects (5.9s → 1.5s):
- Each chip is laid out exactly once; the pre-emit layout pass no longer recurses into children.
- Layout: one toposort bucketed per connected component; prebuilt consumer map + O(1) occupancy checks.
- Emit: gate-data schema classification and interned names resolved once per gate class; no per-brick String clones.
- Lower: dead exec-union pruning is a single incremental worklist pass.
- brdb 0.8.0 - Unset component fields skip a defaults scan and two error-String allocations per field; brz index compression actually works (its size guard was dead code).
- Fixed field access on a call result dropping the call -
arr.find(x).Found/.Indexnow keep the call. - Fixed a standalone
chiplosing its exec output - An exec-bearing body ending inreturn <value>now ships the output. - Fixed
out X = Xemitting no wire - Applies when the output shares its name with a var/array. - LSP: hover on a call-result field resolves its type -
ids.find(x).Foundresolves from the call’s record. - LSP: goto-definition on a namespaced call resolves in the imported file -
u.foowithimport * as uno longer jumps to a same-named local decl. - Chip exec I/O gates are labeled - Exec gates say
exec; the anonymous-> typereturn output saysreturn(synthesized ports had no label). ControllerJoined/ControllerLeftexpose the player’s id -on ControllerLeft(controller, userId)(string); stable when the controller is torn down on disconnect.- Calling a chip/mod before it is declared is a hard error (WS021) - In both the compiler and the LSP (was a silent placeholder reading its default
0). in X: T[]array inputs are first-class - An array-typedinport supports array methods (X.length(),X.push(v), …) and passes to a mod/chip’sT[]parameter.- Namespaced module members resolve inside their own mods -
import * as nsonly; named imports were unaffected. - Chip/mod calls check their argument count (WS022) - Hard error in both the compiler and the LSP (a wrong count silently left a param unbound or dropped an arg). An
exec =trigger isn’t counted; a spread arg skips the check.
0.12.3 - 2026-07-10
- Anonymous-chip constants - Fixed literal constants not reaching anonymous chips.
0.12.2 - 2026-07-09
ReadBrickGrid()- New builtin.
0.12.1 - 2026-07-09
- Zone events bind their
Zoneinput -on ZoneEntered(character, zone = z)wireszinto the event gate’sZoneport, so a wiredinport selects the watched zone. CoversZoneEntered/ZoneLeft,EntityZoneEntered/Left,ProjectileZoneEntered/Left,BrickChanged/BrickRemoved. - Fixed a false recursion flag - An imported namespaced identifier no longer triggers it when conflicting with a local identifier.
0.12.0 - 2026-07-09
Language / Compiler
- Emitted saves label their elements with text decals - The top-level chip is titled with the entry file’s stem (or
--name); named chips, variables/arrays, and microchip I/O gates get diagonal floating name labels._-prefixed ports stay unlabeled. - Var/array exec gates tag their variable -
Var_Get/Var_Set/Var_Incrementand array-var gates carry a smaller tag naming the accessed variable, traced through the ref wire (works across chip boundaries for captured vars).
0.11.0 - 2026-07-08
Language / Compiler
- Gate data mappings derive from game data - Struct names and field lists come from the game-extracted pair table + schema, so new gates need no table edits. Stale entries for components the game lacks were dropped.
- Vector/Rotation literals embed into gate data -
e.SetLocation(Vec(0.0, 0.0, 100.0))bakes the vector into the gate instead of spawning a wiredMakeVector.Split*inputs still materialize. - Exhaustive gate-data write audit - A test serializes a literal into every representable field of every game component through the real writer; a failure names the gate and field.
- Record literals as call args bind their fields -
{ a: 1, b: 2 }passed to a destructured (f({ a, b }: P)) or whole-record (f(p: P)) param now lowers the fields. - String constants inline as wire variants - Ports that can’t hold an inline variant keep the real gate.
- Chips capture the whole enclosing scope -
let/in/event-param references now resolve; constants clone into the chip, solet K = 2used asarr.push(K)bakes2into the gate.
Bug Fixes
min/maxand 14 more expression gates embed literals -min,max,sign,round,exp,ln, the hyperbolics,Deg2Rad/Rad2Deg,BitCount, andScaleVecno longer drop literal args likemin(a, 3.0).ScaleVecwires to the real ports -Input/Scalarinstead of the nonexistentInputA/InputB.- Destructuring record literals - Now properly lowered to bindings.
0.10.2 - 2026-07-08
Language / Compiler
- emit/await loops with
buffer emit-buffer emit sig(1 tick),buffer(N),buffer(0.5s),buffer(myVar), orbuffer(delay, hold)inserts theBuffer(Ticks|Seconds)gate a wire-graph cycle needs. Constants bake into the gate; variables wire the duration port. - Payload ferrying -
emit sig = valuestores the value in hidden per-signal vars (one per record field);let x = await sig/let { a, b } = await sigreads it back. Cost: oneVar_Setper field per emit, oneVar_Getper field at the await. - Body-level
let x: execwires correctly -await xon a body-declared signal no longer lowers to a dead placeholder. - Signals are scoped per declaration - Two mods each declaring
let loop: execno longer share one signal; hubs are keyed per declaration and resolved through the scope. - Handler-local array vars re-init correctly -
var nums = [1,2,3]in a body rebuilds via clear + push instead of wiring a nonexistentVarRefport. - Layout no longer panics on multi-cycle SCCs - Feedback-edge removal iterates until acyclic, so two loops sharing a chain lay out.
Language / Compiler (types)
entitycoerces tocharacter/controller- Character/controller receiver methods and typed params accept entity values (e.g.Sweep’sHitEntity), wiring directly with no adapter gate.
Bug Fixes
CharacterDamagedattacker ischaracter-typed - Wasentity, which receiver methods and typed params rejected. The weapon binding staysentity.ShowStatusMessageand 12 more gates - Literal args now persist.- Recursive chip/mod calls error instead of crashing - Now a
WS020error.
Editor / IDE
- Named-arg hovers only fire on the arg name - In
delay = delay, hovering the value shows the symbol, not the param docs. - Method/call hovers only fire on the actual access - Array-method hovers require a
.methodaccess; builtin call/method hovers requirerecv.methodorname(. A bare identifier (e.g.var sum = 0) hovers as itself.
0.10.1 - 2026-07-07
Language / Compiler
- Asset references are
entity-typed and usable as values -$Type/Nameisentity(wasany), soweapon == $BRItemBase/Weapon_Pickaxetype-checks instead of erroring (WS004). As a value it materializes into the matching*Referencegate (ItemReference,AudioReference,EntityTypeReference, … by asset type), which outputs the asset as an entity wire. DisplayTextgained aneasingparam - The interpolation curve fortransition("Linear"/"EaseIn"/"EaseOut"/"EaseInOut"), a property-only enum likejustify.
0.10.0 - 2026-07-07
Bug Fixes
characterandcontrollerwire directly - No moreGetFromEntityadapter, an admin-only gate that got blocked on paste for non-admins.- Gate brick colours no longer double-darkened - Colours emit as the intended sRGB values instead of being pre-multiplied by γ=2.2.
- Multi-byte string chars survive emit - The lexer reads whole UTF-8 chars, so
█/éno longer mangle. - Long templates no longer drop values -
FormatTexthas only 7 substitution inputs; templates with more${...}values split across chained gates. on <local exec signal>fires across handlers -emit sigin one handler triggerson sigin another, regardless of source order.- Lexer no longer panics on stray multi-byte chars - A non-ASCII char outside a string (e.g.
▲) is now UTF-8-safe instead of crashing the LSP. FindPlayeris an exec gate returningcharacter- Has Exec/ExecOut ports and emits the found player’s character; was mis-declared pure returningentity.
Editor / IDE
$reference highlighting + hovers - Prefab ($./x.brz) and asset ($Type/Name) refs get TextMate scopes and hovers; prefab hovers show the resolved path and (in the LSP) whether the file exists.- Prefab refs are navigable - A resolvable
$./file.brzis a clickable link / go-to-definition target (Ctrl/Cmd-click or F12). - Missing prefab files warn - The LSP flags a
$./file.brzthat isn’t on disk or lacks the.brzextension. - Playground uploads
.brzprefabs - A Prefabs panel (upload + drag-drop) stores files as browser blobs (IndexedDB), offers them in$./completion, and embeds them at compile. - Named-arg completion + hover work in multi-line calls - The enclosing-call scan covers the whole call (skipping strings/comments), not just the current line.
- Enum-valued args complete their values - A named arg backed by a schema enum (e.g.
justify) completes its variants (Left/Center/Right), auto-quoted when no quote is open.
Language / Compiler
- Prefab references embed a
.brzintoSpawnPrefab-$./file.brz(relative) /$/abs.brz(absolute) embeds the archive content-addressed (brdb 0.7add_prefab) and sets the gate’sPrefabpath..brzrequired (WS019); resolution pluggable viaEmitOptions::prefab_resolver.
0.9.0 - 2026-07-07
New Builtins
- Split edge/change detectors -
Edge(bool) -> {Rising, Falling: bool},EdgeExec(float) -> {Rising, Falling: exec},Changed(any) -> bool,Change(any) -> any.
Bug Fixes
SpawnPrefabgained avelocityparam - The gate’sSpawnVelocityinput.SpawnPrefab()- Compiles again.
Gate Catalog / Data
- Gate inventory regenerated - 314 -> 316 entries (the two exec detectors).
- Edge Detector emit mapping key fixed - The class name was missing
Type, so its component data was never written.
Language / Compiler
exec =named arg on chip and mod calls - Pass a trigger when calling exec chips/mods outside an exec context. The call returns the completion exec as anexecresult field:await r.exec/on r.exec { }.- Import dependency pulling fixed - Imports pull same-file deps in record/array literals,
emitvalues,awaitexprs, and buffer inits; type aliases inline into importedlet/var/out/buffer/inannotations, not just chip/mod params. - WS013 understands
emit- The unassigned-output check countsemit x (= expr)and plain assigns anywhere in the body, per-output. - Named chip bodies capture top-level state - Free references to outer vars, arrays, buffers, and record bindings resolve against the caller’s scope.
Parser
- Multi-line array literals - Newlines allowed after
[, around commas, and before], with optional trailing comma - mirroring call-arg rules. Covers top-levelarrayinitializers and runtimefoo = [...]rebuilds.
Editor / IDE
- Formatter indents multi-line array literals - Both formatters (native, prettier plugin) track
[/]depth like(/); delimiter scanning stops at//so comments don’t skew indentation. - Formatter: one indent level per line - A line opening several groups (
f(x, {) indents its continuation once, not once per delimiter; the closing})returns to the opener’s level. - One “Wirescript” entry in the formatter picker - The extension keeps its prettier formatter and sends
provideFormatting: falseso the LSP doesn’t register a duplicate. - Prefab path completion -
$./(or$/) completes.brzrefs: the native LSP scans the document’s directory; the wasm playground offers dragged-in files via a new optionalprefabs_jsonregistry.
0.8.0 - 2026-07-06
New Builtins and Methods
- Chat / messaging -
ctrl.ShowChatMessage(msg)(per-player whisper),ctrl.ShowMessageBox(msg, title?)(modal popup), and globalBroadcastChatMessage(msg)/BroadcastStatusMessage(msg, flash?). - Audio -
entity.PlayAudioAt($BrickOneShotAudioDescriptor/..., volume?, pitch?, innerRadius?, maxDistance?, spatialized?)plays a one-shot at an entity (characters work);PlayGlobalAudio(audio, volume?, pitch?)plays for everyone. The descriptor is an inlined$asset reference. - Entity tags -
entity.SetTag("...")/entity.GetTag() -> stringattach an arbitrary string to any entity and read it back; zones can filter on tags. FindPlayer(name)- Pure value gate looking up a player entity by name.Change(input)- Any-typed companion toEdge: pulses the input value through when it changes.- Quaternion raw components -
Quat(x, y, z, w),q.SplitQuat() -> {X, Y, Z, W},a.QuatDot(b). - Inventory family -
char.AddInventoryItem(item)/SetInventoryItem(item, slot?),AddInventoryBrick(brick, size?)/SetInventoryBrick(...),AddInventoryEntity(entityType)/SetInventoryEntity(...), andAddInventoryItemAdv/SetInventoryItemAdvwith overrides (damage,speed,scale,itemName,projectile). Asset args are$Type/Namereferences.
New Events
CharacterDamaged(character, damage, attacker, attackerWeapon, attackerWeaponName)- A character took damage.EntityZoneEntered/EntityZoneLeft(entity) andProjectileZoneEntered/ProjectileZoneLeft(character,projectile,weapon,weaponName) - Zone events beyond characters; the projectile events’characteris the shooter.
Compiler / Output
- Generic asset-field emission - Gates with a
class/objectdata field (AudioDescriptor,Item,EntityType,BrickAsset,ProjectileOverride) register inlined$asset references in the world’s external-asset table automatically. Binary encoding needs in-game verification.
Gate Catalog / Data
- Gate inventory regenerated - 288 -> 314 entries (26 new classes); the messaging/tag/zone-event/quaternion/inventory gates are wired into the language.
- brdb data regenerated - Component
_maxschema (286 structs) andcomponent_db.rs(296 type mappings).assets/external.rskept the previous full catalog (the dump referenced only 14 assets). - Deliberately not exposed as builtins - The
*Referencegates ($Type/Namecovers them),Convert/ColorConvert(implicit coercions cover them), andAddInventoryEntry(opaque nested struct;GiveWeaponcovers it).
0.7.0 - 2026-07-05
Language Features
- Scalar var type inference -
var foo = ""is a string var,var n = 0an int var,var f = 1.5a float var (also bools, negatives, interpolated strings). A non-literal initializer refines from its expression (var v = Vec(1.0, 2.0, 3.0)isvector), same as buffers. - Everything casts to string - All variant-able primitives (numbers, floats, bools, vectors, rotators, colors, entities, characters, controllers, bricks, prefabs) coerce to
string:let s: string = 5is a cast, not a WS016 warning, and..accepts any of them ("hi " .. player). Unannotated array vars also infer constructor elements (var pts = [Vec(1.0, 1.0, 1.0)]isvector[]). Color()returnscolor- Wasany; matchesColorSRGB/ColorHex/Blend.
Constant Folding
Vec/Rotation/Coloron literal args fold to constants -var v = Vec(1.0, 2.0, 3.0)bakes into the Variable gate’s initial value, and constant constructors are legal top-level array initializer elements (loads pre-populated).- Folded constants inline into consumers - A constant
Vec(...)lands as a literal in the consuming gate’s data (Var_Set, math operands, select branches,arr.push); wire-only consumers (SetLocation/Teleport, component splits, chip inputs) get aMake*gate materialized, never silently zeroed. - Vars and arrays of every wire variant -
rotator(zero),quat(identity), andcolor(opaque white) vars get type-matched initial values;rotator[]/quat[]/color[]back onto the typed array variants (WireGraphRotatorArray/QuatArray/LinearColorArray) instead of doubles.
Dependencies
- Requires
brdb0.6.3 - The wire variant gainedRotator/Quat/LinearColormembers plus matching typed array variants; onlyWireGraphEnumWrapperremains unmapped.
0.6.0 - 2026-06-30
- Data regenerated - Gate inventory (285 -> 288: new
Convert/FindPlayergates), the brdb component_maxschema (258 structs), andcomponent_db.rs. - Sweep upgrade - The raycast
Sweep(...)gate gained optional per-channel flags:detectBricks,detectPlayers1–detectPlayers4,detectPhysics,detectMap, andignoreOwningGrid.
0.5.0 - 2026-06-29
Language Features
quattype + rotation/quaternion builtins - Aquatprimitive (distinct from the eulerrotator) plusdir.ToRotation(),q.ToDirection(),v.Rotate(q),q.Invert(),from.RotationTo(to),a.AngleTo(b),a.Slerp(b, alpha),axis.RotationByAngle(angle),q.ToAxisAngle(), andRotation(p, y, r)/r.ToEuler()for the euler rotator.- sRGB / hex color builtins -
ColorSRGB(r, g, b, a)andColorHex("#rrggbb")constructors;c.ToSRGB()/c.ToHex()/a.Blend(b, alpha)receivers. Cycle(count)/Toggle()- Stateful exec value gates (advance a counter / flip a bool each exec pulse).- User definitions shadow builtins - A
chip/mod/fnnamed like a builtin (e.g.chip Toggle) takes precedence at the call site. - Asset references -
$AssetType/AssetName(e.g.$BRItemBase/Weapon_Pistol) references an external asset embedded by name, encoded as an external-asset-table index on emit. Completion:$offers asset types,$Type/that type’s names (from the brdb catalog). HasRole/GiveWeapon-ctrl.HasRole("Admin") -> bool(role is a config string);char.GiveWeapon($BRItemBase/Weapon_Pistol, slot)sets an inventory slot to an item asset (builds the nestedEntryPlan). Binary encoding needs in-game verification.
Gate Catalog / Output
- Gate inventory refreshed - Adds 26 new gate classes; the rotation/quaternion, sRGB-color, and cycle/toggle ones are wired into the language, with component data structs registered for
.brdboutput.
0.4.0 - 2026-06-28
Language Features
- Pre-initialized arrays -
array foo: int[] = [1, 2, -3]writes literal contents (numbers incl. negatives, strings, bools) straight into the array gate, loading pre-populated. A non-literal top-level element is a clear error. - Inferred array-typed vars -
var foo = [1, 2, 3]infersint[]and lowers to the same array gate as anarraydeclaration; it indexes and iterates as a real array. - Runtime array assignment + spread - In an exec handler,
foo = [a, 1, ...other, 5]rebuilds an array var: clear -> push each item -> append each...spread. Elements can be any runtime value; a spread splices another array in place. - Array methods, one source of truth - Every method derives from a single
catalog::arraystable: completion offers the full set on any array-typed value, return types come from gate output ports.findreturns{ Index, Found, Value }(auto-unwraps to Index);pop/min/maxexpose.IsEmpty;insert/swap/sliceexpose.OutOfBounds. GetAimreplacesAimOrigin/AimDirection- A character’s camera/aim is one gate returningchar.GetAim().Origin/.Direction; reading both fields shares a single gate. The separate calls are removed.- Chat command config -
on ChatCommand("greet", "Greets the player", player, args): string literals fillCommandNamethenHelpTextin order (or namedDescription = "..."), and bare identifiers still bind the event outputs (controller,arguments).
Bug Fixes
- Vector components on stored values -
.x/.y/.z(and color.r/.g/.b/.a) work on a vector held in a variable orletbinding via the SplitVector/SplitColor gates, not just an inlineVec(...). Vec(...)literal arguments - Constant components are no longer dropped to0at emit;MakeVectorgained its component-data mapping.
Compiler / Output
- Gate defaults resolve from component_db - Unspecified data fields are omitted so the brdb writer fills them from
STRUCT_DEFAULTS; DisplayText’sFontSize/Lifetimenow resolve to the game defaults (16/5) instead of0.
0.3.0 - 2026-06-27
Language Features
- String variables -
var/static varof typestringstore in a Variable gate (the WireGraphVariant gained astrmember). TheWS018“strings can’t be stored in vars” diagnostic is gone. - Native string equality -
==/!=on strings lower directly to theCompareEqual/CompareNotEqualgates. Thecontains(a,b) && length(a) == length(b)workaround is removed. - Vector arithmetic -
+ - * / %operate component-wise on two vectors, and a scalar operand (v * 2.0,10.0 * v,v / 4) broadcasts - all on the sameMathAdd/Subtract/Multiply/Divide/Modulogates. TheScalehelper still works. - Any-variant variables - A
varcan hold any WireGraphVariant member (int,float,bool,string,vector, object types); typed vars get a type-matched initial value instead of a number default. - Typed arrays - The declared element type selects the backing
WireGraphArrayVariantmember (int-> Int64,float-> Double, plus Bool/String/Vector/Object), so elements keep their declared type.
Gate Catalog
- Regenerated inventory - Rebuilt from the in-game dump via a new checked-in generator (
scripts/gen_inventory.mjs): adds 76 gate classes (ArrayVar exec, Gamemode/Controller/Character, stringParseInt/ParseNumber, reference gates) and types 86 previously-anyports. 175 -> 260 entries. - Refreshed brdb component tables -
component_db.rsregenerated from the same dump so the new gates emit; the removedGamemode_EndRoundgate is gone.
New Builtins and Methods
- Array methods -
insert,find,sort(desc?),reverse,sum,min,max,average,swap,fill,resize,append,copyFrom,slice,fillFromPlayers,fillFromTeamjoinpush/pop/length/remove/clear/shuffle. Every ArrayVar gate is reachable. - Easing -
Easing(a, b, blend, fn?, dir?)andTween(target, duration, fn?, dir?); function/direction pass as an int or enum-name literal ("Quad","InOut", …) resolved against the engine’sEBREasingFunction/EBREasingDirectionenums. - Timer -
Timer(limit, restart?, pause?, resume?)returns{ Time: float, Expired: exec }; the controls are optional exec inputs andExpiredworks withon/await. - String parsing -
ParseInt(s) -> intandParseNumber(s) -> float(alsos.ParseInt()/s.ParseNumber()). - Controller -
GetUserName,GetUserId,GetDisplayName,IsTrusted,HasPermission,SetCanRespawn,SetTeamPinned. - Character -
GetDamage,SetDamage,IncDamage,SetTempPermission. - Entity -
SetFrozen. - Gamemode -
PlayerWins/TeamWins(replace the removed imperativeEndRoundgate and builtin),GetCurrentRound,SetTeam,GetTeamName,GetTeamLeaderboardValue/SetTeamLeaderboardValue/IncrementTeamLeaderboardValue. - Misc -
PrintToConsole,DeltaTime,ServerUptime,NearlyEqual,Dampen.
Compiler / Output
- Prefab output - Compiled programs emit a Brickadia prefab (
type: "Prefab"+Meta/Prefab.jsonwith brick bounds from the microchip shell) instead of a world, so the.brzpastes like a native copied selection (Ctrl+V) with a correct preview. - Loads on current builds - A bundle embeds only the component structs the program uses plus transitive schema deps, written dependency-first - matching game bundles. Replaces the full-catalog embed recent builds reject; real programs stay within the per-schema struct limit.
0.2.0
Language Features
emit target = expr- Set output value and fire exec in one statement. Works in both pure and exec contexts.await expr- Suspend exec chain and resume when expression fires. Armed-flag guard ensures one-shot execution (~7 gates per await).let name: exec- Local exec signals.emit namefires them from any handler;await nameoron namelistens.let x = await val on trigger- Capture a value when a trigger fires.await a || b- Race semantics via normal binary expressions._placeholder in await - Resolves to the armed flag (bool). Enablesawait Sleep(_, delay = 1.0).- Logical/comparison operator coercion -
&&,||,^^,!,==,!=,<,>,<=,>=now accept all wire variant types (bool, int, float, exec, string, entity, controller, character, brick, prefab).
Builtin Functions
Sleep(input, delay?, hold?)- BufferSeconds gate. Delays a value by seconds.SleepTicks(input, delay?, hold?)- BufferTicks gate. Delays a value by ticks.
Compiler
compile_to_world- New compile path returningbrdb::Worldfor.brdboutput.- CLI
.brdbsupport -just compile file.ws -o file.brdbemits SQLite saves. - Compile progress - LSP sends
wirescript/compileProgressnotifications; VS Code extension shows step counter in status bar. **(pow) fix - Now wires toInput/Exponentports instead ofInputA/InputB.- BRZ double-write fix - Fixed
to_brz_vecwriting the archive twice (exact 2x file size).
Editor / IDE
- Inlay type hints - Ctrl+Alt shows inferred types for
let/bufferbindings. Works in VS Code and the web playground. - Hover gate estimates - Hovering chips/mods/handlers/if-blocks shows estimated gate and microchip counts. Call-graph expansion sums callee costs recursively.
- Record field hover fix -
cpu.regs,cpu.cpsrand nested field access now show types correctly. onhandler hover - Shows gate estimate for the handler scope.ifhover estimates - Shows gate cost for the if/else scope.- Tuple display - Records with numeric keys show as
(bool, int)instead of{0: bool, 1: int}. awaitkeyword highlighting - Added to VS Code tmLanguage and Monaco monarch tokenizer.
Playground
- Inlay hints provider -
wirescript_inlay_hintsWASM binding + MonacoInlayHintsProvider. Hidden by default, shown on Ctrl+Alt. - New
async_signals.wsexample - Demonstrates emit-value, await, local exec signals, Sleep.
Documentation
- Updated
statements.mdwith emit-value, local exec signals, await, Sleep/SleepTicks. - Updated
exec-context.mdwith await section,_placeholder, Sleep examples. - Updated
builtins.mdwith Sleep/Delay section. - Updated
expressions.mdwith operator coercion for all wire variant types. - Updated
types.mdwith exec->bool coercion. - Removed
fnkeyword references from docs. just compile-brdbrecipe added.
Test Files
projects/tests/src/- New in-game test suite:test_await_emit.ws,test_variables.ws,test_operators.ws,test_control_flow.ws,test_chips_mods.ws,test_strings.ws.crates/wirescript/tests/- Integration tests:await_test.rs,emit_value.rs,local_exec.rs.
0.1.0
Language Features
- Records & tuples - User-defined record types (
type Point = { x: int, y: int }), record literals, destructuring (let { x, y } = p), spread operator ({ ...p, y: 99 }), tuple types and literals - Spread in call args - Pass record fields as named parameters:
foo({ ...defaults, x: 1 }) - Destructured params -
mod dist({ x, y }: Point) -> int { ... }in mods and chips on exprsyntax - Trigger handlers on arbitrary exec expressions, not just named events- Exportable vars/buffers/arrays -
var,buffer, andarraydeclarations are now importable across files - String
varerror (WS018) -var s: stringnow errors at typecheck time (Brickadia runtime doesn’t support string variables) - Ref/deref improvements - LSP completions on arrays and refs, output ref/deref fixes
Editor / IDE
- Record field hovers - Hover shows
State.counter: *intfor record fields and type declaration fields - Spread type validation - Extra fields from spread are caught with errors pointing at the
...exprspan - Chip/mod context hover - Hovering
chip/modkeywords shows whether the block is pure or exec - Event parameter hovers - Hover on event handlers shows parameter types
- Mod/chip return type hovers - Hover shows
-> (result: int)return types - Formatter fixes - Multi-line function call args indented correctly; operator continuation lines indented
typekeyword highlighting - VS Code extension highlights user-defined type names
Playground
- Docs panel refactor - Docs fetched from
docs/*.mdinstead of inline JS (~1900 lines removed fromdocs.js) - Examples loaded from files - Playground examples loaded from
sdk/examples/*.wsvia fetch instead of hardcoded JS - New
records.wsexample - Demonstrates records, destructuring, spread, and tuples
Bug Fixes
- Fix branch scoping - variables declared in
if/elsebranches no longer leak across branches - Fix string comparison gate using wrong variant
- Fix inline modules adding extra microchip outputs
- Fix
return exprin pure mods - Fix
on var.valuenot lowering handler body - Fix emit not chaining union gates for multiple
emitpaths - Fix import not pulling in same-file dependencies of imported declarations
- Fix array index access requiring exec context
- Fix array
.length()/.pop()returningAnytype - Fix string wire port emits with literal variant values
0.0.0
Language Features
- Standalone chip instantiation - Named chips with
-> (outputs)now compile to real child microchips, one per call site. Cross-chip wires resolve automatically. static var- Variables that persist across rounds:static var highScore: int = 0return expr- Return values from chips and mods- Single-output auto-unwrap -
chip Foo() -> (result: int)returnsintdirectly instead of{result: int} - Block expressions -
{ stmts; expr }as expressions - Compound assignment -
+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>= ^^logical XOR operator -a ^^ bis true when exactly one operand is truelettype annotations -let x: int = expr- Array params are always pass-by-reference -
mod init(arr: int[])passes the array by reference without needing* fndeprecation -fndeclarations emit a warning (WS015) suggestingletinstead
Builtin Functions (30 new)
- Select/Swap -
Select(cond, a, b),Swap(cond, a, b) -> {a, b} - String ops (all receiver on
string) -s.Length(),s.Contains(search),s.StartsWith(prefix),s.EndsWith(suffix),s.Find(search),s.Substring(start, len),s.Replace(search, repl),s.Split(delim) -> {Left, Right},s.ToLower(),s.ToUpper(),s.Trim() - Math -
tan,log(x, base),lerp(a, b, t),fmod(a, b) - Vector/Color -
v.SplitVec() -> {x, y, z},c.SplitColor() -> {r, g, b, a} - Edge detector -
Edge(input) -> {rising, falling} - Gamemode -
EndRound(winner?),GetTeamByName(name) - Character -
ShowHint(char, title, text) - Controller -
ShowStatusMessage(ctrl, message) - Bitwise -
BitNand(a, b),BitNor(a, b) - Renamed
MakeColor->Color - 93% gate coverage - 163 of 175 Brickadia gates supported
Events
- ChatCommand -
on ChatCommand(controller, arguments) { ... }
Compiler Optimizations
- NAND/NOR gate fusion -
!(a && b)compiles to a single NAND gate instead of NOT + AND. Same for!(a || b)-> NOR,~(a & b)-> BitwiseNAND,~(a | b)-> BitwiseNOR. - 7.2x faster chip compile - Schema parse caching + lower zstd level cuts chip program compile from 334ms to 46ms
- Receiver syntax on all vector ops -
v.Normalize(),a.Distance(b),v.Magnitude(), etc. now work as chained calls with correct type inference
Editor / IDE
- Cross-file go-to-definition - Clicking an imported symbol jumps to its declaration in the source file. Clicking an import path opens that file.
- Hover on
ifkeywords - Shows whether the block is in exec or pure context - Unused import/output warnings - Warnings for imported symbols and outputs that aren’t used
wirescript-checkCLI - Standalone type checker binary- VS Code extension auto-reload - Extension reloads when the LSP binary changes
Removals
ArrayReftype removed - arrays are always references, useint[]everywhereeventkeyword removed (was already deprecated)