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 },
});
});
});
dtdt 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
});
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);
});
});
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.
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 }));
});
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 });
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
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 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 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.