Skip to main content

Your first script

A script is a TypeScript file you attach to an object. It runs in the player, once for every object that uses it.

Here is a complete one — tap the object and it starts spinning:

Scripts/spinner.ts
init((ctx) => {
const props = defineProps({
speed: { type: 'number', default: 1, min: 0, label: 'Speed' },
});

let spinning = false;

ctx.on('on-click', () => {
spinning = !spinning;
});

ctx.tick((dt) => {
if (!spinning) return;

const t = ctx.entity.getComponent(TransformComponent);
t?.update({
rotation: { ...t.$data.rotation, y: t.$data.rotation.y + props.speed * dt },
});
});

return () => {
// optional: tidy up when this object goes away
};
});

Four things are going on:

initreceives ctx, your handle on the running scene. Everything starts here
definePropsdeclares settings the editor shows in the inspector, filled in per object
ctx.onsubscribes to a trigger — the same triggers events and patches use
ctx.tickruns every frame, with dt in seconds

init and defineProps are always available — you never import them.

Reading and writing objects

const transform = ctx.entity.getComponent(TransformComponent);

transform.position; // read one value
transform.$data; // read the whole thing as a plain object
transform.update({}); // write

Two traps, and they are the two that catch everybody:

Writing a nested value replaces all of it

update({ position: { y: 2 } }) also sets x and z to zero — you handed it a whole new position with only y filled in.

Spread what you want to keep:

transform.update({ position: { ...transform.$data.position, y: 2 } });
A material is a list of slots

material.update({ color }) does nothing at all, because color lives inside a slot rather than at the top. Write the slot:

material.update({
materials: [{ ...material.$data.materials[0], color: '#ff0000' }],
});

And never assign into $data directly. It looks like it worked, and the change is dropped.

Settings in the inspector

defineProps is what makes a script worth reusing: the same script on ten objects, configured differently on each, with no copy-paste.

const props = defineProps({
speed: { type: 'number', default: 1, min: 0, softMax: 10, suffix: 'm/s' },
target: { type: 'entity', label: 'Look at' },
sound: { type: 'resource', resource: 'audio' },
mode: { type: 'select', options: ['chase', 'patrol'], default: 'patrol' },
});
TypeThe editor showsYour script gets
numbera field or slidera number
stringa text fielda string
booleana switcha boolean
colora colour pickera colour string
selecta dropdownone of your options
entityan object pickerthe object itself, ready to use
scenea scene pickerthe scene object
resourcea resource pickera reference you can pass along
arraya listan array
groupa titled blocka nested object

Worth knowing: label and help for the panel, min and max for genuine limits, softMax for where a slider ends without forbidding larger typed values, showWhen to reveal a field only when another has a particular value, and group to keep a long list tidy.

defineProps has to be written out literally

The editor reads your settings without running the script, so it needs to see them directly — not built from variables or returned by a function.

Values are read fresh every time, so props.speed always reflects what is in the inspector right now.

Importing things

import { TransformComponent } from '@was/engine';
import helpers from 'Scripts/helpers';

You can import the engine's component classes, and other resources by path — another script gives you its exports, a patch gives you its compiled module, and anything else gives you a reference you can hand to ctx.spawn or ctx.audio.play.

Rename or move a resource and these imports update themselves.

What the sandbox gives you — and does not

Scripts run isolated from the page, which keeps a heavy script from stalling rendering. So these are not available:

  • window, document, the DOM;
  • fetch, localStorage, network access of any kind;
  • any rendering library — you change the scene through components, not by drawing;
  • browser timers — use ctx.tick instead.

What you use instead: UI cards for interface, ctx.audio for sound, ctx.store and globals for keeping things.

When your script starts and stops

An instance is created when its object is live and on screen — nothing above it disabled, its scene active — and destroyed when that stops being true.

on-launch fires as soon as it is created, so you never miss it. The function you return from init is your cleanup: unsubscribe, stop sounds, clear state.

Scripts do not run in the editor, and there is no hot reload

The editor draws your scene but does not execute logic. Edit, then open Preview. A running experience picks up an edited script when it next restarts.


Next: Where a script lives