Scripting
Full reference of the sandbox.* API: events, reading game state, commands, variables and examples ready to paste into the studio.
Scripts add behaviour to a game without touching the engine. They are plain JavaScript files, stored in the game document and run inside an isolated Web Worker, with no access to the page, the DOM or the network. They read a snapshot of the game state and answer with commands the engine applies.
Rules or scripts?
Your first script
This script counts collected coins and wins the game at ten. It subscribes to two events with sandbox.on, writes a variable with sandbox.set and ends the game with sandbox.win.
sandbox.on('start', () => {
sandbox.set('coins', 0)
sandbox.message('Collect 10 coins', 3)
})
sandbox.on('pickup', ({ role }) => {
if (role !== 'coin') return
const n = (sandbox.get('coins') ?? 0) + 1
sandbox.set('coins', n)
if (n >= 10) sandbox.win('All coins collected')
})Go to the Rules step
The Scripts panel is the page’s fifth section. Click + New, or add one of the bundled examples.Paste the code and check it
The Run check button (or CtrlEnter / ⌘⏎) only checks the syntax: it never runs the code.Test
Switch to the Test step.sandbox.log()lines and errors show up in the Scripts panel console.
Where scripts live
Scripts are part of the SandboxGame document, in the optional scripts field. They are therefore hashed and published with the game: a player runs exactly the code you published.
interface ScriptDoc {
id: string
name: string
code: string
enabled: boolean
}
// SandboxGame
scripts?: ScriptDoc[]idstringrequirednamestringrequiredcodestringrequiredenabledbooleanrequiredA document can hold up to 16 scripts. They are compiled and called in document order; several scripts can listen to the same event.
Events
Register a handler with sandbox.on(event, handler). Several handlers per event are allowed; they run in registration order.
sandbox.on(event: 'start' | 'tick' | 'touch' | 'interact' | 'pickup' | 'kill' | 'timer' | 'variable', handler: (payload) => void): void| Event | Handler argument | When |
|---|---|---|
start | none | once, after the scripts are compiled (and again after a watchdog restart) |
tick | dt | up to 30 times per second while the game runs; dt is the time in seconds since the previous tick |
touch | { entityId, role, tag } | the player touched an entity with a trigger |
interact | { entityId, role, tag } | the player pressed E on an entity with a trigger, or talked to an NPC |
pickup | { entityId, role, tag } | the player collected a coin or a key |
kill | { entityId, role, tag } | an enemy died, including through sandbox.hurt |
timer | { name } | a timer set with sandbox.timer elapsed |
variable | { name, value, previous } | a variable changed, whoever changed it (script, trigger, rule, game) |
The tag field
In { entityId, role, tag } payloads, tag is the entity’s tag parameter, otherwise its label, otherwise an empty string. Give your entities a tag in the inspector to recognise them easily.
sandbox.on('interact', ({ tag }) => {
if (tag === 'lever') sandbox.message('Something opened far away', 2)
})Reading state
The engine sends the worker a snapshot of the world, at most 30 times per second. Everything below reads that snapshot and returns a copy: mutating what you get changes nothing in the game. To act, use commands.
sandbox.timenumbersandbox.player{ id, x, y, z, yaw, hp }sandbox.entities(){ id, role, x, y, z, alive, tag, hp }[]sandbox.find(id)Entity | nullsandbox.byRole(role)Entity[]'coin', 'enemy', 'npc'…).sandbox.dist(a, b)numbersandbox.get(name)unknownsandbox.hud{ status, score, time, message }Entity roles
The role field is the document’s EntityRole. The snapshot lists runtime actors, so any role present in the game can show up: player, prop, obstacle, coin, checkpoint, finish, enemy, hazard, boost, spawn, text, door, key, platform, trigger, npc, light, spawner, sound, flag, base, turret, waypoint. See Entity roles.
sandbox.on('tick', () => {
const p = sandbox.player
const near = sandbox.byRole('enemy').filter((e) => e.alive && sandbox.dist(p, e) < 3)
if (near.length) sandbox.message('Watch out!', 0.5)
})Commands
Commands are queued during the handler and applied by the engine, in order, on the next frame. They return nothing. Arguments are coerced (numbers, strings) and validated engine side: an unknown id is ignored without an error.
sandbox.set
sandbox.set(name: string, value: unknown): voidWrites a variable (any JSON-serialisable value). Visible to sandbox.get at once in the same script. See Variables.
sandbox.message
sandbox.message(text: string, seconds = 2.5): voidShows a HUD message for the given duration.
sandbox.score
sandbox.score(delta: number): voidAdds (or removes, with a negative value) points to the score.
sandbox.teleport
sandbox.teleport(x: number, y: number, z: number, entityId?: string): voidMoves the player, or the given entity, and resets its velocity. The camera follows a teleported player.
sandbox.remove / sandbox.show
sandbox.remove(entityId: string): void
sandbox.show(entityId: string): voidremove hides an entity (never the player); it can be shown again with show, which also reveals entities hidden at start.
sandbox.spawn
sandbox.spawn(assetId: string, role: EntityRole, x: number, y: number, z: number, params?: EntityParams): voidCreates an entity from an asset of the document. params is an EntityParams (hp, behavior, speed, tag…). Without params, an enemy gets { behavior: 'chase', speed: 2.4, hp: 1 }. The entity rests on the ground when y is below the terrain; prop and obstacle roles are solid.
Finding an asset id
doc.assets[i].id). The usual trick: copy an existing entity through sandbox.byRole('enemy')[0], or store the id in a variable (sandbox.set('enemyAsset', '…')) then sandbox.spawn(sandbox.get('enemyAsset'), …). At most 20 spawns per frame and 400 actors in total; past that, spawns are ignored.sandbox.hurt / sandbox.heal
sandbox.hurt(entityId: string, amount = 1): void
sandbox.heal(amount = 1): voidhurt on the player removes health; on another entity it removes hp, and the entity dies at 0 (the kill event then fires). heal heals the player.
sandbox.win / sandbox.lose
sandbox.win(text?: string): void // default "You made it"
sandbox.lose(text?: string): void // default "Game over"Ends the game, won or lost, with optional text.
sandbox.move / sandbox.look
sandbox.move(entityId: string, dx: number, dy: number, dz: number): void
sandbox.look(entityId: string, yawDeg: number): voidmove offsets an entity by (dx, dy, dz); look sets its heading in degrees.
sandbox.timer / sandbox.clearTimer
sandbox.timer(name: string, seconds: number, repeat = false): void
sandbox.clearTimer(name: string): voidFires the timer event with { name } after seconds (0.05 s minimum), once or repeating. Timers run inside the worker, with no round trip to the engine. Setting a timer with the same name replaces it; clearTimer cancels it. Each script has its own timers.
sandbox.log
sandbox.log(...args: unknown[]): voidPrints to the studio console, prefixed with the script name. Objects are JSON-stringified. At most 20 lines per handler.
Variables
Variables belong to the engine. Scripts, triggers, rules and game systems can all read and write them, and the HUD can display them (Variables and HUD). The variable event fires for every change seen between two ticks.
- A
setis visible togetimmediately in the same script, even though the engine only stores it a frame later: a counter incremented on two consecutive events counts both. - If the engine writes a different value in between, the engine’s value wins.
- Variables survive a watchdog restart, unlike JavaScript variables in your closures: keep the state that matters in
sandbox.set.
Examples
The first three are also one click away in the studio Scripts panel.
// Counts coin pickups and wins the game at 10.
sandbox.on('start', () => {
sandbox.set('coins', 0)
sandbox.message('Collect 10 coins', 3)
})
sandbox.on('pickup', ({ role }) => {
if (role !== 'coin') return
const n = (sandbox.get('coins') ?? 0) + 1
sandbox.set('coins', n)
sandbox.message(`${n} / 10`, 1)
if (n >= 10) sandbox.win('All coins collected')
})// Two enemies around the player every 8 seconds.
sandbox.on('start', () => sandbox.timer('wave', 8, true))
sandbox.on('timer', ({ name }) => {
if (name !== 'wave') return
const model = sandbox.byRole('enemy')[0]
if (!model) return sandbox.log('no enemy in the world to copy')
const p = sandbox.player
for (let i = 0; i < 2; i++) {
const a = Math.random() * Math.PI * 2
sandbox.spawn(sandbox.get('enemyAsset') ?? model.id, 'enemy', p.x + Math.cos(a) * 6, p.y + 1, p.z + Math.sin(a) * 6)
}
sandbox.message('Incoming!', 1.5)
})// Reacts to a variable, whoever changes it.
sandbox.on('variable', ({ name, value, previous }) => {
if (name !== 'mood') return
sandbox.log('mood', previous, '->', value)
if (value === 'happy') sandbox.message('The sun comes out', 2)
if (value === 'angry') {
sandbox.message('Careful...', 2)
sandbox.hurt(sandbox.player.id, 1)
}
})
sandbox.on('start', () => sandbox.timer('flip', 5, true))
sandbox.on('timer', () => sandbox.set('mood', sandbox.get('mood') === 'happy' ? 'angry' : 'happy'))// 60 seconds to finish, with a message every second.
sandbox.on('start', () => sandbox.set('left', 60))
sandbox.on('tick', (dt) => {
const left = sandbox.get('left') - dt
sandbox.set('left', left)
if (left <= 0) sandbox.lose('Out of time')
else if (Math.floor(left) !== Math.floor(left + dt)) sandbox.message(`${Math.ceil(left)} s`, 1)
})The Scripts panel
On the Rules step, the panel lists the document’s scripts (16 at most), with a checkbox to enable or disable each one, a delete button and the examples to add. The editor indents with Tab (two spaces) and checks the syntax with CtrlEnter. The console keeps the last 50 lines: your sandbox.log() output, runtime errors and watchdog messages.