Things you will actually build
Every script here is complete. Copy one, attach it to an object, change the numbers.
If you have not written one before, start with Your first script — it explains the shape these all share.
Spin something forever
init((ctx) => {
const props = defineProps({
speed: { type: 'number', default: 45, suffix: '°/s' },
});
ctx.tick((dt) => {
const t = ctx.entity.getComponent(TransformComponent);
if (!t) return;
const turn = (props.speed * Math.PI) / 180; // degrees → radians
t.update({
rotation: { ...t.$data.rotation, y: t.$data.rotation.y + turn * dt },
});
});
});
dtdt is how long the last frame took. Multiplying by it means the object turns the same amount
per second on a fast phone and a slow one. Leave it out and your animation runs at whatever
speed the device happens to manage.
Make something bob gently
init((ctx) => {
const t = ctx.entity.getComponent(TransformComponent);
const startY = t?.$data.position.y ?? 0;
ctx.tick((dt, time) => {
t?.update({
position: { ...t.$data.position, y: startY + Math.sin(time * 2) * 0.1 },
});
});
});
Math.sin swings smoothly between −1 and 1 forever. Multiply it to set how far it moves,
multiply the time to set how fast.
Tap to collect
init((ctx) => {
const props = defineProps({
points: { type: 'number', default: 1 },
sound: { type: 'resource', resource: 'audio' },
});
ctx.on('on-click', () => {
ctx.audio.play(props.sound);
ctx.setGlobal('score', ((ctx.getGlobal<number>('score') ?? 0) + props.points));
ctx.destroy(ctx.entity);
});
});
The score lives in a global so anything else (a counter on screen, the win condition) can read it.
Show that score on screen
Put this on the object holding your interface card:
init((ctx) => {
const ui = ctx.getDivKit(ctx.entity);
ctx.subscribeGlobal('score', (value) => {
ui.set('score', Number(value ?? 0));
});
ui.set('score', ctx.getGlobal<number>('score') ?? 0); // show the starting value
});
Your logic owns the numbers; the card just displays them. Trying to keep the score inside the card breaks down as soon as anything else needs it.
A countdown
init((ctx) => {
const props = defineProps({ seconds: { type: 'number', default: 60 } });
const ui = ctx.getDivKit(ctx.entity);
let left = props.seconds;
let finished = false;
ctx.tick((dt) => {
if (finished) return;
left -= dt;
if (left <= 0) {
finished = true;
left = 0;
ctx.emit('on-signal', { signal: 'time-up' });
}
ui.set('time', `${Math.ceil(left)}`);
});
});
Do something every few seconds
init((ctx) => {
const props = defineProps({ every: { type: 'number', default: 3, suffix: 's' } });
let since = 0;
ctx.tick((dt) => {
since += dt;
if (since < props.every) return;
since = 0;
ctx.spawn(props.thing, { parent: ctx.scene });
});
});
There is no setInterval here. Counting seconds in tick is the equivalent, and it stops by
itself when the object goes away.
Spawn something that has its own behaviour
This one catches people out. ctx.spawn builds an object from a resource, so spawning a
model gives you a model — and nothing else. It does not carry a script.
For an enemy that chases, a bullet that flies, a pickup that reacts, build the object yourself and attach the behaviour:
init((ctx) => {
const props = defineProps({
model: { type: 'resource', resource: 'scene' },
brain: { type: 'resource', resource: 'script' },
});
const spawnEnemy = (x: number, z: number) => {
const enemy = ctx.create({
name: 'Enemy',
parent: ctx.scene,
components: [
new ModelRefComponent({ referal: props.model?.id ?? null }),
new ScriptComponent({ referal: props.brain?.id ?? null }),
new RigidBodyComponent({ type: 'dynamic' }),
new ColliderComponent({ type: 'capsule', radius: 0.3, height: 1.6 }),
],
});
enemy.getComponent(TransformComponent)?.update({ position: { x, y: 0, z } });
return enemy;
};
ctx.on('on-launch', () => spawnEnemy(2, 0));
});
Each spawned object gets its own instance of that script, with its own variables — so health, state and timers per enemy need nothing special:
// the enemy's own script
init((ctx) => {
let health = 3;
ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
An object built with create is exactly the components you listed. Forget the collider and it
will not collide; forget the rigid body and physics will not move it.
Find the nearest of something
The other half of a tower, an enemy, or anything that targets:
const nearest = (ctx, from: Entity, candidates: Entity[]) => {
const a = from.getComponent(TransformComponent)?.$data.position;
if (!a) return undefined;
let best: Entity | undefined;
let bestGap = Infinity;
for (const c of candidates) {
const b = c.getComponent(TransformComponent)?.$data.position;
if (!b) continue;
const gap = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (gap < bestGap) { bestGap = gap; best = c; }
}
return best;
};
Get the candidates with ctx.query(...) — ask for a component only enemies carry, or mark them
with tags when you create them and filter on that.
Follow the player, but not too closely
init((ctx) => {
const props = defineProps({
target: { type: 'entity' },
distance: { type: 'number', default: 2 },
speed: { type: 'number', default: 1.5 },
});
ctx.tick((dt) => {
const me = ctx.entity.getComponent(TransformComponent);
const them = props.target?.getComponent(TransformComponent);
if (!me || !them) return;
const a = me.$data.position;
const b = them.$data.position;
const dx = b.x - a.x;
const dz = b.z - a.z;
const gap = Math.hypot(dx, dz);
if (gap <= props.distance) return; // close enough
const step = Math.min(props.speed * dt, gap - props.distance);
me.update({
position: { ...a, x: a.x + (dx / gap) * step, z: a.z + (dz / gap) * step },
});
});
});
Dividing by gap turns the direction into a length of exactly 1, so multiplying by step
moves precisely that far. That trick comes up constantly.
Shoot what you are looking at
init((ctx) => {
ctx.on('on-click', async () => {
const hits = await ctx.raycast();
const hit = hits[0];
if (!hit?.entity) return;
const meta = hit.entity.getComponent(MetaComponent);
if (meta?.$data.name.startsWith('Target')) ctx.destroy(hit.entity);
});
});
await is needed because the ray is traced against the real scene and the answer comes back on
the next frame.
Throw a physics object
init((ctx) => {
const props = defineProps({ force: { type: 'number', default: 6 } });
ctx.on('on-click', () => {
const pose = ctx.camera.pose();
if (!pose) return;
const ball = ctx.spawn(props.ball, { parent: ctx.scene });
ctx.physics.teleport(ball, pose.position);
ctx.physics.applyImpulse(ball, {
x: pose.forward.x * props.force,
y: pose.forward.y * props.force,
z: pose.forward.z * props.force,
});
});
});
teleport, push it with applyImpulseSetting the position of a physics object directly does nothing — physics owns where it is and writes over you on the next step.
A door that opens once
init((ctx) => {
let open = false;
ctx.on('on-click', () => {
if (open) return;
open = true;
ctx.startTransition({ durationMs: 600, easing: 'ease-out' }, () => {
const t = ctx.entity.getComponent(TransformComponent);
t?.update({ rotation: { ...t.$data.rotation, y: Math.PI / 2 } });
});
});
});
startTransition is what makes the change glide instead of snapping. Everything you change
inside it eases together.
React to a collision
init((ctx) => {
ctx.on('on-collide', ({ other }) => {
const hit = ctx.get(other);
const name = hit?.getComponent(MetaComponent)?.$data.name ?? '';
if (name !== 'Player') return;
ctx.audio.play(props.thud);
ctx.postMessage('player-hit', { by: ctx.entity.id });
});
});
postMessage tells other scripts without either of them needing a reference to the other.
Wait for the scene to be tracked
init((ctx) => {
ctx.on('on-detect', () => {
ctx.step('play_animation', { presetId: 'intro' });
});
ctx.on('on-lost', () => {
ctx.step('stop_animation', { presetId: 'intro' });
});
});
ctx.step saves you rewriting the engineAnything the editor can do as a step (animations, transitions, states, navigation) a script can trigger with one call. Look through the step reference before building something by hand.
A product configurator
The commonest commercial build on this platform, and it is mostly two moves: swap what is shown, and remember what was chosen.
init((ctx) => {
const props = defineProps({
variants: {
type: 'array',
item: { type: 'resource', resource: 'material' },
label: 'Finishes',
},
prices: { type: 'array', item: { type: 'number' }, label: 'Price per finish' },
});
const ui = ctx.getDivKit(ctx.entity);
const material = ctx.entity.getComponent(MaterialComponent);
const choose = (index: number) => {
const finish = props.variants[index];
if (!finish || !material) return;
// A material slot can point at a saved material instead of carrying values.
material.update({
materials: [{ ...material.$data.materials[0], type: 'ref', referal: finish.id }],
});
ctx.setGlobal('finish', index);
ui.set('price', props.prices[index] ?? 0);
};
ui.onAction('finish-0', () => choose(0));
ui.onAction('finish-1', () => choose(1));
ui.onAction('finish-2', () => choose(2));
ui.onAction('buy', () => {
const index = ctx.getGlobal<number>('finish') ?? 0;
ctx.step('url_transit_action', {
url: `https://shop.example.com/chair?finish=${index}`,
transitionType: 'new_tab',
});
});
choose(0); // start on the first finish
});
Swapping a whole model rather than a finish is the same shape, writing referal on the
model component instead:
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
A configurator is usually a plain 3D scene: it opens instantly, works on every device, and
needs no marker. Set the camera to orbit and limit the polar angle so nobody ends up looking
at it from underneath. → Cameras
A configurator people can screenshot is a configurator people share. → Project settings
Where to put your state
| The value is… | Put it in |
|---|---|
| used by this script only | a plain variable in init |
| needed after the scene changes | ctx.store |
| needed by other scripts, events or patches | ctx.setGlobal |
| an event others should react to | ctx.postMessage |
| shared with other people in a room | ctx.net.state |
If something must be there next time, send it to your own server while the visitor is still on the page.
Next: Multiplayer