Skip to main content

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 },
});
});
});
Why multiply by dt

dt 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
});
This is the pattern to remember

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);
});
});
Give the spawner a collider too, if physics should see it

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,
});
});
});
Place it with teleport, push it with applyImpulse

Setting 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 engine

Anything 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 });
Give it a camera the visitor can orbit, and no AR

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

Turn photo capture on

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 onlya plain variable in init
needed after the scene changesctx.store
needed by other scripts, events or patchesctx.setGlobal
an event others should react toctx.postMessage
shared with other people in a roomctx.net.state
None of it survives closing the tab

If something must be there next time, send it to your own server while the visitor is still on the page.


Next: Multiplayer