Wander language¶
This page is the language reference for people who write Wander by hand: declarations, triggers, statements, values,
types and the rules the runtime follows. Every example on the page compiles on its own with skywalker check. The
functions scripts can call are listed in the generated builtins reference.
Files and declarations¶
A script is one or more behavior Name ... end blocks, or bare members, in which case the whole file is one behavior
named Main. Around the behaviors, a file may hold use, const, fn and test declarations that its behaviors
share.
| Declaration | Meaning |
|---|---|
intent "..." |
The behavior's natural-language intent. |
var hp = 3, var target: entity? = none |
Per-entity state, read as hp or self.hp. Initializers run when the behavior starts and never overwrite a value the entity already has (set in the editor, by an agent or by a prefab). |
param speed = 3 in 0..10 "doc" |
A var that is a tunable: a constant default, an optional range and a description. The editor shows a slider; the spec lists it. |
const MAX = 10 |
A compile-time constant: literals, arithmetic and other constants. |
fn name(a, b: number) -> number ... end |
A function. Behavior-level functions can read and write the behavior's vars. Recursion is allowed up to a depth of 200. |
on <trigger> ... end |
A handler (see Triggers). |
state Name ... end |
A state of the behavior's state machine, holding its own handlers. |
test "name" ... end |
An in-language test, run by wander_test (Specs and tests). |
use "scripts/combat" (as c) |
A module: a .wander file of fn and const declarations in the project. |
behavior Turret
intent "Turn toward the nearest enemy in range and fire every half second."
param range = 12 in 2..40 "detection radius (m)"
param rate = 2 in 0.5..10 "shots per second"
const BARREL_HEIGHT = 1.2
var target: entity? = none
var cooldown = 0
var shots = 0
on tick
target = nearest("enemy", range)
cooldown -= dt
if target then
look self at target
if cooldown <= 0 then
fire()
cooldown = 1 / rate
end
end
end
fn fire()
shots += 1
let muzzle = self.position + (0, BARREL_HEIGHT, 0)
emit "shot" with {from: muzzle, at: target}
end
end
Modules¶
A module is a project file that holds only fn and const declarations. This one compiles on its own:
-- scripts/combat.wander
const MAX_HP = 10
const CRIT_MULTIPLIER = 2
fn damage(base: number, crit: bool) -> number
if crit then
return base * CRIT_MULTIPLIER
end
return base
end
fn clamp_hp(hp: number) -> number
return clamp(hp, 0, MAX_HP)
end
A behavior imports it with use, by project-relative path without the extension, and calls into it through the
module name or an alias. Because it needs the module file, check it with --project:
use "scripts/combat"
use "scripts/combat" as c
behavior Knight
var hp = combat.MAX_HP
on event "hit" with h
hp = combat.clamp_hp(hp - c.damage(h.get("amount", 1), chance(0.1)))
end
end
Modules reload when play starts and when their files change. Errors inside a module report the module's file.
Triggers¶
Handlers run when their trigger fires. The core triggers are part of the language:
| Trigger | Fires |
|---|---|
on start |
Once, the first tick the behavior runs (after the var initializers). |
on tick |
Every fixed tick (1/60 s). on update is accepted as an alias. |
on event "name" (with x) |
When an event of that name is delivered. The payload is data (or x), the sender is other. |
on key "space" |
When a key is pressed. |
on click |
When the entity is clicked (in the editor's play mode, the player, or sim_input / click in tests). |
on enter, on exit |
Inside a state: when the state is entered or left. |
on frame |
Every displayed frame, for cosmetic effects only (see on frame). |
Subsystems register further trigger words:
| Trigger | Fires | Names available |
|---|---|---|
on action "jump" |
An input action from input.json was pressed (Input). |
|
on collide ("name or tag") |
Two physics bodies touched. | other, contact_point, contact_normal, impact |
on trigger_enter ("name or tag") |
Something entered this trigger collider. | other |
on trigger_exit ("name or tag") |
Something left this trigger collider. | other |
on ui "Name" |
The button, toggle, slider or input named Name was used (2D and UI). |
|
on dialogue "end" |
A conversation event: "start", "line", "choice", "end", or a <<command>> of a .dialogue script. |
|
on anim "footstep" |
An animation event on this entity, such as a sprite clip's frame event or "finished" (Animation). |
|
on pause, on resume |
The game was paused or resumed with pause_game() / resume_game(). Delivered to every behavior, also those the pause stops. |
wander_reference lists every trigger the running engine knows, including those added by native modules.
behavior Crate
intent "Breaks when something hits it hard, drops a coin when the player opens it."
var broken = false
on collide
if impact > 8 and not broken then
broken = true
burst(30)
play_sound("audio/crate_break.wav")
destroy self
end
end
on trigger_enter "player"
log "{other.name} is next to the crate"
end
on action "interact"
if nearest("player", 2) then
spawn("prefab:prefabs/coin.prefab.json", self.position + (0, 1, 0))
end
end
end
behavior MenuAndCutscene
on ui "Play"
log "starting the game"
end
on dialogue "end"
emit "cutscene_done"
end
on anim "footstep"
play_sound("audio/step.wav", 0.4)
end
on pause
self.color = #808080
end
on resume
self.color = #ffffff
end
end
Statements¶
| Statement | Notes |
|---|---|
let x = v, let x: number = v, const y = v |
Block-scoped locals. |
x = v, set x to v, x += v (-=, *=, /=) |
Assign to locals, vars, properties (self.position.x = 1) and elements (items[0] = 3, stats.hp = 2). |
if c then ... elif c then ... else ... end |
then is optional. |
while c ... end |
Bounded by the step budget. |
for x in list, for i, x in list, for k, v in map, for ch in "text" |
Iterates over a snapshot: changing the collection inside the loop does not affect the loop. |
for i in 0..10 (10 excluded), for i in 1..=10, for i in 10..0 step -2 |
Numeric ranges. |
repeat n times ... end, break, continue |
|
return v, stop |
stop (or a bare return) leaves a handler. |
every 2 seconds ... end, after 1 seconds ... end |
Timers that advance each time the statement runs. |
wait 1.5, wait frames 3, wait until hp <= 0 |
Suspend the handler. Allowed in handlers and tests, not inside fn. |
go to State |
Switch the behavior's state machine. |
move e by v, move e toward p at speed, rotate e by (0, 90 * dt, 0), look e at p |
Transforms in meters and degrees. Entities count as their world position. |
emit "evt" (with payload) (to entity) |
Delivered next tick, in emission order. Without to, the event is broadcast. |
destroy e, log value |
destroy is deferred to the end of the tick. log writes to the runtime log (logs). |
expect c ("why"), press "k", hold "k", release "k", click e |
Tests only. |
behavior Loops
var total = 0
var names: list = []
on start
for i in 1..=10
total += i
end
for i in 10..0 step -2
if i == 4 then continue end
names.push("wave {i}")
end
let stats = {hp: 3, armor: 1}
for key, value in stats
log "{key} = {value}"
end
let n = 0
while n < 5
n += 1
if n == 3 then break end
end
repeat 3 times
total -= 1
end
set total to total * 2
end
end
behavior Timers
var lit = true
on tick
every 2 seconds
lit = not lit
if lit then
self.light.intensity = 3
else
self.light.intensity = 0
end
end
end
end
Note
every and after advance only while the statement runs. Put them in on tick (or in a state's on tick)
so they are evaluated every tick.
Values, operators and properties¶
| Kind | Literals and notes |
|---|---|
| Numbers | 3, 1.5, -2 (double precision) |
| Strings | "text" with {interpolation}: "hp: {hp}"; \{ for a literal brace |
| Booleans and none | true, false, none |
| Colors | #rgb, #rrggbb, #rrggbbaa |
| Vectors | (x, y, z); two components give z = 0; four components make a color |
| Lists | [1, 2, 3] |
| Maps | {hp: 3, "max hp": 5} |
| Entities | Results of self, other, find, nearest, spawn, ... |
Operators: + - * / %, comparisons < <= > >= == !=, logic and or not, membership x in list, x in map
(a key), x in "string" (a substring). "text" + anything joins text; list + list concatenates; vectors and colors
do component-wise math. 0, "", none and empty lists and maps are false in conditions; and and or produce
booleans.
Names that are always available:
| Name | Value |
|---|---|
self |
The entity running the behavior |
other |
The sender of an event, or the other entity of a contact |
dt |
Seconds per tick (1/60, scaled by the time scale) |
time |
Game time in seconds since play started (stops while paused) |
frame |
The tick counter |
pi |
3.14159... |
state, state_time |
The current state's name and the seconds spent in it |
data |
The payload of the event being handled |
contact_point, contact_normal, impact |
Contact data in on collide |
hit_point, hit_normal, hit_distance |
Details of the last raycast hit |
Entity properties: position, rotation (Euler degrees), scale, color, name, id, enabled, tags, parent,
state; components as e.light.intensity or e.particles.rate; another entity's vars as e.hp. Vectors have
.x .y .z .length, colors .r .g .b .a, lists and strings .length, maps .key. self.position is relative to the
parent; world_position(e) gives the world position.
behavior Beacon
param pulse = 2 in 0.1..10 "pulses per second"
on tick
let k = (sin(time * pulse * 2 * pi) + 1) / 2
self.light.intensity = lerp(1, 6, k)
self.color = hsv(time * 0.1, 0.8, 1)
let p = nearest("player", 20)
if p and distance(self, p) < 3 then
log "{p.name} is {round(distance(self, p), 1)} m away"
end
end
end
Lists and maps are values¶
Lists and maps have value semantics: assignment copies (copy-on-write, so copies are cheap) and let copy = items
never aliases. Mutate in place with methods: self.inventory.push(item). Methods that change their receiver write the
result back to where it came from, so pushing onto a var updates the var. This keeps every value serializable (vars are
JSON) and makes reference cycles impossible.
behavior Inventory
var items: list = ["sword"]
var stats: map = {gold: 10}
on event "pickup" with p
let snapshot = items
items.push(p.get("item", "rock"))
stats.gold = stats.get("gold", 0) + p.get("gold", 0)
log "had {snapshot.length} items, now {items.length}"
if "key" in items then
emit "can_open_doors" to self
end
end
on event "drop"
if not items.is_empty() then
let dropped = items.pop()
log "dropped {dropped}"
end
end
end
Types¶
Annotations are optional: number, bool, string, vec, color, entity, list, map, any, none, unions
such as number|string, and T? for "T or none". Without annotations, types are inferred: let x = 3 is a number,
var target = none is any. The compiler reports an error only when a mismatch is certain ("a" - 1,
distance("a", self), assigning a string to a number var, v.w on a vector) and stays silent when a value could be
several things. You get type errors before play without writing annotations.
fn mix(a: number, b: number, t: number) -> number
return a + (b - a) * clamp(t, 0, 1)
end
behavior Typed
var label: string|number = "none yet"
var target: entity? = none
on tick
target = nearest("enemy")
if target then
label = round(mix(0, 100, distance(self, target) / 50))
end
end
end
Runtime semantics¶
Tick order¶
Every fixed tick, the runtime visits active entities in scene order (reordered by process.priority, lower first),
and for each script:
- starts it if it is new: var initializers,
on start, the initial state'son enter; - resumes waiting handlers whose wait elapsed;
- delivers the events emitted during the previous tick, in emission order;
- delivers physics contacts, key presses, input actions and clicks;
- runs
on tick: the behavior-level handler first, then the current state's.
Spawned entities start their behaviors on the next tick. Destroyed entities disappear at the end of the tick.
Coroutines and wait¶
A handler that reaches wait is suspended with its locals and resumes on a later tick: wait 0.5 is 30 ticks at
60 Hz, wait frames 3 is 3 ticks, wait until c re-checks the condition once per tick. A waiting on tick handler does
not start again until it finishes; other handlers start a new run per trigger, up to 64 waiting runs per script.
Leaving a state cancels the waiting runs of that state's handlers. Stopping play clears everything.
behavior Door
intent "When it receives open, slide up over a second, stay open three seconds, then close."
var busy = false
on event "open"
if busy then stop end
busy = true
repeat 60 times
move self by (0, 3 / 60, 0)
wait frames 1
end
wait 3
repeat 60 times
move self by (0, -3 / 60, 0)
wait frames 1
end
busy = false
end
end
State machines¶
The first declared state is the initial one. go to X ends the running handler, runs the old state's on exit, then
the new state's on enter. state and state_time read the current state; e.state reads another entity's. More
than 32 transitions in one tick (states bouncing back and forth) is a runtime error.
behavior Firefly
state Idle
on enter
self.light.intensity = 0.5
end
on tick
if state_time > 2 then go to Glow end
end
end
state Glow
on enter
self.light.intensity = 4
end
on exit
log "glowed for {round(state_time, 2)} s"
end
on tick
if state_time > 0.5 then go to Idle end
end
end
end
Vars and tools¶
While the game plays, vars live in fast per-entity tables and are mirrored into the entity's vars after every tick,
so entity_get and the inspector always show current values. Edits made between ticks by tools are picked up on the
next tick. Replacing a behavior while playing restarts its instance.
Step budget and errors¶
Each handler run has a budget of 1,000,000 steps. Every loop iteration charges the loop body's length and every call
the callee's length; heavy builtins such as sort charge per element. Exceeding the budget aborts the run with an
error at the loop's line, so no script can hang the engine.
A runtime error (with its line, and its file for module code) aborts that handler run and is reported in logs. After
five errors the script is disabled until play restarts.
Determinism¶
Randomness comes from the scene's seed (random, chance, pick, shuffle, random_int) and there is no
wall-clock access. Two runs of the same scene produce the same trace, in the VM and in AOT-compiled native code.
Limits¶
| Limit | Value |
|---|---|
| Steps per handler run | 1,000,000 |
| Recursion depth | 200 |
| Waiting runs per script | 64 |
| State transitions per tick | 32 |
spawn calls per tick |
256 |
| Entities | 20,000 |
| Runtime errors before a script is disabled | 5 |
Pause and time scale¶
pause_game(), resume_game() and time_scale(x) apply from the next tick. While the game is paused, behaviors of
pausable entities (the default) stop: no on tick, no waits or timers advancing, no input. They still receive
on pause and on resume, and events they have handlers for wait and arrive on resume. Entities whose
process.mode is always or when_paused keep running, and UI canvases run always by default: that is where pause
menu logic goes.
dt is scaled by the time scale (it is real time on entities with process.clock set to real). time is game time
and stops while paused; unscaled_dt() and unscaled_time() are real time. See
Simulation and time.
behavior PauseMenu
intent "Escape toggles the pause; the panel shows while paused."
on action "pause"
if is_paused() then
resume_game()
else
pause_game()
end
end
on pause
find("PausePanel").ui.visible = true
end
on resume
find("PausePanel").ui.visible = false
end
on ui "Resume"
resume_game()
end
end
Put this behavior on a UI canvas entity (canvases run always) and keep that entity enabled: a disabled entity runs
nothing, not even on pause.
on frame: cosmetic handlers¶
on tick runs at the fixed 60 Hz. On a 120 Hz display the engine already shows motion between ticks (render
interpolation), but some touches are best computed per displayed frame: camera shake, bobbing, flicker, UI tweens.
on frame handlers run once per displayed frame while the game plays, after interpolation, with dt = real seconds
since the last frame (scaled by the time scale) and time = the displayed game time.
They are cosmetic: everything they write is undone after the frame, so the simulation, and every replay, test and trace, is identical however many frames are shown. They may:
- set
position,rotation,scale,colorand fields oftransform,mesh,light,camera,sprite,text,uiandlight2d; - read anything and call pure functions and read-only queries (
find,distance,key,axis,raycast,anim_state,noise,log, ...).
They may not change vars, wait, use every or after, go to, move, rotate, look, emit, spawn,
destroy, or call random (it advances the seeded generator; use noise). The compiler reports these as
frame_not_cosmetic with a hint. Functions they call are checked at run time: the first violation stops that script's
frame handlers until the next play.
behavior CameraShake
var trauma = 0
on event "hit"
trauma = min(1, trauma + 0.5)
end
on tick
trauma = max(0, trauma - dt * 1.5)
end
on frame
let k = trauma * trauma * 0.3
let jitter = (noise(time * 40) - 0.5, noise(time * 40 + 9) - 0.5, 0)
self.position = self.position + jitter * k
end
end
The pattern is general: keep state in vars updated by on tick, and let on frame turn that state into motion.
Idioms¶
| Goal | Idiom |
|---|---|
| Smooth motion toward a goal | move self toward target at speed, or self.position = lerp(self.position, goal, 1 - exp(-8 * dt)) |
| Frame-rate independence | Multiply every per-tick change by dt. |
| Sequences of actions | on event "open" with wait between steps, no manual timers |
| Finding things | nearest("enemy", 10), find_all("coin"), find("Door"); check if x then before using a value that can be none |
| Cooldowns | var cooldown = 0, cooldown -= dt, if cooldown <= 0 then ... cooldown = 1 end |
| Modes | States (Idle, Chase, Flee) rather than boolean flags |
| Shared code | A module with fn and const, imported with use |
| Teleports | teleport(self, checkpoint) so render interpolation does not smear the jump |
Conventions: meters, +Y up, entities face −Z, rotations are Euler degrees (pitch X, yaw Y, roll Z).
behavior Chaser
intent "Chase the player at 4 m/s, dash every 3 seconds, respawn at the start when it falls."
param speed = 4 in 0..12 "chase speed (m/s)"
var dash_cooldown = 0
var home = (0, 0, 0)
on start
home = self.position
end
on tick
let p = nearest("player", 30)
if not p then stop end
move self toward p at speed
dash_cooldown -= dt
if dash_cooldown <= 0 and distance(self, p) < 6 then
move self by direction(self, p) * 2
dash_cooldown = 3
end
if self.position.y < -20 then
teleport(self, home)
end
end
end
Pitfalls¶
- An event emitted this tick arrives next tick. A handler that emits and immediately reads the receiver's state sees the old state.
self.positionis local to the parent. Compare world positions withdistance(a, b), which accepts entities.- Builtins that may return
none(nearest,find,raycast,overlap_sphere) must be checked before use: reading a property ofnoneis a runtime error. - Lists are copied on assignment:
let l = self.items; l.push(x)changes the copy, not the var. Push on the var. randominsideon frameis a compile error; usenoise(time * k).
For agents
Compile every draft before you attach it, and ask for the canonical formatting so diffs stay small:
wander_reference {"topic": "list"} # methods and signatures of one category
wander_check {"source": "behavior Bob\n on tick\n self.position.y = 1 + sin(time) * 0.2\n end\nend\n", "format": true}
wander_inspect {"entity": "Guard"} # while playing: current state, waiting handlers, vars
logs {"limit": 30} # runtime errors with line numbers
Reference¶
- Wander builtins: every function, method and subsystem trigger, generated from the registry
- Tools:
wander_reference,wander_check,wander_inspect,logs - Components:
process(pause modes, priority, clock) - Design document: docs/WANDER.md