Skip to main content

Recipes

Complete scripts for tasks that come up in almost every project. Copy one, attach it to an object and change the numbers.

New to scripts? Start with First script. It explains the parts all these scripts share.

Each script imports the component classes it uses from @was/engine. init and defineProps need no import.

Spin an object​

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

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. When you multiply by it, the object turns at the same speed per second on fast and slow phones. Without it, the speed depends on the device.

Bob up and down​

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

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 moves smoothly between −1 and 1, over and over. Multiply the result to change how far the object moves. Multiply the time to change how fast.

Tap to collect​

init((ctx) => {
const props = defineProps({
points: { type: 'number', default: 1 },
sound: { type: 'resource', resource: 'audio' },
});

ctx.on('on-click', () => {
if (props.sound) ctx.audio.play(props.sound.id);
ctx.setGlobal('score', (ctx.getGlobal<number>('score') ?? 0) + props.points);
ctx.destroy(ctx.entity);
});
});

The score is kept in a global, so other things can read it too, like a counter on screen or a check for the win.

Show the score on screen​

Put this script on the object that has your UI 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
});
Keep the numbers in your script

Your script keeps the numbers, and the card only shows them. If you keep the score inside the card, it stops working as soon as something else needs it.

Countdown​

Counts down on a UI card. When time is up, it sends the signal time-up. Every event with the Signal trigger set to time-up then runs.

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.step('emit_signal', { signal: 'time-up', scope: 'broadcast' });
}

ui.set('time', `${Math.ceil(left)}`);
});
});

Repeat every few seconds​

init((ctx) => {
const props = defineProps({
thing: { type: 'resource', resource: 'scene', label: 'Model' },
every: { type: 'number', default: 3, suffix: 's' },
});
let since = 0;

ctx.tick((dt) => {
since += dt;
if (since < props.every) return;
since = 0;

if (props.thing) ctx.spawn(props.thing, { parent: ctx.scene });
});
});

Scripts have no setInterval. Count the seconds in tick instead. It stops by itself when the object is removed.

A 3D model is a resource of type scene, so a model setting is written as { type: 'resource', resource: 'scene' }.

Spawn an object with a script​

ctx.spawn builds an object from a resource. Spawn a model, and you get only the model, without a script.

For an enemy that chases, a bullet that flies or a pickup that reacts, build the object yourself and add the script:

import {
ColliderComponent,
ModelRefComponent,
RigidBodyComponent,
ScriptComponent,
TransformComponent,
} from '@was/engine';

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 copy of the script, with its own variables. So each enemy has its own health, state and timers, with no extra work:

// the enemy's own script
init((ctx) => {
let health = 3;

ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
Add a collider if physics should see it

An object made with create has only the components you list. Without a collider, it does not collide. Without a rigid body, physics does not move it.

Find the nearest object​

Useful for a tower, an enemy, or anything that picks a target:

import { Entity, TransformComponent } from '@was/engine';

const nearest = (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 that only enemies have. Or add tags when you create them, and filter by tag.

Follow the player​

Moves toward a target and stops at a set distance:

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

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 makes the direction exactly 1 unit long. Then multiplying by step moves the object exactly that far. You will use this often.

Shoot what you look at​

import { MetaComponent } from '@was/engine';

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);
});
});

You need await because the answer comes in the next frame.

Throw a physics object​

import {
ColliderComponent,
InitialVelocityComponent,
ModelRefComponent,
RigidBodyComponent,
TransformComponent,
} from '@was/engine';

init((ctx) => {
const props = defineProps({
ball: { type: 'resource', resource: 'scene', label: 'Ball' },
speed: { type: 'number', default: 6, suffix: 'm/s' },
});

ctx.on('on-click', () => {
const pose = ctx.camera.pose();
if (!pose || !props.ball) return;

const { x, y, z } = pose.forward;
const ball = ctx.create({
name: 'Ball',
parent: ctx.scene,
components: [
new ModelRefComponent({ referal: props.ball.id }),
new RigidBodyComponent({ type: 'dynamic' }),
new ColliderComponent({ type: 'sphere', radius: 0.1 }),
// the speed the body starts with, applied once
new InitialVelocityComponent({
velocity: { x: x * props.speed, y: y * props.speed, z: z * props.speed },
}),
],
});
ball.getComponent(TransformComponent)?.update({ position: pose.position });
});
});

The camera pose is in world space, and the ball's position is measured from its scene. In a 3D scene, the two are the same.

Set the start before physics takes over

Physics reads the position and the initial velocity once, when it builds the body. After that, setting the position does nothing. Use ctx.physics.teleport to place the body, and ctx.physics.applyImpulse to push it.

Open a door once​

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

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 makes the change smooth instead of instant. Everything you change inside it moves together.

React to a collision​

import { MetaComponent } from '@was/engine';

init((ctx) => {
const props = defineProps({
thud: { type: 'resource', resource: 'audio' },
});

ctx.on('on-collide', ({ other }) => {
const hit = ctx.get(other);
const name = hit?.getComponent(MetaComponent)?.$data.name ?? '';
if (name !== 'Player') return;

if (props.thud) ctx.audio.play(props.thud.id);
ctx.postMessage('player-hit', { by: ctx.entity.id });
});
});

postMessage tells other scripts what happened. Neither script needs a reference to the other.

Wait for tracking​

Plays an animation when the scene is found, and stops it when tracking is lost:

init((ctx) => {
const props = defineProps({
animation: { type: 'string', default: 'Intro', label: 'Animation name' },
});

// The steps take the animation's id. Find it by the name shown in the Animation panel.
const presets = ctx.entity.getComponent('TemporalStateComponent')?.$data.presets ?? [];
const presetId = presets.find(
(p: { id: string; name: string }) => p.name === props.animation,
)?.id;
if (!presetId) return;

ctx.on('on-detect', () => ctx.step('play_animation', { presetId }));
ctx.on('on-lost', () => ctx.step('stop_animation', { presetId }));
});
Check the steps first

A script can run anything the editor does as a step in one call: animations, transitions, states, navigation. Look through the steps before you build something by hand.

Product configurator​

A configurator does two things: it changes what is shown, and remembers what the visitor chose.

import { MaterialComponent } from '@was/engine';

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
});

To swap a whole model instead of a finish, add a list of models to defineProps, and change referal on the model component inside choose:

import { ModelRefComponent } from '@was/engine';

// in defineProps
models: { type: 'array', item: { type: 'resource', resource: 'scene' }, label: 'Models' },

// in choose(index)
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
Use a 3D scene and an orbit camera

A configurator is usually a plain 3D scene without AR. It opens instantly, works on every device and needs no marker. Set the camera to orbit and limit how far it can tilt (the polar angle), so nobody sees the product from below. → Cameras

Turn on photo capture

Let visitors take a photo of what they chose, so they can share it. → Project settings

Where to keep values​

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
Closing the tab clears everything

If you need a value next time, send it to your own server while the visitor is still on the page.


Next: Multiplayer — scripts for several people in one room.