Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wirescript Language Reference

Wirescript is a high-level language that compiles to Brickadia’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

  1. Syntax: Language syntax reference: declarations, statements, blocks, statement terminators, comments, and doc comments.
  2. Types: The type system: primitives (int, float, bool, string, entity, controller, character, vector, rotator, color, exec), compound types (ref T, T[], Map<K, V>, tuples, unions, records, enums), and type coercion rules.
  3. Expressions: Operators (arithmetic, comparison, logical, bitwise, string concatenation), operator precedence, string interpolation, conditional expressions, field access, index access, and function calls.
  4. 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).
  5. Builtin Functions: All built-in functions grouped by category: math/trig, vector, entity, controller/character, display, gamemode, raycasting, random, string formatting, and color.
  6. Chips: Anonymous chips (chip {}), chip let, chip on, named chips with parameters, mod (inline expansion), ref/* params, nested chips, open and closed chips, and compiling without microchips (@flat).
  7. Execution Context: Pure vs exec context, what requires exec, handler exec chains, exec unions after handlers, and explicit exec parameters.
  8. Enums: Tagged unions: declaring enum variants (unit, positional, named payload), construction, .Discriminant, match, if let / let else, generic enums, and the built-in Option/Result.
  9. Best Practices: Gate count and scaling: why every call site is a copy (for mod and chip alike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state.
  10. Constant Folding: Compile-time evaluation of pure gates with constant inputs, guarded by an in-game-certified semantics table; fold barriers; the certification story and reproducibility guarantees.
  11. Testing: Writing a program that checks itself in game: the on Loaded() trigger, a check mod over reference counters, staying silent unless something fails, making the failure line diagnostic, comparing two paths rather than one path against a constant, and what an in-game run cannot prove.
  12. Game Knowledge: Brickadia 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.
  13. Diagnostics: Every WSxxx diagnostic code the compiler emits, grouped by category (context, names, types, calls, generics, config, …), with a one-line meaning and trigger for each.
  14. Upgrading: Breaking changes and how to migrate existing .ws code across versions; links the full CHANGELOG.md.

Quick Example

// A simple counter that increments on each round start
var count: int = 0

on RoundStart() {
  count = count + 1
}

out total = count

How It Works

Wirescript compiles down to Brickadia’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 State Values Logic Strings Templates Events Abstraction Data Shapes Enums Loops Random Input References Prefabs Admin Mastery Space Motion Screen ToolsTools First Circuit - after ToolsFirst Circuit Gates and Wires - after First CircuitGates and Wires Pure and Exec - difficulty 1 of 3 - after Gates and WiresPure and Exec Remembering Values - after Pure and ExecRemembering Values Reacting to the Game - difficulty 1 of 3 - after Remembering ValuesReacting tothe Game Is It Working? - difficulty 1 of 3 - after Reacting to the GameIs It Working? Variables - after Remembering ValuesVariables Reading a Variable - difficulty 1 of 3 - after VariablesReading a Variable Buffers - difficulty 2 of 3 - after Reading a VariableBuffers Resetting - difficulty 1 of 3 - after BuffersResetting Numbers - after Remembering ValuesNumbers True and False - after NumbersTrue and False Vectors and Colors - difficulty 1 of 3 - after True and FalseVectors and Colors Converting Values - difficulty 1 of 3 - after Vectors and ColorsConverting Values Lists - difficulty 1 of 3 - after Converting Values, Reacting to the GameLists Maps - difficulty 1 of 3 - after ListsMaps Truth Tables - after Gates and WiresTruth Tables Choosing a Value - difficulty 1 of 3 - after Truth TablesChoosing a Value Toggles and Latches - difficulty 2 of 3 - after Choosing a Value, Remembering ValuesToggles andLatches Bits - difficulty 2 of 3 - after Choosing a ValueBits Code Door - difficulty 2 of 3 - after BitsCode Door Text Values - after Remembering ValuesText Values Taking Text Apart - difficulty 1 of 3 - after Text Values, Reacting to the GameTaking Text Apart Rich Text - after Text ValuesRich Text Safe Text - difficulty 1 of 3 - after Taking Text Apart, Rich TextSafe Text Matching Text - difficulty 1 of 3 - after Taking Text Apart, Choosing a ValueMatching Text Interpolation - after Remembering ValuesInterpolation Fmt - difficulty 1 of 3 - after InterpolationFmt Number Options - difficulty 1 of 3 - after FmtNumber Options Word Forms - difficulty 1 of 3 - after Number OptionsWord Forms Templates Chosen at Run Time - difficulty 2 of 3 - after Word Forms, Reacting to the GameTemplates Chosenat Run Time Score Card - difficulty 2 of 3 - after Templates Chosen at Run Time, Rich TextScore Card Exec Pins - difficulty 1 of 3 - after Reacting to the GameExec Pins Custom Events - difficulty 2 of 3 - after Exec PinsCustom Events Waiting - difficulty 2 of 3 - after Custom EventsWaiting Forking and Merging - difficulty 3 of 3 - after WaitingForking andMerging Mods - difficulty 1 of 3 - after Reacting to the GameMods Chips - difficulty 1 of 3 - after ModsChips Functions - difficulty 2 of 3 - after ChipsFunctions Imports - difficulty 1 of 3 - after FunctionsImports Counting the Cost - difficulty 2 of 3 - after ImportsCounting the Cost Presentation - after ChipsPresentation Records - difficulty 1 of 3 - after Reacting to the Game, ListsRecords Records in Storage - difficulty 1 of 3 - after RecordsRecords in Storage Tuples - difficulty 2 of 3 - after Records in Storage, ModsTuples Destructuring - difficulty 1 of 3 - after TuplesDestructuring Counting Loops - difficulty 1 of 3 - after Reacting to the Game, ListsCounting Loops Looping Over a List - difficulty 1 of 3 - after Counting LoopsLoopingOver a List Looping Over a Map - difficulty 2 of 3 - after Looping Over a List, MapsLooping Over a Map Index and Value - difficulty 1 of 3 - after Looping Over a MapIndex and Value Loops Across Ticks - difficulty 3 of 3 - after Index and Value, WaitingLoops Across Ticks Dice - after Reacting to the GameDice Random Colors - after Dice, Vectors and ColorsRandom Colors Shuffling - difficulty 1 of 3 - after Random Colors, Index and ValueShuffling Weighted Loot - difficulty 2 of 3 - after ShufflingWeighted Loot Seat Controls - difficulty 1 of 3 - after Reacting to the GameSeat Controls Remote Control - difficulty 1 of 3 - after Seat Controls, Choosing a ValueRemote Control Ready Check - difficulty 2 of 3 - after Remote Control, Looping Over a List, Maps, Choosing a Value, WaitingReady Check Object References - difficulty 1 of 3 - after Reacting to the Game, True and FalseObject References Ref Parameters - difficulty 2 of 3 - after Object References, Mods, ListsRef Parameters Ref Values - difficulty 3 of 3 - after Ref ParametersRef Values Per-Player State - difficulty 2 of 3 - after Ref Values, MapsPer-Player State Enums - difficulty 1 of 3 - after RecordsEnums Payloads - difficulty 2 of 3 - after EnumsPayloads Option and Result - difficulty 2 of 3 - after Payloads, ModsOption and Result Enums from Pins - difficulty 1 of 3 - after Option and ResultEnums from Pins Game Enums - difficulty 1 of 3 - after Enums from PinsGame Enums Spawning a Prefab - difficulty 1 of 3 - after Reacting to the Game, Custom EventsSpawning a Prefab Saved Builds - difficulty 1 of 3 - after Spawning a PrefabSaved Builds Prefab Files - difficulty 1 of 3 - after Saved BuildsPrefab Files Talking to a Copy - difficulty 2 of 3 - after Prefab FilesTalking to a Copy Lifecycle and Storage - difficulty 2 of 3 - after Talking to a CopyLifecycleand Storage Nested Prefabs - difficulty 2 of 3 - after Lifecycle and StorageNested Prefabs One Chip per Player - difficulty 3 of 3 - after Nested Prefabs, Looping Over a Map, Seat ControlsOne Chipper Player Admin Gates - (needs admin to load) - after Is It Working?Admin Gates World Control - (needs admin to load) - difficulty 1 of 3 - after Admin Gates, Forking and MergingWorld Control Teams - (needs admin to load) - difficulty 1 of 3 - after World Control, ShufflingTeams Persistent Players - (needs admin to load) - difficulty 2 of 3 - after Admin Gates, Object References, Looping Over a ListPersistent Players Player Control - (needs admin to load) - difficulty 1 of 3 - after Persistent PlayersPlayer Control Chat Commands - (needs admin to load) - difficulty 1 of 3 - after Admin Gates, Taking Text Apart, Safe TextChat Commands Every Player - (needs admin to load) - difficulty 2 of 3 - after Chat Commands, Looping Over a Map, Persistent Players, ModsEvery Player Moderation - (needs admin to load) - difficulty 2 of 3 - after Player Control, Every PlayerModeration Testing - difficulty 2 of 3 - after Mods, Looping Over a ListTesting Const and Folding - difficulty 3 of 3 - after Testing, Counting the CostConst and Folding Performance - difficulty 3 of 3 - after Const and Folding, One Chip per PlayerPerformance Multiplayer Architecture - difficulty 3 of 3 - after Performance, One Chip per Player, Per-Player State, Custom EventsMultiplayerArchitecture Writing Libraries - difficulty 3 of 3 - after Imports, Multiplayer ArchitectureWriting Libraries Distance and Direction - difficulty 1 of 3 - after Vectors and Colors, Reacting to the GameDistance andDirection Dot and Cross - difficulty 2 of 3 - after Distance and DirectionDot and Cross Angles - difficulty 2 of 3 - after Dot and CrossAngles Rotations - difficulty 3 of 3 - after Angles, Looping Over a ListRotations Blending Values - difficulty 1 of 3 - after Numbers, Vectors and ColorsBlending Values Easing - difficulty 2 of 3 - after Blending Values, Reacting to the GameEasing Over Time - difficulty 2 of 3 - after Easing, Seat ControlsOver Time Messages to One Player - after Reacting to the GameMessages toOne Player Text on Screen - difficulty 1 of 3 - after Messages to One Player, Rich TextText on Screen Keeping It Current - difficulty 2 of 3 - after Text on Screen, Object ReferencesKeeping It Current A HUD for Everyone - difficulty 3 of 3 - after Keeping It Current, Per-Player State, Looping Over a Map, Index and ValueA HUD for Everyone

Basics

  1. Tools
  2. First Circuit
  3. Gates and Wires
  4. Pure and Exec
  5. Remembering Values
  6. Reacting to the Game
  7. Is It Working?
  8. Wirescript for Programmers (after Tools)

State

  1. Variables (after Remembering Values)
  2. Reading a Variable
  3. Buffers
  4. Resetting

Values

  1. Numbers (after Remembering Values)
  2. True and False
  3. Vectors and Colors
  4. Converting Values
  5. Lists (after Reacting to the Game)
  6. Maps

Logic

  1. Truth Tables (after Gates and Wires)
  2. Choosing a Value
  3. Toggles and Latches (after Remembering Values)
  4. Bits (after Choosing a Value)
  5. Code Door

Strings

  1. Text Values (after Remembering Values)
  2. Taking Text Apart (after Reacting to the Game)
  3. Rich Text (after Text Values)
  4. Safe Text (after Taking Text Apart)
  5. Matching Text (after Taking Text Apart, Choosing a Value)

Templates

  1. Interpolation (after Remembering Values)
  2. Fmt
  3. Number Options
  4. Word Forms
  5. Templates Chosen at Run Time (after Reacting to the Game)
  6. Score Card (after Rich Text)

Events

  1. Exec Pins (after Reacting to the Game)
  2. Custom Events
  3. Waiting
  4. Forking and Merging

Abstraction

  1. Mods (after Reacting to the Game)
  2. Chips
  3. Functions
  4. Imports
  5. Counting the Cost
  6. Presentation (after Chips)

Data Shapes

  1. Records (after Reacting to the Game, Lists)
  2. Records in Storage
  3. Tuples (after Mods)
  4. Destructuring

Enums

  1. Enums (after Records)
  2. Payloads
  3. Option and Result (after Mods)
  4. Enums from Pins
  5. Game Enums

Loops

  1. Counting Loops (after Reacting to the Game, Lists)
  2. Looping Over a List
  3. Looping Over a Map (after Maps)
  4. Index and Value
  5. Loops Across Ticks (after Waiting)

Random

  1. Dice (after Reacting to the Game)
  2. Random Colors (after Vectors and Colors)
  3. Shuffling (after Index and Value)
  4. Weighted Loot

Input

  1. Seat Controls (after Reacting to the Game)
  2. Remote Control (after Choosing a Value)
  3. Ready Check (after Looping Over a List, Maps, Choosing a Value, Waiting)

References

  1. Object References (after Reacting to the Game, True and False)
  2. Ref Parameters (after Mods, Lists)
  3. Ref Values
  4. Per-Player State (after Maps)

Prefabs

  1. Spawning a Prefab (after Reacting to the Game, Custom Events)
  2. Saved Builds
  3. Prefab Files
  4. Talking to a Copy
  5. Lifecycle and Storage
  6. Nested Prefabs
  7. One Chip per Player (after Looping Over a Map, Seat Controls)

Admin

  1. Admin Gates (after Is It Working?) (needs admin to load)
  2. World Control (after Forking and Merging) (needs admin to load)
  3. Teams (after Shuffling) (needs admin to load)
  4. Persistent Players (after Admin Gates, Object References, Looping Over a List) (needs admin to load)
  5. Player Control (needs admin to load)
  6. Chat Commands (after Admin Gates, Taking Text Apart, Safe Text) (needs admin to load)
  7. Every Player (after Looping Over a Map, Persistent Players, Mods) (needs admin to load)
  8. Moderation (after Player Control) (needs admin to load)

Mastery

  1. Testing (after Mods, Looping Over a List)
  2. Const and Folding (after Counting the Cost)
  3. Performance (after One Chip per Player)
  4. Multiplayer Architecture (after One Chip per Player, Per-Player State, Custom Events)
  5. Writing Libraries (after Imports)

Space

  1. Distance and Direction (after Vectors and Colors, Reacting to the Game)
  2. Dot and Cross
  3. Angles
  4. Rotations (after Looping Over a List)

Motion

  1. Blending Values (after Numbers, Vectors and Colors)
  2. Easing (after Reacting to the Game)
  3. Over Time (after Seat Controls)

Screen

  1. Messages to One Player (after Reacting to the Game)
  2. Text on Screen (after Rich Text)
  3. Keeping It Current (after Object References)
  4. A HUD for Everyone (after Per-Player State, Looping Over a Map, Index and Value)

Tools

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("<", "&lt;")
}

// Clean free-form player input: trim, cap the length, then escape.
// Capping first keeps the cut from landing inside an `&lt;`.
fn sanitize(raw: string, maxLen: int = 24) -> string {
  return raw.Trim().Substring(0, maxLen).Replace("<", "&lt;")
}

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

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

  1. Compile the example and paste it in-game. The chip has a pin labeled a on its left and one labeled b on its right.
  2. Place a Switch to the left of the chip and a Point Light to the right.
  3. Wire the Switch’s Enabled output to pin a.
  4. Wire pin b to the Point Light’s Enabled input.
  5. 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

  1. Replace the Switch with a Button, wired from its Held output. Now the light is on only while you hold the button.

  2. 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
    
  3. 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 = !a
    

    Compile 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

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

  1. Compile the chip and paste it in-game. Place two Switches and two Point Lights.
  2. Wire each Switch’s Enabled to a and b, and both and either to each light’s Enabled.
  3. Try all four positions. both lights only with both switches on. either lights 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

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

  1. Compile the chip and paste it in-game. Build a door from a few bricks and give one of them a Brick Property Changer.
  2. Wire a Button’s Held output to pressed.
  3. Wire solid to the Brick Property Changer’s Collision Enabled and Visibility inputs. One output can feed both.
  4. 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 pressed turns 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 exec when it only starts handlers, and bool when 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

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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to pressed, and text to the Text Decal’s Text input.
  3. 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

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

  • Clock starts when the chip loads and keeps going. Give it enabled = someBool to turn it on and off. Its first pulse comes one interval after it starts, not at once.
  • ZoneEntered and ZoneLeft are 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 with fillFromZonePlayers (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?

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 await that 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

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 main and no instruction pointer: @right out lit = a && b is 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, Random and for are steps, so they only exist in a handler. See Pure and Exec.
  • let is a wire, var is storage. A let names a gate’s output and costs nothing. A var is a Var gate that only handlers write. Every var survives a world save and load. Nothing resets unless you build a reset.
  • Two ways to read a var. count.Value is a pure wire holding the value as of the start of this tick. Bare count inside 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 and on pressed { ... } runs once when Held turns on. Holding a button is one press. on !pressed runs when it turns off. It reads the value, so declare pressed: bool for that. The game starts handlers too: on Clock(interval = 0.5), on ZoneEntered(zone = room) -> (character).
  • Handlers have no order between them. Two on roots 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 nested if.
  • Time is explicit. await Sleep(_, delay = 1.0) pauses a chain, and await hit waits for a pulse (one that came earlier is dropped). buffer before = x is x one 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 a buffer emit and an await.
  • mod inlines, fn calls. A mod’s body is copied into every call: 50 gates called from 10 places is 500 gates. A fn is one callable plane with a Function Call gate per call. It reads a pin or a let only 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 .Found for 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 by GetUserId().
  • 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

  1. Tools: why does @left in on: bool fail to compile?
  2. First Circuit: how many gates does @right out b = a make?
  3. Gates and Wires: let both = a && b, then two outputs both and !both. How many gates?
  4. Pure and Exec: what does on pressed fire on, level or edge? How many times while the button is held?
  5. Remembering Values: in on pressed { count += 1; last = count }, does last get the old count or the new one?
  6. Reacting to the Game: run a handler every half second, and greet the player who walks into a zone.
  7. Is It Working?: name three reasons a circuit can do nothing with no error.
Answers
  1. on is a keyword (WSP001). Pick another name.
  2. One: the rerouter that puts b on the chip’s right side. Without @right it makes none, just a wire from a to b.
  3. Two, an AND and a NOT. A let adds no gate.
  4. The rising edge: once per press, however long it is held.
  5. The new one. A bare name in a handler reads the variable at that step.
  6. on Clock(interval = 0.5) { ... }, and on ZoneEntered(zone = room) -> (character) { ... } with a zone pin.
  7. An unwired pin, a wire to the wrong port, a stale array read, a chain parked at an await that never resumes.

Branches

  • State. Reading a Variable: in a handler, right after count += 1, what does count.Value read? Buffers: build a set/reset latch from pure wires. Variables, Resetting: what does a var hold after the world is saved and loaded?
  • Values. Numbers: what is 7 / 0? Lists: what does xs[5] give on a three-element array? Maps: how do you tell a missing key from one that holds 0? Converting Values: what type does round(2.6) return?
  • Logic. Choosing a Value: what does a pure if c then x else y compile 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 fn smaller than a mod? Imports: what happens to a library’s on handlers under import { name } from "lib"?
  • Data Shapes. Records in Storage: how many gates does a var holding { x: int, y: int } cost?
  • Enums. Enums from Pins: what does a match give for an int that names no variant? Option and Result: read the value out of an Option.
  • Loops. Counting Loops: how long does a 1,000-iteration for take? Loops Across Ticks: a back-edge loop sits inside an if. Where must its let x: exec signal 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.5 run 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: .Value is as of the start of the tick, so read bare count. A latch: let state = set || (held && !reset) with buffer 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. A float. Add as 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): use import "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 { ... }, or let Some(v) = r else { return }.
  • Loops. One tick. Inside that same if. Declared outside it, the loop runs once and stops, and the compiler warns WS118 at the await.
  • Random. Yes, both ends are included. Roll once into a let or a var.
  • 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. GetInputs reads 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

Variables

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

  1. Compile the chip and paste it in-game. Place a Button, a brick with a Text Decal, and a Point Light.
  2. Wire the Button’s Held to toggle, text to the Text Decal’s Text, and lamp to the Point Light’s Enabled.
  3. 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.
  4. 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

  1. Change var isOpen: bool = false to true. A freshly pasted copy now starts open, with the light on.

  2. 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. kept climbs, but local is always 1. A var inside 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. A var at the top of the file keeps its value between runs.

  3. Count closings too: add var closed: int = 0, add one to it in an else branch of the if, 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 var at 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 var in the reference. It keeps its value inside a mod. At the top of a file a plain var already 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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to pressed, and text to the Text Decal’s Text.
  3. 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

  1. Swap the first two lines of the handler so count += 1 comes first. Now before and after always match.

  2. Build some text from count.Value in a let, 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”. label is 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.

  3. 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.Value is the value as of the start of this tick. You will also see a bare count used outside a handler: it is the same read. These lessons write .Value so the delay is visible in the code.
  • Inside a handler, count.Value is still that same pure wire, so after a write it shows the old value. Read the bare name, count, instead.
  • Text built in a let and 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

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

  1. Compile the chip and paste it in-game. Place two Buttons and a Point Light.
  2. Wire one Button’s Held to set, the other’s Held to reset, and alarm to the Point Light’s Enabled.
  3. Press set: the light comes on and stays on. Press reset: it goes off and stays off. Hold both: set wins, 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

  1. Make reset win when both are held: let state = !reset && (set || held).

  2. 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 text to a Text Decal’s Text. It climbs by about 60 every second.

  3. Turn the latch into a burglar alarm. Give a brick an Attached Zone and wire its Occupied output to set instead 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.
  • on already 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

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

  1. Compile the chip and paste it in-game. Place three Buttons and a brick with a Text Decal.
  2. Wire the Buttons’ Held outputs to red, blue and reset, and text to the Text Decal’s Text.
  3. 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.

  1. Paste the chip three times. Give each copy its own Button on point and its own Text Decal on text.
  2. Wire a Button’s Held to the first copy’s reset. Wire the first copy’s resetNext to the second copy’s reset, and the second’s resetNext to the third’s reset.
  3. 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

  1. Wire an Attached Zone’s Occupied output to reset instead of the button. The scores clear whenever someone steps into the zone.

  2. 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.

  3. Flash a light for two seconds after each reset. Add var flash: bool = false and @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 reset runs once per press. Holding the reset button does not keep resetting.

Next: pick a lesson from the tree on the Learn page.

Numbers

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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire the Buttons’ Held outputs to addPizza and addPerson, and text to the Text Decal’s Text.
  3. 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.
  4. 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

  1. Show the exact share in pizzas. as float turns a value into a float before 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
    }
    

    as applies 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.

  2. 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.

  3. 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

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

  1. Compile the chip and paste it in-game. Place three Switches, a brick with a Text Decal, and a Point Light.
  2. Wire each Switch’s Enabled to a, b and c. Wire text to the Text Decal’s Text and passed to the Point Light’s Enabled.
  3. 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

  1. Show each vote on the sign: "${a} ${b} ${c}: ${votes} of 3". A bool in text shows as true or false.

  2. Give judge a two votes: let votes = a * 2 + b + c. Now a and one other judge are enough, but b and c alone are not.

  3. 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 == 0 reads 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 true when it is not zero, so -1 as a condition is true.
  • But comparing a bool with a number compares it as exactly 1 or 0: 1 == true is true, and -1 == true is false.
  • a == b on two bools is true when they match, both on or both off: the opposite of ^^.

Next: Vectors and Colors.

Vectors and Colors

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

  1. 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.
  2. Wire each Switch’s Enabled to red, green and blue. Wire lamp to the Point Light’s Color and lit to its Enabled.
  3. 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

  1. Add a @left in dim: bool pin 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.

  2. Pick a random color on each press. Random on 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, Random runs as a step in a handler. .ToHex() turns a color into text, so a sign can show which color was picked.

  3. 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 aim to it, and text to 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.
  • Random on two colors picks all four channels at random, alpha included.
  • Asking a value for a part it does not have, such as .z on a color, is a compile error, never a silent zero.

Next: Converting Values.

Converting Values

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

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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to roll, and text to the Text Decal’s Text.
  3. Press a few times. The average moves toward 3.5 as the rolls add up, and about shows 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

  • bool to a number. true is 1 and false is 0, as in True and False.
  • int and float. Mixing them in arithmetic gives a float, as in Numbers.
  • Anything into text inside ${}.
  • Text to bool, where a bool is wanted: text is true when it is not empty. The compiler checks != "" for you.
@left in pressed: exec
var message: string = ""
@right out lamp: bool = message.Value
@right out text = message.Value

on pressed {
  message = if message == "" then "Hello" else ""
}

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, float and string into one another.
  • A float to an int drops the fraction toward zero: 2.7 as int is 2, and -2.7 as int is -2. Use round first to get the nearest instead.
  • A number to a bool is true when it is not zero.
  • Text to a number reads the number at its start: "42abc" as int is 42, and "abc" as int is 0.
  • A vector, rotator or color into text: X=1.000 Y=2.000 Z=3.000 for 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

  1. Remove as float from average. After a 3 and a 4 it shows 3: the divide is now between two ints and drops the remainder.
  2. Count sixes. Add var sixes: int = 0, add one when last == 6, and show the share of sixes as a whole percent: `(sixes.Value as float / rolls.Value
    • 100.0) as int`.
  3. 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, ceil and sqrt give back a float, even when the answer is whole. Add as int when you need an int. min, max, abs and clamp give back the type they are given: an int stays an int.
  • Text that does not start with a number converts to 0, with no error.

Next: Lists.

Lists

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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire the Buttons’ Held outputs to roll and step, and text to the Text Decal’s Text.
  3. 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

  1. Show the biggest roll so far. In the roll handler, add best = rolls.max().Value with a new var best: int = 0, and show it on the sign. max() is one gate however long the list is. Like an element read, it gives .Value and a second part, .IsEmpty.

  2. Add a third button that clears the list: rolls.clear(), then set count and index back to 0.

  3. 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 .OutOfBounds whenever the index might be wrong.
  • Every rolls[i] is its own gate and its own read. To use one read twice, bind it to a let, as item is 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

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 with string keys and int values. 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 1 picks 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone.
  2. Wire the Attached Zone’s Zone Ref to room.
  3. 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

  1. Show how many different players have visited. Add var players: int = 0, and at the end of the handler set players = visits.length(). length() gives the number of keys. Wire @right out sign = "${players.Value} players have visited" to a Text Decal.

  2. Add a second zone that makes the chip forget you. has asks only whether a key is there, and remove takes 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.

  3. 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. Each key => value pair 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. set on a key that is already there replaces its value.
  • For a key that might be missing, check .Found or has() rather than counting on what .Value holds.
  • Every get is its own gate and its own read. To use one read twice, bind it to a let, as before is 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

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).

aba && ba || ba ^^ b!a
000001
010111
100110
111100
  • && (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.

abclit
0000
0010
0100
0111
1000
1011
1101
1111

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

  1. Compile the chip and paste it in-game. Place three Switches and a Point Light.
  2. Wire each Switch’s Enabled to a, b and c.
  3. Wire lit to the Point Light’s Enabled.
  4. 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

  1. Count the votes instead. A bool counts as 1 when on and 0 when 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 >= 2
    

    Its truth table is the same as the first chip’s. Open it: two Add gates and a Greater/Equal gate.

  2. Change the rule to “every switch is on”, then to “exactly one switch is on”. Write the truth table first, then the expression.

  3. 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 && b always computes both sides, even when a alone already decides: wires do not skip anything. In a handler, an action called on either side still runs, so to do it only when a is on, put an if inside an if.
  • 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

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: int is 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 % 3 counts 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 from 0.0 to 1.0. Color(1.0, 0.0, 0.0) is red.
  • if pick == 0 then A else B is a value: A when the condition is true, B when it is false. The B here is another if, so the choice has three answers. then and else can 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

  1. Compile the chip and paste it in-game. Place a Button, a Switch and a Point Light.

  2. Paste this counter chip as well. It adds one each time pressed turns on. Remembering Values explains how.

    @left in pressed: exec
    var count: int = 0
    @right out presses = count.Value
    
    on pressed {
      count += 1
    }
    
  3. Wire the Button’s Held to the counter’s pressed, the counter’s presses to the first chip’s presses, and color to 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.

  4. Wire the Switch’s Enabled straight to the Point Light’s Enabled, and flip it on.

  5. 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

  1. Add a fourth color. Change % 3 to % 4 and add one more else if pick == 2 then ... before the last else. Try Color(1.0, 1.0, 0.0), which is yellow.

  2. 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.

  3. Add a @left in dim: bool pin from a Switch, and an output for the Point Light’s Brightness that is one number while dim is 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, presses is 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

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

  1. Compile the chip and paste it in-game. Place a Button and a Point Light.
  2. Wire the Button’s Held to pressed, and light to the Point Light’s Enabled.
  3. 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

  1. Make the toggle flip when the button is let go instead: change on pressed to on !pressed, and the pin to @left in pressed: bool so !pressed can read it. Press and hold: nothing happens until you release.

  2. 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.

  3. In the latch, holding both buttons at once turns the light on: set wins. Change it so that reset wins: !reset && (set || held). Write the truth table for set, reset and held both 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

  • on fires 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

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.

BinaryBits that are onValue
0b000111
0b010044
0b10118, 2, 111
0b11118, 4, 2, 115

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 bool counts as 1 or 0 where a number is expected, so s1 is the number 1 while its switch is on.
  • << shifts left: x << 2 moves every bit of x two places up. 1 << 2 is 0b0100, which is 4. So s2 << 2 is 4 while s2 is 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 of x are on.

Switches s0 and s3 are on, and the others off. What does the sign say?

Run

  1. Compile the chip and paste it in-game. Place four Switches in a row and a brick with a Text Decal.
  2. Wire each Switch’s Enabled to s0, s1, s2 and s3, and text to the Text Decal’s Text.
  3. Flip the switches. With s0 and s3 on, the sign reads code 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:

OperatorNameExampleResult
&AND0b1100 & 0b10100b1000
|OR0b1100 | 0b10100b1110
^XOR0b1100 ^ 0b10100b0110
~NOT~0b1100every bit flipped, all 64 of them
<<shift left0b0011 << 20b1100
>>shift right0b1100 >> 20b0011

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
  • s2On tests one bit: is it on?
  • third reads the same bit as a number, 0 or 1: shift it down to the bottom, then mask it.
  • withS0 turns the bit worth 1 on, and withoutS0 turns it off. ~0b0001 is every bit except the lowest.
  • flipped flips the low four bits. ~code would 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

  1. 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 follows s3.

  2. Show the bits in a row. Add an output for a second Text Decal that writes each bit as 1 or 0, 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}"
    
  3. 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: int pin wired from the counter’s presses. Give four Point Lights one bit each, from (presses & 1) != 0 up 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 int is 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

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 = 0b1011 is the secret code, written in binary so each digit is one switch: s3 on, s2 off, s1 on, s0 on, reading left to right.
  • code packs the four switches into one number, as in Bits.
  • solid is on while the code is wrong. Wired to the door, it keeps the door there until the code matches.
  • code ^ CODE is XOR: a bit of it is on wherever a switch differs from the code. BitCount counts those wrong switches, so 4 - BitCount(...) is the number of right ones.
  • glow: float makes the output a decimal number, the kind a light’s Brightness takes. right is 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

  1. 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.
  2. Wire each Switch’s Enabled to s0, s1, s2 and s3. Put s3 on the left, so the row of switches reads like the binary number.
  3. Wire solid to the Brick Property Changer’s Collision Enabled and Visibility inputs.
  4. Wire glow to the Point Light’s Brightness, and give the light’s Enabled a Switch of its own, flipped on.
  5. With every switch off, one switch is right: s2, which the code wants off. Flip s0, s1 and s3 on. 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

  1. Choose your own code: change CODE to another four-bit number, from 0b0000 to 0b1111. Add a sign that shows the hint as text, @right out hint = "${right} of 4 right", wired to a Text Decal’s Text.

  2. Make the hint something you ask for. Add @left in peek: bool from a Button’s Held, and make the light dark unless it is held: @right out glow: float = if peek then right else 0.

  3. 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 solid to 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 s3 unwired, the code 0b1011 can never match.

Next: pick a lesson from the tree on the Learn page.

Text Values

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

  1. Compile the chip and paste it in-game. Place two Buttons, two bricks with a Text Decal each, and a Point Light.
  2. Wire one Button’s Held to away and the other’s to back.
  3. Wire text to one Text Decal’s Text and count to the other’s Text. Wire lit to the Point Light’s Enabled.
  4. At first the sign reads Shop: , the count reads 0 characters, and the light is off.
  5. Press away. The sign reads Shop: Back in 5 minutes, the count reads 17 characters, and the light comes on. Press back and 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.

  • text comes from an Append String gate, the game’s join gate. Its inputs are Shop: and the variable’s value.
  • count comes 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.
  • lit comes 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 a bool.

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

  1. Change AWAY to 'Ring the "bell"'. Compile and press away: the sign shows the quotes. How many characters does the count show now?

  2. == compares two strings and gives a bool. Add a pin that is on only while the note says exactly AWAY, 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.

  3. Use one button for both jobs. Inside a handler, if note is the same test as lit: 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 if is a Branch gate, and its condition is the same Not Equal against "" that the compiler added for lit.

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

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: Left is the text before the space, Right is the text after it, and Found says 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone and wire its Zone Ref to room.
  2. Wire text to a Text Decal’s Text.
  3. 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.
  4. 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

  1. Greet the rest of the name too. parts.Right is everything after the first space, so use it only when parts.Found says 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)}!")
      }
    }
    
  2. Find gives 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")
      }
    }
    
  3. Text can hold a number. ParseNumber() reads it, and gives two values: Value, the number, and Success, 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, two and enter, shown to a Text Decal’s Text, and brightness to a Point Light’s Brightness. Type 12 and press enter. ParseInt() works the same way for whole numbers, with an int for its Value.

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() and Substring count 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

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, so 44dd44 is green and dd4444 is 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

  1. Compile the chip and paste it in-game. Place two Switches and a brick with a Text Decal.
  2. Wire one Switch’s Enabled to shopOpen and the other’s to busy.
  3. Wire text to the Text Decal’s Text.
  4. With both switches off, the sign shows a large red bold CLOSED over a smaller Come on in. Flip shopOpen: the word turns green and reads OPEN. Flip busy: the second line changes to Busy, please wait in 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

  1. 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.

  2. 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 in Length().

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

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 &lt;. 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

  1. Compile the chip and paste it in-game. Place a Switch and a brick with a Text Decal.
  2. Wire the Switch’s Enabled to safe, and text to the Text Decal’s Text.
  3. With the switch off, the name’s tags take over: Sam and everything after it, Welcome! included, is huge and red.
  4. 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 &lt; 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("<", "&lt;")
}

// Clean free-form player input: trim, cap the length, then escape.
// Capping first keeps the cut from landing inside an `&lt;`.
fn sanitize(raw: string, maxLen: int = 24) -> string {
  return raw.Trim().Substring(0, maxLen).Replace("<", "&lt;")
}
  • escape is a mod. A mod is copied into every place that uses it, so each escape(...) becomes one Replace in String gate right there.
  • sanitize is a fn. 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 = 24 gives the second input a default, so sanitize(name) means sanitize(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 &lt; 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

  1. Change SIGN_WIDTH to 4 and walk in again. Only the start of your name fits.
  2. Run your sign through the switch test: replace name with TRICKY from the first example in the sanitize call, and check that the sign shows the tags as text.
  3. 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.
  • escape only changes <. Text without a < comes out unchanged.

Next: Matching Text.

Matching Text

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, and first becomes everything before the first space. ${_} matches the rest without naming it.
  • _ fits anything. A match on 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone and wire its Zone Ref to room.
  2. Wire text to a Text Decal’s Text.
  3. 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 out first. ${_} 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

  1. 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, match moves 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)}!",
      })
    }
    
  2. 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.

  3. match works on numbers too. An arm can be an exact number, or a range low..high, which fits numbers from low up 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 3 says “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 a name that 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 on name.ToLower() and write the arms in lower case to ignore case.
  • A literal between two holes is found at its first appearance: "${a}-${b}" splits x-y-z into x and y-z.
  • match also works on enums, where each arm names a variant.

Next: pick a lesson from the tree on the Learn page.

Interpolation

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

  1. Compile the chip and paste it in-game. Place two Buttons and two bricks with a Text Decal.

  2. 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 }
    
  3. Wire the first Button’s Held to the counter’s addRed and the second’s to addBlue. Wire the counter’s red and blue to the first chip’s red and blue.

  4. Wire joined to one Text Decal’s Text and templated to the other’s.

  5. Press each button a few times. The two signs always read the same.

Under the Hood

Open the first chip and count the gates.

  • joined is 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.
  • templated is one Format String gate. Its Format String is Red {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 + blue is 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

  1. Add who is ahead. if ... then ... else picks one of two values: here "Red" when red > blue is true, "Blue" when it is false. Give it a name with let, 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?

  2. Fix the tie: an else can hold another if. if red == blue then "Nobody" else if red > blue then "Red" else "Blue".

  3. 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 bool in a slot prints as a number: true shows 1. Word Forms prints words instead.

See String Interpolation for the reference.

Next: Fmt.

Fmt

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

  1. Compile the chip and paste it in-game. Place two Buttons and two bricks with a Text Decal.
  2. Paste the counter chip from Interpolation. Wire the Buttons’ Held to its addRed and addBlue, its red and blue to this chip’s red and blue, and redText and blueText to each Text Decal’s Text.
  3. Press the red button twice and the blue one once. The signs read Red: 2 and Blue: 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) prints y 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, and Fmt does not.

Modify

  1. Change LINE to "{A} team: {B} points". Both signs change, and you edited one line.

  2. 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)
    
  3. 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 wide and 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") shows literal0brace. Fmt passes 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) shows 10.

See String Formatting for the reference.

Next: Number Options.

Number Options

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

  1. Compile the chip and paste it in-game. Place a Button and three bricks with a Text Decal.
  2. Paste the counter chip from Interpolation. Wire the Button’s Held to its addRed and its red to presses. Wire big, share and both to the three Text Decals’ Text.
  3. Press the button three times. The signs read 375,000 points, 0.75 each and 3,703.50 coins. Press once more: share shows 1.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

  1. Leading zeros. No option pads 7 to 07, 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)
    

    mod defines a helper with a name. n: int is its input, -> string says it gives back text, and return gives that text. Each use copies the helper’s gates into the chip (mod). The body puts a 0 in front, then keeps the last two characters: s.Length() counts the characters, and s.Substring(start, length) takes length of them from position start. So 7 becomes 07, and 42 becomes 042 and then 42. It only works below 100: 100 would come out as 00.

  2. 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, so elapsed / 60 is the full minutes. % gives the remainder: the seconds left over. Wire a Button’s Held to pressed and lap to a sign.

  3. Dates. FormatDate turns 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, %Y is the year, %m the month, %d the day, %H the hour and %M the minute. useUTC = true gives the time in UTC, and false gives 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(), not ServerUptime(). Uptime starts again from zero when the server restarts, but a var keeps 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

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: one is the word for a count of one, other the 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, few and other are the categories the gate sorts places into, giving 1st, 2nd, 3rd and 4th.

After one press, what do the signs say? After two?

Run

  1. Compile the chip and paste it in-game. Place a Switch, a Button and three bricks with a Text Decal.
  2. Paste the counter chip from Interpolation. Wire the Button’s Held to its addRed, its red to presses, and the Switch’s Enabled to isOpen. Wire the three outputs to the three Text Decals’ Text.
  3. Flip the switch: Door: Open, Door: Closed. Press the button and watch the words change with the number.
  4. Find out two things the lesson does not tell you. What does appleText say before the first press? What does placeText say 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

  1. A form can insert the value itself. This prints one apple for 1 and 3 apples for 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 other form, so there is one {A} before the bar.

  2. Two values with their own words. Add a second Button, counted on the counter’s blue, on coins: Fmt("{A} {A}|plural(one=player,other=players) found {B} {B}|plural(one=coin,other=coins)", presses, coins).

  3. Mix an ordinal and a bool option. place <= 3 is 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 bool in a plain slot prints 1 when 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

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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone and wire its Zone Ref output to room.
  2. 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

  1. 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()].
  2. 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.".
  3. 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 a 0 on the sign.

See String Formatting and const containers at runtime for the reference.

Next: Score Card.

Score Card

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.
  • CARD is a const template filled by Fmt, as in Fmt.

Before anything is pressed, hits + misses is 0. What does the accuracy line say then?

Run

  1. Compile the chip and paste it in-game. Place two Buttons, a Switch and a brick with a Text Decal.

  2. Paste the counter chip from Interpolation. Wire the Buttons’ Held to its addRed and addBlue, its red to hits and its blue to misses, and the Switch’s Enabled to isOpen. Wire card to the Text Decal’s Text.

  3. 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

  1. Add a best streak. Give the chip a third pin, streak, add <br>Best streak: {E} to the template, and pass streak as the fifth value.
  2. Color the accuracy by how good it is. Pick the color with a lookup Fmt from Templates Chosen at Run Time, using clamp(hits * 3 / (hits + misses), 0, 2) as the position, and pass it into CARD as one more slot: <color="{F}">Accuracy: {C:.1}%. The position must be a whole number: hits * 3 / (hits + misses) divides two ints, which drops the fraction, while accuracy is a decimal and would build a format like {1.47} that picks nothing.
  3. Give the card a place line: You are {G}{G}|ordinal(one=st,two=nd,few=rd,other=th), with G from a second counter chip’s red. A template holds at most seven values, A to G, 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 two Fmts 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

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

  1. Compile the chip and paste it in-game. Place a Button, a brick with a Text Decal, and a One Shot Audio brick.
  2. Open the One Shot Audio’s settings and pick a sound for it.
  3. Wire the Button’s Held to pressed, sign to the Text Decal’s Text, and ring to the One Shot Audio’s Play.
  4. 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

  1. Ring only on every other press. Wrap the emit: if rings % 2 == 0 { emit ring }.

  2. 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.

  3. Combine the dice and the doorbell: wire rolled to a One Shot Audio’s Play and face to 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 pressed takes 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

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

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

  1. 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.
  2. Wire the Button’s Held to pressed. Wire light to the Point Light’s Enabled and sign to the Text Decal’s Text.
  3. Press the button. The light toggles and the sign counts the presses.
  4. 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:

@left in pressed: exec
var presses: int = 0
var heard: bool = false
@right out lamp = heard.Value

on pressed {
  presses += 1
  SendCustomEvent("doorbell", presses, ReadBrickGrid())
}

on CustomEvent("doorbell.heard", isObject = true) {
  heard = !heard
}
var rings: int = 0
@right out sign = "Rings: ${rings.Value}"

on CustomEvent("doorbell") -> (count: int, door: entity) {
  rings = count
  door.SendCustomEvent("doorbell.heard")
}

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 GlobalCustomEvent with the channel’s name runs, in every player’s circuits.
  • Separate channels. A global "doorbell" and a personal "doorbell" never meet: SendGlobalCustomEvent does not reach on CustomEvent, and SendCustomEvent does not reach on 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

  1. Send a second value: add var door: int = 1 to the button chip and send SendCustomEvent("doorbell", presses, door). Receive it as -> (count: int, door: int) and show it on the sign. Paste a second button chip with door = 2, so the sign says which door rang.
  2. Make the light chip’s sign show “Back door” or “Front door” instead of the number.
  3. In the reply version, wire the button chip’s lamp to 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 var instead.
  • A send to a grid needs isObject = true on 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

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

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

  1. Compile the chip and paste it in-game. Place two Buttons, a Point Light and a brick with a Text Decal.
  2. Wire one Button’s Held to start and the other’s to hit. Wire light to the Point Light’s Enabled and sign to the Text Decal’s Text.
  3. 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.
  4. 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

  1. Make the wait before the light random: store Random(1, 4) in a var delay: float first, then await Sleep(_, delay = delay).
  2. Give the reaction timer the race: wait for hit || Sleep(_, delay = 3.0) and show “Too slow” when the light was missed. The race reads hit as a value, so make it @left in hit: bool, as in the race above. Then catch a cheater: check hit right before the light comes on, and show “Too early!” if it is already held.
  3. In the guessing game, add a third button and a third answer. Then add a timeout: a second on ask handler that waits five seconds and does emit answer = 0. Show “Out of time” when picked is 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 await is reached is dropped, not saved for later.
  • await waits 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.
  • Sleep measures seconds and SleepTicks measures ticks. SleepTicks with delay = 0 resumes on the next tick, and delay = N after 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

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

  1. 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.
  2. Wire the Switch’s Enabled to staffed and the Button’s Held to bell. Wire sign to the Text Decal’s Text and chime to the One Shot Audio’s Play.
  3. Flip the switch on and the sign says “Open”. Ring, and the chime plays.
  4. 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

  1. A handler can declare an output of its own. Replace shown and sign in 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.

  2. Give the shop a back door: merge a second bell Button into the first with Union, and branch on staffed after the merge.

  3. Make the chime flash a light too, without delaying the sign: fork a Flash() from on 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 x fires when x turns on and on !x when 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

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.
  • -> string says it gives back one value, a piece of text.
  • return names that value. Here it is one if ... then ... else expression: if busy then A else B is A while busy is true and B otherwise. 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 with name set to "Workshop" and busy set to busy1.

The mod is written once and called twice. How many gates does the chip hold? Which of them belongs to status?

Run

  1. 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.
  2. Wire the first zone’s Occupied to busy1 and the second’s to busy2.
  3. Wire sign1 and sign2 to the two Text Decals’ Text.
  4. Walk into the workshop and its sign says Workshop: busy. Walk out and it says Workshop: 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

  1. Add a third room, an office. Add @left in busy3: bool and @right out sign3 = status("Office", busy3). Open the chip again and there is a third Select.

  2. Give status two 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.lit
    

    With named outputs, a call gives back a record, and workshop.text and workshop.lit read its two values. Wire free1 and free2 to two Point Lights’ Enabled.

  3. 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: *int means “a variable holding an int”, not a copy of its value, so bump(visits1) adds to visits1 itself. Ref Parameters covers * in full. by: int = 1 is a default, so a call that leaves by out uses 1. <br> in the sign’s text starts a new line. Change the storage room to count each visit twice, with bump(visits2, 2).

    on busy1 fires 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 busy until the last person leaves.

See also: mod: Inline Chips, Default and Named Arguments and return.

Next: Chips.

Chips

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

  1. Compile the chip and paste it in-game. Place three Buttons, for Red, Blue and Reset, and a Text Decal.
  2. Wire each Button’s Held to red, blue and reset, and board to the Text Decal’s Text.
  3. 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

  1. Add a third team, Green: one more input, one more Tally(...) call, and one more microchip when you open the chip.

  2. Give Tally a 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: exec is a pulse output. emit won fires it, and on r.won runs when Red’s chip fires it.

  3. 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 = board
    
    • chip on red { ... } is a handler in a chip of its own.
    • chip let board = ... puts one calculation in a chip.
    • ClearScores has steps but no handler. Called inside on reset, it runs as a step of that handler, and the handler carries on after it.
    • @closed makes a chip arrive shut. Chips arrive open by default.

    Open the chip. The board chip has two pins in, one for each score, and one out. ClearScores has 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 var and 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

  1. Compile the chip and paste it in-game. Place three Buttons as targets and a Text Decal.
  2. Wire the Buttons’ Held to a, b and c, and board to the Text Decal’s Text.
  3. 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 hits and a Function Output for the result
  • a Var that holds the answer, a Set Variable for each return and 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

  1. Change fn medal to mod medal and 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.

  2. A fn needs a pulse to run on. Inside a handler it uses the handler’s. Outside one, give it a pulse with exec =. 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.Value
    

    on medal(..., exec = check) -> (m) runs the call each time check turns on, then runs the block with the answer in m.

  3. A function can change top-level variables. Keep the best score of all targets: add var best: int = 0, put if hits > best { best = hits } as the first line of medal, and add Best: ${best.Value} to the sign. Open the Function Definition. A new Var gate labeled &best is the handle that reaches best.

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 fn call runs the whole body before the handler’s next step, within the same tick.
  • A variable declared inside a fn is 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 fn cannot 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

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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone, and place a Text Decal.
  2. Wire the zone’s Zone Ref to room, and text to the Text Decal’s Text.
  3. 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:

  • escape is a mod, so it was copied in as one Replace in String gate beside the Show Chat Message.
  • sanitize is a fn, 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

  1. Change the import to import * as safe from "sanitize" and put safe. in front of both calls. Then try import "sanitize" with the plain names. All three compile to the same gates.

  2. 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, save medals.ws in 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 is import { medal } from "lib/medals".

  3. Move a variable into the library: add var total: int = 0 to medals.ws, import total too, 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 on handlers. import { name } from "x" brings only the names it lists and leaves those handlers out. The compiler warns (WS014). Use import "x" or import * 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

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

  1. Compile the chip and paste it in-game. Place three Buttons and a Text Decal.
  2. Wire the Buttons’ Held to a, b and c, and board to the Text Decal’s Text.
  3. 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 mod or chip costs its body times the number of calls. A chip adds a microchip brick and its pins on top.
  • A fn costs 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

  1. Write medal with early returns. A mod that returns from inside an if needs 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. medal is now 11 gates a call instead of 4.

  2. Change mod medal to fn medal. The count drops to 37, because the body is built once, and each call is one Function Call.

  3. Keep the fn, and write its body as the one-line if ... then ... else again, and the count is 30. Then try ten targets. The four versions come to:

    How medal is written3 targets10 targets
    mod, one expression, in the sign2273
    mod, early returns, in handlers52173
    fn, early returns, in handlers3788
    fn, one expression, in handlers3081

    (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 fn saves gates only when its body is bigger than the cost of calling it. For a few gates of body, a one-line mod is smaller.

See also: Best Practices: Gate Count & Scaling, Profiling and Sliced loops.

Next: pick a lesson from the tree on the Learn page.

Presentation

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, @label its title, @color the color of its brick.
  • @label("Red +1") on a pin changes the name shown beside it in-game.
  • @bottom and @top put a pin on the chip’s bottom or top edge instead of a side.
  • @color and @material on Tally style its microchip brick. Metal is one of the brick materials. Glow lights the rounds variable’s brick.
  • @label("Rounds: ${rounds}") on a top-level var is a live label, which shows the value as it changes.

Only one of these annotations adds a gate. Which one?

Run

  1. Compile the chip and paste it in-game. The brick is blue and labeled Scoreboard. Red +1 and Blue +1 are on its left side, Reset on the bottom, Board on the right, and leader on top.
  2. Wire three Buttons’ Held to the three input pins, and the board and leader outputs to two Text Decals’ Text.
  3. Open the chip. Its plane has a header, with Scoreboard in large letters and the description below it. The two Tally chips are gray metal. Open one and its header shows Tally and Counts presses until cleared.
  4. 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

  1. Lay the gates out like the code. Add @layout("code") as the first annotation at the top, directly under the description’s blank line. @layout and @flat must come before @label and @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.

  2. Make it small. Replace @layout("code") with two lines, @flat and @layout("cube"). @flat builds every chip’s gates onto one grid, so the two Tally chips 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 a fn cannot be flat, because the function runs on its own board. @flat reports WS080 there.

  3. Add @closed in front of chip Tally so the two chips arrive shut, and move leader to 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 @label or @color at 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.
  • @label changes only the name shown in the world. When you wire a pin, it is still listed by the name in the code, red, not Red +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

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.points reads one field. Outside a handler, add .Value, as with any variable.
  • { ...tally, points: tally.points + 1 } is a spread. ...tally copies every field of tally, and points: after it replaces that one field.

Score three points, press nextRound, then score one more. What does the sign say?

Run

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire one Button’s Held to point, the other’s Held to nextRound, and text to the Text Decal’s Text.
  3. Press point three times. The sign reads “Round 1: 3 points, best 0”. Press nextRound and 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

  1. Change the point handler to tally.points += 1. One field can be written on its own, without a spread.
  2. Add a misses field. Add it to the type, give it a starting value in the literal, show it on the sign, and add a third button that adds one to it.
  3. Add a reset button. Name the starting record once with const START: Tally = { round: 1, points: 0, best: 0 }, start with var tally: Tally = START, and write tally = START in 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

  1. Compile the chip and paste it in-game. Place a Target and a brick with a Text Decal.
  2. Wire the Target’s Just Hit to hit, its Character That Just Hit to shooter, and text to the Text Decal’s Text.
  3. 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)
}
DeclarationStorage gates
var here: Point2 Var
var trail: Point[]2 Array Var
var marks: Map<string, Point>2 Map Var
let next: Pointnone

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

  1. Read only the field you need. Replace the before line with let hits = if stats.has(id) then stats.get(id).hits else 0, and use hits + 1 in next. The chip now has one Get Map Element instead of two.
  2. Add a lastHit: int field set to GetUnixTime() on every hit, and show how many seconds passed since that player’s previous hit.
  3. Keep a leader. Add var leader: Stats = { name: "nobody", hits: 0 }, and after each hit, if next.hits > leader.hits, set leader = 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

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 }
  • a and b count the presses of two buttons.
  • -> (lo: int, hi: int) gives the mod two outputs, each with a name and a type.
  • out lo = ... and out 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 becomes lo and the second hi. 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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire the first Button’s Held to pressA, the second’s Held to pressB, and text to the Text Decal’s Text.
  3. 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.hi and range.lo read 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, lo first, then hi.

Past the counters, the chip is again a Min, a Max, a Subtract and a Format String.

Modify

  1. 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.
  2. Make the sign say “a tie” when the gap is 0. Use an if expression on hi - lo.
  3. Write your own tuple: let pair = (a, b) makes one from two values. Spread it into minmax(...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

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, and character: who names it who here.
  • entered.get(...) gives back a record with two fields, Value and Found. let { Value: since, Found } = ... takes both out, renaming Value to since and keeping Found under its own name.
  • mod describe({ name, seconds }: Visit) takes a Visit and names its fields straight away, so the body uses name and seconds with no visit. 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone, and place a brick with a Text Decal.
  2. Wire the Attached Zone’s Zone Ref to room, and text to the Text Decal’s Text.
  3. 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

  1. Destructure a record in a let. In the ZoneLeft handler, after setting last, add let { seconds } = last and send the player their own time with who.ShowChatMessage("You stayed ${seconds} seconds").
  2. Keep the longest stay. Add var longest: Visit = { name: "Nobody", seconds: 0 } and a second sign. Replace it whenever a stay beats it.
  3. 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

  • ZoneLeft may fire for a player with no stored entry time, for example one who was already inside when the chip was pasted. Found is 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. describe escapes 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. A Light is always one of them.
  • Light.Red names a variant. var state: Light = Light.Red holds one and starts red.
  • The first match is 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, since state is already known to be a Light.
  • The second match is 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

  1. Compile the chip and paste it in-game. Place a Point Light and a Switch.
  2. Wire lamp to the Point Light’s Color, and the Switch’s Enabled to the light’s Enabled. Flip the switch on.
  3. 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.
  4. 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

  1. Write the handler with a value match instead: 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.
  2. Add a crossing button. Add @left in walk: exec from a Button’s Held, and an on walk handler with a statement match that turns a green light yellow and leaves red and yellow alone (an arm can be an empty block, {}).
  3. Add @right out stop = match state { ... }, giving true for red and yellow and false for 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.

See Enums and match.

Next: Payloads.

Payloads

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 as Phase.Countdown(3). Playing { started: int } carries a named field, built as Phase.Playing { started: ... }.
  • In a match arm, Countdown(n) names the carried value n, and Over { seconds } names the field. Playing { .. } matches without naming anything.
  • phase is Phase.Lobby is true while phase is that variant.
  • let Playing { started } = phase else { return } takes the data out if phase is Playing, and otherwise runs the else, which must leave the handler, here with return. After it, started is 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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire the Buttons’ Held outputs to start and finish, and sign to the Text Decal’s Text.
  3. Press start. The sign counts “Starting in 3”, 2, 1, then “Go!”. Wait a while and press finish. The sign shows “Finished in 7 seconds”, or however long it was.
  4. finish does nothing until the round is playing, and start does 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

  1. 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.
  2. Ignore a finish in the first five seconds of play. After the let ... else, add if GetUnixTime() - started < 5 { return }.
  3. Add a Paused { left: int } variant and a pause button that stops a countdown and keeps the seconds left. The sign’s match stops compiling until it has a Paused arm. Pressing start while 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 finish pressed 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

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
  }
}
  • a and b count the presses of two buttons, and go divides them.
  • -> Option<int> says the mod gives back an Option that holds an int when there is one.
  • Some, None, Ok and Err are written bare, with no Option. in front.
  • The match must 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 when r is Some, with the number named q. It is a match with 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

  1. Compile the chip and paste it in-game. Place three Buttons and two bricks with a Text Decal each.
  2. Wire the three Buttons’ Held to addA, addB and go, and text and lastGood to the two Text Decals’ Text.
  3. Press go before 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, and go. 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

  1. Use let ... else instead of if let: let Some(q) = r else { return } leaves the handler when there is no answer, and the lines after it can use q freely.
  2. Add a third counted button c and divide twice: a / b, then that answer by c. Show “no answer” if either division fails.
  3. In the Result version, also refuse a b above 10, with its own Err message.

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

  • b starts at 0, so it is 0 until its button is pressed. That is why the first go has no answer.
  • kept is 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

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

  1. Compile the chip and paste it in-game. Place a Button, a Point Light, a Switch and a brick with a Text Decal.

  2. 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
    }
    
  3. Wire the Button’s Held to the counter’s pressed and the counter’s presses to the dimmer’s presses. Wire brightness to the Point Light’s Brightness, and label to the Text Decal’s Text. Wire the Switch’s Enabled to the light’s Enabled and flip it on.

  4. 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

  1. Replace the Switch with the mode itself: add @right out lit = !(mode is Mode.Off) and wire it to the light’s Enabled.
  2. Add a fourth mode, Medium, between Dim and Bright. Both matches stop compiling until they have a Medium arm, and % 3 must become % 4.
  3. 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 match with no _ arm fall through to a default value: 0 for a number, empty text for a string. Keep the number in range, as % 3 does, or end the match with a _ arm, which catches a number no variant has.
  • The two chips are compiled separately, so nothing checks that they agree. If one lists Dim before Off, 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

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

  1. Compile the chip and paste it in-game. Place a Button, a Point Light and a Switch.
  2. Wire the Button’s Held to held, brightness to the Point Light’s Brightness, and the Switch’s Enabled to the light’s Enabled. Flip the switch on.
  3. 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

  1. Try other curves in the first chip: EasingFunction.Elastic, EasingFunction.Back, and direction = EasingDirection.InOut.
  2. Add a second button to the styles chip that steps the direction too: var dir: EasingDirection = EasingDirection.In, passed as direction = dir, and stepped with % 3.
  3. In the brick chip, also set BrickProperty.Material to BrickMaterial.Glow when lit and BrickMaterial.Plastic when 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

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 to WIDTH, leaving WIDTH out: 0, 1, 2 and so on up to 9. Each pass is called an iteration, and i holds the number for that pass.
  • cells starts empty, and each iteration adds one character to the end of it: # while i is below filled, - after.
  • When the loop is done, the handler carries on with the next line, and drawn = cells puts the finished bar on the sign.
  • % (WIDTH + 1) wraps filled back to 0 after a full bar.

Press the button three times. What does the sign show? What does the eleventh press show?

Run

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to add, and bar to the Text Decal’s Text.
  3. 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

  1. 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.

  2. 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.

  3. Count in any step size. count(n, from = ..., step = ...) is another way to write a range: n iterations, starting at from, going up by step. 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 for loop 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.
  • for is 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

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 of rolls, first to last, with r holding that element. An empty array runs it zero times.
  • place counts the iterations, so it says which roll is being looked at.
  • break stops the loop at once. The handler carries on after the loop, with found holding the place of the first six.
  • found stays 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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire the Buttons’ Held outputs to roll and search, and text to the Text Decal’s Text.
  3. 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

  1. Show the total of all rolls. In the roll handler, after the push, add a loop with no break:

    @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}"
    }
    
  2. Count the sixes instead of finding the first one: drop the break, and add one to a sixes variable each time r == 6.

  3. Search for any number. Add a third button that steps a target variable through 1 to 6 (target = target % 6 + 1), show it on the sign, and search for target instead 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”.
  • break ends the whole loop, not only the current iteration. Nothing after the break in the block runs.
  • A read like rolls[i] past the end of the array gives a stale value, but for r in rolls visits 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).Index is the index of the first 6 (-1 when there is none), and rolls.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

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 in laps. key is the user id and n the lap count filed under it.
  • Inside, names.get(key) looks up the name for that id. Every id in laps was 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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to finish, and board to the Text Decal’s Text.
  3. 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

  1. Add up everyone’s laps. Keep a var total: int = 0 before the loop, add n to it inside, and show Total: ${total} under the rows.
  2. Find the leader. Keep var best: int = 0 and var leader: string = "" before the loop. Inside, when n > best, set both: best = n and leader = key. After the loop, show Leader: ${names.get(leader)}.
  3. Number the rows. A map loop has a third slot, the index: for key, n, i in laps counts 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

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).Found is true when the id is in the list already, so each player is added once, however often they walk in. ids holds user ids, and names holds the matching display names in the same order.
  • for i, name in names names two slots. i is the element’s index (0 for the first, 1 for the second) and name is 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 == 0 is 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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to lobby, the Button’s Held to split, and board to the Text Decal’s Text.
  3. 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

  1. 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.

  2. Make three teams. Use i % 3, with a third color and a third line on the sign.

  3. 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
    }
    

    total is read once, above the loop. Written inside the if, 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

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: exec is a local signal, and await ramp waits for it, as in Waiting.
  • buffer emit ramp fires ramp one tick later. By then the handler has reached await ramp and is waiting, so it wakes up.
  • buffer(0.25s) emit ramp fires ramp again a quarter of a second later. The handler goes back to the await and runs the if again. That jump back is the loop.
  • When step reaches STEPS, the else runs instead, nothing fires ramp again, and the loop ends.
  • running stops a second press from starting a second loop on top of the first. return ends the handler early, so a press during the sunrise does nothing.
  • t goes from 0 to 1 as step goes from 0 to 20, and the lamp’s color is the warm white times t. lit turns the light on from the first step.

How long does the sunrise take? What happens if you press the button again halfway through?

Run

  1. 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.
  2. Wire the Button’s Held to pressed, lamp to the Point Light’s Color, lit to its Enabled, and text to the Text Decal’s Text.
  3. 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

  1. Make it a sunset too. Add a second button and a second handler that runs the same loop downward: step starts at STEPS and goes down by one each pass while it is above 0. Give it its own signal, and share running, so neither starts while the other runs.
  2. Change the pace. buffer(0.1s) is faster. buffer(3) emit ramp waits three ticks instead of a time in seconds.
  3. Make the steps visible. Instead of a color, show a bar on the sign, built with a for loop 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 example if !running { ... } around the whole loop) when its signal, let ramp: exec, is declared inside that same if. If the signal is declared outside it, the loop would run once, and the compiler warns WS118 at the await. The code uses return instead, which keeps the loop at the top of the handler.
  • A buffer emit ends its path through the handler. Code after the if that holds it runs only on the path that did not loop, which here is once, when the loop ends. A line after a buffer emit in the same block never runs, and the compiler warns WS058 there.
  • Keep loop counters like step outside the handler. They have to keep their value from one pass to the next.
  • Use a for loop 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 from min to max, and both ends can come up: Random(1, 6) gives 1, 2, 3, 4, 5 or 6.
  • Each Random is a separate roll. a and b get two different rolls, so they can match or not.
  • Random is a step, like a variable write, so it runs in a handler. The sign reads a and b from their variables.

Press the button many times. Which total comes up most often? Would Random(2, 12) give totals in the same way?

Run

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to roll, and text to the Text Decal’s Text.
  3. 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

  1. Flip a coin. Random(0, 1) gives 0 or 1. Store Random(0, 1) == 1 in a bool variable and show Heads or Tails with if heads.Value then "Heads" else "Tails", which gives the first text when heads is true and the second when it is false.

  2. 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 let is one roll, however often you read it, so total is the same number in the if as on the line above. Over many rolls, about one in six is a 7.

  3. 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 Random call rolls again. To use one roll in two places, roll it once into a let or 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

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 burning is on, the Clock picks a new flame.
  • 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.
  • lit passes 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

  1. 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.
  2. Wire the Switch’s Enabled to burning. Wire lamp to the Point Light’s Color and lit to its Enabled.
  3. 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

  1. Make a blue flame. Change the bounds to Color(0.0, 0.2, 0.6, 1.0) and Color(0.3, 0.6, 1.0, 1.0) for flickering blues.

  2. Pick from a list of colors instead of mixing channels. A const array 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 need Random(0, 5).

  3. Flicker the brightness too. Random gives whole numbers, so roll a whole number and divide for a decimal: Random(70, 100) / 100.0 gives 0.70 to 1.00. Keep it in a float variable, 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.
  • Random on two colors picks alpha too. Give both bounds the same alpha to keep it fixed.
  • Random rolls 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 enabled when nothing needs to change.

See Random and Color in the reference.

Next: Shuffling.

Shuffling

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
}
  • names starts with five names typed in, so you can try it alone.
  • names.shuffle() puts the elements in a random order, in place, so names itself 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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to deal, and board to the Text Decal’s Text.
  3. 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

  1. Split into teams after the shuffle. Use i % 2 as in Index and Value: even places on Red, odd places on Blue. The teams now change on every press.

  2. Shuffle real players. Take the lobby from Index and Value, where a zone pushes each player’s id into ids and escaped name into names. Do not shuffle names itself, or it no longer lines up with ids. 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
    }
    

    copyFrom replaces everything in order with the elements of names, and the loop then walks order. Wire an Attached Zone’s Zone Ref to lobby.

  3. 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})"
}
  • NAMES and WEIGHTS are two const arrays, one entry per prize, in the same order. The weights add up to 100, so each is the chance in a hundred.
  • roll is a number from 1 to 100.
  • The loop adds each weight to total in turn: 60, then 90, then 100. The first time roll is at or under total, that prize wins and break stops 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

  1. Compile the chip and paste it in-game. Place a Button (the lever) and a brick with a Text Decal.
  2. Wire the Button’s Held to pull, and text to the Text Decal’s Text.
  3. 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

  1. Add a Legendary prize. Add "Legendary" to NAMES and a weight of 2 to WEIGHTS, and take 2 off Epic so the weights still add up to 100. Then check by hand which rolls give Legendary.

  2. Color the prize by its rarity. A map literal, { key: value, ... }, can be a const too:

    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 way NAMES[pick] looks up a name by its index.

  3. 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 pick was 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

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: true while 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

  1. Compile the chip and paste it in-game. Place a Seat, a Point Light and a brick with a Text Decal.
  2. Wire the Seat’s Sitting Character to rider. Wire brightness to the Point Light’s Brightness, and readout to the Text Decal’s Text.
  3. Give the light’s Enabled a Switch of its own, flipped on.
  4. 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

  1. Steer a second light with input.Right, so left dims it and right brightens it.

  2. Make a horn. Add @right out honk: exec and on input.PressedQ { emit honk }, and wire honk to a One Shot Audio’s Play with a sound picked.

  3. 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.5 turns 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 rider is empty.
  • A handler on a held key runs once per press, when the value turns on, not on every tick it stays held.
  • Forward and Right are decimals between -1 and 1. Compare them with > 0.5 rather 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.PressedQ runs once each time Q goes down, and flips the light.
  • on keys.Forward > 0.5 runs once per push forward, and raises the level by half a step. Back lowers it.
  • min(..., 4.0) and max(..., 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

  1. Compile the chip and paste it in-game. Place a Switch and a Point Light.
  2. Wire the Switch’s Character to holder. Wire shining to the Point Light’s Enabled and brightness to its Brightness.
  3. 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.
  4. 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

  1. Let the remote work only while the switch is on. Add @left in active: bool, wire the Switch’s Enabled to it, and write on keys.PressedQ && active. Do the same for the two forward handlers.
  2. 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.
  3. Show who holds the remote. Add var shown: string = "", @right out sign = shown.Value wired to a Text Decal’s Text, and on Change(holder) { shown = escape(holder.GetDisplayName()) }, with import { escape } from "sanitize" at the top, as in Reacting to the Game. A name can contain <, and escape keeps 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 InputReader reads 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.
  • GetInputs reads 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

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:

  1. A press. holding is 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, and on Change(holding) runs on both. holding > last lets 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.
  2. Someone walks in. They join the board, not ready.
  3. 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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to room, the Button’s Press Count to holding and its Character to presser. Wire board to the Text Decal’s Text.
  3. 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 [ ].
  4. Walk out and you leave the board. Walk back in and you are [ ] again.
  5. 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

  1. 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.
  2. Ignore presses from outside the room. In the press handler, fill players from the room first, and flip the mark only if players.find(presser).Found.
  3. 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 own on 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

  • Change does 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, as escape does 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 redraw from 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

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: character holds a character: the body of the player who is “it”.
  • = null starts it empty. null is a reference to nothing.
  • character != it asks whether the player who walked in is a different character from the one stored. It compares which character, not names.
  • let old = it keeps the previous holder, so they can be told they are free after it has 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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to base, and sign to the Text Decal’s Text.
  3. 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.
  4. 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:

TypeWhat it isWhere a circuit gets one
charactera player’s body, while they are alivezone events, a Seat’s Sitting Character, a Target’s Character That Just Hit
controllerthe player, the same through every death and respawnControllerOf (an admin gate); a character wired into a player input still works, but stays the body
entityany object in the world: a player, a brick gridany character, or ReadBrickGrid(), the grid the chip sits on
brickone brickGetMicrochipBrick(), the chip’s own brick

Modify

  1. Count the tags. Add var tags: int = 0 and show it on the sign. The very first walk-in is not a tag, since nobody was it: add 1 to tags only if old != null.

  2. 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 hit and its Character That Just Hit to shooter.

  3. 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, after it = character:

    GetMicrochipBrick().SetBrickProperty(BrickProperty.Color, Color(1.0, 0.2, 0.2))
    

    BrickProperty.Color picks which setting of the brick to change, and Color(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 with chipBrick = GetMicrochipBrick(), and recolor chipBrick. 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 ControllerOf and CharacterOf helpers are not needed to message them or read their keys. ControllerOf also 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

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: *int reads “a variable holding an int”. The caller passes a variable, and every use of mine inside the mod is that variable.
  • point(red, blue, redRounds) scores for red: mine is red, theirs is blue. 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

  1. Compile the chip and paste it in-game. Place two Targets, one for each team, and a brick with a Text Decal.
  2. Wire the red Target’s Just Hit to redHit, the blue Target’s Just Hit to blueHit, and sign to the Text Decal’s Text.
  3. 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

  1. Make the target score a parameter. Add to: int = 5 to point and compare mine >= to. Play a short match with point(red, blue, redRounds, 3) and point(blue, red, blueRounds, 3).

  2. 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) and swap(redRounds, blueRounds) from on swapEnds { ... }, with @left in swapEnds: exec wired from a Button’s Held. let before = a reads a before the next line overwrites it.

  3. Keep a log of red’s score after every hit. Arrays are already passed as themselves, with no *. A mod with a plain int[] 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 of point are 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 >= 5 reads 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

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: *int is a variable that holds a ref to an int variable. It starts empty.
  • &b is b’s ref, as a value. picked = &b stores it.
  • *picked is the variable picked points at. *picked += 1 adds one to whichever of a, b or c that is.
  • next steps picked from A to B to C and round again. name keeps 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

  1. Compile the chip and paste it in-game. Place two Buttons and a brick with a Text Decal.
  2. Wire one Button’s Held to next, the other’s Held to add, and sign to the Text Decal’s Text.
  3. Press add first: nothing happens, since nothing is picked yet. Press next: A is picked. Press add: A goes up. Press next and add: 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

  1. Add a third button that clears the picked counter: *picked = 0. One handler clears whichever counter is picked.

  2. Keep the refs in a list instead of an if chain. (*int)[] is an array of refs, filled the first time next runs:

    @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 % 3 change.

  3. A ref value can go to a *int parameter. The mod then works on the variable the ref points at. Add this mod and call it with picked:

    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 picked before anything was stored in it ends the handler at that line, with no message.
  • Refs compare only with refs. picked == &a asks whether picked points at a. Comparing a ref with a number is an error (WS084), because the game treats 1 == <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

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.
  • hits counts every hit the player has made.
  • bodies stores the character the player had at their last hit. On the next hit, body.Value == shooter is true only while they are still in that same body.
  • lifeHits counts on while sameLife holds, 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

  1. Compile the chip and paste it in-game. Place a Target and a brick with a Text Decal.
  2. Wire the Target’s Just Hit to hit, its Character That Just Hit to shooter, and sign to the Text Decal’s Text.
  3. 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:

KeyStays the sameStarts over
GetUserId()through deaths and respawnsnever: it belongs to the account
the controllerthrough deaths and respawnsit is the player, but getting it from a character needs the admin ControllerOf
the characterwhile the player stays aliveat 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

  1. Keep each player’s best life. Add var best: Map<string, int>. After working out life, read let oldBest = best.get(id), and if !oldBest.Found || life > oldBest.Value { best.set(id, life) }. Show the best on the sign.

  2. Add a reset button. on reset { ... } calls clear() on every map and sets shown back to "No hits yet".

  3. 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 get is its own gate and its own read. Bind each one to a let, as before and body are here, and read the let.
  • A character is not equal to its player’s controller. A character used as a Map<controller, ...> key, or stored in a controller variable, is still the body, and a respawn leaves it behind.
  • Do not reach for ControllerOf to 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

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.
  • offset is where the copy appears, measured from the Spawn Prefab gate itself. Vec(0.0, 0.0, 150.0) is a vector (three numbers: x, y, z): here 150 units straight up from the spawner.
  • lifetime = 10.0 removes each copy 10 seconds after it appears.
  • limit = 5 caps how many copies of this gate’s prefab exist at once.
  • destroyAll = clear removes every copy this gate has made when clear turns 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone for the pad, and place a Button beside it.
  2. Wire the Attached Zone’s Zone Ref to pad, and the Button’s Held to clear.
  3. Step on the pad. A Box chip appears above the spawner.
  4. Step off and on a few more times. Never more than five exist at once.
  5. 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

  1. Set lifetime = 0.0. A lifetime of 0 means the copies stay until something removes them. Now only the button clears them.
  2. Spawn two boxes per step: one 150 units up and one 300 units up. Each SpawnPrefab call is its own gate with its own limit. What does that do to the most boxes you can have?
  3. Remember where the last player stood in a var spot: vector. From an on Clock(interval = 2.0, enabled = ...) handler, with a Switch’s Enabled turning the clock on, spawn a box and move it to spot.

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 SetLocation it.
  • SetLocation moves 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 limit and either a lifetime or a way to clear it.
  • destroyAll removes 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

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:

  1. Build a crate in-game. Select it with the selector and save the selection as a prefab. Brickadia writes it as a .brz file in its Saved/Prefabs folder (on Windows, %LOCALAPPDATA%\Brickadia\Saved\Prefabs).
  2. Copy that file into the folder with your .ws file and name it crate.brz.
  3. Compile the .ws file (Ctrl+Shift+B in VS Code, or wirescript compile spawner.ws). The compiler finds crate.brz next to it.

In the playground:

  1. Save the crate as in step 1 above.
  2. 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.brz at the cursor.
  3. 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:

  1. Build a target: a post with a Target brick on top.
  2. Write a small chip for it with a @left in hit: exec pin and whatever it should do when hit. Compile it and paste it onto the post.
  3. Wire the Target’s Just Hit to hit.
  4. Select the post, the target and the chip together and save the selection as target.brz.
  5. Spawn $./target.brz from 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

  1. Save a second build (a lamp, a sign) and spawn it from a second pad with a second SpawnPrefab call.
  2. Make the crate drop onto the player who stepped on the pad: keep the copy with let crate = SpawnPrefab(...), then crate.SetLocation(character.GetLocation() + Vec(0.0, 0.0, 500.0)).
  3. Leave prefab out 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 .brz afterwards 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

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

  1. Move the inline prefab from Spawning a Prefab into a file of its own, and check that the spawner behaves the same.
  2. Reference $./box.ws from a second spawner in the same folder. Each spawner’s save gets its own copy of the box.
  3. 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.ws changes 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

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)
}
  • SpawnPrefab gives back the copy it just spawned, as an entity. 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 a float, 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, SetLocation moves it to the player’s position, raised by lift. A spawned copy can move itself like this. A pasted chip cannot.
  • A player who leaves the server turns the stored owner into null. The marker then removes itself with DestroySpawnedPrefab, 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 into null some 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

  1. Compile the chip and paste it in-game. Give a doorway an Attached Zone and wire its Zone Ref to room.
  2. 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.
  3. 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 = true and the copy never hears the event: a send to one grid is heard only by receivers marked that way.
  • Leave out the ready check and the copy moves before the event arrives, with an empty owner and a lift of 0. The event arrives a tick after the send, and the copy’s clock is already running by then.
  • Send the height as 120.0 and the copy may read 0.

Modify

  1. 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?
  2. Remove isObject = true, compile, and walk through the doorway. Where does the marker go? Put it back.
  3. Give the spawner a second zone that raises the newest marker. Keep it in a var last: entity as you spawn it. From the second zone’s handler, send last a "marker.height" event carrying a var taller: float = 300.0, and give the copy a second receiver that sets lift.

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 = true on 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

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 number and counts its own age. 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 with destroyAll, 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

  1. 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.
  2. Wire the Zone Ref to pad, the Button’s Held to clear, and sign to the Text Decal’s Text.
  3. Step on the pad three times, quickly. The sign says Boxes: 3.
  4. 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.
  5. 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

  1. Make every box live five seconds, whatever its number. Which lines of the box change, and does the spawner change at all?
  2. Add the age to the report: send "box.gone" with number and age, and show both on the sign.
  3. 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 lifetime or destroyAll). Nothing else cleans it up.
  • DestroySpawnedPrefab on 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, and GetUserId() on it reads back "". A dead player’s stored character reads as null too, after a delay that is not fixed.
  • The live count here counts steps on the pad. At the limit it 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

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, and SetLocation moves a spawned copy.

The clear button removes the turret. What happens to the shots already in the air?

Run

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone for the pad and place a Button beside it.
  2. Wire the Zone Ref to pad and the Button’s Held to clear.
  3. Step on the pad. A Turret chip appears, and a Shot chip rises out of it every second and flies off.
  4. 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

  1. Fire the shots straight up instead: which numbers in the shot change?
  2. Give the turret a second clock that fires a second kind of shot, slower and longer-lived, every three seconds.
  3. Make the shot fall: each pulse, also lower it a little more than the last time. Keep the drop so far in a var inside 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

  • destroyAll removes what its own gate spawned, not what those copies spawned in turn. Give inner prefabs a lifetime.
  • 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

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.

  • pads maps each user id to that player’s pad, the entity SpawnPrefab returned. 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. return ends a handler early, and nothing after it runs.
  • A player who walks out is sent "pad.stop" and forgotten with pads.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.
  • taps counts each player’s presses, and ShowStatusMessage shows the count to that player only.

Each pad works for one player.

  • owner stores the player from "pad.init", and ready records that it has arrived.
  • InputReader(owner.Value) follows that player’s keys, live, as in Seat Controls.
  • on keys.PressedQ runs 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 null when 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 the owner it checks, but it runs from the moment the pad appears, so it checks ready first.

Ten players are in the room. How many Seat Control Splitters are there, and how many of them are in the central chip?

Run

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone covering the room, and wire its Zone Ref to room.
  2. Walk in. A pad appears above the chip. Press Q: the status line says Taps: 1, then counts up with each press.
  3. 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.
  4. 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 for sweep 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

  1. Add a second key: the pad also sends "pad.e" on keys.PressedE, and the central chip takes a point away for it.
  2. Send the count back to the pad: after taps.set, look up the player’s pad in pads and send it a targeted "pad.score" with the count. In the pad, show it with owner.ShowStatusMessage instead of the central chip doing it.
  3. 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 then owner is 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 into null a 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 = 64 is a ceiling on pads. Raise it if more players may come.
  • SpawnPrefab is 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

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

  1. Compile the chip and paste it in-game, in a world you host. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to ring, and sign to the Text Decal’s Text.
  3. 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:

WirescriptGate
on ChatCommand(...)Chat Command Event
on CharacterSpawned, CharacterDied, CharacterDamaged, CharacterFiredWeaponPlayer Spawned Event and the others
on ControllerJoined, ControllerLeft, ControllerJoinedTeam, ControllerLeftTeamPlayer Joined Event and the others
BroadcastChatMessage, BroadcastStatusMessageBroadcast 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, SetEntityTeamSet Player Team and the others
ForceRespawn, SetCanRespawnForce Respawn Player, Set Can Respawn
SetDamage, IncDamage, the ammo settersSet Player Damage and the others
leaderboards, PlayerWins, TeamWins, GetCurrentRoundSet Leaderboard Value and the others
ShowMessageBox, ShowYesNoBox, ShowOkCancelBox, ShowTextInputBoxthe four dialogs
PlayGlobalAudio, SpawnExplosion, SpawnExplosionAtPlay Global Audio, Spawn Explosion
PrintToConsole, SendGlobalCustomEventPrint 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

  1. 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.
  2. Make the bell work for everyone: remove the broadcast and keep only the sign. The chip has no admin gates left.
  3. Tell only the player who rang. The Button’s Character output says who pressed it: wire it to a @left in ringer: character pin and replace the broadcast with ringer.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

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

  1. Compile the chip and paste it in-game. Place a Switch.
  2. Wire the Switch’s Enabled to night.
  3. Flip it on: the sky turns to midnight. Flip it off: noon.
  4. 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

  1. Hold the night. Write Environment.Sky.AnimateTimeOfDay = false before the time in on night, and = true after it in on !night.
  2. Add fog to the storm: Environment.Sky.CloudyFogDensity = t.
  3. 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

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 the i % 2 split from there puts the even places on Red and the odd places on Blue.
  • GetTeamByName("Red") finds the team with that name, and p.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

  1. Set up the world’s game mode with two teams named exactly Red and Blue.
  2. Compile the chip and paste it in-game. Give a brick an Attached Zone for the lobby, and place a Button.
  3. Wire the Attached Zone’s Zone Ref to lobby and the Button’s Held to pick.
  4. 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

  1. 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.

  2. Tell each player their team. Inside the loop, after the Branch, add p.ShowChatMessage(...) with “You are on Red” or “You are on Blue”.

  3. 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

  • SetTeam moves 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

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.
  • guests is a list of players. guests.find(p).Found is 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone at the front door, and place a Button.
  2. Wire the Attached Zone’s Zone Ref to door and the Button’s Held to call.
  3. Walk through the door: chat says you are on the list. Walk through again: nothing, you are on it already.
  4. 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:

  1. 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.
  2. 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.
  3. 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

  1. Add the hint to the first chip: in the loop, after the chat message, p.CharacterOf().ShowHint("Dinner", "Come to the table").
  2. Let the button empty the list after calling: guests.clear() after the loop.
  3. 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 controller variable 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

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. benched remembers 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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to revive and sign to the Text Decal’s Text.
  3. Fall off the map, or ask a friend to. The player who died does not come back, and the sign counts them.
  4. 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

  1. Announce each elimination to the player who died: p.ShowChatMessage("You are out until the next round").
  2. 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.
  3. 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 CharacterDied runs for every death in the world, from any cause: falling, weapons, damage from another circuit.
  • SetDamage keeps 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

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 /roll to 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: who is the player who typed it, and arguments is the rest of the line after /roll .
  • who.ShowChatMessage answers only that player.

A player types /roll. Who sees the result? Another types /roll 20. Do they get a twenty-sided die?

Run

  1. Compile the chip and paste it in-game. Nothing needs wiring: the command is the input.
  2. Type /roll in chat. You get a number from 1 to 6, and nobody else sees it.
  3. Type /roll 20. Still 1 to 6: the chip ignores arguments.

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

  1. Add /flip: answer “Heads” or “Tails” with Random(0, 1).
  2. Add /roll2: roll two dice, and answer with both rolls and the total. Let /roll2 20 set the sides of both, as /roll 20 does.
  3. Make /pm tell 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.
  • arguments is 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

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 in players with every player on the server. It is fillFromZonePlayers without a zone.
  • on Loaded() runs once, when the chip appears. It fills the list and stores each player’s escaped name in online, under their user id.
  • on ControllerJoined and on ControllerLeft are 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 each redraw() 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

  1. Compile the chip and paste it in-game. Place a brick with a Text Decal.
  2. Wire board to the Text Decal’s Text.
  3. 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")
}
  • ControllerJoined gives the new player, already persistent, with their user id and user name.
  • ControllerLeft gives only the id and the name, since the player is already gone.
  • CharacterDied gives 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

  1. Mark idle players on the board. GetUnixTime() is the time in whole seconds, and p.GetAim().Direction is 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
    }
    
  2. If you have done Ready Check, turn its ready board into one for the whole server: replace fillFromZonePlayers(room) with fillFromPlayers(), and make players a controller[].

  3. Add a death count to each name on the online board, from the deaths map of the second chip. The board now changes on a death too, so call redraw() from on CharacterDied as 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 CharacterDied counts deaths anywhere in the world, not only near it.
  • ControllerLeft has 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 ControllerJoined gives, are controllers: values set on them with SetCustomProperty stay 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

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 named Admin. 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

  1. Check the name of your server’s admin role in its role settings, and set ROLE to it.
  2. Compile the chip and paste it in-game. Nothing needs wiring.
  3. 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 and SetTempPermission overrides.
  • 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

  1. Refuse an empty message: if arguments.Trim() is empty, tell the sender how to use /a and stop.
  2. Let /reset say who did it: send every admin a line naming the player who reset the count.
  3. Add /bench name for admins: find the player with FindPlayer, stop them respawning with SetCanRespawn(false) as in Player Control, and /unbench name to 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

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")
  }
}
  • medal is the code under test: the medal rule from Counting the Cost.
  • hits and wants are 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.
  • assert counts one check, and adds a FAIL line 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 += ok adds 1 for a true ok and 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

  1. Compile the chip and paste it in-game. Place a brick with a Text Decal and wire report to its Text.
  2. 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.
  3. 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

  1. Break medal on purpose: change n >= 5 to n > 5. The sign now says medal checks: 6/7 and FAIL 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.

  2. 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. 12345 is 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.

  3. 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 hits to the medal chip’s hits, the medal chip’s medal to the bench’s medal, a Button’s Held to the bench’s go, and the bench’s report to a Text Decal. Press the button: bench: 3/3 after 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 wants were shorter than hits, the last checks would compare stale values. The table sizes check 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 BroadcastChatMessage instead. 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

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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to fill, and sign to the Text Decal’s Text.
  3. 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 200 typed 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(...) and ReadBrickGrid().
  • 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

  1. Switch a debug readout on and off with a const. Add const DEBUG = false and var presses: int = 0. Put if 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. Set DEBUG = true: 27. With false, the if and everything in it are deleted while compiling, so a feature behind a false const costs nothing but its var: the presses Var stays.

  2. A const parameter. SendCustomEvent sends 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 plain string does 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 value
    

    Inside the mod, chan is a parameter, and a parameter could be anything. Mark it chan: 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, and level stays an ordinary wired input. A call that passes a var as the name is WS046.

  3. Name the levels with a const list. Add const LEVELS = ["empty", "low", "half", "high", "full"], a var level: string = "empty", and in the handler, after fuel changes, 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 on fuel, so the read happens in game. A read at a fixed index, such as LEVELS[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} is 1,000, as the Format String gate writes it.
  • / between two ints drops the remainder, folded or not: MAX / 3 with MAX = 200 is 66.
  • Event names, prefabs and sounds are settings typed into the gate. They take only a value known while compiling (WS028).
  • A var is 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

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

  1. Compile the chip and paste it in-game. Place a Button and a brick with a Text Decal.
  2. Wire the Button’s Held to add, and sign to the Text Decal’s Text.
  3. 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.

TimersWork per pulseShare of one tick
10about 160nothing
1,000about 16,000about 4%
23,000about 375,000all 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 mmssmodfn
13843
24544
35245

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

  1. Loop over the timers themselves: for i, t in timers gives each element’s index i and value t (Index and Value). Use if 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.

  2. 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: .Found says whether a 0 is in the list, and .Index where. Now the list never holds more timers than were running at once, so the pulse never visits a timer that finished long ago.

  3. Add a fourth timer to the clocks. Count both versions: the mod grows from 52 to 68 gates and the fn from 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 ... gives run.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

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 huds records which HUD belongs to which player.
  • rank() loops over every score, keeps the best three, and builds the board text. The vars 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.0 pins it to the top right corner, and positionX and positionY move it in from there. lifetime = 0.0 keeps it up until a later draw with the same textId replaces it.
  • Each HUD checks once a second whether its stored character is still there. It turns into null when the player leaves the server, and also some time after the player dies. The HUD then reports hud.gone and removes itself, and the central chip drops it from huds. The Clock is written inside hud.init, next to the owner it checks, but it runs from the moment the HUD appears. ready stops a HUD whose hud.init has not arrived yet, and whose owner is 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 on board.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

  1. Compile the chip and paste it in-game. Give a gateway an Attached Zone and wire its Zone Ref to join.
  2. Place a Target. Wire its Just Hit to point and its Character That Just Hit to scorer.
  3. Walk through the gateway. The top right of your screen shows You: 0 and No scores yet.
  4. Shoot the target. Your line reads 1, and the board lists you first.
  5. 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

  1. Add a reset button. Give the central chip @left in reset: exec. Its handler clears scores and names, sets board back to "No scores yet", and sends board.top and a new board.reset once each. Every HUD sets mine to 0 on board.reset: one send, every player.
  2. Show the gap to the leader: You: 3 (leader 7). Send the leader’s score on its own channel, board.lead, right after board.top. Store it in the HUD and add it to the first line.
  3. 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

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 says K stands for a type the caller chooses, here either int or string. bump works on a Map<int, int> and a Map<string, int> alike, and getOr adds a second parameter, V, for the kind of value stored.
  • Atoms. :red and :blue are atoms: names that stand for fixed numbers. teams is keyed by them, so the code reads as words while the map holds ints.
  • Variadic parameters. ...rest in announce collects every argument after name, however many there are, and ...rest in the body passes them all on. const string means name must 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:

  1. In the playground, press + under Files and name the new file scores.ws. In VS Code, save scores.ws in the same folder as arena.ws. Paste the library into it and the game into arena.ws.
  2. Compile arena.ws and paste the chip in-game. Place two Buttons and a Text Decal.
  3. Wire one Button’s Held to red and its Character to redBy, the other’s to blue and blueBy, and sign to the Text Decal’s Text.
  4. 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.
  5. Point at bump in arena.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

  1. Add a third team. Give arena.ws a green and a greenBy pin, a handler that calls goal(:green, greenBy), and a Green count on the sign. The library does not change, because it has no list of teams.

  2. Add a helper to the library with a doc comment: /// The highest number in m, or 0 when it is empty. followed by mod best<K: int | string>(m: Map<K, int>) -> int. Loop over the map with for k, v in m and keep the largest value in a local var. Use it to add Best player: ${best(goals)} goals to the chat message.

  3. See why handlers stay out of libraries. Add var horn: bool = false and a handler on CustomEvent("scores.goal") { horn = !horn } to scores.ws. In arena.ws, add horn to 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 too
    

    The 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 into arena.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 SendCustomEvent is typed into the gate instead of wired, so the receiver can read 0. Put an atom in a var before 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

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().Location is 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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to room, and its Occupied to occupied and to the Point Light’s Enabled.
  3. Wire brightness to the Point Light’s Brightness, and readout to the Text Decal’s Text.
  4. Walk into the zone and toward the chip. If you were already inside when you pasted the chip, step out and back in: visitor is 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.0 is Vec(300.0, 400.0, 0.0). So here + Normalize(there - here) * 100.0 is the point 100 units from here on the way to there.

These also work as methods: (there - here).Magnitude(), here.Distance(there). DistanceSq and MagnitudeSq give the same values squared.

Modify

  1. 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 as X=... Y=... Z=....
  2. Add a door that opens only within 200 units. Add const CLOSE = 200.0, var near: bool = false and @right out open = near.Value, and in the handler near = Distance(here, there) < CLOSE.
  3. 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.
  • visitor is 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.
  • Normalize of 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

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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to room and its Occupied to occupied. Wire lit to the Point Light’s Enabled.
  3. 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 betweenDot product
0 degrees1
10 degrees0.985
30 degrees0.866
60 degrees0.5
90 degrees0
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

  1. Widen the cone to 30 degrees: const WITHIN = 0.866. Then try 0.0: the light is on whenever the chip is anywhere in front of the player.
  2. Watch the number. Add var shown: string = "" and @right out readout = shown.Value, and in the handler shown = "${Dot(aim.Direction, toTarget)}". Wire readout to a Text Decal’s Text and turn on the spot: it runs from 1 down to -1 and back.
  3. Give the player a hint. Compute let side = Cross(aim.Direction, toTarget).z in the handler, and set the sign to "on it" when seen would be true, else "turn right" or "turn left" from side.

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 * b on two vectors multiplies part by part and gives a vector. It is not the dot product. Use Dot(a, b) or a.Dot(b) for that.
  • The dot product ignores distance only because both directions are normalized. Compare positions with Distance and directions with Dot.
  • Cross(a, b) and Cross(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 z is 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

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

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. flat is the length of the direction’s shadow on the floor, and atan2(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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to room and its Occupied to occupied. Wire readout to the Text Decal’s Text.
  3. 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 degreesatan2(y, x) in degrees
(3, 4)53.153.1
(-1, 2)-63.4116.6
(1, -2)-63.4-63.4
(-1, 0)0180
(0, 1)divides by zero90
(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 radiansTakes or gives degrees
sin cos tan asin acos atan atan2Rotation(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

  1. Show the heading from 0 to 360 instead of -180 to 180: let heading = fmod(yaw, 360.0). fmod wraps negative angles round: fmod(-90.0, 360.0) is 270. The % operator keeps the sign instead, so -90.0 % 360.0 stays -90.
  2. 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 handler look = Rotation(pitch, yaw, 0.0). Place a Spot Light without turning its brick, and wire beam to its Rotation and a Switch to its Enabled.
  3. 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, S or W for 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 sin gives a wrong answer with no warning.
  • atan2(0.0, 0.0) is 0. Looking straight up or down, flat is 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 with Dot instead.
  • 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

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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to room and its Occupied to occupied. Wire aim to the Spot Light’s Rotation, and a Switch to its Enabled.
  3. 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 forUse a quat for
angles you read or write as numberspointing along a direction: dir.ToRotation()
pins that take a rotation, such as a Spot Light’s Rotationturning a vector: v.Rotate(q)
Rotation(pitch, yaw, roll) and .pitch .yaw .rollundoing 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 by turret, it is 100 units along the turret’s aim: the same as turret.ToDirection() * 100.0.
  • offset is 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: x ahead, y to its right, z up. A positive local.x means 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

  1. Watch the turn. Add var shown: string = "" and @right out readout = shown.Value. In the if, compute let wanted = (target - here).ToRotation(), set shown = "Turned ${round(facing.AngleTo(wanted)) as int} degrees", and then facing = wanted. Walk sideways across the beam and the number grows.
  2. Mount the light on a wall, so it should never aim behind itself. Read the chip’s own rotation with let own = GetOwnTransform() (then own.Location and own.Rotation), and in the loop skip players behind the chip: compute let local = (spot - here).Rotate(own.Rotation.Invert()) and add local.x > 0.0 && to the if. Turn the chip and see which side it guards.
  3. Show where the beam lands 300 units out: add here + facing.Value.ToDirection() * 300.0 to 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: Blend and Tween take rotations too.
  • a * b on two rotations combines the two turns into one. See Arithmetic Operators.
  • axis.RotationByAngle(angle) is a turn by angle around axis, such as Vec(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.
  • ToRotation on 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

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

  1. Compile the chip and paste it in-game. Place a Button, a Point Light and a brick with a Text Decal.
  2. Turn off the Point Light’s Use Brick Color: while it is on, the light takes the brick’s color and ignores the wire.
  3. Wire the Button’s Held to pressed, lamp to the Point Light’s Color, and text to the Text Decal’s Text. Give the light’s Enabled a Switch, flipped on.
  4. 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).

  1. 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.
  2. Wire the Switch’s Enabled to up, target to the slider’s Target Position, and the slider’s Current Position to position. Wire brightness to the Point Light’s Brightness.
  3. 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

  1. Make the light dim as the platform slides out: swap the output range, Remap(position, 0.0, 100.0, 4.0, 0.0).
  2. 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.
  3. Make the fade in the first chip start over. (presses.Value % 11) / 10.0 counts 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

  • t must be a float. presses.Value / 10 is an int divided by an int, which drops the fraction: it is 0 until the tenth press and then jumps (Numbers). The compiler accepts it without a warning. Write 10.0.
  • Remap does not stop at the ends: a value past inMax gives a number past outMax. Wrap it in clamp(...) when the input can overshoot.
  • Blend on 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

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:

  • Linear does not bend: the same as lerp. It is the default.
  • Quad with In squares the progress, so it starts slowly and speeds up.
  • Bounce with Out rushes to the end and bounces there, like a dropped ball.

Halfway through a run, t is 0.5. How bright is each light?

Run

  1. 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.
  2. Wire the Button’s Held to go, and linear, quad and bounce to the three lights’ Brightness.
  3. At load all three are at full: frame starts at 30, the end of a run.
  4. 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.

Curvet = 0.25t = 0.5t = 0.75
Linear0.250.50.75
Quad, In0.06250.250.5625
Quad, Out0.43750.750.9375
Quad, InOut0.1250.50.875
Bounce, Outabout 0.47about 0.77about 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

  1. Change the second light to direction = InOut. It now starts slowly and ends slowly too.

  2. Ease a color. Easing takes the same kinds of value as Blend. 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.

  3. 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. clamp holds t at 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 float from 0.0 to 1.0. frame.Value / FRAMES divides two ints and is 0 until the very end, and the compiler accepts it without a warning. Turn one side into a float first, as FRAMES as float does 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 stored started survives. After a restart the stored time can be ahead of the uptime, and t stays 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 Tween moves every tick by itself.
  • Remap has 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

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

  1. 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.
  2. 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.
  3. Wire the Switch’s Enabled to wanted, position to the slider’s Target Position, and sign to the Text Decal’s Text.
  4. 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.

  1. Place a Seat and a Bearing (Servo) with something attached to turn, like a turret barrel. Paste the chip.
  2. Wire the Seat’s Sitting Character to rider and angle to the Servo’s Target Angle, and turn on the Servo’s Enabled.
  3. 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

  1. Make a door that drops and bounces: function = Bounce, direction = Out on a slider that moves the door down. Use the slider’s Reversed setting if it moves the wrong way.
  2. 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.
  3. 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.
  • Back and Elastic go 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

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:

  • ShowChatMessage adds a line to that player’s chat, like a whisper.
  • ShowStatusMessage shows a short status message on their screen.
  • ShowHint shows 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

  1. 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.
  2. 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.
  3. 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:

CallGateThe words can change
ShowChatMessage(text)Show Chat Messageyes, wired into Message
ShowStatusMessage(text)Show Status Messageyes, wired into Message
ShowHint(title, text)Show Hintno, 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

  1. 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.

  2. Count the crossings. Add var crossings: int = 0 and, 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.

  3. Add a help button at the edge of the pit. Wire the Button’s Press Count to @left in help: int and 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, and help > last keeps 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).
  • ShowHint takes 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

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.
  • flipper is 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: Power in 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: 0 is the left or top edge, 0.5 the middle, 1 the right or bottom edge. 0.0, 0.0 is 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.0 is 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

  1. Compile the chip and paste it in-game. Place a Switch.
  2. Wire the Switch’s Enabled to power and its Character to flipper.
  3. Flip the switch. Near the top left corner of your screen, Power: ON appears with ON in green. A few seconds later it goes away.
  4. Flip it again: Power: OFF, in red.
  5. 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

  1. Move the readout to the bottom middle of the screen: anchorX = 0.5, anchorY = 1.0, positionY = -120.0, and justify = Center. The negative positionY moves it up from the bottom edge.

  2. Greet the player in a Seat. Wire the Seat’s Is Occupied to a seated pin and its Sitting Character to rider:

    @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.0 keeps it up for 3 seconds.

  3. 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.9 does 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 positionX is negative, and from the bottom edge positionY is 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

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, when seated turns 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

  1. 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.
  2. Wire the Seat’s Is Occupied to seated and its Sitting Character to sitter.
  3. Wire the Switch’s Enabled to gate, and the Attached Zone’s Occupant Count to waiting.
  4. Sit down. The top left corner shows the panel: Gate: SHUT in red and Waiting: 0 under it.
  5. Ask a friend to flip the switch and stand in the waiting zone. The panel changes as they do: OPEN in green, then Waiting: 1. It stays one panel, in one place.
  6. 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

  1. Add a third line for the lights. Wire a second Switch’s Enabled to @left in lights: bool, pick a word with if, and add <br>Lights: ${lightWord} to panel. It is still one text and one id.

  2. Delete lifetime = 0.0 from 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.

  3. 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
      }
    }
    

    watcher holds the player who has the panel, and null when nobody does (Object References). && is “and”, and || is “or”. It keeps one watcher, 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.0 means 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 !seated runs when seated turns off.

See DisplayText and Default values in the reference.

Next: A HUD for Everyone.

A HUD for Everyone

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) fills here with 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.
  • shown is who the Clock drew for last time. Someone in here but not in shown just walked in and gets a text. Someone in shown but not in here walked out and gets an empty one. Then shown becomes a copy of here, so it is never more than a second out of date.
  • scores keeps each player’s points by user id (Per-Player State), so they survive a respawn and a walk out and back in.
  • show draws 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. rose is true only when it went up since the last change (last), and a release returns early. last is updated before the early return, so it never goes stale.
  • When top changes, the leader line is different for everyone, so on Change(top) fills here again 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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone over the arena, and wire its Zone Ref to arena.
  2. Place a Button inside. Wire its Press Count to holding and its Character to presser.
  3. Walk in. Within a second the top of your screen shows You: 0 and Leader: nobody (0).
  4. Press the button. Both lines change: You: 1, and you lead with 1.
  5. 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 reads You: 1.
  6. Walk out. Your text goes within a second. Walk back in: You: 1 again.

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

  1. 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 top to 0 starts the redraw, and every player reads You: 0. If top was already 0, nothing needs redrawing.

  2. Add a third line, Players: ${count}, to the text in show. The Clock already fills here once 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()))
      }
    }
    
  3. Show your rank: Rank: 2 of 5. Count the scores higher than yours with for 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 null a 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 shown and 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. leader is 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:

PartUsesLesson
Light switchSwitch, Point LightFirst Circuit
Two-way hall light2 Switches, Point LightGates and Wires
Toggle doorButton, Brick Property ChangerPure and Exec
Press-count signButton, Text DecalRemembering Values
Code door4 Switches, Brick Property ChangerCode Door
Score cardButtons, Switch, Text DecalScore Card
Dice signButton, Text DecalDice
Flickering firelightSwitch, Point LightRandom Colors
Traffic lightSwitch, Point LightEnums
Seat dimmerSeat, Point Light, Text DecalSeat Controls

Catalog

Building Parts

PartUsesBuilds on
Toggle LightButton, Point LightReacting to the Game
Combination LockButtons, Brick Property Changer, Point LightMods, Lists
Countdown SignButton, Text Decal, One Shot Audio, Point LightExec Pins
Color-Cycling LightSwitch, Point LightLists
DoorbellButton, One Shot AudioExec Pins
Zone ChimeAttached ZoneReacting to the Game
DrawbridgeServo, Button, Point LightReacting to the Game, Choosing a Value
Proximity DoorAttached Zone, Brick Property ChangerWaiting
Clock SignText DecalReacting to the Game, Number Options

Player Parts

PartUsesBuilds on
Pressure Pad DoorTouch, Brick Property ChangerReacting to the Game, Waiting
Welcome ZoneAttached Zone, Text DecalMaps
Teleporter PadAttached Zone, Teleport DestinationMaps
Shooting GalleryTarget, Text Decal, ButtonLooping Over a Map, Rich Text, Per-Player State
Healing Zone (needs admin to load)Attached ZoneLooping Over a List
Launch PadAttached ZoneReacting to the Game, Vectors and Colors
Item DispenserButton, Text DecalMaps
Paint GunSwitch, Button, Point LightRemote Control, Forking and Merging, Game Enums, Weighted Loot
Hold to UseButton, Brick Property ChangerWaiting
Moving PlatformRigid Slider, Touch, ButtonWaiting, Choosing a Value
Keycard DoorButton, Attached Zone, Brick Property ChangerMaps, Waiting

Game Systems

PartUsesBuilds on
CheckpointsAttached ZoneMaps, Mods, Per-Player State
Race TimerAttached Zone, Text DecalNumber Options, Mods, Per-Player State
Scoreboard HUDAttached Zone, Target, ButtonRich Text, Per-Player State, Looping Over a Map, A HUD for Everyone
Round ManagerButton, Text Decal, Point LightPayloads, Exec Pins, Number Options
King of the HillAttached Zone, Button, Text Decal, One Shot AudioReady Check, Exec Pins, Per-Player State, Looping Over a Map
ShopButton, TargetMods, Maps, Per-Player State
VotingButton, Text DecalLooping Over a Map, Mods, Exec Pins, Per-Player State

Capstones

Toggle Light

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

  1. Compile the chip and paste it in-game. Place the Button and the Point Light.
  2. Wire the Button’s Held to pressed.
  3. Wire light to 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, make pressed a bool too, and change the handler to on pressed || pressed2. The OR reads both pins’ values, which is why they are bools. 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 Y gives X while A is true and Y otherwise. Wire light to 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

  1. Compile the chip and paste it in-game. Build the door and place the four Buttons in a row beside it.
  2. Wire the Buttons’ Held outputs to b1, b2, b3 and b4, left to right.
  3. Wire solid to the Brick Property Changer’s Collision Enabled and Visibility. One output can feed both.
  4. Optional: wire unlocked to 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: exec and on 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

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

  1. 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.
  2. Wire the Button’s Held to start.
  3. Wire text to the Text Decal’s Text.
  4. Wire done to the One Shot Audio’s Play.
  5. Optional: wire counting to 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 0 the 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 as 1:5. The pad2 helper in Number Options makes it 1:05.
  • Let a press restart the count at any time: remove the if !running check, keeping the two lines inside it.
  • Add a stop button: @left in stop: exec and on 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

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

  1. Compile the chip and paste it in-game. Place the Switch and the Point Light.
  2. 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.
  3. Wire the Switch’s Enabled to running.
  4. Wire lamp to the Point Light’s Color, and lit to 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.25 for 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

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

  1. Compile the chip and paste it in-game. Place the Button beside the door.
  2. Wire the Button’s Press Count to holding, and its Character to visitor. Press Count is how many players are holding the button right now. Character is the player who pressed it last.
  3. Optional: place the One Shot Audio brick inside, open its settings, pick a sound, and wire rang to 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_SciFi and OBA_Door_Knock_Soft suit a door.
  • Make it a ding-dong: after the PlayAudioAt line, add await Sleep(_, delay = 0.6) and a second visitor.PlayAudioAt(SOUND, pitch = 0.8).
  • Count the visitors: add var rings: int = 0, rings += 1 in 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

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

  1. Compile the chip and paste it in-game. Give a brick an Attached Zone that covers the doorway.
  2. 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_Cue makes 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 PlayClientAudio to PlayAudioAt, 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

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

  1. 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).
  2. Wire the Button’s Held to pressed.
  3. Wire angle to the Servo’s Target Angle.
  4. Optional: wire warning to 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_SECONDS to that.
  • Run it from a Switch instead: wire the Switch’s Enabled to a new @left in up: bool pin 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

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

  1. 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.
  2. Wire the Attached Zone’s Occupied to near.
  3. Wire solid to 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

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

  1. Compile the chip and paste it in-game. Place the Text Decal brick.
  2. Wire text to 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 set interval = 10.0 on the Clock so the sign updates less often.
  • Show the date too: const FORMAT = "%Y-%m-%d %H:%M", with %Y the year, %m the month and %d the day.
  • Show the server’s own time zone: const USE_UTC = false.
  • Put the date on a second line: join two FormatDate calls 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

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

  1. 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.
  2. Wire the Touch’s Touching to pad and its Character to stepper.
  3. Wire solid to 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_OPEN for a longer or shorter wait. 0.0 closes the door the moment the pad is free.
  • Light the pad while someone stands on it: add @right out lit = pad and 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: bool and use Sleep(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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Attached Zone’s Zone Ref to room.
  3. Wire sign to 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 PLACE and RULES to 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

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

  1. 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.
  2. Wire the Attached Zone’s Zone Ref to pad.
  3. 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 target to 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Target’s Just Hit to hit and its Character That Just Hit to shooter.
  3. Wire the Button’s Held to reset.
  4. Wire board to 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: float pin, make hits a Map<string, float>, and add damage in place of the 1.
  • Put several targets on one board: wire each Target into its own copy of hit and shooter, 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

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

  1. Compile the chip and paste it in-game, as an admin.
  2. 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 HEAL and EVERY for faster or slower healing.
  • Make a hurting zone: use p.SetDamage(hurt + HEAL) and drop the if hurt > 0.0 check. A player dies when their damage reaches the limit that p.GetDamage().DamageLimit reports.
  • Heal only players who carry a tag: pass it as inside.fillFromZonePlayers(room, "medic"), and give players the tag with character.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

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

  1. Compile the chip and paste it in-game.
  2. 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 UP and AHEAD for 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 in if 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the take Button’s Press Count to holding and its Character to taker.
  3. Wire the restock Button’s Held to restock.
  4. Wire sign to 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 STOCK and PER_PLAYER. A large STOCK makes a free dispenser.
  • Give each player a random item: put three items in a const array and pick one with Random (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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Switch’s Character to painter.
  3. Wire the Button’s Held to next.
  4. Wire swatch to 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 starting paint.
  • Shorten REACH so 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 write on 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Button’s Held to held and its Character to holder.
  3. Wire solid to 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 HOLD for a longer or shorter hold, and OPEN_SECONDS for how long the door stays open.
  • Count the uses on a sign: add var uses: int = 0, put uses += 1 at the top of on 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 held handler that reads them, and fire the same unlock.

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

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

  1. Compile the chip and paste it in-game near the near landing.
  2. In the Rigid Slider’s settings, turn on Positions Are Percentages.
  3. Wire target to the slider’s Target Position.
  4. Wire the deck Touch’s Touching to rider and its Character to who.
  5. Wire the near landing’s Button Held to callNear, and the far landing’s to callFar.

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 DEPART for a longer or shorter wait.
  • Make it travel part of the way: a FAR of 50.0 stops halfway along the slider.
  • Make it shuttle by itself: add an on Clock(interval = 10.0) handler that flips atFar, 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

  1. Compile the chip and paste it in-game.
  2. Wire the keycard Button’s Press Count to holding and its Character to grabber.
  3. Wire the Attached Zone’s Zone Ref to door.
  4. Wire solid to the Brick Property Changer’s Collision Enabled and Visibility inputs.
  5. 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 after opened = 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 cards with for 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

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

  1. Compile the chip and paste it in-game near the course.
  2. Wire each checkpoint’s Attached Zone Zone Ref to cp1, cp2 and cp3, in the order players reach them.
  3. Wire the zone under the course to fall, and the zone at the way out of spawn to spawn.
  4. 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 cp4 pin, a handler that calls reach(character, 4), and COUNT = 4.
  • Make players take the checkpoints in order: in reach, read let last = if had.Found then had.Value else 0 and return unless last == n - 1.
  • Add a restart button at the start: wire a Button’s Press Count and Character to two new pins (an int and a character). Press Count is how many players hold the button right now, so in an on Change(...) handler on it, keep the last count in a var and act only when the count went up: if holding > last { ... } then last = holding. Inside the if, remove that player’s entries from reached and spot. 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the four Attached Zones’ Zone Ref outputs to start, split1, split2 and finish.
  3. Wire sign to the Text Decal’s Text.
  4. 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 split3 pin and handler calling split(character, 3), and SPLITS = 3.
  • Show each racer their best time on a status message when they walk into the start.
  • Add a reset button that clears best, sets holder back 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Attached Zone’s Zone Ref to arena.
  3. For a Target: wire Just Hit to point and Character That Just Hit to scorer. For a Button, wire Held to point and Character to scorer. 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 an int pin instead, and score in on 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: exec pin whose handler clears scores, sets leader back to "nobody" and then top to 0. Changing top redraws everyone inside.
  • Move the text to the top-right corner: anchorX = 1.0, positionX = -40.0 and justify = Right.
  • Show a rank: count the entries in scores above the player’s score with for id, n in scores (Looping Over a Map), and add Rank: ${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

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

  1. Compile the chip and paste it in-game.
  2. Wire the start Button’s Held to start and the other Button’s Held to stop.
  3. Wire sign to the Text Decal’s Text.
  4. Wire playing to anything that should be on only during a round, such as a Point Light’s Enabled. Wire began and ended to 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 whatever began is 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 ended fires.
  • Ten seconds later the sign reads “Press start” again.

Change it

  • Change the times: ROUND = 300 for five minutes.
  • Carry a winner into the results: add winner: string to Over, pass a name in through a pin, and show "${winner} wins!" in the sign’s Over arm.
  • Start automatically: wire a zone’s Occupant Count to a new @left in waiting: int pin, and add on Change(waiting) with the body of on start inside if 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Attached Zone’s Zone Ref to hill and the Button’s Held to reset.
  3. Wire sign to the Text Decal’s Text.
  4. Wire crowned to 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!”, crowned fires 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 in players a second with for p in players around 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 in players with for 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the earn Button’s Press Count to earn and its Character to earner.
  3. Wire the first shop Button’s Press Count and Character to buy1 and buyer1, and the second’s to buy2 and buyer2.
  4. 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/. Change ITEM_1 and PRICE_1.
  • Earn coins from a Target instead of a button: make earn an exec pin, change its handler to on earn without the pressed check, and wire Just Hit to earn and Character That Just Hit to earner. Or give coins at the end of a course, in a zone’s on ZoneEntered handler.
  • Add a third item: two more pins, a last3 var and an on Change(buy3) handler. pressed and pay work 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the start Button’s Held to begin.
  3. Wire the yes Button’s Press Count and Character to yes and yesVoter, and the no Button’s to no and noVoter.
  4. Wire sign to the Text Decal’s Text, and passed and failed to 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 failed fires 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: ShowYesNoBox does 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

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

  1. Compile both chips and paste them in-game: the course chip near the course, the board chip by the Text Decal.

  2. Wire the zones’ Zone Ref outputs to the course chip: the start to start, the checkpoints to cp1, cp2 and cp3 in course order, the finish to finish, and the zone under the course to fall.

  3. Wire the board chip’s board to the Text Decal’s Text, and the Button’s Held to reset.

  4. 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 cp4 pin, a handler that calls checkpoint(character, 4), and CHECKPOINTS = 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 * 5 everywhere time is 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

  1. Compile the chip and paste it in-game.
  2. Number the lights 1 to 9, left to right and top to bottom. Wire each light’s Enabled from the pin with its number: l1 to light 1, up to l9.
  3. Wire the Held of the button in front of light 1 to b1, and so on to b9. Check each pair: a button wired to the wrong pin turns every hit on that light into a miss.
  4. Wire the start Button’s Held to start and its Character to player.
  5. Wire sign to 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 l10 to l16 and b10 to b16 pins with their one-line handlers, and LIGHTS = 16 for 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 Gap arm, when Random gives that one again, take the next light instead (n % LIGHTS + 1).
  • A sound for every hit: add @right out scored: exec, put emit scored after score += 1, and wire scored to 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the four seat zones’ Zone Ref outputs to seat1 to seat4. The seat order is the turn order.
  3. Wire the deal Button’s Held to deal.
  4. Wire the first card Button’s Press Count to card1 and its Character to by1, and the same for the second (card2, by2) and third (card3, by3).
  5. Wire sign to 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 with card4/by4 and card5/by5 pins, held4 and held5 vars, and their on Change(...) handlers, setting pick to 3 and 4. With four players that deals 20 of the 52 cards.
  • Low card wins: start high at 52 instead of -1 (three places), and in play test c < 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 by GetUserId(), added to in finish and 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

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

  1. Compile the chip and paste it in-game.
  2. Wire the Zone Ref outputs: the red pad to redPad, the blue pad to bluePad, the goal at the blue end (where Red scores) to redGoal, the goal at the red end to blueGoal, and the zone over the field to arena.
  3. Wire the Button’s Held to start.
  4. Wire sign to the Text Decal’s Text, redLeads to the red light’s Enabled and blueLeads to 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:00 counting 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:00 the 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 Playing arm, fill an array from a middle zone, count each team’s players with the team map, 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, set result to the winner and move to Over.

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

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:

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

EscapeCharacter
\\Backslash
\"Double quote (in double-quoted strings)
\'Single quote (in single-quoted strings)
\nNewline
\tTab
\rCarriage return
\$Literal dollar sign (prevents interpolation)
\0Null character

String Interpolation

Both single- and double-quoted strings support ${expr} interpolation. Any expression can be embedded:

"Hello, ${name}!"
"Score: ${score + bonus}"
'Position: ${pos.x}, ${pos.y}'

Interpolated expressions are converted to strings. Use \$ to include a literal dollar sign.

Boolean Literals

true
false

Operators

Operators are listed here for reference. See Expressions for full details on precedence and behavior.

Arithmetic

+, -, *, /, %, ** (power)

Comparison

==, !=, <, <=, >, >=

Logical

&&, ||, !

Bitwise

&, |, ^, ~, <<, >>

String

.. (concatenation)

Other

= (assignment), -> (return type / outputs), => (fat arrow, reserved)

Top-Level Declarations

The following forms are valid at the top level of a script:

  • var name: type = expr: Mutable variable
  • let name = expr: Immutable binding
  • buffer name = expr: Buffered value
  • array name: type[]: Array
  • in name: type: Input port
  • out name = expr: Output port
  • chip name(params) -> outputs { body }: Named chip
  • chip { body }: Anonymous chip
  • chip let name = expr: Anonymous chip with let bindings
  • chip on trigger { body }: Anonymous chip with handler
  • mod name(params) { body }: Inline chip (macro-like)
  • fn name(params) -> outputs { body }: Function, compiled once into a function definition plane
  • on trigger { body }: Event handler
  • let name = on trigger { body }: Captured event with handler
  • import "path": Import all declarations from file
  • import { names } from "path": Selective import
  • import * as ns from "path": Namespace import
  • if cond { body }: Conditional (in exec context)
  • return: Early return from handler
  • target = expr: Assignment (in exec context)
  • expr: Expression statement

Types

Wirescript has a static type system that maps directly to 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

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

exec Type

The exec type represents an execution trigger signal. It is not a data value. It represents “this event fired” or “this code path should execute.” Inputs of type exec are used as handler triggers:

in reset: exec

on reset {
  count = 0
}

Special Types

TypeDescription
anyUniversal type, compatible with everything: a value whose kind is decided at run time. See any Type below.
neverBottom type: no value inhabits this type. Used internally.

any Type

any is a wildcard annotation for a value that flows through a wire without the checker 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

TypeDescriptionProduced byConsumed by
zoneReference to a Zone bricka Zone brick’s output (wire it into an in z: zone port)zone = … on the zone events; fillFromZone*
teleportReference to a Teleport Destination (a “teleport point”)a Teleport Destination brick (wire it into an in p: teleport port)Teleport / RelativeTeleport dest/source

These are reference-only types: 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
}
  • &x makes a ref and *e reads or writes through one, in an exec context.
  • A ref never converts to its target type: h + 1 is an error, *h + 1 is 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 is WS084: the game treats 1 == <ref> as true.
  • Passing a *T value to a *T parameter 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 &h where h holds a ref) is WS086.
  • An initializer can hold refs: var h: *int = &a or var refs: (*int)[] = [&a, &b] starts out pointing at a and b. 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 *T parameter is a place, and so is every *T field of a record parameter. A place reads as its var’s value (cpu.cpsr + 1) and binds a *T parameter to that var, and & of it is that var’s ref. It stays a place through a let, a field (let s = o.c), a tuple element, a destructuring pattern and an import * as namespace.
  • A ref value carries a ref, as a *T var 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 *T field of a call’s result and of a record an if or match chooses: a place that a mod or chip returns leaves it as that place’s ref. A ref value reads as the ref (*r.p reads through it, r.p + 1 is WS004) and binds a *T parameter 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>:

ClassMembers
Scalarint, float
Numericint, float, vector, rotator, quat, color
VariantNumeric + 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: int widens to float, so pick(flag, 1, 2.0) infers T = float (the int argument casts up).
  • Object types widen toward entity: character and controller both widen to entity, so pick(flag, aCharacter, aController) infers T = entity.
  • Incompatible operands (e.g. int and vector) have no common widening and are a compile error (WS033):
in n: int
in v: vector
let bad = pick(true, n, v)
// ERROR WS033: cannot infer 'T': it's int from one argument but vector
// from another -- all 'T' arguments must be the same type

The same join is used by the built-in blend-family gates (Blend, lerp, Easing) and by if-then-else expressions. See the widening note there.

Body Checking Is Per-Mask-Member

A generic mod’s body is type-checked against every type in its bound’s mask, not 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 valueBool
""false
anything else (including "0", "false", " ")true

String literals in bool positions (var v: bool = "0", array flags: bool[] = ["x", ""], Select("0", a, b)) are converted at compile time by the same != "" 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

FromToRule
intfloatCoerce
floatintCoerce
boolintCoerce
boolfloatCoerce
intboolCoerce
floatboolCoerce
characterentityCoerce (subtype)
controllerentityCoerce
entitycharacterCoerce (wired directly, since an entity wire can carry a player, e.g. a sweep hit)
entitycontrollerCoerce (wired directly)
charactercontrollerCoerce (wired directly)
controllercharacterCoerce (wired directly)
rotatorquatCoerce (interchangeable rotation values)
quatrotatorCoerce
any primitivestringVia format gate
stringboolCoerce (compiler inserts != "", so only empty is false)
execboolCoerce (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/controllerexecPulsing coerce
ref Tref U (T != U)Mismatch
anyanythingSame
anythinganySame

“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

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

Prefer a destructure, which tests the tag first. 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

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

PrecedenceOperatorsAssociativityDescription
2|| ^^LeftLogical OR, Logical XOR
3&&LeftLogical AND
4|LeftBitwise OR
5^LeftBitwise XOR
6&LeftBitwise AND
7== != isLeftEquality, enum variant test
8< <= > >=LeftComparison
9<< >>LeftBitwise shift
10+ - ..LeftAddition, subtraction, string concat
11* / %LeftMultiplication, division, modulo
12**RightExponentiation
13asLeftType conversion (x as float)
–- ! ~ * ref–Unary prefix operators
–.field [i] (args)LeftPostfix: field access, index, call

Parentheses ( ) override precedence as usual.

// ** is right-associative:
let x = 2 ** 3 ** 2   // = 2 ** (3 ** 2) = 2 ** 9 = 512

// Standard math precedence:
let y = 1 + 2 * 3     // = 1 + (2 * 3) = 7

// String concat 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

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

Mixed int/float arithmetic promotes the result to float. bool values are treated as 0/1 when mixed with int.

+ - * / % also operate component-wise on two vector operands, lowering to the same math gates (whose inputs accept the vector, f64 and i64 wire variants). Mixing a vector with a scalar broadcasts the scalar across the components (v * 2.0, 10.0 * v, v / 4), 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.

OperatorOperationOperand Types
==Equalany wire variant pair
!=Not equal(same as ==)
<Less than(same as ==)
<=Less or equal(same as ==)
>Greater than(same as ==)
>=Greater or equal(same as ==)

Comparison accepts all wire variant types (int, float, bool, string, entity, controller, character) in any combination.

value is Enum.Variant tests which variant an enum value holds and also returns bool; see enums.

let isZero = count == 0
let isPositive = score > 0
let sameTeam = teamA == teamB

Logical Operators

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

Wire variant types are: bool, int, float, exec, string, entity, controller, character. The engine coerces all of these to bool on bool ports (truthy/falsy). 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.

OperatorOperation
&Bitwise AND
|Bitwise OR
^Bitwise XOR
~Bitwise NOT (unary)
<<Left shift
>>Right shift

~(a & b) and ~(a | b) are automatically fused into single NAND/NOR gates.

let mask = 0xFF
let high = (value >> 8) & mask
let combined = a | b
let flipped = ~flags
let shifted = 1 << bitIndex

String Concatenation

The .. operator concatenates strings. It automatically converts numeric types to their string representation.

LeftRightResult
stringstringstring
stringintstring
intstringstring
stringfloatstring
floatstringstring
intintstring
let greeting = "Hello, " .. name
let label = "Score: " .. score
let coords = x .. ", " .. y .. ", " .. z

String Interpolation

Strings (both "double" and 'single' quoted) support ${expr} interpolation. The embedded expression is evaluated and converted to a string.

let msg = "Player ${name} scored ${points} points"
let debug = 'pos=(${pos.x}, ${pos.y}, ${pos.z})'
let nested = "result: ${a + b * c}"

Interpolated expressions can be arbitrarily complex:

let status = "Health: ${if hp > 50 then "OK" else "LOW"}"

Use \$ to include a literal $:

let price = "Cost: \$${amount}"

Both arms evaluate. An if expression compiles to a Select fed by both results, so the arm that is not chosen is computed and discarded. The 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 mod it calls), an arm that writes a variable or an output, sends, mutates a container, runs an action builtin, or returns (directly, through a match block arm, or inside a mod it calls) runs only when it is taken. In that case the if compiles like an if statement whose branches store the value, and the value is read after they rejoin. An if whose value is a record, tuple or enum can’t do this (WS097), so write an if statement instead. A match used as a value treats its arms the same way. This applies only to what the arm itself runs. An on handler inside a mod or chip the 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:

TypeComponents
vector.x .y .z
color.r .g .b .a
rotator.pitch .yaw .roll
quat.x .y .z .w

The names are also accepted upper-case. They work on any expression of that type (a Vec(...) literal, an input, a stored variable, or a let binding) and lower to the matching Split gate (SplitVector, SplitColor, SplitRotation, SplitQuaternion), which outputs the single float component:

in a: vector
in b: vector
let sum = a + b          // vector
let height = sum.z       // float: the z component

in tint: color
let red = tint.r         // float

in facing: rotator
let turn = facing.yaw    // float, 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. a Change detector’s exec port is OnChanged). .exec denotes that exec directly:

    let ch = Change(score)     // exec: fires when `score` changes
    on ch.exec {               // handle it
      ctrl.DisplayText("score changed")
    }
    
  • A chip or mod called with exec =. An exec chip/mod call returns its completion exec as an .exec field on the result record, so callers can sequence on the body having finished:

    let t = InitTables(exec = reset)
    on start {
      emit reset
      await t.exec             // resumes after InitTables ran
    }
    
  • A call inside a handler. .exec binds 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. emit in 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 .exec names that trigger explicitly and lets the event compose with other exec signals:

    in reset: exec
    on Union(reset, CustomEvent("ping").exec) {   // fires on reset or the event
      ctrl.DisplayText("announced")
    }
    

Union also takes an exec receiver, so a.Union(b) is Union(a, b) and a wide fan-in reads as a left-associative chain instead of nested Union(Union(...)):

in reset: exec
on reset.Union(CustomEvent("ping").exec).Union(Change(score)) {
  ctrl.DisplayText("announced")
}

See Explicit Exec Argument and Exec Chips.

Index Access

Use bracket notation to index into arrays:

let item = myArray[i]
// item.value: the element value
// item.bOutOfBounds: bool, true if index was out of range

Tuple Pick

Use .N (dot followed by an integer) to pick an element from a tuple:

let pair = someTupleExpr
let first = pair.0
let second = pair.1

Function Calls

Call functions and chips by name with parenthesized arguments:

// Positional arguments
let dist = Distance(posA, posB)
let s = sin(angle)

// Named arguments (kwargs)
DisplayText(ctrl, "Hello",
  positionX = 0.0,
  positionY = -100.0,
  fontSize = 24
)

Named arguments use name = value syntax. They can be mixed with positional arguments, but positional arguments must come first (matching the parameter order).

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

ExpressionContextGateMeaning
x (bare)ExecVar_GetCurrent tick’s value
*xExecVar_GetCurrent tick’s value (explicit)
*xPurenoneError WS006: use .Value
x.ValuePure or Exec.Value portPrevious tick’s value (delayed)
x.prevPure or Exec.Value portPrevious tick’s value (same as .Value)
x (bare)Pure.Value portThe same read as x.Value; an unannotated out of it is WS017
&xPure or ExecVarRef portx’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 for sweep 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 nested on ServerUptime() above is not registered by the init handler. The circuit is static, so its trigger is live from the moment the prefab spawns, before init lands, which is why real code guards it on an init-set value. See per-entity fan-out and Loops.

At compile the referenced .brz is read and embedded into the output bundle (content-addressed at Prefabs/Uploads/<hash>.brz), and the gate’s Prefab property is set to that embedded path, so the compiled program is self-contained. Typing $./ completes available .brz files (the editor scans the project directory; the web playground has a Prefabs panel where you upload or drag in .brz files).

Statements

Statements are the building blocks of Wirescript programs. They declare data, define behavior, and control execution flow.

Contents

var: Mutable Variable

Declares a mutable variable backed by a 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 on handlers): x auto-dereferences to type T when used in expressions. When passed to a *T parameter, it remains a reference.
  • In pure context (outside handlers): x reads the var’s value (the same as x.Value); x.prev reads the previous tick’s value. &x is 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 as 0, and a non-finite float as 0.

  • String interpolation ("a${1 + 1}b") and the certified string/math builtin methods (.ToUpper(), .Trim(), .Length(), sin, sqrt, …), when every operand is constant.

  • abs, min, max, clamp fold in a let but are not const. let m = max(3, 7) folds to the int 7 (and max(3.0, 7.0) to the float), keeping the type of the arguments, but const m = max(3, 7) is WS046. Bind the result with let, or write the constant directly where a const is required.

  • The Vec/Rotation/Color constructors.

  • Array and map literals, indexing (arr[i], m[k]), and .length().

  • Record literals and field access (.field), including nested records.

  • A call to a const mod (see const Parameters and const mod), including one nested inside an operator, a unary operator, or a Vec/Rotation/Color constructor argument (positional or named):

    const mod double(n: int) -> int { return n * 2 }
    
    const seven = double(3) + 1                 // 7: nested in an operator
    const negSix = -double(3)                   // -6: nested in a unary operator
    const v = Vec(double(1), y = 2.0, z = 3.0)  // nested in a named constructor argument
    

    A call nested one level deeper than that, as an argument to a call whose own callee is not itself a const mod, still is not evaluated, because that callee has no compile-time form of its own to descend through:

    const mod double(n: int) -> int { return n * 2 }
    mod scaleUp(n: int) -> int { return n }
    
    const total = scaleUp(double(3))   // WS046: scaleUp is not itself a `const mod`
    

    Bind the call first, then pass the result: const d = double(3), then scaleUp(d).

An out-of-range array index, and a missing map key or record field, are refused outright rather than falling back to a stale or default value. Unlike a runtime out-of-range array read, which keeps the gate’s previous value, there is no previous value to fall back on at compile time.

A const value reaches every position that requires a literal, not just another const binding: gate config fields, a custom event’s channel name on both the sending and the receiving side, and the contents of a baked array or map:

const CHANNEL = "evt_" .. "died"

in go: exec
var n: int = 0

on go { SendCustomEvent(CHANNEL, n) }
on CustomEvent(CHANNEL) -> (v: int) { n = v }

A const array built at the top level can be indexed there too, in both a baked initializer and a runtime wire operand:

const t = [10, 20]
const z = t[1]        // 20

var counts: int[] = [z, 12345]   // baked into the array's initial contents

var rv: int = 0
in go: exec
on go {
  if z == rv { BroadcastChatMessage("match") }   // baked as a literal operand
}

const containers at runtime

A const array or map is a compile-time value and a runtime container. It folds wherever the answer is known at compile time (t[1] above costs nothing), and the first runtime read builds a real container gate with the constant contents baked into its initial value:

const t = [10, 20, 30]
const m = { "a": 1, "b": 2 }

mod pick(ys: int[], at: int) -> int { return ys[at] }

var i: int = 1
var k: string = "b"
in go: exec

on go {
  BroadcastChatMessage(t[i])        // 20, read at a runtime index
  BroadcastChatMessage(t.length())  // 3
  BroadcastChatMessage(pick(t, i))  // 20, passed as a `T[]` argument
  BroadcastChatMessage(m[k])        // 2, read at a runtime key
}

The container is built only where something needs it, and only once, so a const table used purely at compile time costs no gates and many runtime reads of one table share a single gate.

Outside a const mod body a const container is immutable: t.push(4), t.clear() and t[0] = 4 are all rejected, so the compile-time value and the runtime contents can never disagree. Declare it var to make it mutable.

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

CodeMeaning
WS046Not a compile-time constant. The value names a runtime value, a call to a mod that isn’t const, an unsupported syntactic form, an out-of-range index, or a missing map key/record field. The message names the actual offender.
WS047The certified evaluator refuses to compute the value even though every operand is constant: overflow, a non-ASCII string operand, or a constructor declining its arguments. The fix differs from WS046, because the value is not a stray runtime read. The evaluator declines to guess it.
WS048Const evaluation gave up because the call chain is too deep or took too many steps. Guards a runaway or self-referential const mod call chain, which fails with this diagnostic rather than a stack overflow.
WS028Reused from ordinary constant-config checking: a value that is fully constant but has no scalar form for the slot it’s used in, such as a const record used as a gate’s config field, which has no wire representation and must be consumed at compile time (read a field off it instead of handing the whole record to the slot).
WS044Reused from the container-method backstop: a mutating method (push, clear, set, sort, …) called on a const array or map, which is immutable. Declare it var, or do the mutation inside a const mod body, where it happens at compile time.
WS007Reused from the writable-target check: an index write (t[0] = 4) to a const array or map, rejected for the same reason as a mutating method.

buffer: Buffered Value

Declares a value that is delayed by one tick. Buffers are useful for creating feedback loops where a value depends on its own previous state without creating a circular dependency.

buffer name = expression
buffer name: type = expression
buffer prevScore = score
buffer delayed: int = count

The optional type annotation is useful when the expression type needs clarification (e.g., for self-referential buffers).

Arrays: var name: elementType[]

An array holds multiple values of the same element type. Declare one as a var whose type ends in [] (there is no separate array keyword):

var name: elementType[]
var scores: int[]
var positions: vector[]
var names: string[]
var flags: bool[]

The type annotation must end with [] to indicate it is an array type. The element type selects the backing array variant (int -> Int64 array, float -> double array, bool, string, vector, and object types each map to their matching array kind), so elements keep their declared type rather than all being stored as doubles.

An array can be given constant initial contents with an = [ ... ] initializer. At the top level (outside an exec handler) the contents are baked straight into the array gate, so every element must be a compile-time constant. The array loads pre-populated with no runtime setup:

var scores: int[] = [100, 50, -10]
var names: string[] = ["alice", "bob"]

A constant is a literal (numbers including negatives, strings, and bools), or any expression built from literals and top-level let constants. So a table can name its constants instead of restating their values:

let C_FROZEN = 3
let WIDTH = 8

var masks: int[] = [1 << C_FROZEN, 1 << C_FROZEN | 1]
var cells: int[] = [WIDTH * WIDTH, WIDTH - 1]

Constants resolve through chains (let B = A + 1) and in any declaration order. Arithmetic, bitwise, shift, comparison, logical and .. string concatenation all fold, using the same semantics as the gates they would otherwise have compiled to: 64-bit wrapping integers, divide-by-zero as 0, and a non-finite float as 0.

Initializers may span multiple lines: newlines are allowed after [, around commas, and before ], with an optional trailing comma:

var names: string[] = [
  "alice",
  "bob",
]

An element that is not a compile-time constant (a runtime value such as an in port, a call, or a ...spread) is an error at the top level, because there is no exec context in which to populate it. Build the array from runtime values inside a handler instead (see below).

Inferred element type

The element type is taken from the annotation, or inferred from the literal when there’s no annotation. 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, top and bottom, with one annotation per declaration.
  • Only top-level in/out of the compiled file may be annotated. Inside chip {} or mod bodies the annotation is an error (WS023).
  • Unannotated ports get no rerouter. The feature is fully opt-in.

Placement:

  • Rerouters sit flush against the chosen side of the chip brick, bottom-aligned with it, spaced 2 grid units apart and starting from the top corner (left/right sides) or left corner (top/bottom sides) of the edge.
  • Ports on the same side appear in declaration order, with in and out freely interleaved. Left/right sides run top to bottom; top/bottom sides run left to right.
  • Each rerouter is 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 var they style its Variable gate brick, or every field’s gate for a record.
  • @color takes a color, from Color(r, g, b) (linear, 0 to 1) or ColorSRGB(r, g, b, a) (bytes, 0 to 255). @material takes a BrickMaterial: Plastic, Glass, TranslucentPlastic, Glow, Metal or Hologram (see Built-in game enums). A bare member name works as the whole argument, @material(Glow), as well as the qualified BrickMaterial.Glow. A value of another type is WS003.
  • A constant value is set on the brick itself. On a top-level var the 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 events let 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, in declarations) 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 @nofold also applies only to the file compiled as the entry. An imported library’s own module-level @nofold does not carry into the importer (annotate the individual declarations instead).
  • A module-level @nofold disables the entire constant-fold pass for that compile, with the same effect as --no-fold on the CLI.
  • @nofold also preserves literal-condition if branches. Normally an if whose condition is a literal true/false has its dead side stripped during lowering as a shortcut, ahead of the fold pass proper. Under @nofold (including a module-level one) that shortcut is suppressed too, so both branches stay real gates. See Constant Folding for the full pass.

@fold: Constant Folding (on by default)

  • Constant folding runs on every compile by default, so @fold is now redundant. It is still accepted for backward compatibility but enables nothing that isn’t already on. To turn folding off, use @nofold (above) or --no-fold.
  • Placement, if you do write it: @fold at the very top of the entry file (after any module doc comment), separated from the first declaration by a blank line (the same module-level, blank-line rule as @nofold), and module-level only (there’s no decl-scoped @fold). A directly-adjacent module-doc / @fold pair registers as neither and produces a module-level-only error.
  • If both a module-level @fold and @nofold are present, @nofold wins and the parser warns that the two conflict.
  • --fold on the CLI also only re-affirms the default, and --no-fold disables 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 @fold and @layout("code") can appear together.
  • Entry-file-only, same as module-level @fold/@nofold: an @layout("code") at the top of an imported file does not carry into the importer and has no effect.
  • Nodes with no real position in the entry file (values from an imported file, or synthetic nodes the compiler generates without a source range) adopt the row of whichever node consumes or produces them; a node with no such neighbor lands on an overflow row below the last source line.
  • Three wrapping tiers keep large modules inside their placement budgets. A line wider than the line-width budget soft-wraps into an indented continuation row. Lines stack into vertical bands capped at a height budget. A band that would push a page past its width budget starts a new page, stacked above the previous one. Each page is centered and flipped independently, so it reads top-down on its own.
  • Nested chips inherit the mode from their parent, so a chip’s interior also renders source-shaped when the entry file has @layout("code").
  • Values that many rows read run down a gutter bus rather than fanning out as one long diagonal wire per reader. Such a value (a variable, an input port, a value handed into or read back out of a chip) gets a lane: a column of rerouter bricks standing in the gutter between the input pins and the code body, chained downward from beside the value’s own producer. At every row that reads the value the lane branches off sideways and runs straight across into that row’s gates, so a read reads as a right angle instead of a diagonal. Lanes are packed so a value only holds a column for as long as it is read, and the longest-lived, most-read values take the outermost columns. A value read on a single row keeps its direct wire unless it is stored state, a port, or crosses a chip wall. An exec chain running from one statement to the next 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): @layout is a source annotation only.
  • "code" and "cube" are the accepted arguments; an unknown name (@layout("grid")) or a missing/malformed argument (@layout, @layout(5)) is a compile error. Naming a layout twice warns and the last one wins.
  • @layout("cube") emits no per-gate labels. A cube packs gates shoulder to shoulder, so the floating name on a var, an I/O gate, a var tag, or a chip brick cannot be read there, and each one costs a text component. Dropping them typically removes most of the components in the save. The shell label and every plane header stay, so the block is still identifiable and a chip still shows its title when opened, as does a runtime @label(expr), whose text is a value the program computes rather than decoration.
@layout("code")

var total: int = 0
in tick: exec

// This note gets a row of its own in the plane.
on tick {
  total = total + 1 // ...but this one is not rendered.
}

@layout("cube"): Compact 3D Packing

@layout("cube") packs gates into a cube (rows of bricks, stacked into layers along the plane’s depth axis) without 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:

PatternMatchesBinds
"say " .. resta string that starts with say rest: everything after it
name .. ".ws"a string that ends with .wsname: everything before it
key .. "=" .. valuea string with an = in itkey 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 itnothing

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:

sourceslotsa single binding means
range / count(...)indexindex
arrayindex, valuevalue
mapkey, value, indexkey
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 a var declared before the loop and return it 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 any await inside one (WS079), including await Sleep(...)/await SleepTicks(...). Spread work across ticks with a back-edge loop (see Loops under emit) outside the for instead.
  • @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 @flat over a file containing any for (WS080).
  • buffer. A buffer binding 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 the buffer outside 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.

modfnfor
Compiledonce per call siteonceonce per loop
Runsinline, as part of the callerwhen its call fires, to the endonce per iteration, in one tick (several with @sliced)
Outer storagewired directlythrough handles in its planewired through the plane’s pins
Recursionno (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 suppress EntityZoneEntered. An entry fires only on a boundary crossing, so SetLocation-ing an entity to a zone it is already inside does not re-fire the event. To force a fresh entry, move it out of the zone and back in.

Binding Event Data

on <call>(config…) -> <pattern> { } is the general capture form: the call’s parens hold config/inputs only, and a trailing -> binds whatever data the call produces. It works the same way whether <call> is a built-in event, a custom event, or an ordinary mod/chip call.

Tuple capture (-> (a, b)) binds outputs positionally under local names of your choosing. It works for any call that produces data, built-in or custom, and is the cleanest form when you don’t need to rename or skip fields:

on CharacterSpawned() -> (who) {
  who.ShowStatusMessage("Welcome!")
}

When you’re binding a single, untyped output, the parens are optional: -> who is shorthand for -> (who):

on CharacterSpawned() -> who {
  who.ShowStatusMessage("Welcome!")
}

(Annotating the slot still needs the parenthesized form, -> (who: character).)

Record capture (-> { field: local }) binds outputs by field name instead of position, for events with named data. Rename a field with field: local, or write the bare name (field) to keep it as-is; list only the fields you need. It’s fine to bind a subset:

on CharacterDied() -> { character: victim, killer } {
  victim.ShowStatusMessage("You were killed")
  killer.ShowStatusMessage("You got a kill!")
}

Record capture only works for events whose data outputs are named fields (built-in events); a call with positional-only data (e.g. a custom event or a plain chip/mod) requires tuple capture.

Binding inside the call parens is an error. The old on Event(a, b) { } form, where identifiers inside the parens bound data, no longer parses; the parser points you at -> instead. Parens are reserved for config and wired inputs: a literal/named config arg ("greet", Description = "..."), or a name = value wired input (zone = room, interval = secs).

Custom events write their data types in the tuple capture:

on CustomEvent("dmg") -> (amount: int, source: character) {
  // ...
}

A slot’s type can be omitted and is then inferred from a matching in-unit SendCustomEvent/SendGlobalCustomEvent on the same channel. When no sender supplies a type either, the slot defaults to float and emits WS042. See Custom Events for the full send/receive contract.

General triggers extend the same -> <pattern> capture to any exec-producing call, not just built-in/custom events. on auto-extracts the call’s exec output, so this works for a mod/chip call outside an exec context, driven by an explicit exec = ... input (the same convention as Random(0, 10, exec = trigger)):

var log: string[]

chip Note(msg: string) -> (count: int) {
  log.push(msg)
  out count = log.length()
}

in go: exec
var last: int = 0

on Note("hello", exec = go) -> (count) {
  last = count
}

If the callee has no exec-typed output for on to auto-extract (a plain value-only call with no exec = ...), that’s WS043. See Exec Chips for how exec = ... and the resulting .exec field work on user-defined chips/mods.

Triggering on Input Ports

in trigger: exec

on trigger {
  count = count + 1
}

Triggering on Boolean/Pulsing Values

Any bool, int, float, or vector value can trigger a handler when its value changes:

chip let moved = position != position.prev

on moved {
  // Fires whenever the 'moved' signal transitions
}

Triggering on Arbitrary Expressions

The trigger can be any expression, not just a bare name: a comparison, a method or index result, or a builtin call. It desugars to a hidden let bound to the expression, and the handler fires when that value changes:

on health <= 0 { respawn() }            // comparison
on a.Dot(b) > 0.0 { faceTarget() }      // method call inside the expression
on arr[i] > 0 { ... }                   // index result
on ServerUptime() > 5.0 { ... }         // builtin call in an expression

A builtin call that returns an exec (on ServerUptime(), on Change(v)) fires the handler on that exec, 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 s suffix selects the seconds gate; unadorned durations are ticks.
  • Constant durations bake into the gate; variables/expressions wire into the duration port.

Payload Ferrying

emit sig = value on a local exec signal ferries the value with the signal: each emitted value is written into a hidden per-signal store var on the emit chain, and await sig reads it back on the resumed chain, so the value survives the buffered tick crossing.

let loop: exec

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 await is 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 plain emit loop on the same chain fires before the await arms and the chain parks forever (WS053 warns). 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 await inside an if branch 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 if declares its signal in the same branch. A signal declared in the await’s own branch (if !running { let loop: exec ... }, or in a mod the branch calls, since the mod inlines 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 the await. Declare the signal inside the branch, or give the loop a handler of its own and pulse it.

    The if guarding the loop’s own continuation (if idx < 25) is fine and required, because it is the exit test.

  • A buffer emit ends its chain. Nothing after it on that chain runs: not a statement later in the same block, not one after an if whose branches both end in one, not one after a call to a mod that ends in one. Each such statement is a WS058 warning, and it is not compiled. An await right 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 var declared inside the loop’s own scope is reset before the next iteration reads it. A counter declared there sticks at its initial value forever, and the loop rewrites element 0 on every pass while looking like it is running. Put the counter (and any accumulator) at module level, or make it static, and reset it upstream of the 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 for loop, which finishes within the tick, or give each entity its own chip instance. See per-entity fan-out.

Gate Cost

ConstructGates added
emit sig (bare)0 (joins the signal’s union)
buffer(...) emit sig1 Buffer (Ticks/Seconds)
emit sig = scalar1 Var_Set per emit (+1 hidden store var per signal)
emit sig = { F fields }F Var_Set per emit (+F store vars per signal)
await sig (per await)~5: armed-flag var, arm + reset Var_Set, Var_Get, Branch
let { F fields } = await sig+F Var_Get
per signal1 Union hub (+1 arm Var_Set when same-chain emits exist); a single-input hub is spliced away

await: Suspend Exec Chain

Suspends the current exec chain and resumes from the awaited expression’s exec output. Everything after the await runs when that exec fires. Only valid in exec context.

await signal                         // resume when signal fires
let val = await signal               // capture the signal's ferried payload
let { a, b } = await signal          // destructure a record payload
let val = await value on trigger     // capture value when trigger fires
let n: int = await CustomEvent("c")  // wait for an event, capture its data
await a || b                         // race: first signal wins
await Sleep(_, delay = 1.0)          // sleep 1 second using _ armed flag
await SleepTicks(_, delay = 5)       // sleep 5 ticks

Each await creates an armed flag (static var bool) that guards the continuation. The continuation only fires once per arming, preventing repeated triggers.

Awaiting a Custom Event

await CustomEvent("chan") (or GlobalCustomEvent) suspends until a matching SendCustomEvent fires, and a binding captures its data:

in go: exec
var last: int = 0
on go {
  let amount: int = await CustomEvent("dmg")   // resume + capture DataOut1
  last = amount
}

Annotate the binding’s type (let amount: int = ...). The event’s data ports are untyped in-game, so the annotation 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.

FunctionGateDelay unitParams
SleepBufferSecondsseconds (float)input, delay, hold
SleepTicksBufferTicksticks (int)input, delay, hold
  • input: the value to delay (use _ for the await armed flag)
  • delay: how long to wait before the output follows the input (optional)
  • hold: how long to hold the output after the input drops to zero (optional, set to -1 to use delay instead)

Examples

in start: exec
in done: exec
var count: int = 0

on start {
  count = 1
  await done          // exec chain pauses here
  count = 2           // runs when 'done' fires
}

// Capture a value when a signal fires
on start {
  let val = await score on done
  use(val)
}

// Sleep for 2 seconds
on start {
  await Sleep(_, delay = 2.0)
  count = 99
}

Assignment

Assigns a new value to a mutable variable. Requires exec context.

target = expression
on RoundStart() {
  count = count + 1
  score = 0
  name = "Player " .. playerId
}

Only var declarations are valid assignment targets. Attempting to assign to a let binding, parameter, or other non-variable produces a type error.

Indexed assignment works for arrays:

on trigger {
  scores[i] = newScore
}

Compound Assignment

Compound assignment operators combine an operation with assignment:

OperatorEquivalentGate Used
+=x = x + exprIncVar
-=x = x - exprIncVar by -expr
*=x = x * exprVar_Get + Mul + Var_Set
/=x = x / exprVar_Get + Div + Var_Set
%=x = x % exprVar_Get + Mod + Var_Set
&=x = x & exprVar_Get + AND + Var_Set
|=x = x | exprVar_Get + OR + Var_Set
^=x = x ^ exprVar_Get + XOR + Var_Set
<<=x = x << exprVar_Get + Shl + Var_Set
>>=x = x >> exprVar_Get + Shr + Var_Set

+= compiles to the dedicated IncVar gate (one gate instead of three), 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.

EventParametersDescription
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
RoundStartroundNumber: intGame round started
RoundEndroundNumber: intGame round ended
CharacterSpawnedcharacter: characterA character spawned
CharacterDiedcharacter: character, killer: character, killerWeapon: entity, killerWeaponName: stringA character died (killer / killerWeapon are who/what killed it)
ControllerJoinedcontroller: controller, userId: string, userName: stringA player joined
ControllerLeftuserId: string, userName: stringA player left (the player is already gone, so there is no controller)
ControllerJoinedTeamcontroller: entity, team: entity, userId: string, userName: stringA player joined a team (team is the team they joined)
ControllerLeftTeamcontroller: entity, team: entity, userId: string, userName: stringA player left a team (controller is empty if they left the server)
ZoneEnteredcharacter: characterA character entered a zone
ZoneLeftcharacter: characterA character left a zone
EntityZoneEnteredentity: entityAn entity entered a zone
EntityZoneLeftentity: entityAn entity left a zone
ProjectileZoneEnteredcharacter: character, projectile: entity, weapon: entity, weaponName: stringA projectile entered a zone (character is the shooter)
ProjectileZoneLeftcharacter: character, projectile: entity, weapon: entity, weaponName: stringA projectile left a zone
CharacterDamagedcharacter: character, damage: float, attacker: character, attackerWeapon: entity, attackerWeaponName: stringA character took damage
CharacterFiredWeaponcharacter: character, direction: vector, start: vector, weapon: entity, weaponName: stringA character fired a weapon (start / direction are the shot ray’s origin and aim)
BrickChangedbrick: brick, character: characterA brick was added or changed in a zone (character is the player who changed it)
BrickRemovedbrick: brick, character: characterA brick was removed from a zone (brick is the deleted brick, character the player who removed it)
ChatCommandcontroller: controller, arguments: stringA registered chat command was run. Takes config args for the command name + help text (see above)

return

The return statement terminates the current exec chain early. It can be used inside:

  • on handlers
  • chip on handlers
  • if blocks within handlers
  • mod bodies (when called from exec context)
on RoundStart() {
  if score > 100 {
    return  // skip the rest of this handler
  }
  score = score + 1
}

chip on CharacterDied() -> (character) {
  lives = lives - 1
  if lives <= 0 {
    return  // don't process further
  }
  health = 100
}

mod process(v: *int) {
  if v < 0 { return }  // early exit from mod
  v = v * 2
}

return expr: Return with Value

For mods with a single output (declared with -> (name: type)), return expr sets the output value and exits:

mod clamp(v: int, lo: int, hi: int) -> (result: int) {
  if v < lo { return lo }
  if v > hi { return hi }
  return v
}

let clamped = clamp(x, 0, 255)  // clamped is directly an int

Single-output chips and mods auto-unwrap: let f = Foo(5) gives f the output type directly (e.g. int), no .result field access needed.

How Multiple Returns Compile

A single return expr wires the value directly to the output port (pure, zero-tick).

When a mod has multiple return expr statements, the compiler inserts a variable to hold the return value. Each return expr becomes a Var_Set before jumping to the return union, and a Var_Get after the union reads the result. This means multi-return mods have a one-tick latency on the return value (the var write is visible on the next tick for pure reads, but available immediately for subsequent exec-chain reads via Var_Get).

return is not allowed in pure context (outside exec chains).

Chips

Chips are Wirescript’s primary mechanism for organizing and reusing logic. They map to physical microchip bricks in Brickadia: a separate grid of gates contained within a single brick. (A module-level @flat compiles the same program onto one grid instead, with no microchip bricks at all.)

Contents

Anonymous Chips

An anonymous chip creates a physical microchip for visual organization while sharing the parent scope. Variables, buffers, and arrays declared inside an anonymous chip are accessible from outside it (and vice versa).

Basic Anonymous Chip

chip {
  var x: int = 0
  var y: int = 0

  on trigger {
    x = x + 1
  }
}

// x and y are accessible here because anon chips share parent scope
out xValue = x.Value

Anonymous chips 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. Use out name = expr in the body to wire values to those outputs. Do not redeclare the same name in both the signature and the body. 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 an exec = ... named argument, the same convention as builtin exec calls like Random(0, 10, exec = trigger).
in reset: exec

var vals: int[]

chip InitTables() -> (count: int) {
  vals.clear()
  vals.push(1)
  out count = 1
}

let t = InitTables(exec = reset)  // body runs whenever reset fires

A call with exec = also returns the chip’s completion exec as an exec field on the result record (unless the chip declares its own exec output), so callers can sequence on the body having run:

on start {
  emit reset
  await t.exec       // resumes after InitTables finished
}

on t.exec { }        // or treat it as an event

To bind body work to a specific exec parameter instead, declare the param and use an on handler, 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 (vals above).

Reference Parameters (ref T / *T)

When a chip parameter has type ref T (or equivalently *T), the caller passes a variable by reference. The chip can both read and write the variable.

chip Increment(counter: ref int, amount: int) {
  on trigger {
    counter = counter + amount
  }
}

When a chip has ref parameters, its body is automatically placed in exec context (because ref access implies mutable state operations).

The *T syntax is an alternative shorthand:

// These declarations are equivalent:
chip Swap(a: ref int, b: ref int) { ... }
chip Swap(a: *int, b: *int) { ... }

At call sites, pass variables directly, 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 expr wires the value directly (pure, zero-tick). Multiple return expr statements cause the compiler to insert a hidden variable: each branch does a Var_Set, and a Var_Get after the return union reads the result. This adds exec gates but is correct within the same tick’s exec chain.

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

Featurechipmod
Physical microchipYesNo (inlined)
GatesOwn gridExpanded into caller
Names in the bodyResolved where declaredResolved where declared
Outputs with ->Yes (multi)Yes (single or named, with return)
ref/* paramsYesYes
ReusableYes (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

  • Pure functions are expressions: they produce a value and can be used anywhere.
  • Exec functions require an active exec context (inside an on handler). They are called as statements or in exec expressions.
  • Parameters marked with ? are optional.

Receiver Method Syntax

Many functions support receiver method syntax, where the first parameter is written before the dot instead of as a positional argument. Both forms are equivalent:

// Receiver form (preferred)
entity.SetLocation(pos)

// Traditional form
SetLocation(entity, pos)

Functions that support receiver syntax show both forms in the documentation below.


Math / Trigonometry (Pure)

All trig functions take and return float. Angles are in radians unless converted.

FunctionSignatureDescription
sin(x)(x: float) -> floatSine
cos(x)(x: float) -> floatCosine
tan(x)(x: float) -> floatTangent
asin(x)(x: float) -> floatArc sine
acos(x)(x: float) -> floatArc cosine
atan(x)(x: float) -> floatArc tangent
atan2(y, x)(y: float, x: float) -> floatTwo-argument arc tangent
sinh(x)(x: float) -> floatHyperbolic sine
cosh(x)(x: float) -> floatHyperbolic cosine
tanh(x)(x: float) -> floatHyperbolic tangent
asinh(x)(x: float) -> floatInverse hyperbolic sine
acosh(x)(x: float) -> floatInverse hyperbolic cosine
atanh(x)(x: float) -> floatInverse hyperbolic tangent
exp(x)(x: float) -> floate^x
ln(x)(x: float) -> floatNatural logarithm
sign(x)(x: float) -> floatSign (-1, 0, or 1)
abs(x)(x: T) -> TAbsolute value; T is any math variant, per component
sqrt(x)(x: float) -> floatSquare root
pow(x, exponent)(x: float, exponent: float) -> floatPower
clamp(x, min, max)(x: T, min: T, max: T) -> TClamp to range
round(x)(x: float) -> floatRound to nearest integer
floor(x)(x: float) -> floatRound down
ceil(x)(x: float) -> floatRound up
min(a, b)(a: T, b: T) -> TMinimum of two values
max(a, b)(a: T, b: T) -> TMaximum of two values
log(x, base)(x: float, base: float) -> floatLogarithm with arbitrary base
lerp(a, b, t)(a: T, b: T, t: float) -> TLinear interpolation; T is any math variant (see Easing/Tween)
Blend(a, b, alpha)(a: T, b: T, alpha: float) -> TThe same gate as lerp, with a clampAlpha config (receiver on a)
fmod(a, b)(a: float, b: float) -> floatFloored modulo
Deg2Rad(x)(x: float) -> floatDegrees to radians
Rad2Deg(x)(x: float) -> floatRadians to degrees
let angle = atan2(dy, dx)
let clamped = clamp(value, 0.0, 1.0)
let dist = sqrt(dx * dx + dy * dy)
let radians = Deg2Rad(90.0)

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)

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

The compiler fuses ~(a & b) and ~(a | b) into single NAND/NOR gates.

let bits = BitCount(flags)

Vector (Pure)

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

FunctionSignatureDescription
Rotation(pitch, yaw, roll)(float, float, float) -> rotatorConstruct an euler rotator
r.ToEuler()(rotator) -> {Pitch, Yaw, Roll: float}Split a rotator into components
dir.ToRotation()(vector) -> quatQuaternion that points along dir
q.ToDirection()(quat) -> vectorForward direction of q
v.Rotate(q)(vector, quat) -> vectorRotate a vector by a quaternion
q.Invert()(quat) -> quatInverse rotation
from.RotationTo(to)(vector, vector) -> quatQuaternion rotating from onto to
a.AngleTo(b)(quat, quat) -> floatSmallest angle between two quaternions (degrees)
a.Slerp(b, alpha)(quat, quat, float) -> quatSpherical interpolation
axis.RotationByAngle(angle)(vector, float) -> quatQuaternion 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) -> quatConstruct a quaternion from raw components
q.SplitQuat()(quat) -> {X, Y, Z, W: float}Decompose into raw components
a.QuatDot(b)(quat, quat) -> floatQuaternion dot product
let q = forward.ToRotation()
let spun = velocity.Rotate(q)
let mid = a.Slerp(b, 0.5)
let r = Rotation(0.0, 90.0, 0.0)   // euler rotator
let yaw = r.ToEuler().Yaw

Color (Pure)

FunctionSignatureDescription
Color(r, g, b, a?)(r: float, g: float, b: float, a?: float) -> colorConstruct a color (linear RGBA, 0-1 range)
ColorSRGB(r, g, b, a)(int, int, int, int) -> colorConstruct from sRGB bytes (0-255)
ColorHex(hex)(string) -> colorConstruct from a hex string ("#ff8800")
c.SplitColor()(c: color) -> {r, g, b, a: float}Decompose into linear components
c.ToSRGB()(color) -> {R, G, B, A: int}Decompose into sRGB bytes
c.ToHex()(color) -> stringHex string
a.ColorBlend(b, alpha)(color, color, float) -> colorBlend two colors (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

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

Select / Swap (Pure)

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

FunctionSignatureDescription
Edge(input)(input: bool) -> {Rising, Falling: bool}Bool pulses on boolean transitions
EdgeExec(input)(input: float) -> {Rising, Falling: exec}Exec pulses when a value rises/falls
Changed(input)(input: any) -> boolBool pulse when the input changes
Change(input)(input: any) -> execExec pulse when the input changes
ContainerChange(container)(container: T[] | Map<K, V>) -> execExec 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:

FunctionSignatureDescription
s.Length()(s: string) -> intString length
s.Contains(search, caseSensitive?)(s: string, search: string, caseSensitive?: bool) -> boolCheck if string contains substring
s.StartsWith(prefix, caseSensitive?)(s: string, prefix: string, caseSensitive?: bool) -> boolCheck prefix
s.EndsWith(suffix, caseSensitive?)(s: string, suffix: string, caseSensitive?: bool) -> boolCheck suffix
s.Find(search, caseSensitive?, start?)(s: string, search: string, caseSensitive?: bool, start?: int) -> intFind substring index (-1 if not found)
s.Substring(start, length)(s: string, start: int, length: int) -> stringExtract substring
s.Replace(search, replacement, caseSensitive?, maxReplacements?, start?)(s: string, search: string, replacement: string, caseSensitive?: bool, maxReplacements?: int, start?: int) -> stringReplace occurrences
s.Split(delimiter, occurrence?, caseSensitive?)(s: string, delimiter: string, occurrence?: int, caseSensitive?: bool) -> {Left, Right: string, Found: bool}Split at the delimiter
s.ToLower()(s: string) -> stringConvert to lowercase
s.ToUpper()(s: string) -> stringConvert to uppercase
s.Trim()(s: string) -> stringRemove leading/trailing whitespace
s.ParseInt() / ParseInt(s)(s: string) -> {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)

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

PlaceholderPrintsExample
{A:,}Thousands grouped1,234,567
{A:.2}Fixed decimals, up to 612.50
{A:,.2}Both1,234.50
{A:On=Off}The first word when true, the second when falseOn

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.

MethodSignatureDescription
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()() -> intNumber of elements
arr.remove(index)(index: int) -> boolRemove the element at index; gives true if index was out of range
arr.insert(index, value)(index: int, value: T) -> boolInsert 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) -> boolSort 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) -> boolSwap 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()() -> TSum 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) -> stringJoin 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) -> boolCopy 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 axes
  • MouseWheel: float: mouse wheel delta
  • PressedC / PressedE / PressedQ / PressedLeftMouse / PressedRightMouse: bool: key/button states
let input = char.InputReader()
let moving = input.Forward != 0.0 || input.Right != 0.0
let interacting = input.PressedE

GetInputs

character.GetInputs() -> { ...same fields as InputReader... }
GetInputs(player: character) -> { ...same fields... }

Sample the same twelve controls once, at the point the exec chain reaches this call, rather than reading them continuously. The field names are identical to InputReader’s, so only the context differs. Its operand also accepts a persistent player, which wires straight into the character parameter. 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 position
  • Direction: vector: aim direction vector

Receiver on character. Access the fields with .Origin / .Direction. Both share one gate, so reading both costs a single GetAim.

on trigger {
  let aim = char.GetAim()
  let origin = aim.Origin
  let dir = aim.Direction
}

Display (Exec)

DisplayText

target.DisplayText(text, ...) -> int
DisplayText(target: controller, text: 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

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

// Update text in-place: capture the id, then re-display with the same textId.
on trigger {
  let id = ctrl.DisplayText("Score: ${score}", fontSize = 24, lifetime = 10.0)
  ctrl.DisplayText("Score: ${score}", textId = id, transition = 0.25)
}

Entity Getters (Exec)

All entity getter functions require exec context and support receiver syntax on entity.

GetLocation

entity.GetLocation() -> vector
GetLocation(entity: entity) -> vector

Get entity’s world position.

GetRotation

entity.GetRotation() -> rotator
GetRotation(entity: entity) -> rotator

Get entity’s world rotation.

GetLocationRotation

entity.GetLocationRotation() -> {Vector: vector, Rotation: rotator}
GetLocationRotation(entity: entity) -> {Vector: vector, Rotation: rotator}

Get both position and rotation at once.

GetLinearVelocity

entity.GetLinearVelocity() -> vector
GetLinearVelocity(entity: entity) -> vector

Get entity’s linear velocity.

GetAngularVelocity

entity.GetAngularVelocity() -> vector
GetAngularVelocity(entity: entity) -> vector

Get entity’s angular velocity.

GetVelocity

entity.GetVelocity() -> {Vector: vector, Rotation: rotator}
GetVelocity(entity: entity) -> {Vector: vector, Rotation: rotator}

Get both linear and angular velocity at once.

on trigger {
  let pos = entity.GetLocation()
  let rot = entity.GetRotation()
  let vel = entity.GetLinearVelocity()
}

Entity Manipulation (Exec)

All entity manipulation functions require exec context and support receiver syntax on entity.

SetLocation

entity.SetLocation(pos: vector)
SetLocation(entity: entity, pos: vector)

Set entity position. Use this (not Teleport) to move an entity to world coordinates. pos is a real vector port.

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)

FunctionSignatureDescription
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()() -> floatSeconds elapsed since the previous tick
ServerUptime()() -> floatSeconds the server has been running
ReadBrickGrid()() -> entityThe brick grid this gate’s microchip is on, as an entity
NearlyEqual(a, b, tolerance)(a: float, b: float, tolerance: float) -> boolApproximate float equality
Dampen(target, smoothTime)(target: T, smoothTime: float) -> TCritically-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) -> TEase 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:

GateConfig attributes
SweepbodyPartsOnly
SweepSimpledirection (EBrickDirection), spreadTowardCenter, detectBricks, detectPlayers1 to 4, bodyPartsOnly, detectPhysics, detectMap
BlendclampAlpha
ColorBlendblendSpace (EBRColorSpace), clampAlpha
SlerpshortestPath, clampAlpha
Easingfunction (EasingFunction, schema EBREasingFunction), direction (EasingDirection, schema EBREasingDirection)
Remapfunction (EasingFunction), direction (EasingDirection)
ConvertColorfromSpace, toSpace (EBRColorSpace)
DisplayTexttypeface (TextTypeface, schema EBRTextTypeface), font (a $Font/... asset ref). justify (DisplayTextJustification) and easing (DisplayTextEasing) are wire inputs that also take a bare member name
GetAimlocalAim
AddInventoryItemAdv / SetInventoryItemAdvoverrideColors, meshColors (a color array), ammoOverride

Enum member names: EBRColorSpace Linear Srgb Oklab Hsv; EBREasingFunction Linear Sine Quad Cubic Quart Quint Expo Circ Back Elastic Bounce; EBREasingDirection In Out InOut; EBRTextTypeface Regular Bold Italic BoldItalic; EBRDisplayTextJustification Left Center Right; EBRDisplayTextEasing Linear EaseIn EaseOut EaseInOut; EBrickDirection X_Positive X_Negative Y_Positive Y_Negative Z_Positive Z_Negative.

Each of these schema enum types is also usable directly, under its clean Wirescript name, as a built-in game 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, then HelpText. The description can also be given by name as Description = "...".
  • The -> capture binds the event’s data outputs, in order: controller (the player who typed it), then arguments (the command text as a string).
on ChatCommand("greet", "Greets the player") -> (controller, arguments) {
  // CommandName = "greet", HelpText = "Greets the player"
  // controller: the player who typed the command
  // arguments: the command text as a string
  controller.ShowStatusMessage("You said: ${arguments}")
}

The description is optional and can use the named form. Binding params are also optional, 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 = true on the receiver. Giving a target (including the receiver spelling, ent.SendCustomEvent(...)) makes it an object event, and only an object-scoped receiver matches one. A plain on CustomEvent("x") is scoped grid-wide and silently never fires for it. 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 SpawnPrefab returned and target it, with isObject = true on the prefab’s receivers.

on hit {
  SendCustomEvent("damage", 7, attacker)   // send an int + a character
}

on CustomEvent (Event)

The receiver’s call parens hold only the channel name (positional) and any config (isObject = true); the typed data outputs are bound by a trailing -> (…) tuple capture. Each data slot’s type can be given explicitly (amount: int) or inferred from a matching in-unit SendCustomEvent on the same channel. When neither is available, the slot defaults to float and a WS042 warning is emitted (the game stores each data slot as a typed value, not any). Unused slots default to float. isObject = true is constant config that scopes the receiver to a specific grid/object (an object event) instead of firing grid-wide.

var lastDamage: int = 0   // a top-level var is already persistent, 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 CustomEvent receiver fires on the tick after the SendCustomEvent runs. The pulse is delivered on the next frame, not synchronously within the sender’s exec chain. Anything that must observe the event’s effect immediately has to account for that one-tick latency (and a send → receive → send round trip costs a tick each hop).

Signature checking (WS030). When a send targets a constant channel name, the compiler compares each data value’s wire type against every matching receiver’s declared param types (and a typed let v: T = await CustomEvent("c") capture’s T) and warns (WS030) on a mismatch, e.g. sending a float where the receiver declared int. A constant is sent as the type it has, so 2.5 to an int param 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 same Object variant). The channel name must be a constant literal, so every send’s receiver set is known at compile time. In the editor, go-to-definition on a send’s channel-name string jumps to the receiver.

A non-constant data value is still typed as float on the wire at emit rather than from the value’s real type. Full end-to-end typing waits on generics. For now the receiver’s annotations are the source of truth, and constant sends carry their type.

Prefab Spawning (Exec)

SpawnPrefab

ParameterTypeRequiredDescription
prefabprefab refNoThe prefab to spawn: a $./file.brz archive, a $./file.ws source compiled on reference, or an inline $ triple-backtick block (prefab reference). Embedded into the bundle at compile.
offsetvectorNoSpawn position, relative to the Spawn Prefab gate’s own position (local, not world space)
rotationrotatorNoSpawn rotation offset
velocityvectorNoInitial velocity of the spawned entity
lifetimefloatNoLifetime in seconds (0 = permanent)
limitintNoMax concurrent instances
destroyAllexecNoWire an exec here; pulsing it destroys every entity this gate has already spawned. Independent of the spawn Exec, so one gate both spawns and clears.

Returns: entity, the spawned entity. Give the prefab with a $…brz reference; omit prefab to configure it on the placed gate in-game instead (copy a prefab onto the Spawn Prefab brick).

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.

ParameterTypeRequiredDescription
projectileTypeclass refYesThe explosion/projectile class: a $… asset reference (or a wired value)
instigatorentityNoThe character/entity that caused it (kill credit, etc.)
offsetvectorNoSpawn position offset
scalefloatNoExplosion scale multiplier
damagefloatNoDamage multiplier
on hit {
  SpawnExplosion($BRWeaponProjectile/Grenade, instigator = attacker, scale = 2.0, damage = 1.5)
}

SpawnExplosionAt

Like SpawnExplosion, but at an absolute world position instead of an offset from the gate’s brick.

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

Raycasting (Exec)

Sweep

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

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

Returns a record with fields:

  • HitDistance: float: Distance to hit point
  • HitEntity: entity: Entity that was hit
  • HitLocation: vector: World position of hit
  • HitNormal: vector: Surface normal at hit
  • HitColor: color: Color of the surface hit
  • HitBrick: brick: Brick that was hit (see Bricks)
  • Hit: exec: Fires if something was hit
  • Miss: exec: Fires if nothing was hit
on trigger {
  let aim = char.GetAim()
  // Run the Sweep inside the exec handler 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)

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

FunctionSignatureDescription
Sleep(input, delay?, hold?)(input: T, delay?: float, hold?: float) -> TDelay by seconds (BufferSeconds gate)
SleepTicks(input, delay?, hold?)(input: T, delay?: int, hold?: int) -> TDelay by ticks (BufferTicks gate)
  • input: the value to delay. Use _ inside await to wire the armed flag.
  • delay: seconds/ticks to wait before the output follows the input.
  • hold: seconds/ticks to hold the output after the input drops to zero. Set to -1 to use delay instead.
// Sleep 2 seconds using await
on start {
  await Sleep(_, delay = 2.0)
  doAfterDelay()
}

// Sleep 60 ticks (~1 second at 60Hz)
on start {
  await SleepTicks(_, delay = 60)
  doAfterDelay()
}

// Pure usage: delay a signal by 5 ticks
let delayed = SleepTicks(rawSignal, delay = 5)

Exec Override

Exec functions that are called outside of an exec context can be given an explicit exec named argument to provide the execution trigger:

// Outside a handler, provide exec explicitly
let r = Random(0, 10, exec = someTrigger)

This wires someTrigger as the exec input of the gate, bypassing the requirement for an enclosing handler context.

Newer builtins

Player-reference gates (DisplayText, ShowChatMessage, HasRole, leaderboard and team setters, the join/left/chat events, ControllerOf/CharacterOf) target the persistent player-state on the current build. The controller type is unchanged and still wires straight into them, 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.

MemberNumberMemberNumber
Type.Bool0Type.String7
Type.Int1Type.Enum8
Type.Float2Type.Object9
Type.Color3Type.Brick10
Type.Vector4Type.Component11
Type.Rotator5Type.Exec12
Type.Quat6

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 .A or .B depending on cond at the instant it fires (a runtime if for exec flow). Bind or trigger on the named outputs:

    on Branch(isRed, tick).A { redTick() }
    on Branch(isRed, tick).B { blueTick() }
    

Callable gate builtins

Every variable / array / map wire gate is also exposed as a plain function named after the in-game gate, alongside its method / operator / assignment form. The two forms are identical (the call desugars to the method or assignment at parse time), so pick whichever reads better. The names match the game gates so they are easy to find (and complete).

Variables

BuiltinSame as
GetVariable(v)v
SetVariable(v, x)v = x
IncrementVariable(v, n)v = v + n

Arrays: the function name maps to the array method of the same operation; the receiver is the first argument:

BuiltinSame asBuiltinSame as
GetArrayElement(a, i)a.get(i)SortArray(a, desc?)a.sort(desc?)
SetArrayElement(a, i, x)a[i] = xReverseArray(a)a.reverse()
PushToArray(a, x)a.push(x)ShuffleArray(a)a.shuffle()
PopFromArray(a)a.pop()SwapArrayElements(a, i, j)a.swap(i, j)
InsertArrayElement(a, i, x)a.insert(i, x)SliceArray(a, src, s, n)a.slice(src, s, n)
RemoveArrayElement(a, i)a.remove(i)AppendArray(a, src)a.append(src)
GetArrayLength(a)a.length()CopyArray(a, src)a.copyFrom(src)
FindArrayElement(a, x)a.find(x)SumArray(a)a.sum()
ClearArray(a)a.clear()AverageArray(a)a.average()
FillArray(a, x)a.fill(x)ArrayMaximum(a)a.max()
ResizeArray(a, n, x)a.resize(n, x)ArrayMinimum(a)a.min()
JoinArray(a, sep?)a.join(sep?)

Array fills (Gamemode / Zone gates, the function-call twins of the fillFrom* array methods):

BuiltinSame as
FillArrayFromPlayers(a)a.fillFromPlayers()
FillArrayFromTeamMembers(a, team)a.fillFromTeam(team)
GetPlayersInZone(a, zone, tagFilter?)a.fillFromZonePlayers(zone, tagFilter?)
GetEntitiesInZone(a, zone, tagFilter?)a.fillFromZoneEntities(zone, tagFilter?)

Maps: map to the map methods of the same name:

BuiltinSame asBuiltinSame as
GetMapElement(m, k)m.get(k)ClearMap(m)m.clear()
SetMapElement(m, k, v)m.set(k, v)CopyMap(m, src)m.copyFrom(src)
HasMapElement(m, k)m.has(k)GetMapLength(m)m.length()
RemoveMapElement(m, k)m.remove(k)GetMapKeys(m, 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 after Environment..
  • Types. Each property has its own type, listed below, and a write must fit it (WS003). Colors read and write as color.
  • 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 an int or 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, and Environment.Sky with no property is an error naming the properties it has.
  • Shadowing. A binding of the program’s own named Environment hides 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.

PropertyReads and writes as
SelectedAmbienceTypenot available: an asset reference (class), which a property gate cannot carry yet
AmbienceVolumefloat
ReverbEffectint, or a ReverbEffect member

Environment.Ground

The game’s Ground environment (stored as GroundPlate).

PropertyReads and writes as
Variancefloat
VarianceBrickSizefloat
GroundColorcolor
GroundAccentColorcolor
IsVisiblebool
bUseStudTexturebool

Environment.Sky

The game’s Sky environment.

PropertyReads and writes as
TimeOfDayfloat
AnimateTimeOfDaybool
DayLengthfloat
NightLengthfloat
SunAnglefloat
SunScalefloat
SunHorizonScaleMultiplierfloat
SunlightColorcolor
SkyIntensityfloat
SkyColorcolor
MoonPhasefloat
MoonScalefloat
MoonlightIntensityfloat
MoonlightColorcolor
StarsIntensityfloat
StarsColorcolor
AuroraIntensityfloat
CloudCoveragefloat
Rainfloat
Snowfloat
Dustfloat
Thunderfloat
Windfloat
WindDirectionfloat
bCloseLightningbool
RainVolumefloat
CloseThunderVolumefloat
DistantThunderVolumefloat
WindVolumefloat
ClearFogDensityfloat
CloudyFogDensityfloat
ClearFogHeightFallofffloat
CloudyFogHeightFallofffloat
FogColorcolor

Environment.Lighting

The game’s Lighting environment (stored as Studio).

PropertyReads and writes as
Scenariosnot available: an array, which a property gate cannot carry

Environment.Universe

The game’s Universe environment.

PropertyReads and writes as
UniverseRotationrotator
UniverseLightColorcolor
UniverseLightIntensityfloat
UniverseAmbientColorcolor
UniverseAmbientIntensityfloat
UniverseGravityScalefloat
bUniverseFloorEnabledbool
NebulaTexturenot available: an asset reference (object), which a property gate cannot carry yet
NebulaColorcolor
NebulaRedBlueSwapfloat
NebulaSaturationfloat
NebulaPowerfloat
NebulaBrightnessfloat
NearStarsTexturenot available: an asset reference (object), which a property gate cannot carry yet
NearStarsPowerfloat
NearStarsBrightnessfloat
FarStarsTexturenot available: an asset reference (object), which a property gate cannot carry yet
FarStarsPowerfloat
FarStarsBrightnessfloat

Environment.Water

The game’s Water environment.

PropertyReads and writes as
WaterHeightfloat
WaterAbsorptionvector
WaterScatteringvector
WaterFogIntensityfloat
WaterFogAmbientColorcolor
WaterFogAmbientScalefloat
WaterFogScatteringColorcolor
WaterFogScatteringScalefloat

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

Any string the game renders as text accepts markup: DisplayText, BroadcastChatMessage, ShowStatusMessage, chat, and the text components a @label writes. Markup is ordinary string content, so it composes with interpolation and .. concatenation like any other text.

TagEffect
<b>Bold
<i>Italic
<br>Line break. It is not a container, so it takes no close
<color="RRGGBB">Text 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 / anchorY are fractions of the screen. 0 is left / top, 0.5 is center, 1 is right / bottom. This is the only pair that takes a 0..1 value.
  • positionX / positionY are an offset from that anchor in slate units, which are roughly pixels at 1080p. They are not a fraction. A realistic offset is in the tens or hundreds. positionX = 0.98 does 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:

FieldDefaultNote
Scale(1, 1)not zero, so unset scale never hides text
FontColoropaque whitenot transparent
Anchor(0.5, 0.5)screen center
Position(0, 0)no offset from the anchor
Pivot(-1, 0.5)
FontSize16
OutlineSize2text has an outline unless you pass 0
Lifetime5.0seconds; 0 means infinite, not “vanish now”
TextId00 uses the brick’s persistent handle. A non-zero id is shared by every gate using it, which is how several gates update one piece of text

lifetime interacts with how often you redraw. Text redrawn every tick 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 VarRef from that var reads, writes and increments the target, and copying the ref var-to-var keeps it.
  • Handle containers are kind-strict: a component_handle array or map value keeps a component ref, a brick_handle array keeps a brick ref, and each only keeps its own kind.
  • A number container (an i64/f64 array, or an f64 map value) turns a pushed ref into the plain number 1. 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_handle array holding array refs, with an element read back out able to drive Push, Get and GetLength on 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: GetLocation on one returns the origin, and nothing outputs one. A brick ref comes only from the editor or from save data.
  • FormatText prints a var ref as Var, an array ref as Array Var, and a brick as Brick.

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 held fires 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 an int pin holding and keep its last value in a var last: in on Change(holding), act only if holding > last, reading Character there, then set last = holding. Change does 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 offset is relative to the Spawn Prefab gate’s own position, not world space. For a world position, spawn the copy and then SetLocation the returned entity.
  • SetLocation moves a spawned prefab, from the spawner (the entity SpawnPrefab returns) 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 takes ControllerOf, an admin gate; without admin, key per-player state by GetUserId().
  • A player who leaves. A stored reference to a player who has left the server (in a var, an array or a map) turns into null, so owner == null detects 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. An owner == null check therefore also removes a dead player’s copy, a little later than the death.
  • Zone events. ZoneEntered and ZoneLeft are 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 interval after 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 pending await Sleep(...) or a buffer emit still 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 with ReadBrickGrid().GetCustomProperty("owner"). Store the read in a typed var before sending it in an event: an annotated let still 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

Pure Context

Pure context is the default. Code in pure context defines continuous signal-flow relationships, like wiring gates together. Pure expressions re-evaluate whenever their inputs change.

What runs in pure context:

  • Top-level let bindings
  • var initializers
  • out bindings
  • buffer expressions
  • Chip output expressions
// All of these are pure context:
var count: int = 0                              // initializer is pure
let doubled = count.Value * 2                   // let binding is pure
out result = doubled                            // out binding is pure
buffer prev = count.Value                       // buffer expression is pure

Exec Context

Exec context represents imperative, sequential execution triggered by an event. Code in exec context runs once per trigger, in order, along an exec chain.

What runs in exec context:

  • on handler bodies
  • Code after on handlers at the same scope level (exec union)
  • Named chip bodies when the chip has ref parameters
  • Exec function calls (DisplayText, Random, SetLocation, etc.)
// This is exec context:
on RoundStart() {
  count = count + 1          // assignment requires exec
  let r = Random(0, 10)      // Random is an exec call
  if r == 0 {                // if statement requires exec
    emit special             // emit requires exec
  }
  DisplayText(ctrl, "Go!")   // DisplayText is an exec call
}

What Requires Exec Context

The following operations are only valid in exec context:

OperationWhy
var = expr (assignment)Writing to a variable gate requires an exec chain
if { ... } (statement)Conditional execution requires an exec branch gate
emit eventFiring an event requires an exec signal
Exec function callsFunctions like Random, DisplayText, SetLocation have exec pins
*var (explicit deref)Reads the variable’s current-tick value via Var_Get; not allowed in pure context (WS006)

The typechecker emits error WS007 when any of these appear outside exec context:

var n: int = 0
n = 1              // 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 * 2 use a Var_Get gate (exec) to read x when inside an exec context. This ensures the read is sequenced correctly in the exec chain: the Var_Get fires at the right point and its value output feeds the pure * gate. In pure context, x reads directly from the PseudoVar’s Value port (no exec gate).

You can also use *x as an explicit deref. It compiles to the same Var_Get gate and is equivalent to the bare name in exec context:

on tick {
  let a = x    // implicit deref via Var_Get
  let b = *x   // explicit deref, identical result
}

*x in pure context is an error (WS006): use .Value for pure reads.

In Pure Context

In pure context, 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

AccessContextGateMeaning
x (bare)ExecVar_GetCurrent tick’s value (auto-deref)
*xExecVar_GetCurrent tick’s value (explicit deref, same as bare)
*xPurenoneError WS006: use .Value
x (bare)Pure.Value portThe same read as x.Value; an unannotated out of it is WS017
&xEitherVarRef portx’s ref, as a value
x.ValueEither.Value portPrevious tick’s value (delayed read)
x.prevEither.Value portPrevious tick’s value (same as .Value)

.prev is essential for change detection:

// Detect when count changes
chip let changed = count != count.prev

on changed {
  DisplayText(ctrl, "Count changed!", fontSize = 24)
}

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

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

Await

await suspends the current exec chain and resumes when the awaited expression fires. It rewires ctx.current_exec. 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

  • await only works in exec context, because it modifies the exec chain.
  • await works inside if blocks.
  • Each await creates an armed flag (static bool) that guards the continuation. The continuation fires exactly once per arming.
  • _ inside await is typed as bool (the armed flag). It is only valid inside await expressions.
  • let foo = await 1 is valid but dangerous: the pure value pulses once, so the continuation runs immediately and never again.
  • Multiple sequential awaits chain: each one gets its own armed flag and redirects exec.

Best Practices: Gate Count & Scaling

Wirescript makes it easy to write logic that reads like a normal program but compiles into an enormous number of gates. The patterns below came out of shrinking a real game circuit from roughly 300,000 gates to about 8,000. The logic 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 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
modchipfnfor
Compiles toInline gates in the caller’s gridThe same gates, in a microchip instanceOne function definition planeOne loop plane
N call sites / iterationsN copies of the whole subtreeN copies of the whole subtreeOne copy, plus one Function Call gate per siteOne copy, swept N times
RunsAs part of the callerAs part of the callerWhen its call fires, to the endEvery iteration in one tick (@sliced: several)
Pure (no exec)YesYesNo: an exec chain or exec = triggerNo: exec only
Reads outer in ports and letsYesYesNo (WS113)Yes
emitYesYesNo (WS113)Yes
Outer var, array, mapWired directlyWired directlyThrough a handle var in the planeThrough the plane’s pins
Named multi-outputsVia a returned recordYes (-> (a: int, b: int))Yes (-> (a: int, b: int))Write a var before the loop
ref/* paramsYesYesYesn/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..n emits 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 a for. It is one gate, evaluated once, instead of a loop over every slot.
  • Two masks beat a tri-state array: store votedMask and yesMask rather than an array of -1/0/1. “Voted no” is then votedMask & ~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 check IsEmpty when the array can be empty). .sum() / .average() return a bare scalar.
  • If the slots you want to exclude already hold the identity value (0 for a sum, or a value below every real one for a max), reduce the whole array. No masking is needed.
  • The other whole-array methods work the same way: find, fill, copyFrom and resize each 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 fn inside the prefab can’t use the prefab’s storage. Pass values in as parameters and return them as results, or use a mod.

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:

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

512 DisplayText calls per tick cost 3us. If a HUD redraws twenty elements at 10Hz, drawing is not your problem and rate-limiting it will not help.

Map reads are free and O(1). A 1024-entry map reads no slower than an 8-entry one. A permanent record store can grow forever without pruning, and keying per-entity state by character or account id costs nothing at runtime.

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:

ConstructionBodies (gates)Ticks for N items
Unrolled callsN1
fn called N times1, plus N call gates1
for loop11 (@sliced: several)
Back-edge loop (await)1N
Self-event fan-out1N
One send to N distinct receiversN1
N sends on N distinct channelsN1

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 mod inlines per call site, so its cost is roughly body x call sites. That is an upper bound. The compiler shares loop-invariant subexpressions across the copies, so hoisting a repeated read out of an unrolled mod by hand usually saves very little.
  • A fn costs 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 outer var, array or map its body touches.
  • A for costs 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 local var becomes a storage gate at every call site. Three locals in a helper called from a 30-wide unroll is ninety gates. Making the helper a fn collapses 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 early return returns through a storage gate, which is opaque to the constant folder, and costs several times more per site. Rewriting an early-return helper as one expression is usually the cheapest change available to it.
  • Unused storage is never removed. Guarding a feature behind a false constant removes its logic but not its var declarations. Nothing prunes unused state, and the compiler never warns about it.

Feature flags fold completely

A body guarded by a compile-time-false constant is deleted, not skipped:

const FEATURE = false
on tick { if FEATURE { expensiveThing() } }   // compiles to nothing

Measured: a 1056-gate body behind const FEATURE = false compiled to 3 gates. Shipping two builds of a chip (one with a feature, one without) is therefore close to free, and cuts both gate count and artifact size for anyone who does not need it.

Size is usually the real constraint

There is plenty of runtime headroom, but artifact size is limited.

  • Every .brz has a ~25KB floor, whatever it contains. A 10-gate helper chip and a 500-gate chip cost about the same. Splitting work across many small chips pays that floor every time.
  • A SpawnPrefab reference embeds the whole prefab into the spawning chip. Shrinking the prefab shrinks every chip that spawns it, and each spawner carries its own copy.
  • On-screen text is capped at roughly 32 elements per player, shared across every circuit that player has loaded, not 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-if is a Select gate, so both arms evaluate. Guard possibly-out-of-bounds array reads with a statement-if, never a ternary.
  • Delete dead arithmetic. If a value’s range makes an op a no-op, drop it. For example, (x / 16) % 4, where x / 16 is already proven <= 2, is the same as x / 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 let once and reuse it. Each call is its own gate subtree, so f(x) + f(x) builds f twice. With a fn, it builds two call gates and runs the body twice.
  • A mod-local static var is per-copy, not shared. Each inlined instance gets its own. Hoist shared state to a root var.
  • A fn-local var is 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, or if for its gate count. The estimate covers the whole construct (following its calls), and a mod reports that it is inlined per call site. 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:

  1. Profile with --dump-ir first, then refactor the dominant gate kind / source line instead of guessing.
  2. How many call sites reach the biggest mod? Its cost is its body times that count. Can it be a fn?
  3. Are repeated steps that differ only by an index written as one for?
  4. Can many producers be funneled through one queued dispatch site?
  5. Can near-identical entry points collapse into one parameterized mod or fn?
  6. Can a hot shared routine be deferred behind a dirty flag to one call per tick?
  7. Is any per-slot boolean state an array that should be a bitmask?
  8. Is anything being re-derived inside a callee that could be passed in?
  9. Can an unrolled or looped max/sum/count/search be a one-gate .max() / .sum() / .find()?
  10. Would a derived parallel array turn a per-element decode-and-add into a slice + sum?
  11. Is display/derived logic running on the exec chain when it could be a pure output binding?
  12. Is a per-tick roster sweep a for, or one chip per entity when each needs its own handlers?

See also

Testing

A Wirescript program can check itself. The pattern below compiles to a circuit that runs its own assertions when the grid loads, says nothing when everything passes, and prints what it needs to diagnose the failure when something does not.

Type checking cannot do this job. It proves a program is well formed, not that a gate computes what you expected, and most unexpected behavior comes from the gates.

Contents

The shape

var pass: int = 0
var total: int = 0

mod assert<T: int | float | string>(want: T, got: T, label: string) {
  total = total + 1
  let ok = want == got
  pass = pass + ok
  if !ok {
    BroadcastChatMessage("FAIL: ${label} want ${want} got ${got}")
  }
}

on 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

Folding is on by default: every compile folds unless you opt out. An unannotated program folds:

let x = 2 + 3 * 4          // folds to the literal 14

Turn it off for a whole program with a module-level @nofold: on the first line of the entry file (after any module doc), separated from the first declaration by a blank line.

@nofold

let x = 2 + 3 * 4          // stays as gates; nothing folds or is elided
  • --no-fold on the CLI’s compile command disables folding for that compile, equivalent to a module-level @nofold.
  • --fold, and a module-level @fold, still parse but are redundant now that folding is the default. They never override a @nofold.
  • If both a module-level @fold and @nofold are present, @nofold wins and the parser warns that the annotations conflict.
  • A @nofold in an imported file has no effect, 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) stores 0.25 in the Clock’s IntervalSeconds data, and a constant Sweep distance, DisplayText fontSize, etc. do the same. A variable value still wires (so the setting stays dynamic).
  • A matching-type constant operand inlines too: the 0 in x | 0, a false operand of ||, and string literals into str fields (Contains’s search text, an interpolation template) all bake into the gate instead of spawning a throwaway carrier gate.
  • A type mismatch (e.g. a float constant into an i64 field) does not inline. It keeps a converting carrier gate. Wire-variant math inputs (+/-/*) and Vector/Rotator/Quat/Color fields inline as wire variants instead.

Strings and composite values (wave 2)

Folding now also covers string operators/methods and vector/rotator/color/quat math and constructors, certified the same way as everything else: an in-game probe records the real output, and only a (gate class, input-variant) pair the probe 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 1e15 in 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. 1e15 itself is certified to print fine. The bound is exclusive.
  • A handful of composite-constructor gates whose only table evidence is a blank render and that have no transitive proof either (unlike Quat(...)/3-argument Color(...) above): a bare rotator constructor, the sRGB-byte and hex-string color constructors, and rotation inversion. These 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 @nofold at the top of a file, which disables the whole fold pass for that compile.
  • Rerouters, var/Var_Get reads, arrays, buffers, events, ReadBrickGrid(), and any wire carrying an object-typed value.
  • Any gate class or input-variant combination the probe never certified: absent from the table means permanently unknown, never folded.
  • Certified signatures whose specific values the evaluator declines to compute, as a safety net layered on top of coverage: math involving a string operand (the recorded observations rule out every parsing model), integer overflow, mixed-sign division/modulo with a nonzero remainder (truncation direction was never probed), and any result that would be non-finite. These are refused rather than guessed at, even when every input is constant.

The certification story

Every fold decision traces back to data/gate_semantics.json, a table built by probing each gate combination in-game and recording its real output. Two things hold the compiler to that table:

  • Replay gate: a build-time test feeds every table case through the compiler’s evaluator and asserts the recorded output. The table cannot drift from what the compiler does without breaking the build.
  • Coverage gate: a gate only folds when its (gate class, input-variant signature) pair is present in the table. A combination the probe never ran stays unknown, even if the evaluator could compute an answer for it.

A companion probe invariant test compiles the certification circuits themselves with folding on and off and asserts identical gate and wire counts. 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-fold on the CLI’s compile command has the same effect for that compile.
  • Folding is on by default everywhere it runs (CLI compile, the LSP, and the wasm build), so all tools fold consistently unless a program opts out with @nofold.

Guarantee

A gate is only value-folded when its class and input-variant signature are certified, and only using a law the evaluator implements for it. Anything else is left as a real gate. Opaque(...), @nofold, rerouters, variables, arrays, buffers, events, ReadBrickGrid(), and object-carrying wires are never folded, elided, or seen through. Nothing is removed that the certified table does not license.

See also

Diagnostics

Every problem the compiler reports carries a stable WSxxx code. This page lists every code the type-checker and lowering passes emit, grouped by the kind of problem. Codes are errors (they stop compilation) unless the entry marks them (warning). Warnings compile but flag something likely unintended.

Diagnostics run in a fixed order (parse, resolve/import, type-check, lower, circuit analysis), so an early error can suppress later ones on the same construct.

Some numbers in the WS0xx range are not used: WS009 and WS018 have no emit site. WS034 was once a generic-chip cross-wiring guard and has since been removed.

Contents

Execution context

Wirescript has a pure context (continuous signal-flow) and an exec context (imperative code inside on handlers). These codes fire when a construct is used in the wrong one, or when a feedback loop has no tick barrier. See Execution Context.

CodeMeaningTrigger
WS005Gate cycle with no barrier: a feedback loop must cross a Buffer/Queue/EdgeDetector; break it with buffer emit.a gate loop with no buffer on any edge
WS006*x deref used in pure context. Use x.Value for a pure read.out v = *count
WS007Exec-only construct outside an exec context: assignment, emit, await, an array index read, 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
WS101An 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

CodeMeaningTrigger
WS001Unknown event or trigger: the name after on isn’t a known event, input, let, buffer, var, or param.on Nope { }
WS002Unknown name or type: an undefined variable, an unknown type, an undefined namespace base, a namespace base shadowed by a local binding of the same name, or a misused generic alias (bare, wrong arity, or recursive). 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() }
WS012Import error: a circular import, an unresolvable file, or a named binding not found in the target module.import { nope } from "utils"
WS013Duplicate declaration, or an output that is never assigned. 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
WS103An 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" }
WS105A 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
WS107A 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[]
WS110A 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
WS021Use 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() }
WS093A 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 }
WS043An on <call> -> <pattern> general (non-event) trigger’s call has no exec-typed output for on to auto-extract. It needs an event, or a call whose result includes an exec field (e.g. via exec = ...). 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 }
WS060Unknown variant on an enum path (Enum.Variant): the enum has no variant with that name.enum Shape { Empty } then Shape.Nope
WS064Duplicate discriminant value in an enum declaration. Each variant’s tag must be unique.enum E { A = 1, B = 1 }
WS065Wrong bracket form for an enum variant’s payload: a named-payload variant called with (...), or a positional-payload variant called with { ... }.enum S { Box { w: float } } then S.Box(1.0)
WS054Non-exhaustive match: the arms don’t cover every value of the scrutinee. 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 }
WS092A 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 } }
WS095A 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 }
WS097An 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
WS062A let <pattern> = ... else { } whose else block can fall through. It must diverge on every path (return/emit, or an if/match whose arms all diverge), since the binding is unavailable when the pattern doesn’t match.let Some(x) = o else { let y = 1 }
WS063A generic enum’s type parameter can’t be inferred: a variant with no payload to pin it (Option.None) 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.

CodeMeaningTrigger
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)
WS003Type 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() }
WS004No 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
WS008Taking &/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]
WS011No 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
WS025A 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 }
WS031A 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
WS067A bare variant name for a variant that has a payload (Circle instead of Circle(_)) in a match arm binds the whole value like a catch-all, which can leave later arms unreachable.match s { Circle => .., Empty => .. }
WS120A 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
WS121An 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.
WS122A 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 }
WS068A custom-event data param is a record or an enum. A data slot is one wire and those span several, so the value cannot travel through it. Pass the fields as separate params, or send a key and read the value from shared storage on the other side.on CustomEvent("c") -> (p: Point) { }
WS069An enum payload field would need container storage. A payload slot is filled by constructing the variant, and a container cannot be constructed into one, so the value would read back empty. Fires on the declaration, or on a generic instantiation that binds a stored parameter to one (Option<int[]>).enum E { B { xs: int[] } }
WS070An unsafe payload access does not name both a variant and a field. The form is unsafe <value>.<Variant>.<field>; the variant is what selects the slot.unsafe e.B = x
WS071A record value used where a single value is expected 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[]
WS072An expression that produces no value read as one: a void container mutation (a.push(x), m.set(k, v)) or a mod/chip with no output. There is no wire to pass, so the consumer would read its port default. Use the call as a statement, or give the mod an output. 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)
WS084A 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
WS086A 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
WS100A 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

CodeMeaningTrigger
WS020Recursive 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) }
WS022Wrong argument count in a user mod/chip call.mod f(a: int) {} then f(1, 2)
WS035A self-receiver mod shadows a builtin receiver-method of the same name/receiver, so it could never be reached as a method. Rename it.mod Dot(self: vector, o: vector) -> float { ... }
WS036A non-self mod called with method syntax x.f(…). Call f(x, …) directly, or rename its first param to self. 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()
WS038The callee isn’t callable: it’s a var/let/array/param, not a mod/chip/fn (often an index typo).xs(i) for xs[i]
WS041Unknown named argument: it matches no parameter and no settings-menu config field, so it does nothing (exec = and a variadic call’s trailing options are exempt).p.DisplayText(t, positionX = 0.0)

Generics

See Generics.

CodeMeaningTrigger
WS033Generic inference failure: T can’t be inferred (conflicting args, unpinnable, or out of its bound), an explicit type-arg count/bound is wrong, or type args were given to a non-generic function.pick(true, anInt, aVector)
WS037(warning) Explicit type arguments on a builtin are ignored, because a builtin’s result type comes from its arguments.Random<int>(0, 5)
WS096Too 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

CodeMeaningTrigger
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
WS019A prefab reference must end in .brz.$./level
WS023A side annotation (@left/@right/@top/@bottom) is only valid on a top-level port of the compiled file, not inside a chip/mod body.chip { @left in go: exec }
WS040A @label(<expr>) isn’t a compile-time constant, in a position that requires a baked label (a port, chip, or nested var). 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
WS073An 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() } }
WS090A 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 } }
WS099return <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

CodeMeaningTrigger
WS010Destructure / field-access shape mismatch: wrong destructure arity, no such record field, or a tuple index out of range.rec.missingField
WS024(warning) An asset/prefab reference inlined into a constant array initializer is silently dropped. Build the array with .push(...) in an exec handler.var s: entity[] = [$Type/Name]
WS026A map literal used somewhere other than initializing or assigning a Map variable, 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 })
WS027Assigning 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
WS039Invalid 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>
WS044An array/map method (.push, .set, .remove, …) called on something that isn’t an array or map. The receiver didn’t resolve to a container, so the operation would otherwise be silently dropped. Also reported for 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)
WS087A 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 }
WS088An 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]
WS089A 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() }
WS098A 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.

CodeMeaningTrigger
WS074A 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
WS075A 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
WS076A 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 { }
WS077A 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) { }
WS078Control 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 }
WS079An 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 }
WS082A 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[]
WS083A 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.

CodeMeaningTrigger
WS111A 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()
WS112A record parameter of a fn given an argument with no record fields to pass.f(5) for fn f(p: { a: int })
WS113A 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 }
WS114A 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)
WS115A 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.

CodeMeaningTrigger
WS046Not a compile-time constant: the value names a runtime value, a call to a mod that isn’t const mod, an unsupported syntactic form, an out-of-range compile-time array index, or a missing compile-time map key/record field. The message names the actual offender. A const mod the file does not declare or import is the call’s WS002 alone.in live: int then const n = live + 1
WS047The certified evaluator refuses to compute the value even though every operand is constant: integer overflow, a non-ASCII string operand, or an uncertified gate/operand combination. The value is computable in principle, but the compiler will not guess it.const s = "café".ToUpper()
WS048Const evaluation gave up: the call chain is too deep or took too many steps (guards a runaway or self-referential const mod chain against a stack overflow, since a const mod calling itself type-checks fine).a const mod that calls itself, reached from a constant-only position such as a custom-event channel name
WS049A record 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) }
WS050A method with no per-field meaning was called on a record array or map. A record container is stored as parallel per-field gates, so sort/shuffle (would desync the fields), the aggregates (sum/min/max/average), find, and the dual-container ops (append/copyFrom/slice/values) are not available. Use pts[i] / m[k] for element access.var pts: Point[] then pts.sort()
WS051null was used for a type that has no null value. null adopts its target type and produces that type’s zero / unset, 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
WS052A ...rest variadic parameter was declared on a chip. Only a mod may be variadic: a mod inlines per call site (so the trailing args are captured into a compile-time tuple), while a physical microchip instantiates once and cannot vary its pin count per call. Change the chip to a mod.chip C(a: int, ...rest)
WS053A plain emit X is followed, in the same chain, by an await X on the same signal. The emit fires in the current tick, but the await is what arms the resume and only arms afterward, so the await never catches that kick and the chain parks forever. Use buffer emit X, which lands the next tick after the await is armed.emit go then await go
WS055 (warning)let x = await CustomEvent(...) captures the event’s data but its type could not be determined, so the wire defaults to a float and mis-delivers non-float data. Annotate the binding (let x: int = await CustomEvent("c")). A tuple let (p, t) = ... positional capture has no annotation surface, so capture typed values one at a time or receive them with a handler (on CustomEvent("c") -> (p: T, ...)).let foo = await CustomEvent("c")
WS056let x = await sig binds a value from a signal that carries no payload. A bare emit sig fires the signal without a value, so there is nothing to capture and the binding would read the signal’s exec pin as garbage. Emit a value (emit sig = ...), capture a live value at resume (let x = await expr on sig), or drop the let.let sig: exec then let v = await sig
WS057emit X targets something that is not an out port or a let ...: exec signal reachable here, so it would compile to nothing. An input port, a var, or an out/signal declared outside an enclosing named chip (whose fresh scope can’t see it) is not a valid target, 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 } }
WS059Change/Changed (or an Edge detector) is watching a value it can’t observe. A change/edge detector watches a single wire value. A reference or container (a Map, an array, a *T ref, a zone/teleport) has none, so Change(m) compiled to a dead gate. Watch a scalar the container produces instead.in m: Map<int,int> then on Change(m) {}

Gate & event config

Some gates and events take config args that bake into gate data rather than wiring in as ports (see Built-in Events and Custom Events).

CodeMeaningTrigger
WS028Invalid gate/event config: an unknown enum member, an out-of-range int, a missing required config field, or a non-constant value for constant-only config. 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

CodeMeaningTrigger
WS032(warning) An any annotation on a non-storage position (port, param, let, output). Prefer a generic type parameter, which keeps the type.mod f(a: any) -> any { return a }

Internal errors

CodeMeaningTrigger
WS094Internal 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: WS002 for f(x), WS036 for x.f(). This includes a helper of a named or aliased import (import { g as gg } then f(k), where g calls f). Import the helper by name or import the whole module.
  • a library body calling a mod its importer declares: WS002 or WS036, 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.ws imports a.ws and b.ws, and a.ws calls b.ws’s bf): WS002, located in a.ws. Import b.ws into a.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: an if or match expression of a record, tuple or enum with an arm that writes, sends or returns. Use an if or match statement whose arms assign a var.
  • WS099: return <value> in a top-level handler, which drove the file’s one out from load time. Write emit o = value, then a bare return.
  • 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 = 5 and out 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 is Magnitude(vv), an array’s count xs.length().
  • WS088: an array assigned a literal that spreads the same array anywhere but first (xs = [0, ...xs]), which emptied it. Use xs.insert(0, v), or put ...xs first.
  • WS095: a block arm (A => { k = 5 }) of a match used as a value, whose value was the exec pin. Write the arm as a value, end it in return, or make the match a statement whose arms assign a var.
  • WS057: a bare emit o of a value output, at the top level or in a chip, which wired the exec into the value. Write emit o = value.
  • WS021: a mod or chip body reading a top-level let declared below the call, which read 0. Declare the let above the first call.
  • WS044: changing a constant array or map (a const, a let bound 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 a var and change that.
  • WS049: an enum at a chip parameter or output, or nested in a record or tuple at a port, which was an unwired pin. Only a top-level in/out of a payload-free enum carries one, as its tag. Pass it to a mod, pass a chip a *E, or pass the tag and payload as plain values.
  • WS004: arithmetic on a *T field of a call’s result or of a record an if/match chooses (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: a mod or chip with 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 the if an else that returns.
  • WS003: an annotated buffer whose 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: a type or enum named like a builtin type (type float = int), or named Array or Ref with 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 an int in a place typed int (n = 1e30), which was stored as the largest int.
  • WS002: a library body reading a top-level value (a const, let, var or 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 anonymous chip { }. Rename one. A top-level anonymous chip { } may still declare a name the top level does (see Anonymous chips).
  • WS013: two outputs of one mod or chip with 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) returning fs.min()), which filled them by position, so Value carried IsEmpty. 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

  • match tests an int, float, bool or string too, 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 let and let ... else take 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.
  • match arms take alternatives (N | S => ...), built once, and guards (Some(n) if n > 10 => ...), which fall through to the next arm when false.
  • @sliced on a loop inside another loop’s body is a warning (WS119): only the outermost loop can be sliced.

2.1.0

  • v.getType() and GetValueType(v) give the built-in enum Type (Type.Int, Type.String, Type.Object, …), so match v.getType() { Int => ... } branches on what an any holds, and inside the Int arm (or an if v.getType() is Type.Int block) v is an int. GetValueType gave an int before; test with is Type.Int instead of == 1.
  • any can be stored: var x: any, any[] and Map<K, any> hold values of every kind, each keeping its own (the game’s Any Value). A map key still cannot be any.
  • GetCustomProperty(...).Value is an any: an annotated let gives 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 any shows the type it has there and the test that narrowed it.
  • is has 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 of on 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; leave visibleTo out to show it to everyone again.

2.0.3

  • arr.join(separator?) joins every element of an array into one string, with separator between them (the Join Array gate, also callable as JoinArray(a, sep?))
  • BrickChanged and BrickRemoved now bind brick (the added, changed or deleted brick) and character (the player who did it): on BrickChanged(zone = z) -> (brick, character) { ... }.

2.0.2

  • Fixed: in the playground, a layout argument that set one Vector2D axis (anchorY = 0.25 alone) 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 if blocks) instead of reporting WSP001.
  • 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 fmt formats .ws files in place (--check lists the unformatted ones), following .editorconfig: indentation, max_line_length, end_of_line and insert_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 statement else if one 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, not EBRReverbEffect), 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] and var 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 fn reading a named constant that stays a gate (a string let interpolated beside a runtime value) was WS113. 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, ShowYesNoBox and ShowTextInputBox, and ShowMessageBox now answers too, each with ClickedBy, OkClicked/YesClicked and Declined.
  • value as T converts with the game’s Convert gate, one gate per value that changes type (WS109 for a conversion it cannot make yet).
  • Entity custom properties: SetCustomProperty, GetCustomProperty, RemoveCustomProperty, CopyCustomProperties, and GetValueType(x).
  • ContainerChange(c) fires when an array or map changes, entity.SetVisible(visible) shows or hides a physics object or dynamic grid, and Environment.Sky.TimeOfDay reads and writes an environment setting.
  • overrideColors on the advanced inventory calls takes a runtime bool.
  • projectileScale on AddInventoryItemAdv and SetInventoryItemAdv scales the size of the weapon’s projectiles.
  • @color(c) and @material(m) set the color and material of a chip’s, a var’s or the root chip’s brick: a constant on the brick itself, a runtime value wired live through a Brick Property Changer; @material takes the new built-in enum BrickMaterial.
  • compile --dump-ir prints the compiled program as .wsir text (--emit-ir <path> writes it to a file), and wirescript assemble builds the same save from it.

Changes

These are breaking:

  • ControllerLeft no longer binds controller; use userId and userName (WS010).
  • A var initializer reads the names around it, as a let’s does, and a file uses only what it declares or imports (WS002, WS036 for x.f()).
  • WS003 for a read returned whole to outputs named partly like its own, a record where one value goes, an annotated buffer’s initializer, and a string method on a non-string.
  • WS090 for a value-returning mod or chip with a path that ends before its outputs are set.
  • WS004 for mk().x + 1 on a *T field of a call’s result (write *mk().x).
  • WS107 for a type or enum named like a builtin type, or Array/Ref with one type parameter.
  • WS028 for constants an int or a gate setting cannot hold.
  • An expression the compiler cannot build is a WSP001 error that ws-ignore does not silence.
  • open is no longer a keyword; open chip is now just chip.

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. An if or match arm that writes, sends or returns runs only when taken (WS097, WS102). Misused emit and return, 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 an if that does not write it.
  • Control flow: an _ arm catches a tag that names no variant instead of being dropped (WS061), a statement after a buffer emit on its chain is a WS058 warning and is not compiled, a back-edge loop inside an if re-arms when its signal is declared in that branch (WS118 otherwise), and an enum or record returned from a mod branch outside a handler is WS007.
  • Names and imports: a mod or chip body 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 a mod binds its parameter instead of being dropped, and a namespaced call takes defaults and named arguments. Importing a declaration brings the names its handler settings and buffer initializers read, self mods 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 in float places (WS104 beyond 2^53), float constants in int places truncate (WS106), and unannotated values take their joined type (WS103, WS105). Forms that compiled to placeholders are errors (WS098-WS101). A nonzero float in a bool array or map saves as true, average() always gives a float, and a const mod call takes parameter defaults and named arguments. x == null and x != null test for an unset object, brick or ref.
  • Math: abs, min, max, clamp and Dampen keep their argument’s type (max(3, 7) is the int 7, a bool reads as an int, vectors, rotators, quats and colors work per component), Remap takes lowercase function = and direction = as Easing does, and f -= STEP steps an int by 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 record var resets every run.
  • Custom events: every receiver of a channel and typed await CustomEvent captures are checked (WS030).
  • Captured events: let x = on <trigger> names any trigger, its config and Union(...) included. A captured event runs its body, applies its trigger’s config and binds outputs with ->, and a captured CustomEvent gets a handler’s checks (WS030).
  • Output: n -= d compiles to a single increment. A Fmt with constant inputs that uses {A}-style inputs, : options or |plural/|ordinal stays a real gate instead of compiling to the wrong text. A number, bool, vector, color or player wired into a string pin, 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 || b with an object operand, which fires once and never again (WS116, use on Union(a, b)), and for a comparison used directly as an operand of &, | or ^ (code & 4 == 4, WS117); any on an in/out port no longer warns WS032, 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/quat components, eventName, and if let, let else and match arms; 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-bench and just editor-scale fail a stage or request that grows faster than linear.

1.11.1

  • // ws-ignore-line:WS014 silences that warning on its own line, or on the line below when the comment stands alone; // ws-ignore-file:WS014 silences 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[] = [] and var 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 refused any[] into int[].
  • wirescript-check reports diagnostics from imported modules. It filtered them to the file named on the command line, so a rejected program printed no errors and exited 0.
  • A program nested more than 400 levels deep is a WSP001 error. Every later pass recurses over the tree, and a stack overflow aborts rather than panicking, so the editor died silently.
  • cargo clippy passes 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 leaves lib’s on handlers 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 enum can 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 let is a WS013, as it already was for a mod/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 an x: int and a["str"] = 1 on an int[] compiled clean.
  • An array literal’s element type is the widening join of its elements, not element 0’s. [1, 2.5] typed int[] and rode the float into an int-variant gate.
  • var c: exec, var c: never, int[][] and Map<int, int[]> are WS025 errors. A storage gate holds one wire variant; the check listed only zone/teleport/prefab/any.
  • Both quote styles read the escape table the syntax reference publishes. The single-quoted reader had no \r or \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 match arm or if let pattern accepts Shape.Circle(r), the spelling construction and is both require. It was a parse error that cascaded through the rest of the match.
  • A multi-output mod’s return is checked per port. -> (a: int, b: string) took return { a: someString, b: someInt } and wired the string into the int gate.
  • A container method given exec = trigger no longer captures the statements after it. The saved exec context was restored past fourteen early returns.
  • on x / on !x over a var compiles, as the statement reference documents. The check matched the var’s symbol type, which is a reference to what it stores.
  • *x in exec context reads the variable, as the expression reference documents. The bare name auto-derefs first, so *x saw a value where it wanted a reference.
  • out y: *int = &x and = ref x bind the same storage as out y: *int = x. Only the expected side of the check unwrapped the reference.
  • "x ${n GARBAGE} y" is a WSP001 error. 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 match arm, an if let or a let ... else counts as used. The import walkers reached if alone, so WS014 called it unused and Organize Imports deleted it.
  • WS053 reaches every branch form. The walker descended into if alone, so the same parked emit/await pair inside an if let or a match arm 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 a Timer fires on its own.
  • A mod returning 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 naming n0.
  • 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, @side rerouters and @label wires 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.5 reads as -1000.5. The negative path skipped the digit-separator strip and swallowed the failure as 0.0, and 1e and 1e999 baked 0.0 and inf in silence.
  • A parse error at the end of a ${...} slot reports where it sits. The Eof token bypassed the offset shift, so a line-6 error landed at 1:6.
  • A match or if let pattern naming no variant of the scrutinee is a WS067 error. It read as an irrefutable binding, so a typo swallowed every later arm and if let dropped the test entirely.
  • var s: string[] = [1, 2] is a WS003. An array element is baked as a constant with no FormatText gate 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 helper with no diagnostic.
  • Two modules that each privately write import * as U is a WS012. 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 own K bound. The closure was keyed on the pre-alias name, so the module’s own x + K bound to whatever the importer called K.
  • arr[i].Value reads the element, as arr[i].value already did. The field list was case-sensitive and omitted the capitalisation the docs teach, so it lowered to an _Unsupported placeholder.
  • 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 someVar binds the variable, as the expression reference documents. A ref in a value position fell to the _Unsupported catch-all and the binding was dropped.
  • A chip’s array, map or ref parameter wires the rerouter’s own port. Its pin was bound as storage, so every read named ArrayVarRef on a MicrochipInput, 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 and out bindings were dropped and the chip emitted no gates.
  • A handler whose trigger cannot resolve is a WS001 and 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].Found reads the map lookup’s found flag, as m[k].bFound already 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 + true bakes 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, as on v.prev already did. That capitalisation dropped the whole handler with no diagnostic.
  • A void builtin used as a value is a WS072, as a no-output mod already was. v = PrintToConsole("x") compiled clean and produced a dead circuit.
  • Two always-live out sites that each carry a value are a WS013. Both drive the port at once, so the value it reads is undefined.
  • GlobalCustomEvent(CH) inside a larger expression bakes its channel when CH is a const, 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, as a.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, as in p: vector and var p: vector already did. The handler check spelled the trigger-type list by hand and one copy omitted vector.
  • 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 the mod an output (-> (r: int)).
  • WS013: two always-live out sites that both carry a value. out o = x + 1 then out o = x + 2. Give the port one value, or write it with emit 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-ignore comment.
  • The playground reports WSP001 placeholder warnings. A program that lowers to a dead gate looked clean and still offered its .brz for 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 WS005 in the playground, as it already was everywhere else.
  • Top-of-file @invisible and @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 compile reserves 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.ws reached 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 match arm 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_at re-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_at re-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 wirescript runs in 4.4s where it took 17.1s.

1.11.0

  • out ports are readable by name, and a top-level on handler can declare its own port: on Clock { @top out flash: bool = Toggle() }.
  • An out written directly in an anonymous chip { } is a module-boundary port too, readable by name from anywhere in the file.
  • An out with no port to bind to is a WS073 error 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>.exec binds a call’s exec output without joining the current chain, so the call runs as a branch while the chain carries on, and emit in 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=" .. n emits 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 a const array 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 *T parameter is invariant, since it aliases the caller’s storage rather than riding a wire: binding one to a differently-typed variable is a WS003.

1.10.1

Fixes

  • A match expression whose arms are records or enum values compiles, choosing per leaf field. The record spelling dropped the statement silently; the enum spelling was a WS071 per arm.
  • let m = if c then a else b / let m = match s { .. } bind a record per field, calls in an arm included.
  • A multi-output result chosen by an if/match picks each port separately. One Select over port 0 meant c.Found read the value and bFound was wired nowhere.
  • A mod/chip with one record-typed output hands back the record itself, as typecheck already reported. Keyed by the output name, r = mk(1) wrote no field.
  • (match s { .. }).field reads that field, distributing over the arms like if does.
  • A mod record argument accepts a container element or a record-valued if/match, as its chip twin already did.
  • An expression with no value read as one is a WS072 error. A no-output mod/chip typed as any, so o = noret(1) wired the caller’s exec continuation in as data.
  • A namespace reached two levels deep resolves. The seal over a namespaced body also hid the alias that module imported for itself.
  • A namespaced let initialized through such an alias carries its type, so reading it is no longer a WS002.
  • An import * as alias is file-local, so an importer and the module it imports may both use the same one. Both tables were keyed by name alone, so the inner namespace was dropped and every reference through it was an unknown identifier.
  • A namespaced record type (Ns.Point) resolves to its fields, so a var or array declared with one decomposes and keeps its element type. It typed as any and loaded empty.
  • Two import * as in one file under the same alias is a WS012; unreported, the second shadowed the first and every reference read whichever module came last.

Editor

  • The LSP watches the workspace’s .ws files, so a module changed or created on disk while closed refreshes the files importing it. Only edits to OPEN documents did, leaving stale diagnostics about a version that was gone.
  • A record-literal key hovers wherever the literal is written, including a var, an array initializer, and a literal nested in another; only let x: T = { resolved before.

1.10.0

  • value is Enum.Variant tests which variant an enum value holds, the shorthand for comparing discriminants. It binds like ==, and a test whose two sides are both constant folds away.

Fixes

  • on Clock takes only interval and enabled. pulseOn, onTime and offTime are inert fields of the gate’s data struct, so passing them baked config the gate never reads; naming one is now a WS041 error.

1.9.0

  • Const-heavy programs compile about a third faster. Lowering deep-copied the whole constant table on every expression it lowered.
  • A program with many enum variants and match arms compiles dramatically faster. Exhaustiveness checking and match lowering were each cubic in the arm count.
  • Large programs compile 10% to 15% faster, from fewer allocations in the fold and CSE passes and faster hashing in emit and layout.
  • A source prefab referenced from several sites is compiled once and embedded once, so a program that spawns the same $./file.ws repeatedly produces a much smaller bundle.
  • Deeply nested chips, deeply nested record types, and files with many imports no longer slow down faster than they grow.

brdb

  • Updated to brdb 0.11.0, which writes a save roughly three times faster.
  • Each schema struct compiles into a write plan once and replays per instance, instead of re-resolving every field’s type for every component.
  • Brick and wire chunks use specialized encoders when the loaded schema matches the expected shape, and fall back to the generic writer when it does not.
  • Archive blobs are compressed in parallel.

Fixes

  • A union-trigger handler (on a | b { emit r = v }) compiles. Its body lowers once per part, so one emit became two drivers on the output and failed at emit with no earlier diagnostic.

1.8.1

  • Refreshed the baked gate inventory from the current game build: the string gates carry their clearer display names (Contains is now String Contains, Format Text is now Format String, Length is now Get String Length). No gates, ports, or behavior changed.
  • Added an external-asset catalog (data/asset_inventory.simple.json) covering 832 assets across 8 descriptor types, generated by scripts/gen_assets.mjs from the in-game dump. Weapon one-shot audio is renamed BOSA_* to OSA_*, the smoke grenade is gone, and aircraft wheel-engine audio plus an electrical-arc particle system are new.

Fixes

  • Reading a reference-typed record field yields its value, like reading a var. var f: float = state.a on a *T parameter reported expected float, got *float, and an if-then-else over two such fields reported a branch mismatch.
  • rec.field.Value reads the field’s backing variable. Only x.Value on a plain identifier resolved, so the field spelling lowered to a placeholder and left its consumer unwired.
  • A record literal passed to a *T parameter is a WS008 error when a reference field has no variable behind it. An in port or expression has no storage to write back to, and the call previously compiled with the caller wiring a value where the body read a reference.
  • A chip with a *T parameter binds each call site to its own argument. Every call after the first reused the first instance’s captured variables, so writes through the reference landed on the first argument and the later arguments’ variables were left unwired.
  • &x and ref x bind a chip’s reference, array, record, or map parameter to the caller’s storage. Only the mod form stripped the sigil, so the chip form left the parameter’s pin unfed and silently dropped every write through it.

1.8.0

  • *T on a record, tuple, or enum parameter distributes over its fields, so a chip or mod can write through to the caller’s storage (chip M(s: *T) { s.a = x }). None of these has a single wire to reference, so the ref now names each backing gate: a record’s fields, a tuple’s elements, or an enum’s discriminant and payload slots.
  • Tuple variables get one backing gate per element, so g.0 = v writes. A tuple var collapsed to a single gate and element writes emitted nothing.

Fixes

  • A field read directly on a multi-output chip call (Chip(x).field) wires the named output. Only the two-step let r = Chip(x) / r.field spelling resolved it; the inline form compiled to a placeholder wired to nothing.
  • A record used where one value is expected is a WS071 error instead of a silent placeholder. A record is several wires, so "x=" .. rec had no value to concatenate and dropped the operand.

1.7.4

  • unsafe <value>.<Variant>.<field> reads or writes one enum payload slot directly, without testing the tag. Reading a variant the value is not returns stale contents, and writing leaves the tag alone. Contextual keyword, so the name stays usable.

Fixes

  • A declaration inside a chip { } nested in a handler initializes on that handler’s chain when its initializer calls something. It was held off the chain like a top-level chip’s, which dropped the call and then reported a missing exec context.

1.7.3

Fixes

  • Assigning a match / if let / let else capture writes the matched enum’s payload slot in place, when the scrutinee is storage. A container element is read by value, so its captures stay read-only.
  • Assigning a field with no storage behind it (an enum payload field, a misspelled record field, a field of a scalar) is a WS007 error instead of compiling to nothing.
  • An enum payload field that needs container storage is a WS069 error, at the declaration or at a generic instantiation that picks one (Option<int[]>). The slot could never be filled, so the value read back empty.

1.7.2

Fixes

  • A record or enum value spans several wires, and every position that collapsed one to a single wire is fixed. Most of these compiled clean and emitted nothing at all.
    • Producing one: a record-valued if/then/else selects per leaf, in assignment, out and chip-argument position alike; a record-returning call used as a nested record-literal field writes every leaf, where the array and map forms had left the columns at different lengths; an enum payload that is itself a record or another enum is constructed field by field; a declaration initializer bakes a record payload instead of zeroing it.
    • Storing one: an enum-element array or map decomposes into parallel columns, one per tag and payload slot, so pushes and element reads carry the value; an enum-typed record field gets its own tag and payload storage rather than collapsing to one gate.
    • Crossing a boundary: a nested record in/out port creates a pin per leaf instead of stopping one level down; a record-typed chip signature output gets one pin per field, so the body emits and the caller’s pins are wired; &p on a record variable passed to a *P parameter binds the caller’s record instead of dropping the whole body.
    • Copying one: a whole-record copy carries array and map fields instead of skipping them.
    • Matching on one: a match or if let accepts a container element (match m[k], match arr[i]) or a .Value projection off a map get as its scrutinee. Only a named binding of the same read resolved before, so the inline form hit a placeholder.
    • Referencing one: &p.a on a record field is accepted, since each field is its own storage gate. It lowered correctly and was rejected by WS008 anyway.
    • A record or enum as a custom-event data param is now WS068, since a data slot is one wire and cannot carry one.
  • A scalar initializer is baked in the declared type, so var x: float = 0 builds a float Variable gate instead of an integer one. Applies to every bool/int/float pair, to record and enum-payload fields, and to a var-backed output’s default (out y: float = 0).
  • A component read straight off a call (d.ToRotation().ToEuler().Yaw, v.SplitVec().y) reads that call’s own port; it used to emit a second Split gate fed by the first one’s primary output, yielding the wrong component.
  • A local exec signal consumed inside a chip compiles. The union cleanup redirected and pruned using one chip’s wires at a time, deleting a node the chip’s own wire still named, which failed the compile with a dropped-wire error.
  • .Value / .prev opens a variable that spans several storage gates (a record, an enum); it used to emit a placeholder.
  • A var initializer naming a constant (var x: float = K) bakes that constant.

1.7.1

  • enum is a contextual keyword: it opens a declaration only when a type name follows, so it stays usable as a variable, parameter, or field name.
  • A rotator’s .pitch/.yaw/.roll and a quat’s .x/.y/.z/.w now read their components, lowering to a Split gate the way a vector’s .x already did; several components of one value share a single gate.
  • Each language-reference page opens with a Contents list of its sections, regenerated by just doc-toc and gated by just doc-check.

Fixes

  • A constant Rotation(...) passed to a quaternion port (v.Rotate(Rotation(0, -90, 0))) is converted and baked into the gate, instead of panicking the compiler.

1.7.0

  • enum types (tagged unions): enum Shape { Empty, Circle(float), Box { w: float, h: float } } supports C-like members, positional and named payloads, explicit = N discriminants (later members auto-number from there; a collision is WS064), and generics (enum Box<T> { Value(T), Empty }). Enums are nominal, so two identically-shaped ones are different types. .Discriminant reads a value’s tag as an int, and on a variant path (Shape.Circle.Discriminant) it folds to a compile-time constant.
  • match branches on a variant and binds its payload, both as an expression (comma-separated value arms, lowering to a Select tree) and as a statement (block arms, lowering to Branch/Union). Coverage is checked: an uncovered variant is WS054 and names the missing patterns, an arm that can never run is WS061, and patterns nest into payloads including other enums. A wrong bracket form for a variant’s shape is WS065, and .Discriminant or match on a non-enum is WS066.
  • if let and let else are single-variant refutable binds: if let Some(x) = o { ... } else { ... } runs the block only for that variant, and let Some(x) = o else { return } binds into the surrounding scope with a required diverging else (WS062).
  • Built-in Option<T> and Result<T, E> prelude enums, with their variants usable bare (Some/None/Ok/Err); a payload-less bare variant needs a type annotation to pin its parameters (WS063).
  • Built-in game enums for the game’s own config enums (easing function and direction, brick direction, color space, text justification and typeface): they need no declaration, .Discriminant gives their real integer value, and a variant such as EasingFunction.Bounce passes directly as the matching gate config argument alongside the older bare-name form.
  • Enum and int conversion: value.ToInt() is an alias for .Discriminant, and Enum.FromInt(n) builds a value from a (possibly runtime) int tag with payloads defaulted to zero. The EnumToInt / IntToEnum builtins (renamed from EnumToInteger / IntegerToEnum) are the gate-backed twins: they now require an enum-typed value instead of accepting any, fold a compile-time-known value to its tag, and use the game gate at runtime. IntToEnum’s result enum type is pinned by the target (WS063 when it can’t be).
  • Editor support for enums: completion of variants, enum type names, and .Discriminant; a fill-missing-arms code action; and hover and go-to-definition on enum types, variants, and named payload fields.

Fixes

  • Calling a mod that reads or writes a container in its body (m.get(k), arr[i], m.set(...)) from a pure position now reports WS007, instead of silently wiring the container reference into the caller. This now also covers a container reached through the mod’s own parameter (mod f(m: Map<int, int>) { m.get(0) }) or a body-local, not just a top-level one.
  • A multi-return mod whose single output is a record now yields the branch actually taken at runtime; previously one return’s record literal leaked to the caller and folded the result to a fixed, often wrong, value.
  • A reference inside an emit, await, or buffer statement now counts as a use, so a genuinely-used import is no longer falsely reported unused (WS014) or dropped by Organize Imports.

1.6.2

  • Raise microchips so nested ones are not underground

Fixes

  • Reading a namespaced out or in member (L.count, L.level) now resolves to the port’s value instead of reporting “not found in namespace” and lowering to a placeholder.
  • A namespaced defaulted output with no emit (out count = counter) is a single direct drive again, instead of also getting a backing variable that fanned two wires into its port (which failed to load).
  • Indexing a record-array or record-map column (pts.x[i], m.x[k]) now reads the field’s value instead of typing any and dropping to a placeholder; it works in expressions (a comparison, arithmetic), not just a bare let.

1.6.1

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

Fixes

  • An inline record-returning mod call read by field (f(x).sum) now projects the field instead of an _Unsupported placeholder.
  • A record-typed module output (out p: Point = { .. }) now dissolves into per-field boundary pins instead of dangling.
  • A whole-array assignment from a record-array literal (arr = [rec, rec]) now rebuilds the array instead of being silently dropped.
  • A constant read through an import * as ns namespace (ns.NAME) now folds, so it works in constant-only positions (array initializer, channel name, label).
  • Spreading a tuple that came from a multi-output mod call (f(n, ...g()), or a bound let t = g() then f(n, ...t)) now expands its elements instead of dropping them.
  • A field or index access on an aggregate-typed if expression ((if c then a else b).x, (if c then a else b)[i]) now distributes over the branches instead of dropping to a placeholder.
  • A nested record passed as a chip parameter now wires every field, instead of collapsing the inner record to one unwired input.
  • A tuple parameter on a chip (chip f((a, b): (int, int)) { }) now binds its elements, instead of leaving the body reading unresolved values.
  • LSP: hovering a record field written through an array index (arr[i].field = v) now shows the field’s type.
  • An on handler in an import * as module no longer resolves a free name against the importing file’s state; an undefined name there is now reported instead of silently wiring to a same-named importer variable.
  • State imported from one file both plainly and through import * as (or via import { g } plus import { g as h }) now shares one storage gate instead of duplicating into two that drift apart.
  • A namespace a module imports privately (import * as B) no longer leaks to files that import that module; naming it from another file is an undefined identifier.
  • A namespaced output written by more than one emit, or by a conditional or defaulted one, now routes through a backing variable instead of fanning two wires into its port (which failed to load).

1.6.0

  • A ...tuple spread now expands into a call’s positional arguments. SendGlobalCustomEvent(name, ...t) splats a tuple across the event’s data slots, and f(...t) / f(a, ...rest) binds each element to consecutive parameters (a tuple literal ...(a, b), a bound tuple, or a field chain reaching one). Over-filling a fixed-arity callee reports the usual arity error, and spreading a non-tuple is a WS003.
  • A mod can take a trailing ...rest variadic parameter that captures every argument past its fixed params. Each call site binds rest to a compile-time tuple of the extra args, and a ...rest in the body splats them onward, so mod broadcast(name: const string, ...rest) { SendGlobalCustomEvent(name, ...rest) } forwards any number of values. A call must still supply the fixed params (fewer is WS022). Only mods may be variadic; a ...rest on a chip is WS052.
  • await CustomEvent("c") can now capture the event’s data inline: let n: int = await CustomEvent("c") suspends until the event fires and binds the first data output (typed by the annotation). A tuple let (p, t) = ... captures data outputs positionally. Annotate the type or the wire defaults to a float (WS055 warns when it can’t be determined). Bare await CustomEvent("c") (no binding) just waits.
  • A tuple return’s parentheses are optional: return a, b, c returns the same tuple as return (a, b, c), and .0/.1 access the elements.

Fixes

  • Compile progress now accounts for embedded prefabs: the reported step total grows by one per $./file reference and inline $```...``` block, so the editor indicator advances through them as it compiles.
  • The editor’s compile status indicator shows only while a compile is actually running.
  • SpawnPrefab(..., destroyAll = sig) fed a local let sig: exec signal wires the signal into the spawner’s destroy pin, so those prefabs are destroyed (a local exec let folds to a placeholder 0, so it needs this dedicated wiring).
  • wirescript-check runs through lowering, so it surfaces the same _Unsupported-placeholder warnings compile emits.
  • New WS053: a plain emit X in the same chain as a following await X warns that the kick fires before the await arms (parking the chain forever); use buffer emit X.
  • A record passed to a chip wires its fields: a record literal argument (Foo({ x: a, y: 2 })), a field whose value is a var (read by value, not through the reference port), and a destructured chip parameter (chip f({ x, y }: P)) all bind correctly.
  • Assigning a field of a let, input-port, or literal record (p.x = 5 where p is not a var) reports WS007. A var-backed record field stays assignable.
  • == and != between two whole record values report WS004. Comparing a multi-output result to a scalar (arr.pop() == 5) still works.
  • A ...rest parameter in a destructured mod parameter types as the record of the remaining fields, and a tuple-typed field inside a record annotation (type T = { pair: (int, int) }) type-checks correctly.
  • A record literal used as a spread source ({ ...{ ...p, y: 2 }, x: 9 }) or read directly (({ x: 1, y: 2 }).x) resolves its fields.
  • A nested record inside a record array or map decomposes to leaf columns like a flat field: arr[i].inner.a / m[k].inner.a read and write a single leaf, arr[i].inner / m[k].inner read and write the whole sub-record, and let row = arr[i] binds the element as a record value.
  • Namespaced member access is stricter. Reading a missing or non-value member (L.nope, or a namespaced out port) and a nested path (A.B.bar()) report WS002; a named-argument call through a namespace reports WS022 when the positional count is wrong. A namespaced container read (S.scores.get(k)), &S.g, and an import used only as a handler trigger type-check cleanly.
  • An if expression written directly inside a string interpolation ("${if n < 10 then "0" else ""}${n}") emits its comparison and select.
  • Passing a non-lvalue (a literal, expression, let, arr[i], or call result) to a *T/ref parameter reports WS008.
  • A statement written after a nested on handler inside a handler body stays on the outer exec chain rather than binding to the nested handler’s trigger.
  • New WS057: emit X is an error when X is not an out port or a let ...: exec signal (an input port, a var, or an out/signal declared outside an enclosing named chip).
  • New WS056: let v = await sig on a signal that carries no payload is an error. Emit a value (emit sig = ...) or capture one with await <expr> on sig.
  • New WS058 (warning): an exec statement that never runs is flagged
  • A tuple return (a, b) to a mod with named multi-outputs wires each element to the matching output in declaration order, so let (a, b) = f() reads the returned values.
  • Three constructs that type-checked but built no working circuit now report an error instead of compiling to a silent miscompile: a component field that isn’t on its type (v.r on a vector, color.x) reports WS010 rather than emitting a wrong-typed SplitColor/SplitVector; a negated-union or double-negation handler trigger (on !(a | b), on !!x) reports WS001 rather than dropping the whole handler; and assigning to a non-lvalue (f() = 5) reports WS007 rather than dropping the assignment.
  • Emit rejects a wire fan-in (two sources driving one input port, such as a duplicate out o) with a compile error, rather than writing a format-valid save the game refuses to load.
  • Unary negate (-x) resolves correctly during generic-mod monomorphization, so a generic -x emits a negate gate with the right numeric type.
  • A constant Substring with a very large length clamps to the string end instead of overflowing into a panic.
  • New WS059: Change/Changed (and the Edge detectors) on a reference or container (a Map, an array, a *T ref, a zone, or a teleport) is an error, since a change/edge detector watches a single wire value and those carry none. Watch a scalar the container produces instead.
  • A map access gate (m.get(k) and friends) is colored by its value type, like the var and array access gates, rather than a neutral grey.
  • A get/set on a reference-passed container input (in m: Map<K,V> / in xs: T[]) is tagged with the input port’s name, matching how an access on a stored var is tagged.

1.5.0

  • Added a null literal. It adopts its target type and produces that type’s zero/unset value (entity/character/controller -> an unset object, a number -> 0, bool -> false, string -> "", vector/rotation/color -> zero) in any typed position: a var/out initializer, an assignment, a call argument, or a record field. null for a container, record, or reference-only type has no value and reports WS051; a bare let x = null with no target types as any.
  • Records can now be stored. A record variable, array, or map (var p: Point, var pts: Point[], Map<K, Point>) decomposes into one backing gate per field, so field reads/writes, whole-record assignment (p = { x, y }, p = q), and the container methods all lower to real gates: arrays get push/pop/insert/remove/fill/resize/swap/reverse/clear/length plus element access (pts[i], pts[i].x, pts[i] = rec, p = pts[i]); maps get set/get/has/remove/clear/length/keys plus m[k] access. A record variable used to collapse to a single gate whose fields read a bogus vector swizzle, and a record pushed into an array lowered to a placeholder that did nothing.
  • A stored record with a field that has no storable value (a ref, zone, teleport, prefab reference, or exec) now reports WS049 instead of silently backing it with an unusable gate.
  • A record-container method with no per-field meaning (sort/shuffle, the aggregates sum/min/max/average, find, and the dual-container append/copyFrom/slice/values) now reports WS050 instead of silently lowering to a no-op placeholder.
  • A constant record-array/map initializer (var pts: Point[] = [{ x: 1, y: 2 }]) now bakes each field’s column into its backing container; it used to compile clean but silently start empty. A record array’s per-field arrays are also reachable directly as pts.field (struct-of-arrays access) — index, read, and aggregate a single column (pts.x[i], pts.x.sum(), pts.x.min()). pts.field.sort(descending?) sorts the whole record by that field, keeping rows intact.

1.4.5

  • An operator or sibling call inside a namespaced module’s on handler (import * as L from "lib" where lib has on ReadBrickGrid() { arr.push(n << 10) }) is now type-checked, so it lowers to a real gate instead of _Unsupported. Namespaced handlers began lowering in 1.4.4 but typecheck never descended into their bodies, so operators got no resolution.
  • --dump-ir node locations now include the source file (@ lib.ws:3:1) and render each node’s snippet from that file. Imported nodes used to show the entry file’s text at their offset and an ambiguous @ line:col; the IR ranges themselves were already correct, only the dump’s preview read the wrong source.

1.4.4

  • A module imported as a namespace (import * as L from "lib") now runs its top-level on handlers.
  • Two import * as namespaces (or a local declaration plus an imported one) that share a state name now get distinct storage gates.
  • A var/array/map/buffer declared inside a handler, if, or block now gets its own storage gate instead of silently reusing an outer same-named one.
  • &x passed to a *T/ref parameter now binds the caller’s var, so writes through it land.
  • An or-triggered handler (on a | b { ... }) now runs its body when either trigger fires instead of silently dropping the whole handler.
  • Two different modules declaring the same top-level name, merged via import "m" or import { x }, now report WS013 instead of silently collapsing both onto one storage gate.
  • A container mutation (arr.push(x), m.set(k, v)) outside an exec context now reports WS007 instead of silently lowering to a placeholder that does nothing.
  • A parse error in an imported file now surfaces instead of being silently swallowed; the identical source only errored when compiled as the entry file.
  • An anonymous chip (chip { … } / chip on t { … }) in an imported module now runs; it was filtered out before the merge and silently dropped along with its writes.
  • A single emit out = <expr> inside an if is now gated by the branch instead of wired unconditionally to the output; a guarded write no longer becomes permanent.
  • A let that shadows an in/out port of the same name now reports WS013 instead of silently hijacking it (let go before on go, with in go: exec, left the exec input dead). Ordinary value shadowing (let a = 1; let a = 2) is unaffected.
  • An output reached by an emit plus a default initializer (out r = 0 then emit r = …), or by emits split across a handler and an anonymous chip { … }, is now var-backed instead of driven by two wires - a load-breaking fan-in on the output rerouter.
  • A let aliasing an input-port array or map (in a: int[] then let x = a) now resolves for x[i] and container methods, like a var alias; it used to lower the index to a placeholder.
  • A namespaced mod’s body now resolves its OWN module’s siblings, vars, and constants. Two imported modules with a same-named private helper no longer make one module’s public functions run the other’s code, and a namespaced mod mutating g now writes its own module’s storage gate instead of the last-imported namespace’s.
  • A container method READ outside an exec context (out r = arr.length()) now reports WS007 instead of silently lowering to a placeholder, matching the pure index-read rule. A const receiver or an explicit exec = <trigger> arg is exempt.
  • A captured handler with a non-Event trigger (let e = on go { … } where go is an exec input, var, or let) now runs its body and captures its exit as e; the body used to be dropped entirely unless the trigger was a built-in event.

1.4.3

  • An imported let no longer clobbers a same-named declaration in the importing file. An imported let start overwrote the file’s own in start: exec, so on start bound the imported value instead of the input and silently dropped the handler body.
  • Void container operations (push/clear/set, the keys/values fills) now type as never, so using their result as a value (let r = a.push(x)) is a type error instead of silently accepted.
  • Top-level on handlers in an imported file now run; they were silently dropped, and an on <expr> handler additionally left a dangling trigger gate with no body.
  • A let aliasing an array or map (let ar = a) now resolves for indexing, writes, and methods (ar[0], ar[0] = x, ar.push(...)) instead of lowering to a placeholder or silently dropping the write.
  • A namespaced member (ns.name) whose bare name the importing file also owns is now reachable as ns.name, so ns.tuple.0 resolves instead of falling through to the local binding.

1.4.2

  • Folding enabled by default now that it’s stable
  • Inline nested-prefab blocks now compile in the browser build. An inline nested-prefab block passed to SpawnPrefab (the $-fenced source form) is compiled to its own prefab and embedded, matching the native CLI. The browser previously rejected any inline nested block with “no nested compiler configured for this compile”; dragged-in $./file.brz prefab references already worked. Blocks nested past a fixed depth fail with a clear error instead of hanging.
  • An import read only in the config of an event handler inside a chip body is no longer reported as unused. The 1.4.1 fix covered handlers at module level but not the statement path a chip body takes, so on CustomEvent(CH) inside a chip warned WS014 while the identical handler outside one did not, and Organize Imports would then delete the import and leave the handler naming nothing.

1.4.1

  • A record assigned to a mod output (mod f() -> (o: Rec) { out o = rec }) now reaches the caller. A record has no single value port, so the output silently carried a placeholder that read a default, and everything downstream of it did too, including a record result passed straight to another mod (Take(Make())). The warning only appeared when the result went unused, so the broken cases were the quiet ones.
  • An imported module’s root-level in and out ports are now declared and reachable as ns.name. They were dropped entirely, so on ns.trigger { ... } matched nothing and silently discarded the whole handler body while both stages reported the file clean. Root-level var, array, map and buffer members already worked.
  • An import read only in an event handler’s config (on Clock(interval = TICK)) is no longer reported as unused. The scan walked the handler body but not its config args, so a constant used only to configure the gate warned WS014, and Organize Imports would then delete it.
  • Two import * as namespaces that export the same member name now stay distinct. A.foo and B.foo had both resolved to whichever module was imported last, so a field access on the other read the wrong value and lowered to a placeholder while type-checking reported the file clean.

1.4.0

  • const compile-time evaluation: const bindings, const parameters (f(name: const string, v: int)) and const mod declarations are evaluated at compile time and can be used anywhere a literal is required, such as gate config and custom-event channel names. Const expressions compose freely (operators, constructor arguments, destructuring, indexing, collection assembly) and emit no gates. An if on a const condition drops its untaken branch, and a const that fails to be compile-time is reported at the binding.
  • @layout("cube") no longer emits per-gate name labels, var tags, or chip-brick labels. Plane headers, the shell label, and a runtime @label(expr) are kept.
  • GetInputs: samples a player’s twelve controls once when the exec chain reaches it, the exec-form counterpart of InputReader. Same field names, so char.GetInputs().PressedQ reads like the splitter, and its operand also accepts a persistent player.
  • An exec builtin called with receiver syntax in pure position now reports WS007. The check only covered the plain spelling, so GetLocation(e) was an error while e.GetLocation() compiled to a placeholder that read a default.
  • A local binding that shadows an import * as ns alias now reports WS002 at the call. A parameter or let named ns made every ns.f(...) in that scope resolve against the local value, which has no such member, so the call typed as any and compiled to a placeholder that did nothing while type-checking reported the file clean. The failure then surfaced wherever the any was finally consumed, on a line that was not the mistake.
  • LSP: Hover gate estimates now refresh as you type, so a mod added or renamed since the last save shows a count instead of none.
  • LSP: Hovering a namespaced call (ns.f()) now shows its gate estimate.

1.3.0

  • An array/map method on a record field (g.ready.sum()) now types its result instead of any, so arithmetic on it works. Args are checked through the chain too.
  • A type alias whose body names another alias (type Rect = { a: Point, ... }) now expands all the way down when imported, so ns.rect.a.x keeps its type instead of reading as any.
  • Namespace members (import * as ns) written without an annotation, or bound by a destructuring let, now carry their record type across the import too.
  • Compiler source reorganised

1.2.0

  • map[key] subscript syntax - m[k] and m[k] = v now work on a Map<K, V>, desugaring to the same get/set the m.get(k) / m.set(k, v) methods use (m[k] reads the value, auto-unwrapping the found flag; m[k] = v writes it). The read types as the value type V and, like array indexing, only works in an exec context. Previously this parsed and type-checked but silently did nothing (the read produced 0, the write was dropped).
  • Fill record fields (editor code action) - inside a record literal whose expected type is a record (let x: Card = { … }), the lightbulb / Ctrl+. offers Fill record fields, inserting every missing field with a type-appropriate default and recursing into nested records — e.g. { foo: "", bar: { baz: 0 } }. Present fields are kept, so a partial literal completes; nested, aliased, and imported record types are resolved server-side.

Fixes

  • Output values (return, emit out = …, statement-level out name = …) are now checked against the declared output type (WS003) instead of baking a mismatch into the wire.
  • Conflicting generic-builtin arguments (Select(c, 5, "hello"), Swap) now error (WS033) instead of widening to any.
  • let t: (A, B) = … and let x: R = { … } now check the value against the annotation (WS003), not just field names.
  • A multi-output result used as a scalar unwraps only through its first field — a later-field match wired the wrong port (now WS003).
  • A tuple literal no longer collapses to a scalar (let x: int = (1, "abc") is WS003).
  • A heterogeneous array literal in a handler (xs = [1, "hello", 2]) reports WS003 on the odd element.
  • An any[] / Map<any, …> parameter now accepts a concrete array/map argument.
  • == / != on two vector/rotator/quat/color values is now accepted (ordering stays scalar-only).
  • Assigning to a scalar let reports WS007 instead of silently emitting no gate.
  • An unknown named argument on a mod/chip call reports WS041 instead of being dropped.
  • A typo’d event config/input name (on Clock(intreval = …)) reports WS041 instead of no-opping.
  • An out-of-range integer literal reports a parse error instead of compiling to 0.
  • A captured event inside a handler (let x = on Clock(1.0) { … }) reports one clear “top level only” error instead of misleading tail-parse errors.
  • A var read after await re-reads fresh, so a value changed during the wait is visible.
  • ns.myValue on an import * as ns now reads the real value and type — it typed as any and compiled to a placeholder reading 0, while only ns.f(...) calls worked.
  • A chip that writes a global no longer leaves a stale read after the call; the next read is fresh. (Inline mods were already correct.)
  • A chip called in two contexts (pure-then-exec, captured-vs-passed arg, or same name in two modules) compiles a separate body per context instead of reusing the first’s mis-wired one.
  • A chip with multiple returns, and an output emitted from multiple sites, each route through one holder variable instead of fanning two wires into one pin (a load failure).
  • A chip … -> (sig: exec) that does emit sig now wires the emit to the output (was silently dropped).
  • A wire whose endpoint can’t be resolved is now a compile error, not a silently dropped wire in a shipped save.
  • The formatter no longer splits a :kebab-case atom literal at its hyphen.
  • A field access on a scalar (x.whatever on an int) reports WS010 instead of silently reading the whole value. Projecting a single-output result by its output name (f.result) still works; a mis-typed one is caught.

Performance

  • Repeated calculations build one gate and share it. Write x + 1 in three outputs, or call the same mod twice, and you get one gate instead of a copy per use. (State-holding and @nofold gates are left alone.)
  • A variable read is reused across an if when the branch doesn’t touch it, instead of being re-read afterward. A variable a branch writes still re-reads fresh, so it’s never stale.
  • The editor keeps up better while you type — ~20% less work per keystroke, and no longer multiplied by your open tab count. It parses the file once instead of twice, leaves hover’s gate-count estimates to open/save, and re-analyzes only the open files that import what you changed.
  • Imports resolve against unsaved edits. A file importing something you’re editing was analyzed against the last saved bytes on disk until you hit save.

1.1.1

  • Scope-aware rename & find-references - rename/textDocument/references now resolve the identifier under the cursor to its exact binding instead of matching the name as text, so they never touch comments, strings, a same-named type, another scope’s binding, or an unrelated file; renaming an exported symbol updates its importers (and a local alias stays local). (This replaced the old textual scan.)
  • Semantic type highlighting - the LSP emits semantic tokens so a name in type position highlights as a type (including user type aliases the grammar’s builtin list can’t know), while every value identifier is highlighted uniformly - so a binding whose name collides with a type keyword (a character capture) or a builtin function (a value named round) reads as a plain identifier, not a type or a call. Genuine builtin calls are unaffected. Atom literals (:name, kebab-case allowed like :kebab-casea, including a :name: map key) highlight as the integer constants they compile to; hovering an atom shows the compile-time xxHash64 value it resolves to, and find-references gathers every use of that atom across the workspace.
  • .ws source prefabs - a prefab reference may now point at a .ws source file, SpawnPrefab(prefab = $./control.ws, …), which compiles that file into a prefab and embeds it on the spot - the file form of an inline $```…``` block. Errors in the referenced file surface on the $./control.ws reference itself, so a broken prefab underlines where it’s used. .brz archives work as before; any other extension is WS019.
  • Fixed: a no-receiver gate called with a receiver - x.SweepSimple(…) / x.Sweep(…) (the sweep gates act on their own brick and take no receiver) now reports a clear WS036 error instead of silently type-checking as any and lowering to a do-nothing placeholder.
  • Fixed: @fold dropping a runtime @label - a runtime @label(<expr>) (on a var or, blank-line-separated, on the root microchip) is wired into its text at emit time, so a folded module’s dead-code sweep no longer prunes the label’s source as “unused” and silently drops the label.

1.1.0

  • Events as expressions - an event called as an expression emits its gate and yields its exec, so an event composes in expressions like Union(RoundStart(), other). It takes the same inputs as the on form (Clock(interval = 2.0)), and a data-carrying event exposes its outputs by field access (CharacterSpawned().character); a bare call auto-unwraps to the exec. The on E { ... } trigger form is unchanged.
  • LSP: named-argument values complete in-scope identifiers - typing the value of a named argument (itemName = <here>) now offers the in-scope identifiers, not the call’s other argument names (those are only offered when completing an argument name). Enum/asset value slots still complete their specific members.
  • Event output capture (on ... -> ...) - an event handler binds its data outputs in a trailing capture instead of inside the event call: on Foo() -> (a, b) (tuple, positional - the cleanest form, for any event) or on Foo() -> { field: local } (record, by field name - named events, for subset/rename). on <call> -> ... also triggers on any exec-producing call - a mod/chip call, or a gate driven by on Foo(exec = x) -> (...) - auto-extracting the call’s exec output; a general call that exposes no exec (or an exec = with nowhere to attach) is a WS043 error. A single untyped output may drop the parens: -> who is shorthand for -> (who).
  • Custom-event data-type inference - an unannotated custom-event receiver slot takes its type from the matching in-unit SendCustomEvent on that channel; when none is inferable the slot defaults to float and warns WS042 (which replaces the old WS029 “annotate this param” lint). A written type goes in the tuple capture: on CustomEvent("ch") -> (x: int).
  • Whole-grid interaction events - on WholeGridInteracted() -> (character, held) fires when the grid is interacted with; on WholeGridTargeted() -> (character, damage, weapon, weaponName) fires when it is hit.
  • Maps work wherever arrays do - a Map<K, V> can now be a mod/chip parameter, an in/out port, or a record field (not just a file-scope var). A container method whose receiver isn’t a container is WS044, not a silent no-op.
  • Generic value builtins - Select, Swap, Sleep, SleepTicks, and Tween carry their argument’s type instead of any, so Select(c, 1, 2) is an int.
  • Tuple arguments match tuple parameters - passing a tuple value or literal to a (A, B)-typed parameter type-checks.
  • Ref-insensitive record fields - a record value matches a record type when fields differ only in ref/array exposure (a *T field and a plain T are interchangeable at a call boundary, as parameters already are).
  • Tighter builtin argument types - DisplayText/PrintToConsole/Fmt/SetTag take string, SetLeaderboard/IncLeaderboard take int, and SpawnPrefab takes a prefab reference.
  • Fixed: exec after an emitting mod - a statement following a mod whose body ends in emit no longer drops to an unsupported placeholder.

Migrating to 1.1.0

The on handler form now binds event outputs with ->, and no longer accepts data params (or their types) inside the event call.

  • Move data outputs to -> - on CharacterDied(character, killer) { ... } becomes on CharacterDied() -> (character, killer) { ... }. Config and inputs stay inside the parens: on ChatCommand("greet", "help") -> (controller, args), on ZoneEntered(zone = z) -> (character). Config-only handlers are unchanged (on Clock(interval = 2.0) { ... }, on RoundStart() { ... }).
  • Custom-event types move to the capture - on CustomEvent("dmg", amount: int) { ... } becomes on CustomEvent("dmg") -> (amount: int) { ... }; an omitted type is inferred from the sender, or warns WS042.
  • Event triggers are calls - an event trigger is written with (): on RoundStart(), not on RoundStart. The no-parens form - a handler head (on RoundStart { }) or a captured-event alias (let x = on RoundStart) - is now an error. The () marks the event as a gate/call, uniform with the on <call> -> (...) model.
  • WS029 is removed - the “annotate your custom-event param” lint is replaced by inference plus WS042.

1.0.0

  • Maps (Map<K, V>) - a keyed variable collection paralleling arrays: var scores: Map<string, int>, keyed by int/string/object reference and holding any wire-storable value, with exec-context methods set/get/has/remove/clear/copyFrom/length/keys/values (get gives { Value, Found }, auto-unwrapping to Value). A non-int/string/object key type is a WS039 error.
  • Map literals - { k => v } keys by any expression, "s": v / :atom: v by a string/atom/int literal, and [expr] => v by a computed key; a fully-constant literal bakes the map pre-populated at rest, {} is an empty map, and m = { ... } in a handler desugars to clear() plus one set per entry in source order.
  • Atom literals (:name) - a compile-time int constant (the deterministic xxHash64 hash of the name), a readable stand-in for a magic number as an int-map key or enum-like tag; it only ever resolves at compile time, never from a runtime string.
  • Generic type syntax - Array<V> and Ref<V> are exact aliases of V[] and *V.
  • Gate config properties - a gate’s non-wire settings-menu fields (checkboxes, dropdowns, values) are now settable as optional, constant-only call args, both by friendly alias and by raw game name (SweepSimple(Direction = X_Negative, ...), p.DisplayText("hi", typeface = Bold)); enum args take bare member names validated against the game’s enum list, and an unknown name or non-constant value is a WS028 error.
  • Custom events - SendCustomEvent(name, data...) pulses every on CustomEvent("name", a: int, b: float, ...) receiver on that channel with up to 8 typed data values; a receiver fires the tick after the send, and an untyped receiver param warns (WS029).
  • Custom-event signature check and navigation - a SendCustomEvent on a constant channel whose data types disagree with the matching receiver’s declared params warns (WS030), and go-to-definition on a send-site channel-name string jumps to the receiver.
  • zone & teleport reference types - rerouter-only component references (like a var ref): passable through ports and parameters but not storable in a var/array/buffer (WS025) or selected with if-then-else (WS031). Zone events’ zone input is now zone-typed, and Teleport/RelativeTeleport dest/source are now teleport-typed - teleporting to a raw position uses SetLocation.
  • Clock reads as an event - on Clock(interval = 2.0, enabled = running) { ... } runs its body on each pulse; interval and enabled are wire inputs (constant or dynamic, so the clock toggles at runtime) and pulseOn/onTime/offTime are constant-only config.
  • New entity/character builtins - GetSpeed, GetVelocityAtPoint, GetEntityTeam/SetEntityTeam, IsFrozen, DestroySpawned/DestroySpawnedPrefab, a character ammo family (GetAmmo/GrantAmmo/SetAmmo, weapon-chamber ammo, GetInventoryEntry, GetCurrentInventorySlot), GetOwnTransform, and ForceRespawn(player) (also player.ForceRespawn()).
  • New date/time and conversion builtins - GetUnixTime, FormatDate, Remap, LogicalShiftRight, EnumToInteger/IntegerToEnum, ItemToPickup, ConvertColor, and ToCharCode/FromCharCode; ParseInt/ParseNumber gained a .Success flag and auto-unwrap to their parsed value.
  • Zone array fills - arr.fillFromZoneEntities(zone, tagFilter?) / fillFromZonePlayers(...) populate an array from a zone, and arr.sortMultiple(other, ...) sorts a value array plus up to seven parallel arrays together.
  • SpawnExplosion - an exec gate spawning an explosion of a given projectile/explosion class, with optional instigator, offset, scale, and damage.
  • PlayClientAudio - player.PlayClientAudio(audio, volume?, pitch?) plays a non-spatial one-shot for a single player (also PlayClientAudio(player, audio, ...)); the descriptor is an inlined $BrickOneShotAudioDescriptor/... asset reference, like the other Play* audio gates.
  • Expanded InputReader - reads look axes and key/button states (Up, Pitch/Yaw/Roll, MouseWheel, PressedC/E/Q/LeftMouse/RightMouse) alongside Forward/Right.
  • DisplayText returns a textId - capture the returned int to update or clear the same on-screen text later, with new color/outline/shadow/spacing/wrap styling params; position/anchor/scale/pivot/shadowOffset are Vector2D layout properties on the reworked gate.
  • GetDamage gives { Damage, DamageLimit } - auto-unwraps to Damage where a float is expected, and .DamageLimit reads the death threshold.
  • Richer event outputs - the fired-weapon event exposes the weapon and its name, CharacterDied the killer’s weapon and name, and ControllerJoined/ControllerLeft the player’s user name; Sweep/SweepSimple results carry a HitColor.
  • Player-reference gates target persistent player-state - DisplayText, chat/leaderboard/team setters, and the join/left/chat events resolve the current build’s persistent player-state; existing controller-typed scripts keep working unchanged.
  • Fixed fixed-size component arrays failing to load - weapon ammo resources and mesh colors are native fixed-length arrays; emit now pads them to their full length so a save that sets one loads instead of rejecting on size.
  • LSP: config-aware completions and hovers - enum sibling completion, hovers for events, enum values, Clock, and settings-menu config fields, and asset-type dropdown completions for $Type/Name config refs.
  • @label(<expr>) labels - @label accepts an expression, not just a string literal. A constant folds to baked text; on a top-level var a runtime value becomes a dynamic label, wired live into the variable’s floating text - including a variable labelling itself with its own value (@label(x) var x). A blank-line-separated @label(<expr>) at the very top of the file labels the root microchip rather than a declaration, and may forward-reference declarations below it. A runtime @label on a port/chip (which has no wireable text) is a WS040 error.
  • @invisible - hide a port’s rerouter brick and label, or - as a top-of-file annotation - the whole microchip shell (hidden, non-colliding, no labels), for microchips that spawn other microchips.
  • Inline nested prefabs - a triple-backtick block prefixed with $ compiles an isolated inner program and embeds it as a prefab, usable directly as a SpawnPrefab argument. The inner block can import but shares no wires with the outer file (it is a separate prefab); its diagnostics, highlighting, and completions all resolve against the inner program.
  • on <expr> triggers - an on handler can fire on an arbitrary boolean expression - a comparison (on hp <= 0), a method/index result (on a.Dot(b) > 0.0, on arr[i] > 0), a negation (on !flag), or a bare variable - not only a named event or input.
  • Chained method calls across lines - a call chain may continue with a leading . on the next line (f(...) then .g(...) below it); the formatter indents the continuation.
  • Fixed a var initializer missing its = - var x: int 5 silently dropped the value; it now reports a missing = error (and still recovers by taking the expression as the value).
  • Callable gate builtins - every variable/array/map wire gate is now callable as a function named after the in-game gate; this now includes the array-fill gates (FillArrayFromPlayers(arr), FillArrayFromTeamMembers(arr, team), GetPlayersInZone(arr, zone), GetEntitiesInZone(arr, zone)), the function-call twins of the arr.fillFrom* methods.
  • Callable exec-flow gates - Union(a, b) merges two exec signals into one (fires when either fires); Branch(cond, exec) routes an exec to .A or .B on a condition.
  • Fixed multi-output builtin field names - a gate returning several outputs (Edge, Branch, …) now exposes them under their friendly names (Edge(b).Rising/.Falling, Branch(c, e).A/.B) as intended, instead of only the raw port names.
  • on <call>() triggers - a builtin call that returns an exec (on ServerUptime(), on Change(v)) fires the handler on that exec, desugaring like any other on <expr> trigger - distinct from the event-with-args form (on Clock(...)).
  • LSP: scope-aware completion & hover - the editor now resolves a name to its in-scope declaration, so a name reused across scopes (a string at file scope, a character[] inside a handler) is read as the one actually visible at the cursor rather than the first declaration found. This applies to receiver. member completion (the correct method table), the bare-identifier list (a single entry with the in-scope type, no leak of a handler-local into file scope), and hover (the declaration under the cursor shows its own type).
  • LSP: default values in gate hovers - hovering a gate/builtin (e.g. SpawnPrefab, Sweep, DisplayText) now shows a table of its parameter and settings-menu defaults (limit = 5, distance = 100, fontSize = 16, …), read from the same source the emitter uses; enum defaults show the member name (direction = X_Positive). Hovering a named argument (fontSize = ) shows that field’s default too.
  • DisplayText layout is per-axis - the Vector2D layout ports (position/anchor/scale/pivot/shadowOffset) are set per axis: positionX/positionY, anchorX/anchorY, scaleX/scaleY, pivotX/pivotY, shadowOffsetX/shadowOffsetY. A constant axis bakes the property (an unset axis keeps its default); a runtime value wires its X/Y sub-port.
  • LSP: gate hovers render composite defaults - composite/color parameter defaults show as their constructor or sRGB hex (outlineColor = #181425), and a Vector2D sub-port axis shows that axis of its parent default (anchorY = → 0.5).
  • Unknown call arguments now error - a named argument that matches no parameter and no settings-menu config field is a WS041 error instead of being silently dropped (a typo’d argument name that previously did nothing). The universal exec = override and a variadic call’s trailing options are exempt.

Migrating from 0.x

1.0.0 tightens several call signatures and reworks a few gates, so some .ws that compiled under 0.x needs edits.

  • Teleporting to a position - Teleport/RelativeTeleport dest/source are now the teleport reference type, so e.Teleport(Vec(x, y, z)) and e.Teleport(other) no longer typecheck. Use e.SetLocation(Vec(x, y, z)) to move to a raw position, or wire a teleport point into an in p: teleport port and e.Teleport(p).
  • Zone events take a zone reference - the events’ zone input is now zone-typed (and tagFilter is string), so zone = e with an entity/brick value errors. Feed an in z: zone port from a Zone brick: on ZoneEntered(character, zone = z) { ... }.
  • zone / teleport are reference-only - like a var ref, they can’t be stored in a var/array/buffer (WS025) or picked with an if-then-else (WS031); pass them straight through ports and parameters.
  • DisplayText.outlineSize/fontSize are now int - pass an integer. The per-axis layout args (positionX/positionY/anchorX/anchorY/scaleX/scaleY) are unchanged and keep working; pivotX/pivotY/shadowOffsetX/shadowOffsetY are new axes on the reworked gate.
  • Swap result fields renamed - Swap(cond, a, b) returns { Output, OutputB } (was { a, b }); read r.Output / r.OutputB (a bare r still auto-unwraps to the first value).
  • InputReader().Jump removed - the movement record dropped Jump; read the new axis/button fields instead (Up, Pitch/Yaw/Roll, MouseWheel, PressedC/E/Q/LeftMouse/RightMouse).
  • BrickChanged / BrickRemoved lost their brick output - these events no longer carry a brick value, so on BrickChanged(brick) { ... } won’t bind; drop the parameter (on BrickChanged { ... }).
  • RotToDir removed - its gate no longer exists in the build; use q.ToDirection() (takes a quat) to turn a rotation into a forward direction.
  • array declaration keyword removed - declare container variables with var instead: var scores: int[] (was array scores: int[]). Storage and behavior is identical - only the keyword changed - so the fix is a mechanical rename. Using array as a declaration keyword is now a parse error that points at the var form.

0.20.0

  • @layout("code") – source-shaped gate layout - a row per source line, expressions left to right, widely-read values down reusable gutter lanes, and own-line // comments rendered onto the plane.
  • @layout("cube") – compact 3D packing - stacks gates into brick layers without analysing the wire graph. Minimal brick mass, no visible dataflow.
  • @flat – inline every chip onto one grid - no microchip bricks or nested planes; chip-wall crossings become ordinary wires. Behavior unchanged.
  • Every chip-wall crossing now routes through a labeled boundary pin - crossings get their own MicrochipInput/MicrochipOutput rerouter, even when not a declared parameter. Constant arguments still inline.

0.19.0

  • Constant expressions in var / array initializers - an initializer may name a top-level let constant and compute with it: array mask: int[] = [1 << C_FLAG, WIDTH * HEIGHT].
  • A named import now carries the constants an imported array is built from - the dependency closure skipped array initializers, so the import failed with “unknown identifier”.
  • Fixed a spurious “unused import” on a constant used only by an array initializer - WS014 fired and Organize Imports would delete it, breaking the table it fed.
  • Fixed a playground rename clobbering another file - the active-file pointer stayed on another file, so the next autosave overwrote it.

0.18.0

  • exec = on any exec call - an exec-gate call (array method, builtin, mod/chip) can run off an explicit exec = <trigger>, e.g. an array read in a pure binding: lut.get(i, exec = i + 1).
  • Fixed emit output = value fan-in - it wired both the value and the exec to the output’s input pin, failing to load; now just the value.
  • Certified constant folding - pure gates whose inputs are known constants are evaluated at compile time against the in-game-certified semantics table, constant-selector Selects short-circuit, constant-condition branches truncate their dead side (including across chip boundaries), all before layout. Opaque(...) and @nofold exempt code. The pass is opt-in while it stabilizes: enable it with a module-level @fold (or --fold); --no-fold (or a module-level @nofold) disables it and always wins over @fold. String concatenation, string methods, and ${...} interpolation now fold too, byte-exact against the game’s own text rendering, alongside vector, rotation, color, and quaternion constructors and their certified math operations.
  • Fixed returning a record through another mod - return f(x) or return r with a record value wired the caller to a phantom node whose wires were silently dropped at emit. The record’s fields now forward to the caller.
  • any type - an in/let/mod-or-chip-param/-output can now be annotated any, an operator-wildcard type that resolves real overloads (x & 1, x == "y", …) instead of erroring on an unknown type. It works anywhere, but the side effects of whichever overload gets picked are on you. A var/static var/array/buffer can’t store one: a variable gate needs one concrete wire type to hold, so an explicit any there is now a compile error.
  • String truthiness - a string now coerces to bool wherever a bool is expected (an if condition, a bool-typed let/var, a bool port or chip param). The coercion compiles to an inserted != "" compare gate, so the semantics are deterministic: empty is false, everything else — including "0" and "false" — is true. (Strings wired into bool ports manually via any still get the gates’ native content-aware truthiness, where "0"/"false" are also falsy.)

0.17.1

  • Fixed a constant on a data-only port failing the build - DisplayText.fontSize and 13 other params name settable fields with no wire input; binding one emitted a wire emit rejects. Now written as data, with a test pinning the list.
  • Fixed destructuring a builtin multi-output call binding nothing - let { Forward, Right } = c.InputReader() left every name an unwired placeholder. Fields now bind to the gate’s ports, and an unknown field errors with a suggestion instead of binding silently.
  • LSP reports lowering and emit errors on save - Live analysis stays typecheck-only, so lowering problems only surfaced on an explicit Compile. Saving now runs the full pipeline and publishes its diagnostics.
  • New arr.get(index) - A checked read giving { Value, OutOfBounds }; used bare it is the element. Completion now offers those fields on arr[i]. too.
  • Blend is the math blend gate - An alias for lerp, accepting any math variant (float/int/vector/rotator/quat/color), as do lerp, Easing, and Tween. The colour-space gate is now ColorBlend.
  • Opaque hovers with its own docs - It showed the Rerouter gate’s blurb, which says nothing about the fold-hiding and type-erasing behaviour it exists for.
  • Fixed .Value on a multi-output result - a.pop().Value typed as the whole record, so every use of it mismatched.
  • Fixed a type alias not resolving through a namespace import - import * as T with mod f() -> MyType failed with “unknown type”. Aliases now inline as they do for a named import, and T.MyType parses as a qualified type.
  • GetLeaderboard returns int - It was typed any, so arithmetic on its result had no operator overload.
  • bool arithmetic with two bools - bool + bool (and - * / %) now promotes to int, matching bool/int mixes and the bitwise ops; (a && b) + (c && d) compiles.

0.17.0

  • Opaque(x) builtin + @nofold annotation - Opaque passes a value through a rerouter and hides it from constant folding; @nofold suppresses folding for a declaration, or for the whole file when placed at the top separated by a blank line. No-op placements warn. Groundwork for gate-semantics verification circuits.
  • Gate semantics probe - probes/gate_semantics.ws prints every probed gate interaction to the console on paste; scripts/gen_semantics.mjs turns the dump into data/gate_semantics.json, and scripts/gen_verifier.mjs generates probes/verify_semantics.ws, which re-asserts every recorded case in-game.
  • Fixed a chip output named x/y/z/r/g/b/a reading garbage - Those names collide with vector/color component access, so reading one split the scalar and returned a component instead of the output. Field access now splits only when the value really is a vector or color.
  • Fixed a chip called from inside a nested anon chip never firing - Its exec trigger stayed at the root, and an exec pulse cannot cross into an instance grid nested inside another anon chip. Partition now routes boundary-pin wires into the module that directly contains the instance.
  • A constant argument to a chip no longer costs a gate per instance - F(1) materialized a _Var in the caller and wired it across the boundary. The constant now folds into the instance itself and its input pin is dropped, matching what the equivalent mod emits.
  • Fixed a tuple-destructured mod parameter binding nothing - mod f((a, b): (int, int)) left every name unbound, so the body silently computed on zeros.
  • Fixed let (a, b) = t on a tuple value - Both names bound nothing, and the shape was rejected as a non-tuple (WS010).
  • out f(x) is now a parse error - The trailing call was dropped and re-parsed as a separate declaration, leaving a bare port.
  • Fixed a namespace import lost through a re-export - Ns didn’t travel with the imported declarations calling through it, so every Ns.f(...) silently did nothing at runtime.
  • Fixed a namespaced call losing its return type - Ns.f(x) typed as any, so Ns.f(x) + 1 failed operator resolution (WS004) and dropped the expression.
  • New tree-sitter grammar - editors/tree-sitter-wirescript/, with highlight/locals/indent queries.
  • Docs: dropped match expressions - Reserved keyword, but the parser has no expression form for it.
  • New docs page: Best Practices - Gate count and scaling: why every call site is a copy (for mod and chip alike), the call-site multiplier, single-dispatch event queues, deferred flags, and bitmask state.

0.16.4 - 2026-07-17

  • Fixed a constant shared across two chips reading 0 in one of them - A literal used as a wired operand (e.g. x * 4) inside two separate chip { ... } blocks was merged by constant-deduplication into a single gate before anon-chip partitioning, leaving the second chip’s operand wired across the chip boundary — where emit’s per-module literal inlining can’t reach it, so the operand silently read its port default (0). Deduplication now groups by owning chip, keeping a shared constant once per chip.
  • Fixed on handlers bound to Change(x) - Change’s OnChanged output is now typed exec (was any), so let c = Change(x) + on c { ... } fires on the change pulse.
  • then may start its own line in an if expression - let x = if cond followed by indented then ... / else ... lines now parses; the formatter indents both keywords one level as expression continuations.
  • Fixed transitive imports resolving in the wrong order - When an imported file had imports of its own, its declarations were placed before the ones it imported, so any call into a deeper module was a use-before-declaration (WS021) or lowered against a missing declaration. This surfaced through a file that only re-exports another (import "b" alone in a.ws). Nested imports now resolve ahead of the importing file’s own declarations, matching how the entry file already behaved.

0.16.3 - 2026-07-16

  • Compiler is ~2x faster on large projects - mimalloc in the native binaries, thin LTO, a single-pass anon-chip partition, a quadratic wire-scan fix in inline chip calls, and Arc-shared ports/templates: lowering −69%, end-to-end −42%, lowering allocations −46%.
  • Compiled output is deterministic - Anon-chip partitioning iterated a randomly-ordered set, so emitted gate/wire structure varied run to run; chips now partition in sorted order and repeated compiles produce identical graphs.
  • New fuzz_programs example - Seeded grammar fuzzer that hunts silent miscompiles: programs with no error diagnostics whose output has _Unsupported gates, duplicate/fan-in wires, or dangling endpoints. Findings write to a gitignored fuzz_findings/.

0.16.2 - 2026-07-15

  • Fixed LSP crash (stack overflow) on large programs - The cycle-analysis SCC walk is now iterative, and every compile* entry point runs on a worker thread with a 256 MiB reserved stack.
  • Fixed LSP crash on multi-byte text - Hover/completion word scanners now step past characters by their real width, and member-receiver lookup converts the cursor column from chars to bytes.
  • Stale compile-command diagnostics clear on edit - Editing or saving any .ws file clears the previous Compile command’s diagnostics; the next explicit compile repopulates them.
  • Rename applies to every reference find-references sees - Three textDocument/rename fixes:
    • Open files match by canonical path and references are deduplicated, so edits are no longer doubled and rejected.
    • import { foo } rewrites to import { bar }; shorthand expands to { foo: bar }, and value-position names are untouched.
    • Rename works from any reference site (u.foo, record fields); built-in event names refuse rename.
  • Compiler is ~15% faster end-to-end - Internal tables use the Fx hasher (crate::collections) instead of SipHash: lowering −15%/−26%, cycle analysis −45%/−55%, layout −36%, world building −21%/−33%. Map iteration is now deterministic, so output is more stable run-to-run.
  • ~30% fewer allocations during lowering - Chip declarations are shared via Arc instead of deep-cloned per call, and scope keys ride the interner; mod-heavy programs lower ~14% faster. New count_allocs example reports per-stage allocation counts/bytes.

0.16.1 - 2026-07-15

  • chip let labels the chip with its binding name - chip let x = ... now shows the binding name(s) as its display label; an explicit @label(...) still overrides.
  • Wider vertical gap between chip-pane rows - The wall layout’s row-to-row gutter was widened so stacked chip planes read as separated.
  • Fixed repeated chip calls sharing wire endpoints - Later instances of the same chip (foo(0), foo(1)) wired their boundaries to the first instance and failed to load (“Failed to connect wire”). Boundary wires now remap to each instance’s own nodes.
  • Hover on a namespace member shows its signature - u.foo (via import * as u) now shows the full signature and exec-ness, matching a direct call’s hover.
  • Unresolved namespace/method call is now a hard error (WS002) - ns.foo(...) whose base is not in scope errors at the dangling identifier instead of lowering to a silent _Unsupported gate.
  • Organize Imports preserves namespace, bare, and multi-line imports - Alt+Shift+O now keeps every import form (namespace/bare imports are never pruned; unused named imports still are) and sorts a namespace import before a named import from the same module.

0.16.0 - 2026-07-14

  • Fixed field triggers on a local in handlers - on x.field (and negated on !x.field) now fires the matching output port instead of the local’s default port.
  • Duplicate constant gates merged per chip - A repeated constant is emitted once and fanned out. Pure gates with no wired input only; Random/stateful detectors are never merged; cut ~1200 gates on a large project.
  • LSP: member completion after receiver. wins inside a call arg - Call(arg = recv.<here> completes recv’s members (records complete their fields, including in on handlers); Call(<here> still completes params.
  • LSP: more completion contexts -
    • import * as u then u.<here> lists the module’s members.
    • pos.<here> on a var pos: vector offers type methods + swizzle (x/y/z, r/g/b/a) alongside .Value/.prev; static var gets .Value/.prev.
    • Values typed by a type Foo = { ... } alias complete Foo’s fields.
    • User mod/chip/fn calls complete their param names instead of the global list.
    • All-required calls (Vec(<here>)) offer their params; method calls drop the bound receiver param.
    • @-annotation list adds @label and @closed.
    • Native LSP and web playground share these paths.
  • Doc comments on record-type fields - A /// on a field inside type T = { ... } now parses (was a parse error) and shows on hover of that field.
  • Fixed hover on a namespace alias - Hovering u in import * as u shows namespace u and lists its members (was namespace u: unknown).
  • VS Code formatter (Prettier plugin) - Adds a space after commas; splits long braced imports (fill) and binary-op statements (one operator per line, lowest precedence first) at 100 cols; joins } else {; honors // fmt-ignore (standalone guards the next line, trailing its own). /// doc comments auto-continue on Enter.
  • Opened-plane headers space the doc off the title - A blank line now separates the size-96 title from the chip/module doc comment.
  • Warn on asset/reference values in an array initializer (WS024) - Assets ($Type/Name) and prefab refs can’t bake into a constant array/var initializer; build with .push(...) in an exec handler. All reference types (entity/character/controller/brick/prefab/assets) share the object wire and can’t be inlined.
  • Module doc comments stay separate from the first declaration - A top-of-file /// block followed by a blank line (or // comment) is the module doc (root plane header); a block directly above a declaration still documents it.

0.15.0 - 2026-07-13

  • Color arithmetic - + - * / % operate RGBA channel-wise on two color operands; a scalar broadcasts across channels (tint * 0.5). Same PrimMath gate as vectors/rotations.
  • Random is polymorphic - min/max may be vector, rotator, quat, or color; each component rolls independently and the same type is returned (Random(Vec(0,0,0), Vec(1,1,1)) → point in the unit cube). Scalar int form unchanged.
  • Fixed anonymous-record mod returns - A mod returning a record literal (return { head: ..., rest: ... }) now destructures into per-field sources, so each field wires to its own value (was one _Unsupported gate).
  • Non-root chips compile open by default - Opened planes stack as a wall above the compiled microchip (root at bottom, deeper nesting higher). New @closed collapses a chip but keeps its wall slot; open chip is now a no-op.
  • New @label("text") annotation - Display-text override for chip labels/headers and in/out port labels (stacks with @side in any order); the wiring-UI port name is unchanged.
  • Opened planes render a header - A size-96 title (the @label text, else the chip name) plus the chip’s /// doc comment, on an invisible brick at the plane’s top edge.

0.14.1 - 2026-07-13

  • LSP: fixed a return <expr> mod mislabeled exec on hover - return alone no longer forces the exec label; only an exec op in the returned expression (e.g. an array read) does.
  • Pruned duplicate constants from dual imports - A module imported via both import * as x and a named import no longer ships its top-level let constants twice; fully-disconnected pure gates and orphan literals are pruned.

0.14.0 - 2026-07-12

  • Port-side rerouter pins - @left/@right/@top/@bottom on a top-level port (same line or the line above) emits a pre-wired rerouter brick flush against that side of the microchip. Ports keep declaration order per side (ins/outs interleave), each pin is labeled with its port name; annotations inside chip {}/mod bodies error (WS023).

0.13.1 - 2026-07-11

  • Fixed array.pop() returning 0 - Both gate outputs are now declared: .Value reads the popped element and .IsEmpty reads bIsEmpty (true once the array is empty after the pop).
  • Fixed buffer initializers inside chip/mod/handler bodies never wiring - The initializer expression was silently dropped, leaving the buffer’s input dangling.
  • Silently-dropped var initializers now warn (WSP001) - Warns on a non-constant init in pure position, any non-constant static var init, and an exec-context array-var init that isn’t an array literal. Use a let for a pure computed binding, or assign inside an exec handler.

0.13.0 - 2026-07-10

  • ~4x faster compiles on large projects (5.9s → 1.5s):
    • Each chip is laid out exactly once; the pre-emit layout pass no longer recurses into children.
    • Layout: one toposort bucketed per connected component; prebuilt consumer map + O(1) occupancy checks.
    • Emit: gate-data schema classification and interned names resolved once per gate class; no per-brick String clones.
    • Lower: dead exec-union pruning is a single incremental worklist pass.
  • brdb 0.8.0 - Unset component fields skip a defaults scan and two error-String allocations per field; brz index compression actually works (its size guard was dead code).
  • Fixed field access on a call result dropping the call - arr.find(x).Found / .Index now keep the call.
  • Fixed a standalone chip losing its exec output - An exec-bearing body ending in return <value> now ships the output.
  • Fixed out X = X emitting no wire - Applies when the output shares its name with a var/array.
  • LSP: hover on a call-result field resolves its type - ids.find(x).Found resolves from the call’s record.
  • LSP: goto-definition on a namespaced call resolves in the imported file - u.foo with import * as u no longer jumps to a same-named local decl.
  • Chip exec I/O gates are labeled - Exec gates say exec; the anonymous -> type return output says return (synthesized ports had no label).
  • ControllerJoined/ControllerLeft expose the player’s id - on ControllerLeft(controller, userId) (string); stable when the controller is torn down on disconnect.
  • Calling a chip/mod before it is declared is a hard error (WS021) - In both the compiler and the LSP (was a silent placeholder reading its default 0).
  • in X: T[] array inputs are first-class - An array-typed in port supports array methods (X.length(), X.push(v), …) and passes to a mod/chip’s T[] parameter.
  • Namespaced module members resolve inside their own mods - import * as ns only; named imports were unaffected.
  • Chip/mod calls check their argument count (WS022) - Hard error in both the compiler and the LSP (a wrong count silently left a param unbound or dropped an arg). An exec = trigger isn’t counted; a spread arg skips the check.

0.12.3 - 2026-07-10

  • Anonymous-chip constants - Fixed literal constants not reaching anonymous chips.

0.12.2 - 2026-07-09

  • ReadBrickGrid() - New builtin.

0.12.1 - 2026-07-09

  • Zone events bind their Zone input - on ZoneEntered(character, zone = z) wires z into the event gate’s Zone port, so a wired in port selects the watched zone. Covers ZoneEntered/ZoneLeft, EntityZoneEntered/Left, ProjectileZoneEntered/Left, BrickChanged/BrickRemoved.
  • Fixed a false recursion flag - An imported namespaced identifier no longer triggers it when conflicting with a local identifier.

0.12.0 - 2026-07-09

Language / Compiler

  • Emitted saves label their elements with text decals - The top-level chip is titled with the entry file’s stem (or --name); named chips, variables/arrays, and microchip I/O gates get diagonal floating name labels. _-prefixed ports stay unlabeled.
  • Var/array exec gates tag their variable - Var_Get/Var_Set/Var_Increment and array-var gates carry a smaller tag naming the accessed variable, traced through the ref wire (works across chip boundaries for captured vars).

0.11.0 - 2026-07-08

Language / Compiler

  • Gate data mappings derive from game data - Struct names and field lists come from the game-extracted pair table + schema, so new gates need no table edits. Stale entries for components the game lacks were dropped.
  • Vector/Rotation literals embed into gate data - e.SetLocation(Vec(0.0, 0.0, 100.0)) bakes the vector into the gate instead of spawning a wired MakeVector. Split* inputs still materialize.
  • Exhaustive gate-data write audit - A test serializes a literal into every representable field of every game component through the real writer; a failure names the gate and field.
  • Record literals as call args bind their fields - { a: 1, b: 2 } passed to a destructured (f({ a, b }: P)) or whole-record (f(p: P)) param now lowers the fields.
  • String constants inline as wire variants - Ports that can’t hold an inline variant keep the real gate.
  • Chips capture the whole enclosing scope - let/in/event-param references now resolve; constants clone into the chip, so let K = 2 used as arr.push(K) bakes 2 into the gate.

Bug Fixes

  • min/max and 14 more expression gates embed literals - min, max, sign, round, exp, ln, the hyperbolics, Deg2Rad/Rad2Deg, BitCount, and ScaleVec no longer drop literal args like min(a, 3.0).
  • ScaleVec wires to the real ports - Input/Scalar instead of the nonexistent InputA/InputB.
  • Destructuring record literals - Now properly lowered to bindings.

0.10.2 - 2026-07-08

Language / Compiler

  • emit/await loops with buffer emit - buffer emit sig (1 tick), buffer(N), buffer(0.5s), buffer(myVar), or buffer(delay, hold) inserts the Buffer(Ticks|Seconds) gate a wire-graph cycle needs. Constants bake into the gate; variables wire the duration port.
  • Payload ferrying - emit sig = value stores the value in hidden per-signal vars (one per record field); let x = await sig / let { a, b } = await sig reads it back. Cost: one Var_Set per field per emit, one Var_Get per field at the await.
  • Body-level let x: exec wires correctly - await x on a body-declared signal no longer lowers to a dead placeholder.
  • Signals are scoped per declaration - Two mods each declaring let loop: exec no longer share one signal; hubs are keyed per declaration and resolved through the scope.
  • Handler-local array vars re-init correctly - var nums = [1,2,3] in a body rebuilds via clear + push instead of wiring a nonexistent VarRef port.
  • Layout no longer panics on multi-cycle SCCs - Feedback-edge removal iterates until acyclic, so two loops sharing a chain lay out.

Language / Compiler (types)

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

Bug Fixes

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

Editor / IDE

  • Named-arg hovers only fire on the arg name - In delay = delay, hovering the value shows the symbol, not the param docs.
  • Method/call hovers only fire on the actual access - Array-method hovers require a .method access; builtin call/method hovers require recv.method or name(. A bare identifier (e.g. var sum = 0) hovers as itself.

0.10.1 - 2026-07-07

Language / Compiler

  • Asset references are entity-typed and usable as values - $Type/Name is entity (was any), so weapon == $BRItemBase/Weapon_Pickaxe type-checks instead of erroring (WS004). As a value it materializes into the matching *Reference gate (ItemReference, AudioReference, EntityTypeReference, … by asset type), which outputs the asset as an entity wire.
  • DisplayText gained an easing param - The interpolation curve for transition ("Linear" / "EaseIn" / "EaseOut" / "EaseInOut"), a property-only enum like justify.

0.10.0 - 2026-07-07

Bug Fixes

  • character and controller wire directly - No more GetFromEntity adapter, an admin-only gate that got blocked on paste for non-admins.
  • Gate brick colours no longer double-darkened - Colours emit as the intended sRGB values instead of being pre-multiplied by γ=2.2.
  • Multi-byte string chars survive emit - The lexer reads whole UTF-8 chars, so █/é no longer mangle.
  • Long templates no longer drop values - FormatText has only 7 substitution inputs; templates with more ${...} values split across chained gates.
  • on <local exec signal> fires across handlers - emit sig in one handler triggers on sig in another, regardless of source order.
  • Lexer no longer panics on stray multi-byte chars - A non-ASCII char outside a string (e.g. ▲) is now UTF-8-safe instead of crashing the LSP.
  • FindPlayer is an exec gate returning character - Has Exec/ExecOut ports and emits the found player’s character; was mis-declared pure returning entity.

Editor / IDE

  • $ reference highlighting + hovers - Prefab ($./x.brz) and asset ($Type/Name) refs get TextMate scopes and hovers; prefab hovers show the resolved path and (in the LSP) whether the file exists.
  • Prefab refs are navigable - A resolvable $./file.brz is a clickable link / go-to-definition target (Ctrl/Cmd-click or F12).
  • Missing prefab files warn - The LSP flags a $./file.brz that isn’t on disk or lacks the .brz extension.
  • Playground uploads .brz prefabs - A Prefabs panel (upload + drag-drop) stores files as browser blobs (IndexedDB), offers them in $./ completion, and embeds them at compile.
  • Named-arg completion + hover work in multi-line calls - The enclosing-call scan covers the whole call (skipping strings/comments), not just the current line.
  • Enum-valued args complete their values - A named arg backed by a schema enum (e.g. justify) completes its variants (Left / Center / Right), auto-quoted when no quote is open.

Language / Compiler

  • Prefab references embed a .brz into SpawnPrefab - $./file.brz (relative) / $/abs.brz (absolute) embeds the archive content-addressed (brdb 0.7 add_prefab) and sets the gate’s Prefab path. .brz required (WS019); resolution pluggable via EmitOptions::prefab_resolver.

0.9.0 - 2026-07-07

New Builtins

  • Split edge/change detectors - Edge(bool) -> {Rising, Falling: bool}, EdgeExec(float) -> {Rising, Falling: exec}, Changed(any) -> bool, Change(any) -> any.

Bug Fixes

  • SpawnPrefab gained a velocity param - The gate’s SpawnVelocity input.
  • SpawnPrefab() - Compiles again.

Gate Catalog / Data

  • Gate inventory regenerated - 314 -> 316 entries (the two exec detectors).
  • Edge Detector emit mapping key fixed - The class name was missing Type, so its component data was never written.

Language / Compiler

  • exec = named arg on chip and mod calls - Pass a trigger when calling exec chips/mods outside an exec context. The call returns the completion exec as an exec result field: await r.exec / on r.exec { }.
  • Import dependency pulling fixed - Imports pull same-file deps in record/array literals, emit values, await exprs, and buffer inits; type aliases inline into imported let/var/out/buffer/in annotations, not just chip/mod params.
  • WS013 understands emit - The unassigned-output check counts emit x (= expr) and plain assigns anywhere in the body, per-output.
  • Named chip bodies capture top-level state - Free references to outer vars, arrays, buffers, and record bindings resolve against the caller’s scope.

Parser

  • Multi-line array literals - Newlines allowed after [, around commas, and before ], with optional trailing comma - mirroring call-arg rules. Covers top-level array initializers and runtime foo = [...] rebuilds.

Editor / IDE

  • Formatter indents multi-line array literals - Both formatters (native, prettier plugin) track [/] depth like (/); delimiter scanning stops at // so comments don’t skew indentation.
  • Formatter: one indent level per line - A line opening several groups (f(x, {) indents its continuation once, not once per delimiter; the closing }) returns to the opener’s level.
  • One “Wirescript” entry in the formatter picker - The extension keeps its prettier formatter and sends provideFormatting: false so the LSP doesn’t register a duplicate.
  • Prefab path completion - $./ (or $/) completes .brz refs: the native LSP scans the document’s directory; the wasm playground offers dragged-in files via a new optional prefabs_json registry.

0.8.0 - 2026-07-06

New Builtins and Methods

  • Chat / messaging - ctrl.ShowChatMessage(msg) (per-player whisper), ctrl.ShowMessageBox(msg, title?) (modal popup), and global BroadcastChatMessage(msg) / BroadcastStatusMessage(msg, flash?).
  • Audio - entity.PlayAudioAt($BrickOneShotAudioDescriptor/..., volume?, pitch?, innerRadius?, maxDistance?, spatialized?) plays a one-shot at an entity (characters work); PlayGlobalAudio(audio, volume?, pitch?) plays for everyone. The descriptor is an inlined $ asset reference.
  • Entity tags - entity.SetTag("...") / entity.GetTag() -> string attach an arbitrary string to any entity and read it back; zones can filter on tags.
  • FindPlayer(name) - Pure value gate looking up a player entity by name.
  • Change(input) - Any-typed companion to Edge: pulses the input value through when it changes.
  • Quaternion raw components - Quat(x, y, z, w), q.SplitQuat() -> {X, Y, Z, W}, a.QuatDot(b).
  • Inventory family - char.AddInventoryItem(item) / SetInventoryItem(item, slot?), AddInventoryBrick(brick, size?) / SetInventoryBrick(...), AddInventoryEntity(entityType) / SetInventoryEntity(...), and AddInventoryItemAdv / SetInventoryItemAdv with overrides (damage, speed, scale, itemName, projectile). Asset args are $Type/Name references.

New Events

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

Compiler / Output

  • Generic asset-field emission - Gates with a class/object data field (AudioDescriptor, Item, EntityType, BrickAsset, ProjectileOverride) register inlined $ asset references in the world’s external-asset table automatically. Binary encoding needs in-game verification.

Gate Catalog / Data

  • Gate inventory regenerated - 288 -> 314 entries (26 new classes); the messaging/tag/zone-event/quaternion/inventory gates are wired into the language.
  • brdb data regenerated - Component _max schema (286 structs) and component_db.rs (296 type mappings). assets/external.rs kept the previous full catalog (the dump referenced only 14 assets).
  • Deliberately not exposed as builtins - The *Reference gates ($Type/Name covers them), Convert/ColorConvert (implicit coercions cover them), and AddInventoryEntry (opaque nested struct; GiveWeapon covers it).

0.7.0 - 2026-07-05

Language Features

  • Scalar var type inference - var foo = "" is a string var, var n = 0 an int var, var f = 1.5 a float var (also bools, negatives, interpolated strings). A non-literal initializer refines from its expression (var v = Vec(1.0, 2.0, 3.0) is vector), same as buffers.
  • Everything casts to string - All variant-able primitives (numbers, floats, bools, vectors, rotators, colors, entities, characters, controllers, bricks, prefabs) coerce to string: let s: string = 5 is a cast, not a WS016 warning, and .. accepts any of them ("hi " .. player). Unannotated array vars also infer constructor elements (var pts = [Vec(1.0, 1.0, 1.0)] is vector[]).
  • Color() returns color - Was any; matches ColorSRGB/ColorHex/Blend.

Constant Folding

  • Vec/Rotation/Color on literal args fold to constants - var v = Vec(1.0, 2.0, 3.0) bakes into the Variable gate’s initial value, and constant constructors are legal top-level array initializer elements (loads pre-populated).
  • Folded constants inline into consumers - A constant Vec(...) lands as a literal in the consuming gate’s data (Var_Set, math operands, select branches, arr.push); wire-only consumers (SetLocation/Teleport, component splits, chip inputs) get a Make* gate materialized, never silently zeroed.
  • Vars and arrays of every wire variant - rotator (zero), quat (identity), and color (opaque white) vars get type-matched initial values; rotator[]/quat[]/color[] back onto the typed array variants (WireGraphRotatorArray/QuatArray/LinearColorArray) instead of doubles.

Dependencies

  • Requires brdb 0.6.3 - The wire variant gained Rotator/Quat/LinearColor members plus matching typed array variants; only WireGraphEnumWrapper remains unmapped.

0.6.0 - 2026-06-30

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

0.5.0 - 2026-06-29

Language Features

  • quat type + rotation/quaternion builtins - A quat primitive (distinct from the euler rotator) plus dir.ToRotation(), q.ToDirection(), v.Rotate(q), q.Invert(), from.RotationTo(to), a.AngleTo(b), a.Slerp(b, alpha), axis.RotationByAngle(angle), q.ToAxisAngle(), and Rotation(p, y, r) / r.ToEuler() for the euler rotator.
  • sRGB / hex color builtins - ColorSRGB(r, g, b, a) and ColorHex("#rrggbb") constructors; c.ToSRGB() / c.ToHex() / a.Blend(b, alpha) receivers.
  • Cycle(count) / Toggle() - Stateful exec value gates (advance a counter / flip a bool each exec pulse).
  • User definitions shadow builtins - A chip/mod/fn named like a builtin (e.g. chip Toggle) takes precedence at the call site.
  • Asset references - $AssetType/AssetName (e.g. $BRItemBase/Weapon_Pistol) references an external asset embedded by name, encoded as an external-asset-table index on emit. Completion: $ offers asset types, $Type/ that type’s names (from the brdb catalog).
  • HasRole / GiveWeapon - ctrl.HasRole("Admin") -> bool (role is a config string); char.GiveWeapon($BRItemBase/Weapon_Pistol, slot) sets an inventory slot to an item asset (builds the nested EntryPlan). Binary encoding needs in-game verification.

Gate Catalog / Output

  • Gate inventory refreshed - Adds 26 new gate classes; the rotation/quaternion, sRGB-color, and cycle/toggle ones are wired into the language, with component data structs registered for .brdb output.

0.4.0 - 2026-06-28

Language Features

  • Pre-initialized arrays - array foo: int[] = [1, 2, -3] writes literal contents (numbers incl. negatives, strings, bools) straight into the array gate, loading pre-populated. A non-literal top-level element is a clear error.
  • Inferred array-typed vars - var foo = [1, 2, 3] infers int[] and lowers to the same array gate as an array declaration; it indexes and iterates as a real array.
  • Runtime array assignment + spread - In an exec handler, foo = [a, 1, ...other, 5] rebuilds an array var: clear -> push each item -> append each ...spread. Elements can be any runtime value; a spread splices another array in place.
  • Array methods, one source of truth - Every method derives from a single catalog::arrays table: completion offers the full set on any array-typed value, return types come from gate output ports. find returns { Index, Found, Value } (auto-unwraps to Index); pop/min/max expose .IsEmpty; insert/swap/slice expose .OutOfBounds.
  • GetAim replaces AimOrigin/AimDirection - A character’s camera/aim is one gate returning char.GetAim().Origin / .Direction; reading both fields shares a single gate. The separate calls are removed.
  • Chat command config - on ChatCommand("greet", "Greets the player", player, args): string literals fill CommandName then HelpText in order (or named Description = "..."), and bare identifiers still bind the event outputs (controller, arguments).

Bug Fixes

  • Vector components on stored values - .x/.y/.z (and color .r/.g/.b/.a) work on a vector held in a variable or let binding via the SplitVector/SplitColor gates, not just an inline Vec(...).
  • Vec(...) literal arguments - Constant components are no longer dropped to 0 at emit; MakeVector gained its component-data mapping.

Compiler / Output

  • Gate defaults resolve from component_db - Unspecified data fields are omitted so the brdb writer fills them from STRUCT_DEFAULTS; DisplayText’s FontSize/Lifetime now resolve to the game defaults (16 / 5) instead of 0.

0.3.0 - 2026-06-27

Language Features

  • String variables - var/static var of type string store in a Variable gate (the WireGraphVariant gained a str member). The WS018 “strings can’t be stored in vars” diagnostic is gone.
  • Native string equality - == / != on strings lower directly to the CompareEqual / CompareNotEqual gates. The contains(a,b) && length(a) == length(b) workaround is removed.
  • Vector arithmetic - + - * / % operate component-wise on two vectors, and a scalar operand (v * 2.0, 10.0 * v, v / 4) broadcasts - all on the same MathAdd/Subtract/Multiply/Divide/Modulo gates. The Scale helper still works.
  • Any-variant variables - A var can hold any WireGraphVariant member (int, float, bool, string, vector, object types); typed vars get a type-matched initial value instead of a number default.
  • Typed arrays - The declared element type selects the backing WireGraphArrayVariant member (int -> Int64, float -> Double, plus Bool/String/Vector/Object), so elements keep their declared type.

Gate Catalog

  • Regenerated inventory - Rebuilt from the in-game dump via a new checked-in generator (scripts/gen_inventory.mjs): adds 76 gate classes (ArrayVar exec, Gamemode/Controller/Character, string ParseInt/ParseNumber, reference gates) and types 86 previously-any ports. 175 -> 260 entries.
  • Refreshed brdb component tables - component_db.rs regenerated from the same dump so the new gates emit; the removed Gamemode_EndRound gate is gone.

New Builtins and Methods

  • Array methods - insert, find, sort(desc?), reverse, sum, min, max, average, swap, fill, resize, append, copyFrom, slice, fillFromPlayers, fillFromTeam join push/pop/length/remove/clear/shuffle. Every ArrayVar gate is reachable.
  • Easing - Easing(a, b, blend, fn?, dir?) and Tween(target, duration, fn?, dir?); function/direction pass as an int or enum-name literal ("Quad", "InOut", …) resolved against the engine’s EBREasingFunction/EBREasingDirection enums.
  • Timer - Timer(limit, restart?, pause?, resume?) returns { Time: float, Expired: exec }; the controls are optional exec inputs and Expired works with on/await.
  • String parsing - ParseInt(s) -> int and ParseNumber(s) -> float (also s.ParseInt() / s.ParseNumber()).
  • Controller - GetUserName, GetUserId, GetDisplayName, IsTrusted, HasPermission, SetCanRespawn, SetTeamPinned.
  • Character - GetDamage, SetDamage, IncDamage, SetTempPermission.
  • Entity - SetFrozen.
  • Gamemode - PlayerWins / TeamWins (replace the removed imperative EndRound gate and builtin), GetCurrentRound, SetTeam, GetTeamName, GetTeamLeaderboardValue / SetTeamLeaderboardValue / IncrementTeamLeaderboardValue.
  • Misc - PrintToConsole, DeltaTime, ServerUptime, NearlyEqual, Dampen.

Compiler / Output

  • Prefab output - Compiled programs emit a Brickadia prefab (type: "Prefab" + Meta/Prefab.json with brick bounds from the microchip shell) instead of a world, so the .brz pastes like a native copied selection (Ctrl+V) with a correct preview.
  • Loads on current builds - A bundle embeds only the component structs the program uses plus transitive schema deps, written dependency-first - matching game bundles. Replaces the full-catalog embed recent builds reject; real programs stay within the per-schema struct limit.

0.2.0

Language Features

  • emit target = expr - Set output value and fire exec in one statement. Works in both pure and exec contexts.
  • await expr - Suspend exec chain and resume when expression fires. Armed-flag guard ensures one-shot execution (~7 gates per await).
  • let name: exec - Local exec signals. emit name fires them from any handler; await name or on name listens.
  • let x = await val on trigger - Capture a value when a trigger fires.
  • await a || b - Race semantics via normal binary expressions.
  • _ placeholder in await - Resolves to the armed flag (bool). Enables await Sleep(_, delay = 1.0).
  • Logical/comparison operator coercion - &&, ||, ^^, !, ==, !=, <, >, <=, >= now accept all wire variant types (bool, int, float, exec, string, entity, controller, character, brick, prefab).

Builtin Functions

  • Sleep(input, delay?, hold?) - BufferSeconds gate. Delays a value by seconds.
  • SleepTicks(input, delay?, hold?) - BufferTicks gate. Delays a value by ticks.

Compiler

  • compile_to_world - New compile path returning brdb::World for .brdb output.
  • CLI .brdb support - just compile file.ws -o file.brdb emits SQLite saves.
  • Compile progress - LSP sends wirescript/compileProgress notifications; VS Code extension shows step counter in status bar.
  • ** (pow) fix - Now wires to Input/Exponent ports instead of InputA/InputB.
  • BRZ double-write fix - Fixed to_brz_vec writing the archive twice (exact 2x file size).

Editor / IDE

  • Inlay type hints - Ctrl+Alt shows inferred types for let/buffer bindings. Works in VS Code and the web playground.
  • Hover gate estimates - Hovering chips/mods/handlers/if-blocks shows estimated gate and microchip counts. Call-graph expansion sums callee costs recursively.
  • Record field hover fix - cpu.regs, cpu.cpsr and nested field access now show types correctly.
  • on handler hover - Shows gate estimate for the handler scope.
  • if hover estimates - Shows gate cost for the if/else scope.
  • Tuple display - Records with numeric keys show as (bool, int) instead of {0: bool, 1: int}.
  • await keyword highlighting - Added to VS Code tmLanguage and Monaco monarch tokenizer.

Playground

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

Documentation

  • Updated statements.md with emit-value, local exec signals, await, Sleep/SleepTicks.
  • Updated exec-context.md with await section, _ placeholder, Sleep examples.
  • Updated builtins.md with Sleep/Delay section.
  • Updated expressions.md with operator coercion for all wire variant types.
  • Updated types.md with exec->bool coercion.
  • Removed fn keyword references from docs.
  • just compile-brdb recipe added.

Test Files

  • projects/tests/src/ - New in-game test suite: test_await_emit.ws, test_variables.ws, test_operators.ws, test_control_flow.ws, test_chips_mods.ws, test_strings.ws.
  • crates/wirescript/tests/ - Integration tests: await_test.rs, emit_value.rs, local_exec.rs.

0.1.0

Language Features

  • Records & tuples - User-defined record types (type Point = { x: int, y: int }), record literals, destructuring (let { x, y } = p), spread operator ({ ...p, y: 99 }), tuple types and literals
  • Spread in call args - Pass record fields as named parameters: foo({ ...defaults, x: 1 })
  • Destructured params - mod dist({ x, y }: Point) -> int { ... } in mods and chips
  • on expr syntax - Trigger handlers on arbitrary exec expressions, not just named events
  • Exportable vars/buffers/arrays - var, buffer, and array declarations are now importable across files
  • String var error (WS018) - var s: string now errors at typecheck time (Brickadia runtime doesn’t support string variables)
  • Ref/deref improvements - LSP completions on arrays and refs, output ref/deref fixes

Editor / IDE

  • Record field hovers - Hover shows State.counter: *int for record fields and type declaration fields
  • Spread type validation - Extra fields from spread are caught with errors pointing at the ...expr span
  • Chip/mod context hover - Hovering chip/mod keywords shows whether the block is pure or exec
  • Event parameter hovers - Hover on event handlers shows parameter types
  • Mod/chip return type hovers - Hover shows -> (result: int) return types
  • Formatter fixes - Multi-line function call args indented correctly; operator continuation lines indented
  • type keyword highlighting - VS Code extension highlights user-defined type names

Playground

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

Bug Fixes

  • Fix branch scoping - variables declared in if/else branches no longer leak across branches
  • Fix string comparison gate using wrong variant
  • Fix inline modules adding extra microchip outputs
  • Fix return expr in pure mods
  • Fix on var.value not lowering handler body
  • Fix emit not chaining union gates for multiple emit paths
  • Fix import not pulling in same-file dependencies of imported declarations
  • Fix array index access requiring exec context
  • Fix array .length() / .pop() returning Any type
  • Fix string wire port emits with literal variant values

0.0.0

Language Features

  • Standalone chip instantiation - Named chips with -> (outputs) now compile to real child microchips, one per call site. Cross-chip wires resolve automatically.
  • static var - Variables that persist across rounds: static var highScore: int = 0
  • return expr - Return values from chips and mods
  • Single-output auto-unwrap - chip Foo() -> (result: int) returns int directly instead of {result: int}
  • Block expressions - { stmts; expr } as expressions
  • Compound assignment - +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=
  • ^^ logical XOR operator - a ^^ b is true when exactly one operand is true
  • let type annotations - let x: int = expr
  • Array params are always pass-by-reference - mod init(arr: int[]) passes the array by reference without needing *
  • fn deprecation - fn declarations emit a warning (WS015) suggesting let instead

Builtin Functions (30 new)

  • Select/Swap - Select(cond, a, b), Swap(cond, a, b) -> {a, b}
  • String ops (all receiver on string) - s.Length(), s.Contains(search), s.StartsWith(prefix), s.EndsWith(suffix), s.Find(search), s.Substring(start, len), s.Replace(search, repl), s.Split(delim) -> {Left, Right}, s.ToLower(), s.ToUpper(), s.Trim()
  • Math - tan, log(x, base), lerp(a, b, t), fmod(a, b)
  • Vector/Color - v.SplitVec() -> {x, y, z}, c.SplitColor() -> {r, g, b, a}
  • Edge detector - Edge(input) -> {rising, falling}
  • Gamemode - EndRound(winner?), GetTeamByName(name)
  • Character - ShowHint(char, title, text)
  • Controller - ShowStatusMessage(ctrl, message)
  • Bitwise - BitNand(a, b), BitNor(a, b)
  • Renamed MakeColor -> Color
  • 93% gate coverage - 163 of 175 Brickadia gates supported

Events

  • ChatCommand - on ChatCommand(controller, arguments) { ... }

Compiler Optimizations

  • NAND/NOR gate fusion - !(a && b) compiles to a single NAND gate instead of NOT + AND. Same for !(a || b) -> NOR, ~(a & b) -> BitwiseNAND, ~(a | b) -> BitwiseNOR.
  • 7.2x faster chip compile - Schema parse caching + lower zstd level cuts chip program compile from 334ms to 46ms
  • Receiver syntax on all vector ops - v.Normalize(), a.Distance(b), v.Magnitude(), etc. now work as chained calls with correct type inference

Editor / IDE

  • Cross-file go-to-definition - Clicking an imported symbol jumps to its declaration in the source file. Clicking an import path opens that file.
  • Hover on if keywords - Shows whether the block is in exec or pure context
  • Unused import/output warnings - Warnings for imported symbols and outputs that aren’t used
  • wirescript-check CLI - Standalone type checker binary
  • VS Code extension auto-reload - Extension reloads when the LSP binary changes

Removals

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