Reference

The script API

JavaScript in a sandboxed interpreter, with a genuinely closed scope: self, world, getEntityByName and log are in it, and nothing else is. The dividing line against rules is one sentence: verbs for what happens, scripts for what a thing knows— a position that depends on last frame's, a count, a distance, a cadence.

What a script is

The Scripts panel open beside the shooter's viewport, listing the rail, mine and dust scripts with how many blueprints run each

The shooter's Scripts panel: three scripts behind fifty-seven things.

  1. 1The document’s scripts, each with how many blueprints run it. Opening one gets an editor with autocomplete, live compile checks, and a one-second dry run.
  2. 2The panel’s own dividing line: triggers and verbs stay the better answer for anything that fits them — they read as three rows rather than thirty lines.

The source lives in the document — an XP is one file, and a level whose behaviour is in four other files arrives half missing. Each source is capped at 64 kB, and each entity gets its own run of its script: two turrets sharing patrol each get their own variables, because a script is compiled as a factory, not evaluated as a module.

A script attaches to a blueprint — or to the level itself, as a director: no body, self is a handle onto nothing, and everything goes through world and getEntityByName. The director gets onSpawn and onTick, never onTrigger.

The three hooks

onSpawn()Once, when the thing comes into being — placed by the document, spawned by a rule, or spawned by another script.
onTick(dt)Every frame; dt is seconds, capped at 0.05. A script with no onTick costs nothing per frame.
onTrigger(event, other)Something happened to it — the events below. Runs after the entity’s own verbs, so a property read sees what the rules just did.

All three are optional. Within a frame: new entities get their onSpawn, then every onTick runs, then what the scripts set off among themselves is delivered — a thing spawned this frame gets its onSpawn this frame and its first tick the next.

What onTrigger hears

Eventother isFires when
enterwhoever walked ina body starts overlapping this entity
exitwhoever walked outit stops overlapping
collidewhoever hit ita collision, as opposed to an overlap
heldthe holderit goes into anybody’s hands
droppednullit is put down, however
damagednullonly when a script dealt the damage, same frame

Two things decide whether a script hears anything at all. A blueprint only receives enter/exit if it carries at least one trigger — a bare listener is conventionally a harmless emit. And the rest of the vocabulary — pressed, finished, returned, emitted — fires rules, never scripts: a script that must react to the whistle watches a property a rule wrote.

self — the entity

.x .y .z .rotation .scaleRead and write. Reading gives world coordinates; writing moves it locally — the one asymmetry, and it only shows on something with a parent.
.moveTo(x,y,z) / .moveBy(dx,dy,dz)One crossing instead of three.
.aliveWhether it still exists.
.heldRead-only: in anybody’s hands, ours or a peer’s.
.get(k) / .set(k,v) / .add(k,v)Properties. Numbers only; a missing one reads as zero.
.damage(n) / .heal(n)damage runs the entity's own damaged rules and its onTrigger; heal is add('hp', n) and wakes nothing.
.spawn(blueprint, dx, dy, dz)Relative to this entity. Gives back the new entity, or null.
.despawn()Finally.
.score(n) / .emit(event)Effects — the host decides what they mean. score credits the entity you called it on.
.distanceTo(o) / .flatDistanceTo(o)Flat ignores height, which is what “how close” means in a level with stairs.
.push(x,y,z) / .speed / .dx .dy .dzFor bodies: hit it, ask how fast it is going, steer or stop it.
.runAnimation(clip, loop?, parts?)Plays a document clip; parts like ['arms'] lays it over what the body is doing. runAnimation(null) clears it.
.intensity .range .colour .angleLamps only — writable on a blueprint the document gave a light block, clamped rather than refused. On anything else, writes do nothing.

world — the clock, the dice, the data

world.tick / world.timeFrames since the start, and seconds — the number every client agrees about, which makes it the only correct basis for movement and cooldowns.
world.random() / roll(n) / randomInt(a,b) / pick(list)Deterministic chance from the room’s seed — every client rolls the same. randomInt is inclusive at both ends; pick of an empty list is undefined, not a crash.
world.get(k) / set / addThe level’s declared data. An undeclared field does not stick — get reads 0, set says so in the log.
world.spend(k, n)Take some if there is some, and answer whether there was. One call, so checking and taking cannot come apart.
log(...)To the host’s log panel, capped at 200 lines. Not a console.

One gap worth knowing before it costs you an evening: the level's data is not reachable from onTrigger — reads answer 0 and writes do nothing, silently. The pattern round it is a flag: the hook sets a variable, and onTick does the spending.

let asked = false

function onTrigger(event) {
  if (event === 'enter') asked = true
}

function onTick() {
  if (!asked) return
  asked = false
  if (world.spend('coins', 5)) self.spawn('prize', 0, 1, 0)
  else log('not enough coins')
}

What was taken away

A fresh sandbox simply is the language: no fetch, no setTimeout, no window. Two more are removed deliberately, because two clients run the same script over the same entities and have to agree: Date is deleted — a clock is per machine — and Math.random throws, with a message naming the replacement, rather than disappearing. Both are the first things anybody reaches for, and a script using either looks correct on the machine it was written on and desynchronises everywhere else.

There is no setTimeout because a delay is world.time and a number you kept — the only version of a delay two clients agree about:

let ready = 0

function onTick() {
  if (world.time < ready) return
  ready = world.time + 1
  self.add('shots', 1)
}

When one goes wrong

One throw stops that entity'sscript, permanently, for that run — it would otherwise throw the same failure sixty times a second and bury the one that mattered. The rest of the level keeps running. Failures are shown on the HUD during play with the script's name and your line numbers; compile errors arrive as document problems when the level opens. A script that quietly stopped is the failure all of this exists to prevent.

Limits, and what a hook costs

SourceSixty-four kilobytes per script — every byte is compiled before anything draws.
MemoryFour megabytes, shared by every script in one XP.
FuelRoughly twenty thousand operations per hook call — an accidental while(true) is cut off. A count of operations rather than a deadline, so a slow machine and a fast one cut off at the same place and stay in sync.
LogTwo hundred lines, oldest dropped.

The real cost is not lines of JavaScript but crossings — each call that touches the world crosses the sandbox boundary. A thousand entities doing arithmetic is four per cent of a frame; the same thousand looking each other up is most of one. Cache what getEntityByName gave you, read self.x once into a variable — the way to raise the ceiling is to touch the world less, not to write less code.

What a script cannot do

React to the whistlefinished, pressed and returned fire rules, never scripts. Watch a property a rule wrote.
Hear an emitted nameemit reaches rules. A script listens by having such a rule write a property it watches.
WaitNo setTimeout — a deadline in world.time is the only delay clients agree on.
Keep a secretEverything a script computes, every client can recompute. Hidden state needs a server.
Push youBeing carried is standing on top of something; a block moving into you stops you, like a wall somewhere else next frame.

For working code to start from, the shooter write-up walks two real scripts — the runner on a clock and the mine that comes for you — line by line.