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 shooter's Scripts panel: three scripts behind fifty-seven things.
- 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.
- 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
| Event | other is | Fires when |
|---|---|---|
enter | whoever walked in | a body starts overlapping this entity |
exit | whoever walked out | it stops overlapping |
collide | whoever hit it | a collision, as opposed to an overlap |
held | the holder | it goes into anybody’s hands |
dropped | null | it is put down, however |
damaged | null | only 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 .scale | Read 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. |
.alive | Whether it still exists. |
.held | Read-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 .dz | For 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 .angle | Lamps 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.time | Frames 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 / add | The 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
| Source | Sixty-four kilobytes per script — every byte is compiled before anything draws. |
| Memory | Four megabytes, shared by every script in one XP. |
| Fuel | Roughly 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. |
| Log | Two 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 whistle | finished, pressed and returned fire rules, never scripts. Watch a property a rule wrote. |
| Hear an emitted name | emit reaches rules. A script listens by having such a rule write a property it watches. |
| Wait | No setTimeout — a deadline in world.time is the only delay clients agree on. |
| Keep a secret | Everything a script computes, every client can recompute. Hidden state needs a server. |
| Push you | Being 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.