---
title: Scripting Walkthrough
description: Learn Needle Engine scripting one step at a time — short, self-contained examples running live next to the code that produces them.
editLink: true
---

<ask-ai />

# Scripting Walkthrough

Learn Needle Engine scripting one idea at a time. Each step runs live beside the script that drives it.

The first few steps cover the basics the rest builds on. After that you can jump to whatever you need.

Each example is an HTML page that loads the engine from a CDN, plus the script shown here. The page never changes, so only the script is printed. Both files are in [the docs repository](https://github.com/needle-tools/needle-engine-support/tree/main/documentation/.vuepress/public/code-samples).

There is no build step, so the code is plain JavaScript. See [marking fields as serializable](#marking-fields-as-serializable) for what TypeScript adds.

::: tip New to Needle Engine?
The components you write here are the same ones an artist configures in Unity or Blender, and the same code runs on desktop, mobile and in XR. [Why Needle Engine exists](/docs/why) covers the problem it solves and how it compares to three.js, React Three Fiber and Unity WebGL.
:::

::: tip Looking for something else?
[Scripting Examples](/docs/reference/scripting-examples) has copy-paste snippets by topic. The [samples gallery](https://engine.needle.tools/samples?utm_source=needle_docs&utm_content=walkthrough) has finished projects to pull apart.
:::

---

## 01 · Your first component

<walkthrough-tags symbols="Behaviour, update, addComponent" />

<walkthrough-takeaway>

Write a class, attach it to an object, and it starts running. That same class shows up as a component in Unity and Blender, so an artist can use it without touching code.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-01-first-component.html" title="A rotating shape driven by a Behaviour component">

```js
import { Behaviour, onStart } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';
configureDemoScene({ 
  useContactShadows: true,
});

// A component is a class extending Behaviour.
// Override only the lifecycle methods you need.
class Rotate extends Behaviour {
  speed = 0.6;

  update() {
    const dt = this.context.time.deltaTime;
    this.gameObject.rotation.y += this.speed * dt;
    this.gameObject.rotation.x += this.speed * 0.35 * dt;
  }
}

onStart(context => {
  const radius = 1;
  const shape = new THREE.Mesh(
    new THREE.IcosahedronGeometry(radius, 0),
    new THREE.MeshPhysicalMaterial({
      color: '#b3ffbd',
      roughness: 0.4,
      metalness: 1,
      iridescence: 1,
      iridescenceIOR: 1.2,
      iridescenceThicknessRange: [10, 120],
      flatShading: true,
    })
  );
  // Lift it by its radius so it sits on the floor instead of through it.
  shape.position.y = radius;
  context.scene.add(shape);

  // Attach it — the component wires itself into the render loop.
  shape.addComponent(Rotate);
});
```
</walkthrough-step>

A component is a class extending `Behaviour`. Override the methods you want, then attach it with `addComponent`. This one overrides `update`, which the engine calls once per frame.

Two properties are available inside any component. `this.gameObject` is the object it is attached to. `this.context` is the shared runtime: time, input, physics, the scene.

The rotation is multiplied by `this.context.time.deltaTime`, the seconds since the last frame. This is what keeps the speed the same on every device. Leave it out and the shape turns per frame instead of per second, so it spins faster on hardware that draws more frames.

::: info Coming from plain three.js?
Normally you keep one `animate()` function that calls into every moving part, and add each new one to it by hand. Here `update` sits on the component itself. Adding behaviour to an object never means editing a shared function, and deleting the object takes its logic with it.
:::

→ [Create Components](/docs/how-to-guides/scripting/create-components) · [Lifecycle Hooks](/docs/how-to-guides/scripting/use-lifecycle-hooks)

---

## 02 · Several components on one object

<walkthrough-tags symbols="addComponent, awake" />

<walkthrough-takeaway>

Build behaviour by stacking small components rather than writing one big one. An object can hold any number, and you can add or remove them while the scene runs.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-02-composition.html" title="One shape driven by three independent components at once">

```js
import { Behaviour, onStart } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

// Each component does one small thing, and knows nothing about the others.
class Rotate extends Behaviour {
  speed = 0.6;

  update() {
    this.gameObject.rotation.y += this.speed * this.context.time.deltaTime;
  }
}

class MoveUpDown extends Behaviour {
  amplitude = 0.5;
  frequency = 0.5;

  awake() {
    // Remember where we started, so the motion is relative to it.
    this.baseY = this.gameObject.position.y;
  }

  update() {
    const t = this.context.time.time;
    const wave = Math.sin(t * this.frequency * Math.PI * 2);
    // Map the wave to 0..1 so the object only ever rises from where it
    // started, instead of sinking below the ground on the way down.
    this.gameObject.position.y = this.baseY + (wave + 1) * 0.5 * this.amplitude;
  }
}

class Breathe extends Behaviour {
  amount = 0.3;
  frequency = 1.5;

  update() {
    const t = this.context.time.time;
    const s = 1 + Math.sin(t * this.frequency) * this.amount;
    this.gameObject.scale.set(s, s, s);
  }
}

onStart(context => {
  const shape = new THREE.Mesh(
    new THREE.IcosahedronGeometry(1, 0),
    new THREE.MeshStandardMaterial({
      color: '#7dd3a0',
      roughness: 0.35,
      metalness: 0.1,
      flatShading: true,
    })
  );
  // The shape has a radius of 1 and Breathe scales it up to 1.3, so start it
  // 1.3 above the floor. Its lowest point then just touches y = 0.
  shape.position.y = 1.3;
  context.scene.add(shape);

  // Three components, one object. Stack them in any order.
  shape.addComponent(Rotate);
  shape.addComponent(MoveUpDown);
  shape.addComponent(Breathe);
});

configureDemoScene({ 
  useContactShadows: true,
});
```
</walkthrough-step>

One shape, three components: `Rotate` turns it, `MoveUpDown` lifts it, `Breathe` scales it. Each is a few lines long and knows nothing about the other two.

That is the point of the pattern. Three small components are easier to write, reuse and remove than one component doing three jobs. They combine here because each writes to a different property — `rotation.y`, `position.y` and `scale`. Order only starts to matter when two of them write the same one.

Components can reach each other when they need to. `getComponent` finds another on the same object.

`MoveUpDown` and `Breathe` both read `this.context.time.time`, the seconds since the scene started. Passing it through `Math.sin` gives a value that rises and falls forever, so neither has to track a position or a direction of its own.

`MoveUpDown` records its starting height in `awake`. That is the first method to run once a component becomes active, and the earliest point where `this.gameObject` exists. A field initializer would run before the component is attached to anything, with nothing yet to read.

→ [Lifecycle Hooks](/docs/how-to-guides/scripting/use-lifecycle-hooks)

---

## 03 · The component lifecycle

<walkthrough-tags symbols="awake, onEnable, start, onDisable, onDestroy, destroy, enabled" />

<walkthrough-takeaway>

Every component runs through the same sequence: set up, switch on, update each frame, switch off, clean up. Knowing which method runs when is what stops setup code landing in the wrong place.

</walkthrough-takeaway>

<walkthrough-step
  src="/docs/code-samples/walkthrough-03-lifecycle.html"
  title="A component that logs each of its lifecycle methods as it runs"
  :actions='[
    { "name": "disable", "code": "component.enabled = false", "label": "Disable" },
    { "name": "enable",  "code": "component.enabled = true",  "label": "Enable" },
    { "name": "hide",    "code": "beacon.visible = false",    "label": "Hide object" },
    { "name": "show",    "code": "beacon.visible = true",     "label": "Show object" },
    { "name": "destroy", "code": "destroy(component)",        "label": "Destroy" }
  ]'>

```js
import { Behaviour, onStart, destroy } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ useContactShadows: true });

class Beacon extends Behaviour {
  awake() {
    console.log('awake — runs once, right after addComponent');
  }

  onEnable() {
    console.log('onEnable — the component switched on');
  }

  start() {
    console.log('start — runs once, before the first update');
  }

  update() {
    // Only runs while the component is enabled. Disable it and the cone
    // stops turning — nothing else about the object changes.
    this.gameObject.rotation.y += 0.9 * this.context.time.deltaTime;
  }

  onDisable() {
    console.log('onDisable — the component switched off');
  }

  onDestroy() {
    console.log('onDestroy — the component is gone');
  }
}

onStart(context => {
  const height = 1.2;
  const beacon = new THREE.Mesh(
    new THREE.ConeGeometry(1, height, 6),
    new THREE.MeshStandardMaterial({
      color: '#7dd3a0',
      roughness: 0.35,
      flatShading: true,
    })
  );
  // A cone is centred on its middle, so lift it by half its height to
  // stand it on the floor.
  beacon.position.y = height / 2;
  context.scene.add(beacon);

  const component = beacon.addComponent(Beacon);

  // The buttons live on the docs page around this scene and send their name in.
  window.addEventListener('message', event => {
    // Switch off this one component. Others on the object keep running.
    if (event.data === 'disable') component.enabled = false;
    if (event.data === 'enable') component.enabled = true;

    // Switch off the whole object — every component on it and on its
    // children stops too, and onDisable fires on each of them.
    if (event.data === 'hide') beacon.visible = false;
    if (event.data === 'show') beacon.visible = true;

    if (event.data === 'destroy') destroy(component);
  });
});
```
</walkthrough-step>

Each button runs the line printed beside it. Try them in order and watch the cone — the three buttons switch things off at three different levels.

**Disable** stops this one behaviour: `update` is no longer called, so the cone stops turning. Nothing else about the object changes — it keeps its position and material, and any other components on it keep running. **Enable** starts it again from where it left off.

**Hide object** sets `visible = false`, which in Needle does more than hide. It deactivates the whole object: every component on it *and on its children* gets `onDisable` and stops updating. To hide an object but keep it running, disable its `Renderer` component instead.

**Destroy** removes the component for good.

::: info Coming from Unity?
`visible = false` is the equivalent of `SetActive(false)`. It deactivates the object and everything under it, rather than only hiding it from view.
:::

### When each method runs

On first activation the order is `awake` → `onEnable` → `start`, then `update` on every frame after that.

`awake` and `start` run once and never again. `onEnable` and `onDisable` run every time the component is switched on and off. After `destroy` the instance is finished; attaching the behaviour again creates a new one, starting from `awake`.

That difference decides where your code belongs. Read a starting value once in `awake`. Put anything that has to happen on every switch-on, such as subscribing to an event, in `onEnable`.

One rule keeps components tidy. Undo in `onDestroy` whatever you set up in `awake`. Unsubscribe in `onDisable` whatever you subscribe to in `onEnable`.

For subscriptions there's a shortcut. Wrap one in `this.autoCleanup(...)` and the component unsubscribes it for you. You don't write the `onDisable` half at all — [step 11](#11-networking) uses it for a network listener.

→ [Lifecycle Hooks](/docs/how-to-guides/scripting/use-lifecycle-hooks) · [Lifecycle Methods reference](/docs/reference/api/lifecycle-methods)

---

## 04 · One class, many instances

<walkthrough-tags symbols="addComponent" />

<walkthrough-takeaway>

One class, many copies, each set up differently. `addComponent` takes a second argument with values, so you configure an instance instead of writing a subclass for every variation.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-04-many-instances.html" title="100 cubes sharing one component class, each given a different phase offset">

```js
import { Behaviour, onStart } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ useContactShadows: true });

// One class, configured per instance. `addComponent` takes an init object,
// so each copy gets its own values without a separate subclass.
class Wave extends Behaviour {
  // Defaults — anything the init object doesn't set keeps these.
  amplitude = 0.45;
  speed = 2.2;
  offset = 0;

  awake() {
    this.baseY = this.gameObject.position.y;
  }

  update() {
    const t = this.context.time.time;
    const wave = Math.sin(t * this.speed + this.offset);
    // Map the wave to 0..1 so cubes only rise from where they started,
    // instead of sinking through the floor on the way down.
    this.gameObject.position.y = this.baseY + (wave + 1) * 0.5 * this.amplitude;
  }
}

onStart(context => {
  const size = 0.1;
  const geometry = new THREE.BoxGeometry(size, size, size);
  const material = new THREE.MeshStandardMaterial({
    color: '#7dd3a0',
    roughness: 0.35,
    flatShading: true,
  });

  const grid = 10;
  const gap = 0.2;
  for (let x = 0; x < grid; x++) {
    for (let z = 0; z < grid; z++) {
      const cube = new THREE.Mesh(geometry, material);
      // Half the cube's height, so it rests on the floor rather than
      // straddling it.
      cube.position.set(
        (x - grid / 2) * gap, 
        size / 2 + .1, 
        (z - grid / 2) * gap
      );
      context.scene.add(cube);

      // One component per cube, phase-shifted by distance from the centre
      // so the grid reads as a single wave. amplitude and speed are left
      // out, so every cube keeps the defaults declared on the class.
      cube.addComponent(Wave, {
        offset: Math.hypot(x - grid / 2, z - grid / 2) * 0.6,
      });
    }
  }
});
```
</walkthrough-step>

100 cubes, one `Wave` class, one geometry and one material. Only `offset` differs, and it is what turns 100 identical cubes into a wave.

Anything the second argument leaves out keeps the value declared on the class. `amplitude` and `speed` are never passed here, so every cube uses the defaults on `Wave`. Values are assigned after the instance is built, which is why they are plain class fields rather than constructor parameters.

### Marking fields as serializable

In a TypeScript project, mark those fields with `@serializable()`:

```ts
import { Behaviour, serializable } from "@needle-tools/engine";

export class Wave extends Behaviour {
    @serializable()
    amplitude: number = 0.45;

    @serializable()
    speed: number = 2.2;
}
```

That does two things a plain field cannot. The field appears in the Unity or Blender inspector, so someone who does not write code can set it per object. Its value is also written into the glTF on export, so it arrives in the running app instead of falling back to the default.

The examples here leave it out because decorators need TypeScript, and these pages run straight from a CDN. Use it for any component you write in a project.

→ [Create Components](/docs/how-to-guides/scripting/create-components) · [@serializable reference](/docs/reference/typescript-decorators#serializable)

---

## 05 · Components in a hierarchy

<walkthrough-tags symbols="Group, addComponent, rotation" />

<walkthrough-takeaway>

Nesting does the hard part. Each component moves only the object it sits on, but a child inherits its parent's position and rotation, so simple parts combine into complex motion.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-05-hierarchy.html" title="A sun, two planets and a moon, all driven by the same one-line Orbit component at different depths">

```js
import { Behaviour, onStart } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false });

// Turns whatever it is attached to. That's all it does.
class Orbit extends Behaviour {
  speed = 1;

  update() {
    this.gameObject.rotation.y += this.speed * this.context.time.deltaTime;
  }
}

const ball = (radius, color) =>
  new THREE.Mesh(
    new THREE.IcosahedronGeometry(radius, 1),
    new THREE.MeshStandardMaterial({ color, roughness: 0.2, flatShading: true })
  );

// A pivot that turns, with `child` parked `radius` out to one side.
// Turning the pivot carries the child around it — the child has no logic.
const orbitAround = (parent, radius, speed, child) => {
  const pivot = new THREE.Group();
  parent.add(pivot);
  pivot.addComponent(Orbit, { speed });

  child.position.x = radius;
  pivot.add(child);
  return pivot;
};

onStart(context => {
  const sun = ball(0.7, '#e8d16a');
  context.scene.add(sun);
  sun.addComponent(Orbit, { speed: 0.3 });

  const planet = ball(0.35, '#7dd3a0');
  orbitAround(context.scene, 2.4, 0.6, planet);

  // The moon's pivot is a child of the planet, so it inherits the planet's
  // orbit and adds its own on top.
  orbitAround(planet, 0.75, 2.4, ball(0.14, '#c9d1cc'));

  // A second planet further out. Tilting its pivot tilts the whole orbit,
  // because everything under the pivot moves with it.
  const outerOrbit = orbitAround(context.scene, 4.1, 0.28, ball(0.26, '#6aa9e8'));
  outerOrbit.rotation.z = 0.38;

  context.scene.background = new THREE.Color('#1a1d1b');
  context.domElement.setAttribute("background-image", "https://cloud.needle.tools/-/assets/ZUBcksfeyof-feyof-hdri-pmrem/file.pmrem.ktx2");
  context.domElement.setAttribute("background-intensity", "0.01");
  context.domElement.setAttribute("background-blurriness", "0.2");
});
```
</walkthrough-step>

`Orbit` does one thing: it turns the object it is on. Four copies produce a spinning sun, two planets and a moon.

The pivots do the rest. A pivot sits at the centre of an orbit with the child parked out to one side, so turning the pivot carries the child around it. Nothing in `Orbit` knows what an orbit is.

Two results are visible in the scene. The moon's pivot is a child of the **planet**, so it inherits the planet's orbit and adds its own on top. Tilting the outer pivot by `rotation.z` tilts that whole orbit, because everything under a pivot moves with it.

That is the difference from [step 04](#04-one-class-many-instances). There the copies were siblings and independent. Here they are nested, so their transforms compound.

→ [Create Components](/docs/how-to-guides/scripting/create-components)

---

## 06 · Cloning objects

<walkthrough-tags symbols="instantiate, destroy, getComponentInChildren" />

<walkthrough-takeaway>

Build something once, then copy it as often as you like while the scene runs. `instantiate` takes the object, its children and every component on them.

</walkthrough-takeaway>

<walkthrough-step
  src="/docs/code-samples/walkthrough-06-instantiate.html"
  title="Windmills cloned from one original, each turning at its own speed"
  :actions='[
    { "name": "spawn", "code": "instantiate(original, { parent, position })", "label": "Add one" },
    { "name": "clear", "code": "destroy(clone)",                              "label": "Remove all" }
  ]'>

```js
import { Behaviour, onStart, instantiate, destroy, Mathf, OrbitControls, showBalloonMessage } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, useContactShadows: true });

/*
  Where each windmill stands.

  Turning by the golden angle every time and pushing out by the square root
  of the index gives the spiral you see in a sunflower. It spreads the copies
  evenly and keeps them apart however many there are, with no random numbers
  and no checking for overlaps.
*/
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const SPACING = 0.45;

function placementFor(index) {
  const angle = index * GOLDEN_ANGLE;
  // The square root keeps them equally dense as the circle grows.
  const radius = SPACING * Math.sqrt(index);
  return { angle, radius };
}

// Enough that a held-down button can't fill the scene.
const MAX_CLONES = 100;
const START_CLONES = 1;

// Spins whatever it is attached to. Sits on the blades, a child object.
class Spin extends Behaviour {
  speed = 2;

  update() {
    this.gameObject.rotation.z += this.speed * this.context.time.deltaTime;
  }
}

// Rocks the whole windmill. Sits on the root.
class Sway extends Behaviour {
  offset = 0;

  awake() {
    // Each copy reads its own starting angle, so it sways around wherever
    // it was placed.
    this.restZ = this.gameObject.rotation.z;
  }

  update() {
    const t = this.context.time.time + this.offset;
    this.gameObject.rotation.z = this.restZ + Math.sin(t) * 0.06;
  }
}

const material = (color, roughness = 0.5) =>
  new THREE.MeshStandardMaterial({ color, roughness, flatShading: true });

// One windmill: a tower, and blades that turn. Two components, on two
// different objects in the hierarchy.
function buildWindmill(color) {
  const root = new THREE.Group();
  root.addComponent(Sway);

  const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.06, 0.36, 12), material('#e8e4dc'));
  tower.position.y = 0.18;
  root.add(tower);

  const blades = new THREE.Group();
  blades.position.set(0, 0.37, 0.055);
  root.add(blades);
  // The component lives on the child, not the root.
  blades.addComponent(Spin);

  blades.add(new THREE.Mesh(new THREE.SphereGeometry(0.025, 12, 10), material('#5c6b63', 0.3)));

  for (let i = 0; i < 4; i++) {
    const blade = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.17, 0.01), material(color));
    blade.position.y = 0.1;

    // Turning the arm swings the blade around the hub.
    const arm = new THREE.Group();
    arm.rotation.z = (i / 4) * Math.PI * 2;
    arm.add(blade);
    blades.add(arm);
  }

  return root;
}

onStart(context => {
  // The original stands in the middle, at index 0.
  const original = buildWindmill('#7dd3a0');
  context.scene.add(original);

  // Every clone made here, so they can all be removed again.
  const clones = new Array();

  const orbit = context.mainCamera.getComponent(OrbitControls);

  const spawn = () => {
    if (clones.length >= MAX_CLONES) {
      showBalloonMessage(`That's ${MAX_CLONES} clones — enough for one page.`);
      return;
    }

    const index = clones.length + 1;
    const { angle, radius } = placementFor(index);

    /*
      One call copies the object, its children, and the components on all of
      them. `parent` puts the clone straight into the scene — without it the
      clone exists but isn't anywhere yet, and you would add it yourself.
    */
    const clone = instantiate(original, {
      parent: context.scene,
      position: [Math.cos(angle) * radius, 0, Math.sin(angle) * radius],
      rotation: [0, 0, 0],
    });

    /*
      A clone's components are ordinary components — get one and set it like
      any other. Each clone has its own instances, so giving this one a new
      speed leaves the rest turning at theirs.
    */
    const spin = clone.getComponentInChildren(Spin);
    spin.speed = Mathf.random(1.2, 3.2);

    clone.getComponent(Sway).offset = index * 0.8;

    clones.push(clone);
  };

  const clear = () => {
    // destroy removes objects as well as components.
    clones.forEach(clone => destroy(clone));
    clones.length = 0;
  };

  /*
    Pull the camera back to hold the whole field.
  */
  const frameAll = () =>
    orbit?.fitCamera({ objects: context.scene, fitOffset: 1 });

  while (clones.length < START_CLONES) spawn();
  frameAll();

  window.addEventListener('message', event => {
    if (event.data === 'spawn') spawn();
    if (event.data === 'clear') clear();
    // Pull the camera back as the circle grows, so new clones stay in view.
    frameAll();
  });
});
```
</walkthrough-step>

`buildWindmill` builds one windmill, and `instantiate` makes every copy after that. Press **Add one** to add one, and **Remove all** to leave only the original.

`Sway` sits on the root and `Spin` on the blades, which is a child object. Both come along with the copy, because `instantiate` takes the whole subtree rather than the top object.

Use `instantiate` to copy objects. (three.js `clone()` doesn't take components along.)

The second argument places the copy. `parent` adds it to the scene as part of the call. Leave it out and you get the copy back without it being added, so you add it yourself. `position`, `rotation` and `scale` work the same way and take plain arrays.

A clone's components are ordinary components. `getComponent` and `getComponentInChildren` find them, and each clone owns its own instances. Giving one clone a new `speed` leaves the rest turning at theirs.

::: tip Where the original usually comes from
Here the original is built in code so the example stays self-contained. In a project it's more often a model you loaded, or an object placed in Unity or Blender and referenced with `@serializable`. `instantiate` treats them all the same.
:::

To spawn a copy for everyone in a networked scene, `syncInstantiate` does the same job across the connection — see [step 11](#11-networking).

→ [Duplicatable component](/docs/how-to-guides/components/duplicatable) · [syncInstantiate](/docs/how-to-guides/networking/sync-state#syncinstantiate)

---

## 07 · Pointer input

<walkthrough-tags symbols="onPointerEnter, onPointerExit, onPointerClick, MaterialPropertyBlock" />

<walkthrough-takeaway>

Handling clicks takes no setup. Pointer methods belong to a component, like `awake` or `update`, and the engine calls them on whichever object is under the pointer.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-07-input.html" title="Five boxes that highlight on hover and start spinning when clicked">

```js
import { Behaviour, onStart, MaterialPropertyBlock } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false });

// Pointer methods are part of a component, like awake or update. Needle
// raycasts for you and calls them on whatever is under the pointer.
class Pressable extends Behaviour {
  spinning = false;

  awake() {
    // Overrides this object's material properties without cloning the
    // material, so all five boxes keep sharing the same one.
    this.block = MaterialPropertyBlock.get(this.gameObject);
  }

  onPointerEnter() {
    this.block.setOverride('color', new THREE.Color('#f2c14e'));
    // Use the input system rather than setting style.cursor yourself: it
    // counts how many objects asked for a cursor, so moving between two
    // hovered objects doesn't reset it back to the default.
    this.context.input.setCursor('pointer');
  }

  onPointerExit() {
    // Remove the one property this component set, rather than clearing
    // every override on the object. Anything else using the block keeps its own.
    this.block.removeOveride('color');
    this.context.input.unsetCursor('pointer');
  }

  onPointerClick() {
    this.spinning = !this.spinning;
  }

  update() {
    if (!this.spinning) return;
    this.gameObject.rotation.y += 1.6 * this.context.time.deltaTime;
  }
}

onStart(context => {
  // One material for every box. Without the property block above, hovering
  // one box would recolour all of them.
  const shared = new THREE.MeshStandardMaterial({
    color: '#7dd3a0',
    roughness: 0.4,
    flatShading: true,
  });

  for (let i = 0; i < 5; i++) {
    const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), shared);
    box.position.x = (i - 2) * 1.1;
    context.scene.add(box);

    box.addComponent(Pressable);
  }
});
```
</walkthrough-step>

Hover a box to highlight it, click to start and stop it spinning. The same component is on all five, and each copy handles its own box. Adding a sixth box means adding the component to it, and nothing else.

::: info Coming from plain three.js?
This is the part you normally write yourself. You set up a `Raycaster`, convert pointer coordinates to normalised device space, and intersect the scene each frame. You also track which object was hit last, so you can tell enter from exit. Needle does all of that and calls the methods on the object instead.

It also does it faster than a plain raycast. Meshes get a [BVH](https://github.com/gkjohnson/three-mesh-bvh) built for them. A hit test then descends a tree instead of walking every triangle, which keeps clicking a dense mesh cheap.
:::

`onPointerEnter` and `onPointerExit` come in pairs. Whatever one changes, the other puts back.

Recolouring a single box is where this usually gets awkward. All five meshes share one `MeshStandardMaterial`, so setting `material.color` on hover would recolour the whole row. The common workaround is to clone the material per object, which creates five materials to vary one property.

`MaterialPropertyBlock` solves it. `MaterialPropertyBlock.get(object)` returns a set of overrides for one object, and the engine applies them per object as it renders. The material itself is never touched and stays shared.

On exit the component removes the one property it set, and the shared colour comes back. There is no original to save in `awake` and no stale value to put back later. `clearAllOverrides()` removes every override at once, which is worth avoiding when something else may have set one.

The same methods fire for touch and for VR controllers, so this component works on a phone and in a headset with no changes.

→ [Handle User Input](/docs/how-to-guides/scripting/handle-input) · [Perform Raycasting](/docs/how-to-guides/scripting/perform-raycasting) · [MaterialPropertyBlocks](/docs/how-to-guides/scripting/material-property-blocks)

---

## 08 · Moving the camera

<walkthrough-tags symbols="OrbitControls, fitCamera, setCameraTargetPosition, setLookTargetPosition" />

<walkthrough-takeaway>

Camera controls come with the scene, so orbit, zoom and double-click-to-focus already work. From code you can frame any object, or send the camera to a shot you chose.

</walkthrough-takeaway>

<walkthrough-step
  src="/docs/code-samples/walkthrough-08-camera.html"
  title="Three objects the camera can frame, focus and fly between"
  :actions='[
    { "name": "frameAll",  "code": "orbit.fitCamera()",                            "label": "Frame everything" },
    { "name": "frameOne",  "code": "orbit.fitCamera({ objects: [tower] })",        "label": "Frame one object" },
    { "name": "fromFront", "code": "orbit.fitCamera({ fitDirection })",            "label": "Frame from the front" },
    { "name": "viewpoint", "code": "orbit.setCameraTargetPosition(pos, 1.2)",      "label": "Fly to a viewpoint" }
  ]'>

```js
import { Behaviour, Gizmos, Mathf, onStart, OrbitControls } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, useContactShadows: true, autoFrame: false });

// How far the camera may be panned, as a box around the origin.
const PAN_AREA = new THREE.Vector3(2.4, 1.2, 2.4);

onStart(context => {
  // The scene the camera looks at. Built at the bottom of this file.
  const exhibits = buildDemoScene(context);

  const orbit = context.mainCamera.getComponent(OrbitControls);

  /*
    Keep the camera pointed inside a box. targetBounds takes an object, and
    reads its world position as the centre and its world scale as the size —
    so an empty object scaled to the area you want is all it needs. Without
    it, panning can carry the view off the scene entirely.
  */
  const bounds = new THREE.Object3D();
  bounds.scale.copy(PAN_AREA);
  bounds.position.y = PAN_AREA.y / 2;
  context.scene.add(bounds);
  orbit.targetBounds = bounds;

  // Draws the marker and the bounds. See ShowOrbitTarget below.
  context.scene.addComponent(ShowOrbitTarget, { orbit, bounds });

  /*
    Frame everything in the scene. Called with no arguments it fits to the
    whole scene; pass `objects` to fit to a selection instead.
  */
  const frameAll = () => orbit.fitCamera();

  const frameOne = () => orbit.fitCamera({ objects: [exhibits.tower] });

  /*
    Fit from a direction you choose rather than from wherever the camera
    happens to be. The vector points from the scene towards the camera, so
    this ends up looking at the front of the scene from slightly above.
  */
  const frameFromFront = () =>
    orbit.fitCamera({ fitDirection: new THREE.Vector3(0, 0.45, 1) });

  /*
    Move the camera somewhere specific. The second argument is the travel
    time in seconds — pass `true` instead to jump there with no animation.
    Both calls take a point, because the controls aim the camera at a look
    target rather than at a rotation.
  */
  const flyToViewpoint = () => {
    orbit.setCameraTargetPosition(new THREE.Vector3(2.6, 1.5, 2.6), 1.2);
    orbit.setLookTargetPosition(new THREE.Vector3(0, 0.4, 0), 1.2);
  };

  window.addEventListener('message', event => {
    if (event.data === 'frameAll') frameAll();
    if (event.data === 'frameOne') frameOne();
    if (event.data === 'fromFront') frameFromFront();
    if (event.data === 'viewpoint') flyToViewpoint();
  });
});

/*
  A marker on the point the camera orbits around. Normally invisible, which
  makes panning and focusing hard to reason about — drag with the right mouse
  button and watch it slide, and stop at the edge of the bounds.
*/
class ShowOrbitTarget extends Behaviour {
  orbit = null;
  bounds = null;

  awake() {
    this.marker = new THREE.Mesh(
      new THREE.SphereGeometry(0.06, 16, 12),
      new THREE.MeshBasicMaterial({ color: '#e8536d' })
    );
    /*
      Layer 2 is IgnoreRaycast, and raycasts skip it by default. Without this
      the marker sits between the camera and the scene, so double-clicking to
      focus would keep hitting the marker instead of the object behind it.
    */
    this.marker.layers.set(2);
    this.gameObject.add(this.marker);
  }

  update() {
    // `controls` is the three.js OrbitControls underneath, and its target is
    // the point the camera turns around.
    const target = this.orbit?.controls?.target;
    if (target) this.marker.position.copy(target);

    // The box the target is kept inside. Gizmos are drawn per frame and
    // never end up in an export, so they suit showing something that has
    // no geometry of its own.
    if (this.bounds) {
      const t = Math.sin(this.context.time.time);
      const col = new THREE.Color().setHSL(0, 0, Mathf.remap(t, -1, 1, 0.5, 0.8));
      Gizmos.DrawWireBox(
        this.bounds.worldPosition,
        this.bounds.worldScale,
        col,
        0,
        true
      );
    }
  }
}

/* ------------------------------------------------------------------------
   Scenery — three things worth pointing a camera at. Nothing below is
   specific to camera control.
   ------------------------------------------------------------------------ */

function buildDemoScene(context) {
  const material = (color, roughness = 0.5) =>
    new THREE.MeshStandardMaterial({ color, roughness, flatShading: true });

  const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.3, 1.1, 6), material('#7dd3a0'));
  tower.position.set(-1.1, 0.55, 0.2);

  const block = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 0.6), material('#6aa9e8'));
  block.position.set(0.9, 0.3, -0.6);
  block.rotation.y = 0.4;

  const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.32, 0), material('#f2c14e', 0.25));
  gem.position.set(0.35, 0.32, 0.9);

  [tower, block, gem].forEach(o => context.scene.add(o));
  return { tower, block, gem };
}
```
</walkthrough-step>

For most projects you never touch them. This step covers the two times you do: framing an object from code, and sending the camera to a viewpoint you chose.

Try the scene before the buttons. Drag to orbit and scroll to zoom. **Double-click** any object to focus it, and **double-click empty space** to frame everything again. All of that comes with `OrbitControls`.

### Framing objects

`fitCamera()` frames the whole scene. It works out the distance from the bounds of whatever it is framing, so you never guess at a camera position. That helps most with a model whose size you don't know ahead of time.

Two options change what it does:

- `objects` frames a selection instead of everything. This is how you point the camera at one thing without knowing where that thing is.
- `fitDirection` also chooses which side to look from. Without it, the camera fits from wherever it already happens to be. That is the only difference between the third button and the first two.

### The point the camera turns around

The red dot marks it. That point is normally invisible, so `ShowOrbitTarget` draws a marker on it. Making something invisible visible is worth doing whenever it is hard to reason about.

Right-drag to pan and watch the dot slide, then stop at the edge of a box. That box is `targetBounds`. It takes an object, and reads its world position as the centre and its world scale as the size. Panning then stays inside it, which keeps a visitor from wandering off the scene and losing it.

### Moving to a viewpoint

`setCameraTargetPosition` and `setLookTargetPosition` place the camera somewhere specific. The second argument is the travel time in seconds: `1.2` eases over that long, and `true` arrives instantly.

Both take a point rather than a rotation, because the controls aim the camera at a look target. The second call is what decides where the camera ends up pointing.

::: tip Driving the camera yourself
`<needle-engine camera-controls="false">` stops the engine adding controls at all, and [step 09](#09-custom-camera-controls) does exactly that. If you only want to remove part of the behaviour, keeping the controls and switching off rotation, zoom or panning is the smaller change.
:::

→ [Camera Controls (OrbitControls)](/docs/how-to-guides/components/orbit-controls) · [OrbitControls API](https://engine.needle.tools/docs/api/OrbitControls)

---

## 09 · Custom camera controls

<walkthrough-tags symbols="camera-controls, getPointerPositionRC, Mathf.clamp" />

<walkthrough-takeaway>

Writing your own camera controls is a component, not a project. Turn the built-in ones off, move the camera in `update`, and you decide exactly what it can do.

</walkthrough-takeaway>

<walkthrough-step
  src="/docs/code-samples/walkthrough-09-custom-camera.html"
  title="A camera that looks towards the cursor, and zooms in on what you hold"
  :actions='[
    { "name": "front", "code": "rig.moveTo(VIEWPOINTS.front)", "label": "Move to the front" },
    { "name": "side",  "code": "rig.moveTo(VIEWPOINTS.side)",  "label": "Move to the side" }
  ]'>

```js
import { Behaviour, Camera, MaterialPropertyBlock, Mathf, onStart } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, useContactShadows: true, autoFrame: false });

// Yaw turns around this, so the horizon never tilts.
const WORLD_UP = new THREE.Vector3(0, 1, 0);

// How much lighter a prop gets while the cursor is over it.
const HIGHLIGHT_AMOUNT = 0.25;

// How much of the frame a held object takes up. Higher leaves more room
// around it.
const FRAMING = 1.4;

/*
  A camera rig, in place of the built-in controls.

  The page sets camera-controls="false", so nothing moves the camera but this
  component. Two jobs: travel to whichever viewpoint was asked for, and glance
  towards the cursor once there — but only so far.
*/
class CameraRig extends Behaviour {
  /*
    Tells the engine this object already has camera controls. When a scene is
    exported with camera-controls enabled, the engine checks for a controller
    on the main camera before adding its own — so a component that says yes
    here replaces OrbitControls rather than fighting it.

    That check runs when the context is created. This page adds the rig from
    code afterwards, so it also sets camera-controls="false" in the HTML,
    which is the surer route for a code-only scene.
  */
  get isCameraController() {
    return true;
  }

  /** How far it can turn left and right, in degrees. */
  maxYaw = 18;
  /** How far it can look up and down, in degrees. */
  maxPitch = 9;
  /** Higher follows the cursor more closely; lower drifts after it. */
  responsiveness = 3;
  /** Higher arrives at a new viewpoint sooner. */
  travelSpeed = 1.8;
  /** Field of view at rest, and the tightest it may zoom to. */
  restFov = 50;
  minFov = 14;

  awake() {
    this.targetPosition = this.gameObject.position.clone();
    this.targetLookAt = new THREE.Vector3();
    // Where it is aiming right now, easing towards whatever it should be.
    this.currentLookAt = new THREE.Vector3();
    this.focus = null;
    this.yaw = 0;
    this.pitch = 0;
  }

  /** Head for a viewpoint. The rig eases the rest of the way itself. */
  moveTo({ from, at }) {
    this.targetPosition.set(...from);
    this.targetLookAt.set(...at);
  }

  /** Zoom in and turn towards one object, until it is let go. */
  focusOn(object) {
    this.focus = object;
  }

  releaseFocus() {
    this.focus = null;
  }

  /*
    How narrow the view has to be for one object to fill a comfortable part
    of the frame from where the camera is standing.

    A fixed zoom level can't work: the same angle that frames a distant block
    is far too tight for something an arm's length away. Working back from
    the object's size and distance gives every object the same apparent size,
    near or far.
  */
  fovFor(object) {
    const distance = this.gameObject.position.distanceTo(object.worldPosition);
    object.geometry?.computeBoundingSphere?.();
    const radius = object.geometry?.boundingSphere?.radius ?? 0.3;

    // FRAMING leaves room around it, rather than filling the frame edge to edge.
    const angle = 2 * Math.atan((radius * FRAMING) / Math.max(0.01, distance));
    return Mathf.clamp(angle * Mathf.Rad2Deg, this.minFov, this.restFov);
  }

  update() {
    const dt = this.context.time.deltaTime;

    /*
      Ease the field of view rather than setting it outright. A narrower
      angle magnifies what is in front of the camera without moving it, so
      holding an object reads as leaning in for a closer look.
    */
    const camera = this.gameObject.getComponent(Camera);
    if (camera) {
      const wanted = this.focus ? this.fovFor(this.focus) : this.restFov;
      const current = camera.fieldOfView ?? this.restFov;
      camera.fieldOfView = current + (wanted - current) * Math.min(1, dt * 5);
    }

    // Ease towards the chosen viewpoint. Interrupting mid-flight just changes
    // where it is heading — there is no transition to cancel.
    this.gameObject.position.lerp(this.targetPosition, Math.min(1, dt * this.travelSpeed));

    /*
      getPointerPositionRC gives the cursor in screen coordinates from -1 to
      1, with 0 in the middle — already the shape needed for "how far from the
      centre", whatever the size of the canvas.
    */
    const pointer = this.context.input.getPointerPositionRC(0);
    if (pointer) {
      // Clamp first, so a cursor leaving the canvas can't push it further.
      let targetYaw = Mathf.clamp(-pointer.x, -1, 1) * this.maxYaw * Mathf.Deg2Rad;
      let targetPitch = Mathf.clamp(pointer.y, -1, 1) * this.maxPitch * Mathf.Deg2Rad;

      /*
        Drop the glance while an object is held. Otherwise the cursor offset
        pulls the camera off the very thing it is zooming in on — and the
        cursor is sitting on that object, so the two work against each other.
      */
      if (this.focus) {
        targetYaw = 0;
        targetPitch = 0;
      }

      // Ease towards the angle rather than snapping to it, so the camera has
      // some weight instead of tracking the cursor exactly.
      const t = Math.min(1, dt * this.responsiveness);
      this.yaw += (targetYaw - this.yaw) * t;
      this.pitch += (targetPitch - this.pitch) * t;
    }

    /*
      Aim at the held object if there is one, otherwise at the viewpoint's own
      centre, easing between them so letting go swings back rather than
      snapping.

      Working from that point each frame — rather than turning the camera a
      little more each time — is what keeps the limits meaningful; accumulating
      would drift. Yaw then turns around world up and pitch around the camera's
      own right, so the horizon stays level. Rotating in the camera's local
      space instead would roll it, slightly but visibly.
    */
    const wantedLookAt = this.focus ? this.focus.worldPosition : this.targetLookAt;
    this.currentLookAt.lerp(wantedLookAt, Math.min(1, dt * 4));

    const direction = this.currentLookAt.clone().sub(this.gameObject.position).normalize();
    direction.applyAxisAngle(WORLD_UP, this.yaw);
    const right = new THREE.Vector3().crossVectors(direction, WORLD_UP).normalize();
    direction.applyAxisAngle(right, this.pitch);

    // lookAt orients against the object's own up, which is world up here —
    // so there is nowhere for roll to come from.
    this.gameObject.lookAt(this.gameObject.position.clone().add(direction));
  }
}

/*
  Shakes an object for a moment when asked.

  It stores where the object belongs and always offsets from that, rather than
  nudging the current position. Adding to the current position accumulates
  error and leaves the object somewhere slightly wrong when the shake ends.
*/
class Shake extends Behaviour {
  /** How far it moves at the start of a shake, in metres. */
  strength = 0.035;
  /** How long one shake lasts, in seconds. */
  duration = 0.4;

  awake() {
    this.restPosition = this.gameObject.position.clone();
    // Start finished, so nothing happens until something asks.
    this.elapsed = this.duration;
  }

  shake() {
    this.elapsed = 0;
  }

  update() {
    if (this.elapsed >= this.duration) return;

    this.elapsed += this.context.time.deltaTime;

    // Fades out over the shake, so it settles instead of stopping dead.
    const remaining = Math.max(0, 1 - this.elapsed / this.duration);
    const t = this.context.time.time;

    // Three different frequencies, so the motion doesn't read as a wobble
    // along one axis.
    this.gameObject.position.set(
      this.restPosition.x + Math.sin(t * 47) * this.strength * remaining,
      this.restPosition.y + Math.sin(t * 61) * this.strength * remaining * 0.6,
      this.restPosition.z + Math.sin(t * 53) * this.strength * remaining
    );

    if (this.elapsed >= this.duration) {
      // Put it back exactly, rather than wherever the last frame left it.
      this.gameObject.position.copy(this.restPosition);
    }
  }
}

/*
  Makes a prop worth pointing at. The cursor changes over it, and holding it
  down asks the rig to zoom.
*/
class Inspectable extends Behaviour {
  rig = null;
  /** Seconds between shakes while this object is being looked at. */
  shakeInterval = 1.2;

  awake() {
    this.shaker = this.gameObject.getComponent(Shake);
    this.sinceShake = 0;
    this.block = MaterialPropertyBlock.get(this.gameObject);
    // Its own colour, lifted — so each prop stays recognisable rather than
    // every one of them turning the same shade.
    this.highlight = this.gameObject.material.color
      .clone()
      .lerp(new THREE.Color(1, 1, 1), HIGHLIGHT_AMOUNT);
  }

  onPointerEnter() {
    // A per-object cursor, so it reads as something you can interact with.
    this.context.input.setCursor('zoom-in');

    /*
      Lift the colour a little. MaterialPropertyBlock overrides the value on
      this one object, so a shared material would still be safe — setting
      material.color would tint every object using it.
    */
    this.block.setOverride('color', this.highlight);
  }

  onPointerExit() {
    // Back to whatever the shared material says, with nothing to remember.
    this.block.clearAllOverrides();
    // this.release(); // we could release here on pointer exit as well
  }

  onPointerDown() {
    // Zoom in, and aim at this object rather than the viewpoint centre.
    this.rig?.focusOn(this.gameObject);
    // Not straight away: the camera is still moving in, and a shake during
    // the zoom reads as a glitch rather than as the object reacting.
    this.sinceShake = 0;
  }

  onPointerUp() {
    this.context.input.unsetCursor('zoom-in');
    this.release();
  }

  update() {
    // Only the object currently being looked at keeps twitching, and only
    // every so often — a constant shake would just read as broken.
    if (this.rig?.focus !== this.gameObject) return;

    this.sinceShake += this.context.time.deltaTime;
    if (this.sinceShake >= this.shakeInterval) {
      this.sinceShake = 0;
      this.shaker?.shake();
    }
  }

  // Also called on exit, so dragging off an object can't leave it stuck.
  release() {
    this.rig?.releaseFocus();
  }
}

// Two places to stand. The camera starts at neither, so the first button
// press always travels somewhere.
const VIEWPOINTS = {
  front: { from: [-0.2, 0.5, 4], at: [-.1, 0.3, -1] },
  side:  { from: [-4.0, 1.5, 2.0], at: [-0.4, 0.5, -0.9] },
};

onStart(context => {
  const props = buildDemoScene(context);

  // Opens low and a little to the right, so the props overlap and the scene
  // reads as having depth rather than as shapes side by side.
  context.mainCamera.position.set(.5, 1.75, 4.3);

  const rig = context.mainCamera.addComponent(CameraRig);
  rig.targetPosition.copy(context.mainCamera.position);
  rig.targetLookAt.set(-0.2, 0.3, -0.7);
  // Start aiming there rather than easing over from the origin.
  rig.currentLookAt.copy(rig.targetLookAt);

  // Every prop can be held to zoom in on it.
  props.forEach(prop => {
    prop.addComponent(Shake);
    prop.addComponent(Inspectable, { rig });
  });

  window.addEventListener('message', event => {
    const view = VIEWPOINTS[event.data];
    if (view) rig.moveTo(view);
  });
});

/* ------------------------------------------------------------------------
   Scenery — something with depth, so turning the camera reads clearly.
   ------------------------------------------------------------------------ */

function buildDemoScene(context) {
  const material = (color, roughness = 0.55) =>
    new THREE.MeshStandardMaterial({ color, roughness, flatShading: true });

  const props = [
    { geo: new THREE.CylinderGeometry(0.22, 0.3, 1.1, 6), color: '#7dd3a0', pos: [-1.5, 0.55, -0.4] },
    { geo: new THREE.BoxGeometry(0.55, 0.55, 0.55), color: '#6aa9e8', pos: [1.4, 0.28, -0.2] },
    { geo: new THREE.OctahedronGeometry(0.3, 0), color: '#f2c14e', pos: [0.1, 0.3, 0.9] },
    { geo: new THREE.BoxGeometry(0.4, 1.6, 0.4), color: '#98a49c', pos: [-0.9, 0.8, -2.2] },
    { geo: new THREE.BoxGeometry(0.5, 2.2, 0.5), color: '#98a49c', pos: [1, 1.1, -5.8] },
    { geo: new THREE.BoxGeometry(0.55, 0.55, 0.55), color: '#6aa9e8', pos: [0, 0.28, -3.2] },
    { geo: new THREE.ConeGeometry(0.35, 0.9, 20), color: '#e86a9b', pos: [-2.4, 0.45, -1.4] },
  ];

  return props.map(({ geo, color, pos }) => {
    const mesh = new THREE.Mesh(geo, material(color));
    mesh.position.set(...pos);
    context.scene.add(mesh);
    return mesh;
  });
}
```
</walkthrough-step>

A camera controller is a component like any other. It reads input in `update` and moves the object it sits on. There is no special base class and no system to register with. `CameraRig` below is the whole thing.

That matters because built-in controls are general by design. Sometimes you want a camera that behaves one specific way: locked to a corridor, driven by scroll, or limited like this one. Writing forty lines is usually less work than bending orbit controls into shape.

Try it first. Move the cursor across the scene and the camera turns towards it, up to 18° to either side and 9° up and down. Hold down any object and the camera leans in. The field of view narrows, the aim moves onto that object, and the cursor glance eases back to centre. Let go and all three reverse.

### Switching the defaults off

The page sets `camera-controls="false"`. The engine then adds no controls of its own, and the camera is left to this component.

A component can also take their place. When the context is created, the engine looks for a camera controller on the main camera, and only adds `OrbitControls` if it finds none. `CameraRig` reports `isCameraController`, which is what marks it as one. That check runs once at startup, so it suits a camera set up in Unity or Blender. For a scene built entirely in code, the attribute is the simpler route.

### What the rig does

`getPointerPositionRC` gives the cursor from `-1` to `1`, with `0` at the centre. That is already what you want: how far from the middle, whatever the canvas size. Clamp it before use and a cursor leaving the canvas stops pushing the camera further.

Position, field of view, look-at point and glance angles all ease towards a value instead of jumping to it. This is what makes an interruption safe. Press the other button while the camera is moving and it simply heads somewhere else.

The rig aims from a point each frame rather than turning the camera a little more each time. That keeps the angle limits exact, however long you move the cursor around. Yaw turns around world up, and pitch around the camera's own right, which keeps the horizon level. Rotating in the camera's local space would roll it, slightly but visibly.

Hovering brightens the object with a `MaterialPropertyBlock`, the same as [step 07](#07-pointer-input). It overrides the colour on that one object, not on the material it shares.

→ [Camera Controls (OrbitControls)](/docs/how-to-guides/components/orbit-controls) · [OrbitControls API](https://engine.needle.tools/docs/api/OrbitControls) · [Handle User Input](/docs/how-to-guides/scripting/handle-input)

---

## 10 · Physics and collisions

<walkthrough-tags symbols="Rigidbody, BoxCollider, SphereCollider, PhysicsMaterial, onCollisionEnter, applyImpulse" />

<walkthrough-takeaway>

Physics is two components, not a system you set up. A `Rigidbody` makes an object move. A collider gives it a shape. The engine then calls your component when something hits it, the same way it calls pointer events. You don't have to write any physics code to receive them.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-10-physics.html" title="Three balls with different bounciness dropped onto a floor that flashes when hit — click a ball to launch it">

```js
import {
  Behaviour,
  onStart,
  Rigidbody,
  BoxCollider,
  SphereCollider,
  PhysicsMaterialCombine,
} from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false });

// Physics events are component methods too, like awake or update.
class FlashOnHit extends Behaviour {
  awake() {
    this.material = this.gameObject.material;
    this.restColor = this.material.color.clone();
    this.fade = 0;
  }

  // `collision.gameObject` is the other object involved in the hit.
  onCollisionEnter(collision) {
    console.log('hit by', collision.gameObject.name);
    this.fade = 1;
  }

  update() {
    if (this.fade <= 0) return;

    this.fade -= this.context.time.deltaTime * 1.5;
    this.material.color
      .copy(this.restColor)
      .lerp(new THREE.Color('#f2c14e'), Math.max(this.fade, 0));
  }
}

// Click a ball to punt it upwards. An impulse is an instant change in
// velocity, so this is a kick rather than a force applied over time.
class ClickToLaunch extends Behaviour {

  strength = 1;

  awake() {
    this.body = this.gameObject.getComponent(Rigidbody);
  }

  onPointerClick() {
    this.body?.applyImpulse(new THREE.Vector3(0, this.context.time.deltaTime * this.strength, 0));
  }

  onPointerEnter() { this.context.input.setCursor("pointer") }
  onPointerExit() { this.context.input.setCursor("default") }
}

// Bounciness runs from 0 (stops dead) to 1 (keeps all its energy).
// Ordered dullest to bounciest, so they read left to right in the scene.
const kinds = [
  { name: 'clay', color: '#6aa9e8', bounciness: 0 },
  { name: 'plastic', color: '#7dd3a0', bounciness: 0.75 },
  { name: 'rubber', color: '#f2c14e', bounciness: 0.98 },
];

onStart(context => {
  // The floor. A collider without a Rigidbody never moves, so everything
  // else lands on it.
  const floor = new THREE.Mesh(
    new THREE.BoxGeometry(9, 0.5, 9),
    new THREE.MeshStandardMaterial({ color: '#8d9a93', roughness: 0.6 })
  );
  floor.position.y = -1.5;
  context.scene.add(floor);

  // BoxCollider.add() fits the collider to the geometry. Adding the component
  // directly instead gives you the default 1×1×1 box, whatever the mesh size.
  BoxCollider.add(floor);
  floor.addComponent(FlashOnHit);

  const radius = 0.5;
  const geometry = new THREE.SphereGeometry(radius);

  // One ball per material, spaced apart so they only ever hit the floor and
  // never each other — otherwise the bounce heights aren't comparable.
  kinds.forEach((kind, i) => {
    const ball = new THREE.Mesh(
      geometry,
      new THREE.MeshStandardMaterial({ color: kind.color, roughness: 0.35 })
    );
    ball.name = kind.name;
    ball.position.set((i - 1) * 1.8, 3, 0);
    context.scene.add(ball);

    // A Rigidbody makes it move. The collider gives it a shape, and the
    // physics material on that collider decides how it behaves on impact.
    ball.addComponent(Rigidbody);
    ball.addComponent(SphereCollider, {
      radius: radius,
      sharedMaterial: {
        bounciness: kind.bounciness,
        // Maximum: the ball's own value wins, rather than being averaged
        // with the floor's.
        bounceCombine: PhysicsMaterialCombine.Maximum,
      },
    });

    ball.addComponent(ClickToLaunch, {
      strength: radius * 400,
    });
  });
});
```
</walkthrough-step>

Click any ball to launch it upwards. The difference in bounciness shows on the way back down.

`applyImpulse` is an instant change in velocity, like a kick. Use `applyForce` instead for a push applied over time.

You usually need both components. **`Rigidbody`** makes an object fall and respond to forces. A **collider** gives it a shape to collide with. An object with a collider and no `Rigidbody` never moves, which is exactly right for the floor here, and for walls and scenery.

`onCollisionEnter(collision)` fires on the component when its object is hit. `collision.gameObject` is the *other* object involved, so the floor can report what landed on it without keeping a list of balls.

There are two more: `onCollisionExit` when the contact ends, and `onCollisionStay` on every frame it lasts.

Each ball carries a different **physics material** on its collider. `bounciness` rises from `0` on the left to `0.98` on the right. That is the entire difference between the clay, plastic and rubber balls. `bounceCombine: Maximum` lets each ball's own value decide the result. The default averages it with the floor's, so a bouncy ball on a dead floor would only half bounce.

A collider is its own shape and doesn't read the mesh. `BoxCollider` defaults to 1×1×1 whatever the object's size, so anything landing off that pad falls through. `BoxCollider.add(object)` fits it to the geometry for you. Other collider types have no such helper.

::: tip Needle Engine only downloads what you use
The physics engine is a separate chunk, fetched the first time a physics component is added. A project with no physics never downloads that code at all — it isn't shipped in the page and left unused.
:::

### Mass and density

Nothing here sets a mass, because you rarely need to. A `Rigidbody` has `autoMass` on by default and works out its mass from the colliders attached to it, using `mass = density × volume`.

That means **size already affects weight**. Double a ball's radius and it gets heavier on its own, with no code change. Hardcoding a mass breaks that.

To make something heavier or lighter than its size suggests, set `density` on the collider rather than `mass` on the body. Density is a real-world figure: water is `1.0` (the engine default), rubber `1.2`, steel `7.8`. Setting `mass` directly still works, but it switches `autoMass` off, and from then on the value stays fixed even if the object is rescaled.

Physics is powered by [Rapier](https://rapier.rs/), which the engine loads on demand the first time a scene uses it.

→ [Use Physics](/docs/how-to-guides/scripting/use-physics)

---

## 11 · Networking

<walkthrough-tags symbols="SyncedRoom, connection.send, connection.beginListen, syncField" />

<walkthrough-takeaway>

Multiplayer needs no separate architecture. Join a room, send a message when something changes, and listen for the same message to apply what other people did.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-11-networking.html" title="Two visitors in one room, clicking cubes to change their colour for both" split>

```js
import { Behaviour, onStart, SyncedRoom } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false });

const COLORS = ['#f2c14e', '#7dd3a0', '#6aa9e8', '#e88a8a', '#b7aaf0'];

// Click a cube and everyone in the room sees it change colour.
class SharedColor extends Behaviour {
  index = 0;
  // Identifies which cube a message is about. Both visitors must agree on
  // it, so it is set where the component is added.
  key = '';

  awake() {
    this.material = this.gameObject.material;
  }

  onEnable() {
    // Listen while enabled. autoCleanup unsubscribes when the component
    // is disabled or destroyed.
    this.autoCleanup(
      this.context.connection.beginListen("change-index", data => {
        if(data.guid === this.key)
          this.apply(data.index);
      })
    );
  }

  onPointerClick() {
    const next = (this.index + 1) % COLORS.length;

    // Apply it here ourselves. `send` goes to everyone else in the room,
    // not back to the sender, so without this our own view wouldn't change.
    this.apply(next);

    // Then tell everyone else in the room. Including a `guid` makes the
    // server keep this message in the room state, so whoever joins later
    // still gets it. Without one it only reaches people already here.
    this.context.connection.send("change-index", { index: next, guid: this.key });
  }

  apply(index) {
    this.index = index;
    this.material.color.set(COLORS[index]);
  }
}

onStart(context => {
  // A fixed room, so both views on this page meet in the same one.
  context.scene.addComponent(SyncedRoom, {
    roomName: 'code-walkthrough-networking',
    urlParameterName: undefined,
  });

  for (let i = 0; i < 3; i++) {
    const cube = new THREE.Mesh(
      new THREE.BoxGeometry(1, 1, 1),
      new THREE.MeshStandardMaterial({ color: COLORS[0], roughness: 0.4 })
    );
    cube.name = `cube-${i}`;
    cube.position.x = (i - 1) * 1.5;
    context.scene.add(cube);

    /*
      The key has to be the same for this cube in every visitor's browser.
      Here the cube's name gives one. In a scene exported from Unity or
      Blender it is common to use the component's own `guid` instead, which
      the export assigns and every client receives.
    */
    cube.addComponent(SharedColor, { key: `color-${cube.name}` });
  }
});
```
</walkthrough-step>

Two views of the same room, side by side. This is what two people opening the same link would see. Click a cube in either one and it changes in both.

`SyncedRoom` joins a room, and that is the whole connection setup. See [Set Up Networking](/docs/how-to-guides/networking/setup) for rooms, servers and hosting.

From there, `connection.send(channel, data)` broadcasts to everyone else in the room, and `connection.beginListen(channel, callback)` receives. The channel is any string both sides agree on. All three cubes share one channel here, and the message says which cube it is about.

Three details are worth knowing:

**The `guid` makes the change persist.** A message sent with one is stored in the room state on the server, so anyone joining later receives it. Without a `guid` the message only reaches people already in the room and is then forgotten.

The value also identifies which cube changed, so it has to mean the same thing in every visitor's browser. Each component is given one where it is added, built from the cube's name. A scene exported from Unity or Blender usually uses the component's own `guid`, which the export assigns and every client receives.

**The click applies the colour locally as well as sending it.** `send` broadcasts to everyone else in the room. It does not come back to the sender. Leave out the local `apply` and the one person who clicked is the only one who sees nothing happen.

**`beginListen` is wrapped in `autoCleanup`.** A listener that outlives its component keeps firing against an object that's gone. Subscribing in `onEnable` and letting `autoCleanup` unsubscribe is the pairing rule from [step 03](#03-the-component-lifecycle).

### Syncing a field instead

Explicit messages are worth understanding. To keep a single value in step, though, there is far less to write:

```ts
export class SharedColor extends Behaviour {
    @syncField("onIndexChanged")
    index: number = 0;

    onIndexChanged() {
        this.apply();
    }
}
```

Assigning `this.index = 2` now syncs on its own. There is no channel name, no `send` and no listener to clean up. Persistence is handled for you, rather than depending on you remembering the `guid`.

Like [`@serializable`](#marking-fields-as-serializable), it's a decorator and needs TypeScript — which is why the runnable example on this page uses the explicit calls instead.

→ [Networking Overview](/docs/how-to-guides/networking/) · [Sync Component State](/docs/how-to-guides/networking/sync-state) · [Manual Networking](/docs/how-to-guides/networking/manual-networking) — including `dontSave` and `deleteOnDisconnect` for finer control over what persists

---

## 12 · AR and VR

<walkthrough-tags symbols="WebXR, XRRig" />

<walkthrough-takeaway>

One component adds AR and VR. There is no separate build and no separate code path — the components you already wrote keep running in a headset.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-12-webxr.html" title="A scene with AR and VR enabled by a single WebXR component">

```js
import { Behaviour, onStart, WebXR, XRRig } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false });

class Spin extends Behaviour {
  update() {
    this.gameObject.rotation.y += 0.4 * this.context.time.deltaTime;
  }
}

onStart(context => {
  const shape = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.7, 0.25, 128, 24),
    new THREE.MeshStandardMaterial({ color: '#7dd3a0', roughness: 0.8, metalness: 0 })
  );
  context.scene.add(shape);
  shape.addComponent(Spin);

  // That's the whole XR setup. The component adds the buttons, handles the
  // session, and provides controllers and hand tracking in VR.
  context.scene.addComponent(WebXR, {
    createVRButton: true,
    createARButton: true,
    // Movement and teleport in VR, without writing any of it.
    useDefaultControls: true,
    // On a desktop, show a QR code so the page can be opened on a phone.
    createQRCode: true,
    // In AR the scene is placed at real-world scale, so one unit is one
    // metre. arScale scales you rather than the scene: larger values make
    // you bigger, so everything else looks smaller.
    arScale: 8,
  });

  /*
    Where the visitor stands when the session starts.

    In XR the user is parented to a rig, so moving the rig moves the user.
    Put it where you want someone to arrive, and face it the way you want
    them looking. Without one they start at the world origin.
  */
  const rig = new THREE.Object3D();
  rig.position.set(0, 0, 2.5);
  rig.lookAt(0, 0, 0);
  context.scene.add(rig);
  rig.addComponent(XRRig);
});
```
</walkthrough-step>

The buttons appear on their own when a mode is available. A phone offers AR, a headset offers VR, and so does a desktop with a headset connected.

Two options cover the machines without one. `createSendToQuestButton` offers to open the page on a Quest. `createQRCode` shows a QR code, so you can open the same URL on a phone and try AR. `useDefaultControls` adds movement and teleporting once you are in VR.

`arScale` is worth setting for AR, because a scene is placed at real-world size. A cube one unit across arrives as a one-metre block in the room.

The value scales *you*, not the scene. Raise it and you become larger relative to everything, so the scene looks smaller. `8` here brings it down to something that sits on a table.

`XRRig` decides where the visitor arrives. In XR the user is parented to a rig, so moving the rig moves the user — put one where you want someone to start, facing the way you want them to look. Without one they begin at the world origin, which may be inside your scene. A scene can hold several rigs and switch between them during a session with `setAsActiveXRRig()`.

`Spin` is the same component from step 01, unchanged, running in a headset.

→ [WebXR Guides](/docs/how-to-guides/xr/) · [iOS WebXR](/docs/how-to-guides/xr/ios-webxr-app-clip) · [Everywhere Actions](/docs/how-to-guides/everywhere-actions/) for AR on iOS via QuickLook

---

## 13 · Following the cursor

<walkthrough-tags symbols="CursorFollow, LookAt" />

<walkthrough-takeaway>

Check for a built-in component before writing one. Two of them make a head that watches you, with no code of your own: one follows the pointer, the other aims an object at a target.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-13-cursor.html" title="A head whose eyes track the mouse pointer">

```js
import { onStart, CursorFollow, LookAt, Behaviour, Mathf } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, orbitZoom: false, useContactShadows: true });

const material = (color, roughness = 0.4) =>
  new THREE.MeshStandardMaterial({ color, roughness });

// An empty object that trails the pointer. Nothing is drawn for it — it
// exists so other objects have something to aim at. `damping` is how far
// behind it lags, in seconds.
function cursorTarget(context, damping) {
  const target = new THREE.Object3D();
  target.worldPosition = context.mainCamera.worldPosition;
  target.worldPosition = target.worldPosition.multiplyScalar(1.1);
  context.scene.add(target);
  target.addComponent(CursorFollow, { damping });
  return target;
}

onStart(context => {
  // Two targets at different speeds: the head swings round slowly, the
  // eyes flick across almost instantly.
  const slow = cursorTarget(context, 0.1);
  const quick = cursorTarget(context, 0.05);

  const head = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 24), material('#7dd3a0'));
  head.position.y = 1.5;
  context.scene.add(head);
  head.addComponent(LookAt, { target: slow, keepUpDirection: false });

  const blink = head.addComponent(Blink);

  // Creating the eyes and pupils in a loop
  for (const side of [-1, 1]) {
    const eye = new THREE.Mesh(
      new THREE.SphereGeometry(0.26, 24, 18),
      material('#ffffff', 0.2)
    );
    eye.position.set(side * 0.34, 0.16, 0.86);
    head.add(eye);

    const pupil = new THREE.Mesh(
      new THREE.SphereGeometry(0.12, 16, 12),
      material('#14201a', 0.3)
    );
    pupil.position.z = 0.19;
    eye.add(pupil);

    // Each eye aims independently of the head. The pupil is a child of the
    // eye, so it comes along without any logic of its own.
    eye.addComponent(LookAt, { target: quick, keepUpDirection: false });
    blink.objects.push(eye); 
  }
});


class Blink extends Behaviour {

  objects = new Array();

  _hidden = false;
  _nextShowTime = 0;

  update() {

    if(this._hidden && this.context.time.time > this._nextShowTime) {
      this.objects.forEach(o => o.visible = true);
      this._hidden = false;
    }
    else if(Math.random() > 0.99) {
      this.objects.forEach(o => o.visible = false);
      this._hidden = true;
      this._nextShowTime = this.context.time.time + Mathf.random(0.1, 0.3); // blink duration
    }
  }
}
```
</walkthrough-step>

`CursorFollow` moves an object towards the pointer. `damping` sets how smoothly it gets there: higher values ease in gradually, lower values track the pointer closely. `LookAt` turns an object to face a target.

The pupils are children of the eyes, so turning an eye takes its pupil with it. That is the same nesting idea as [step 05](#05-components-in-a-hierarchy).

The [Component Reference](/docs/reference/components) lists everything that ships with the engine. It is worth a look before writing something yourself.

→ [Cursor Follow](/docs/how-to-guides/components/cursor-follow) · [Component Reference](/docs/reference/components)

---

## 14 · Loading a model

<walkthrough-tags symbols="loadAsset" />

<walkthrough-takeaway>

Load a model from any URL while the scene is running. What comes back is an ordinary object, so components attach to it like anything else.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-14-loading.html" title="A model downloaded from a URL at runtime and framed by the camera">

```js
import { Behaviour, onStart, loadAsset, OrbitControls } from '@needle-tools/engine';
import { configureDemoScene } from './walkthrough-base.js';

// A large model, so allow the camera much further out than the default.
configureDemoScene({ showGrid: false, maxZoom: 200, autoFrame: false });

const URL = 'https://cloud.needle.tools/-/assets/Z23hmXBZ21QnG-world/file';

onStart(async context => {
  // One line to fetch and parse the file. It is not in the scene yet.
  const asset = await loadAsset(URL);
  
  if(asset) {
    context.scene.add(asset.scene);
  }

  const orbit = context.mainCamera.getComponent(OrbitControls);
  orbit?.fitCamera();
});
```
</walkthrough-step>

`loadAsset(url)` fetches and parses the file, then hands back an object with `.scene` and `.animations`. It doesn't add anything to your scene, so you decide where the model goes and when it appears.

`asset.scene` is a plain `THREE.Object3D`. Add components to it, move it, or parent it to something else, exactly as with a shape you built yourself. A loaded object is not special in any way.

`fitCamera()` on `OrbitControls` frames whatever is in the scene. That saves guessing at a camera position for a model whose size you don't know in advance.

Assets exported through Needle are compressed and progressively loaded by default, so a model like this one appears early instead of arriving all at once.

This is one of four ways to load a model. Which one fits depends on what you are doing: a single root scene, switching between many, spawning copies of one, or a quick one-off like this.

→ [Load 3D Web Assets at Runtime](/docs/how-to-guides/scripting/load-3d-web-assets-at-runtime) compares all four

---

## 15 · Seeing what your code is doing

<walkthrough-tags symbols="Gizmos" />

<walkthrough-takeaway>

Draw into the scene from code to see what a value is doing. Gizmos put it on the object it belongs to, instead of in the console.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-15-gizmos.html" title="An orbiting marker with its path, position and heading drawn as gizmos">

```js
import { Behaviour, onStart, Gizmos, OrbitControls, ObjectUtils, getBoundingBox } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: true });

// The orbit spans twice this, so the whole scene fits in about 2×2.
const RADIUS = 1;

class Orbit extends Behaviour {
  radius = RADIUS;
  speed = 0.8;

  update() {
    const t = this.context.time.time * this.speed;
    this.gameObject.position.x = Math.cos(t) * this.radius;
    this.gameObject.position.z = Math.sin(t) * this.radius;
  }
}

// Call a Gizmos method and it draws where you tell it to. Each call lasts one
// frame, which is why these run in update.
class ShowWhatItIsDoing extends Behaviour {
  radius = RADIUS;

  _lastPosition = new THREE.Vector3();

  start() {
    this._lastPosition = this.gameObject.worldPosition.clone();
  }

  update() {
    const position = this.gameObject.worldPosition;
    const direction = position.clone().sub(this._lastPosition).normalize();

    // The path being followed.
    Gizmos.DrawCircle(new THREE.Vector3(), new THREE.Vector3(0, 1, 0), this.radius, 0x9aa8a0);

    // Where the object is, and where it is heading.
    Gizmos.DrawWireSphere(position, 0.22, 0xf2c14e);
    Gizmos.DrawLine(new THREE.Vector3(), position, 0x6aa9e8);
    Gizmos.DrawArrow(position, position.clone().addScaledVector(direction, 0.5), 0x0000ff);

    // Values you would otherwise print to the console, shown in place.
    Gizmos.DrawLabel(
      position.clone().add(new THREE.Vector3(0, 0.4, 0)),
      `Time: ${this.context.time.time.toFixed(1)}, x ${position.x.toFixed(1)}  z ${position.z.toFixed(1)}`,
      0.08
    );

    this._lastPosition.copy(position);
  }
}

class SceneBoundsOnClick extends Behaviour {

  onPointerClick() {
    const bounds = getBoundingBox(this.context.scene);
    Gizmos.DrawWireBox3(bounds, 0x55ff00, 1, true);
    this.context.time.timeScale = 5;
    setTimeout(()=> {
      this.context.time.timeScale = 1;
    }, 1000);
  }

}

onStart(async context => {
  const marker = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.18, 0),
    new THREE.MeshStandardMaterial({ color: '#7dd3a0', roughness: 0.4, flatShading: true })
  );
  marker.position.y = 1;
  context.scene.add(marker);

  marker.addComponent(Orbit);
  marker.addComponent(ShowWhatItIsDoing);

  const cylinder = ObjectUtils.createPrimitive("Cylinder", { parent: context.scene, scale: [1, .1, 1] });
  cylinder.addComponent(SceneBoundsOnClick);

  context.mainCamera.position.z = 9;
});
```
</walkthrough-step>

`Orbit` moves the marker. `ShowWhatItIsDoing` draws the circle it follows, a sphere at its position, a line back to the centre, and a label with the live coordinates.

Call a `Gizmos` method and it draws at the position you give it. Positions are in world space.

Each call lasts one frame, which is why these sit in `update`. Stop calling and the gizmo is gone. Pass a duration to keep one on screen longer, which suits things that happen once — a raycast hit, or a collision point.

`Gizmos.DrawLabel` draws readable text in the scene. That suits a value which changes every frame, because you see it on the object it belongs to rather than scrolling past in the console.

→ [Debugging & Profiling](/docs/how-to-guides/debugging/) · [Gizmos API](https://engine.needle.tools/docs/api/Gizmos)

---

## 16 · Adapting to the device

<walkthrough-tags symbols="DeviceUtilities, XRFlag, XRStateFlag" />

<walkthrough-takeaway>

The same page runs on a phone, a desktop and a headset. Check which one you are on, and mark objects to appear only in the modes you choose.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-16-device.html" title="A sphere whose detail depends on the device, plus two boxes that only appear in AR or VR">

```js
import {
  Behaviour,
  onStart,
  Gizmos,
  DeviceUtilities,
  XRFlag,
  XRStateFlag,
  WebXR,
} from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, useContactShadows: true });

const material = (color, roughness = 0.5) =>
  new THREE.MeshStandardMaterial({ color, roughness });


// Draws which branch was taken, so you can see the result of the check.
class ShowDetectedDevice extends Behaviour {
  update() {
    const label = DeviceUtilities.isMobileDevice()
      ? "You're on a phone, so you get the phone"
      : "You're on a desktop, so you get the monitor — open this on a phone to see the other one";
    Gizmos.DrawLabel(new THREE.Vector3(0, 0.62, 0), label, 0.03);
  }
}

// A CRT monitor, roughly 40cm across. Sizes are in metres throughout,
// because AR places a scene at real-world scale.
function buildMonitor() {
  const monitor = new THREE.Group();
  const beige = material('#d6cfbc', 0.6);

  // A 4-sided cylinder is a tapered box: wide at the front, narrow at the
  // back, which is what gives a CRT its shape.
  const casing = new THREE.Mesh(
    new THREE.CylinderGeometry(0.28, 0.22, 0.34, 4),
    beige
  );
  casing.rotation.set(Math.PI / 2, Math.PI / 4, 0);
  casing.position.y = 0.26;
  monitor.add(casing);

  const glass = new THREE.Mesh(
    new THREE.BoxGeometry(0.28, 0.22, 0.01),
    material('#1b2b24', 0.25)
  );
  glass.position.set(0, 0.26, 0.171);
  monitor.add(glass);

  const stand = new THREE.Mesh(
    new THREE.CylinderGeometry(0.09, 0.13, 0.09, 20),
    beige
  );
  stand.position.y = 0.045;
  monitor.add(stand);

  return monitor;
}

// A candybar phone, roughly 4.5 x 13cm. Standing up so you can see it.
function buildPhone() {
  const phone = new THREE.Group();

  const shell = material('#3b4a53', 0.45);

  const body = new THREE.Mesh(new THREE.BoxGeometry(0.048, 0.13, 0.018), shell);
  body.position.y = 0.065;
  phone.add(body);

  const screen = new THREE.Mesh(
    new THREE.BoxGeometry(0.034, 0.026, 0.002),
    material('#9fc98a', 0.3)
  );
  screen.position.set(0, 0.035, 0.009);
  body.add(screen);

  // Keypad: three columns, four rows.
  const keyGeometry = new THREE.BoxGeometry(0.011, 0.006, 0.002);
  const keyMaterial = material('#8d9aa3', 0.4);
  for (let row = 0; row < 4; row++) {
    for (let column = 0; column < 3; column++) {
      const key = new THREE.Mesh(keyGeometry, keyMaterial);
      key.position.set((column - 1) * 0.014, -0.005 - row * 0.009, 0.009);
      body.add(key);
    }
  }

  // The stubby aerial every phone had.
  const aerial = new THREE.Mesh(
    new THREE.CylinderGeometry(0.002, 0.003, 0.018, 8),
    shell
  );
  aerial.position.set(0.018, 0.074, -0.004);
  body.add(aerial);

  /*
    A phone is about 13cm tall and a monitor about 40cm, so at life size
    the phone sits tiny under the label. Scale it up for the demo, which
    keeps the parts modelled at real dimensions relative to each other.
  */
  phone.scale.setScalar(2.6);

  return phone;
}

onStart(context => {
  context.menu.showQRCodeButton('desktop-only');
  context.scene.addComponent(WebXR);

  // Check the device once, then build for it. A phone and a desktop want
  // different things on screen, not just different detail levels.
  const device = DeviceUtilities.isMobileDevice() ? buildPhone() : buildMonitor();
  device.rotateY(Math.PI / 2 * .3);
  context.scene.add(device);
  device.addComponent(ShowDetectedDevice);

  // XRFlag hides an object outside the modes you list. This one is only
  // visible in AR, so it stays hidden here in the browser.
  const arOnly = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), material('#f2c14e'));
  arOnly.position.set(0.4, 0.05, 0);
  context.scene.add(arOnly);
  arOnly.addComponent(XRFlag, { visibleIn: XRStateFlag.AR });

  // Combine modes with `|`. This one shows in AR and in VR, but not here.
  const xrOnly = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), material('#6aa9e8'));
  xrOnly.position.set(-0.4, 0.05, 0);
  context.scene.add(xrOnly);
  xrOnly.addComponent(XRFlag, { visibleIn: XRStateFlag.AR | XRStateFlag.VR });
});
```
</walkthrough-step>

`DeviceUtilities` answers questions about the device. `isMobileDevice()` and `isDesktop()` cover most cases, with `isIPad()`, `isAndroidDevice()`, `isiOS()` and `isVisionOS()` for the rest.

Here the answer decides what gets built: a CRT monitor on a desktop, a candybar phone on a mobile. Open this page on your phone to see the other branch. The label reports which one ran.

Results are cached, so a check costs nothing and can go wherever it reads best. This one runs at startup, because it decides what to build.

`XRFlag` covers the other case: an object that should exist in some modes only. Set `visibleIn` and the object hides everywhere else. That is why two boxes are missing above — one is marked AR only, the other AR and VR, and you are in a browser.

The avatar head is the case this exists for. In VR you are looking out through it, so rendering it fills your view with the inside of your own skull. Everyone else still needs to see it, and so do you in third person or when the scene is mirrored into AR:

```ts
head.addComponent(XRFlag, {
    visibleIn: XRStateFlag.Browser | XRStateFlag.ThirdPerson | XRStateFlag.AR,
});
```

Combine modes with `|`. The options are `Browser`, `AR`, `VR`, `FirstPerson` and `ThirdPerson`. The last two are what make this work, because the same headset session switches between them.

The rule lives on the object. The head knows when to hide itself, and nothing has to go looking for it when a session starts.

→ [Detect Mobile Devices](/docs/how-to-guides/scripting/detect-mobile-devices) · [WebXR Guides](/docs/how-to-guides/xr/)

---

## 17 · Audio

<walkthrough-tags symbols="AudioSource, play, pause, getOrAddComponent" />

<walkthrough-takeaway>

Sound is a component you put on an object. This one sits on the radio, so the sound comes from the radio and fades as you orbit away.

</walkthrough-takeaway>

<walkthrough-step src="/docs/code-samples/walkthrough-17-audio.html" title="A radio with 3D buttons for play, pause and track switching, and bars that move while it plays">

```js
import { Behaviour, onStart, AudioSource, getOrAddComponent } from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

// The radio is only 30cm across, so it needs closer limits than the default.
configureDemoScene({ showGrid: false, useContactShadows: true, minZoom: 0.2, maxZoom: 5 });

// Real-world sizes, in metres — a portable radio is about 30cm across.
// Worth getting right: in AR the scene is placed at life size.
const RADIO = { width: 0.3, height: 0.18, depth: 0.09, radius: 0.02 };
const SCREEN = { width: 0.14, height: 0.07, padding: 0.008 };

// The front face of the case, where the screen and buttons sit.
const FRONT = RADIO.depth / 2;

const BAR_COUNT = 9;
const BAR_MIN = 0.004;
const BAR_MAX = SCREEN.height - SCREEN.padding * 2;
// Bars stand on the bottom edge of the screen, inside the padding.
const BAR_FLOOR = -SCREEN.height / 2 + SCREEN.padding;

// One AudioSource, however many clips you give it.
class Radio extends Behaviour {
  // A plain field, so it can be overridden per instance through
  // addComponent — but a default here means it works without that.
  tracks = [
    './audio/marcel-elzach.ogg',
    './audio/tribal.ogg',
    './audio/beach.ogg',
    './audio/disney.ogg',
  ];
  _currentTrack = 0;

  awake() {
    // The radio brings its own AudioSource, so adding Radio is enough.
    // getOrAddComponent reuses one if the object already has it, which
    // keeps this safe if somebody adds their own.
    this.audio = getOrAddComponent(this.gameObject, AudioSource, {
      playOnAwake: false,
      preload: true,
      loop: true,
      // 1 is positional: the sound comes from the radio and fades with distance.
      spatialBlend: 1,
      minDistance: 1, 
      maxDistance: 10,
    });
  }

  // play() takes a clip, so switching track is a single call.
  play() {
    this.audio.play(this.tracks[this._currentTrack]);
  }

  // One button for both, the way a real play button behaves.
  toggle() {
    if (this.audio.isPlaying) this.audio.pause();
    else this.play();
  }

  next() {
    this._currentTrack = (this._currentTrack + 1) % this.tracks.length;
    this.play();
  }

  previous() {
    this._currentTrack = (this._currentTrack + this.tracks.length - 1) % this.tracks.length;
    this.play();
  }
}

// A button in the scene. Clicking calls the named method on the radio.
class Button extends Behaviour {
  action = 'toggle';

  awake() {
    this.radio = this.gameObject.parent.getComponent(Radio);
    this.restZ = this.gameObject.position.z;
  }

  onPointerEnter() {
    this.context.input.setCursor('pointer');
  }

  onPointerExit() {
    this.context.input.unsetCursor('pointer');
    // Release it if the pointer leaves while still held.
    this.gameObject.position.z = this.restZ;
  }

  onPointerDown() {
    // The buttons sit on the front face, so pressing pushes along -Z,
    // into the case rather than down across it.
    this.gameObject.position.z = this.restZ - 0.004;
  }

  onPointerUp() {
    this.gameObject.position.z = this.restZ;
  }

  onPointerClick() {
    this.radio[this.action]();
  }
}

// Bars driven by the audio itself, read through the Web Audio API.
class Visualiser extends Behaviour {
  bars = [];

  start() {
    // start() runs after every awake, so the Radio has added its
    // AudioSource by now.
    this.audio = this.gameObject.getComponent(AudioSource);
  }

  // The analyser can only be built once the audio exists, which happens on
  // the first play. Returns null until then.
  getFrequencies() {
    if (!this.analyser) {
      const sound = this.audio.Sound;
      const context = this.audio.audioContext;
      if (!sound || !context) return null;

      this.analyser = context.createAnalyser();
      // 32 bins is plenty for nine bars, and cheap.
      this.analyser.fftSize = 64;
      sound.getOutput().connect(this.analyser);
      this.frequencies = new Uint8Array(this.analyser.frequencyBinCount);
    }

    this.analyser.getByteFrequencyData(this.frequencies);
    return this.frequencies;
  }

  update() {
    const frequencies = this.audio.isPlaying ? this.getFrequencies() : null;

    this.bars.forEach((bar, i) => {
      // Skip the lowest bins: they hold most of the energy and would leave
      // the other bars barely moving.
      const level = frequencies ? frequencies[i + 2] / 255 : 0;
      const target = BAR_MIN + level * (BAR_MAX - BAR_MIN);

      // Ease towards the target so the bars settle instead of snapping.
      bar.scale.y += (target - bar.scale.y) * this.context.time.deltaTime * 12;
      // The bar is 1 unit tall, so scale.y is its height. Offset by half of
      // that to keep its base on the floor as it grows.
      bar.position.y = BAR_FLOOR + bar.scale.y / 2;
    });
  }
}

// A rounded box, built by extruding a rounded rectangle.
function roundedBox(width, height, depth, radius, material) {
  const shape = new THREE.Shape();
  const w = width / 2 - radius;
  const h = height / 2 - radius;
  shape.absarc(-w, -h, radius, Math.PI, Math.PI * 1.5);
  shape.absarc(w, -h, radius, Math.PI * 1.5, 0);
  shape.absarc(w, h, radius, 0, Math.PI * 0.5);
  shape.absarc(-w, h, radius, Math.PI * 0.5, Math.PI);

  const geometry = new THREE.ExtrudeGeometry(shape, {
    depth: depth - radius * 2,
    bevelEnabled: true,
    bevelSize: radius,
    bevelThickness: radius,
    bevelSegments: 6,
    curveSegments: 12,
  });
  geometry.center();
  return new THREE.Mesh(geometry, material);
}

onStart(context => {
  const body = roundedBox(
    RADIO.width, RADIO.height, RADIO.depth, RADIO.radius,
    new THREE.MeshStandardMaterial({ color: '#7dd3a0', roughness: 0.45 })
  );
  // Half its height, so the case stands on the ground instead of through it.
  body.position.y = RADIO.height / 2;
  context.scene.add(body);
  body.addComponent(Radio);

  // Antenna: a thin rod leaning off the top corner, with a tip on the end.
  const antenna = new THREE.Mesh(
    new THREE.CylinderGeometry(0.002, 0.003, 0.16, 10),
    new THREE.MeshStandardMaterial({ color: '#5c6b63', roughness: 0.3, metalness: 0.7 })
  );
  antenna.position.set(RADIO.width / 2 - 0.02, RADIO.height / 2 + 0.06, -0.015);
  antenna.rotation.z = -0.28;
  body.add(antenna);

  const tip = new THREE.Mesh(
    new THREE.SphereGeometry(0.006, 16, 12),
    new THREE.MeshStandardMaterial({ color: '#f2c14e', roughness: 0.3, metalness: 0.5 })
  );
  tip.position.y = 0.085;
  antenna.add(tip);

  // Screen, with bars standing on it.
  const screen = roundedBox(
    SCREEN.width, SCREEN.height, 0.012, 0.007,
    new THREE.MeshStandardMaterial({ color: '#1b2b24', roughness: 0.9 })
  );
  screen.position.set(-0.065, 0.035, FRONT);
  body.add(screen);

  const visualiser = body.addComponent(Visualiser);
  // 1 unit tall, so scale.y is the bar's height in world units.
  const barGeometry = new THREE.BoxGeometry(0.008, 1, 0.006);
  const barStep = (SCREEN.width - SCREEN.padding * 2) / (BAR_COUNT - 1);
  const barMaterial = new THREE.MeshStandardMaterial({
    color: '#7dd3a0',
    emissive: '#2f6b52',
  });

  for (let i = 0; i < BAR_COUNT; i++) {
    const bar = new THREE.Mesh(barGeometry, barMaterial);
    bar.position.set((i - (BAR_COUNT - 1) / 2) * barStep, BAR_FLOOR, 0.007);
    bar.scale.y = BAR_MIN;
    screen.add(bar);
    visualiser.bars.push(bar);
  }

  // Three buttons along the front of the case. Play doubles as pause.
  const buttons = [
    { action: 'previous', color: '#8d9a93' },
    { action: 'toggle', color: '#f2c14e' },
    { action: 'next', color: '#8d9a93' },
  ];

  buttons.forEach((entry, i) => {
    const button = roundedBox(
      0.032, 0.02, 0.014, 0.006,
      new THREE.MeshStandardMaterial({ color: entry.color, roughness: 0.35 })
    );
    // Centre the row: 32mm wide with a 13mm gap between them.
    button.position.set((i - (buttons.length - 1) / 2) * 0.045, -0.055, FRONT);
    body.add(button);

    button.addComponent(Button, { action: entry.action });
  });
});
```
</walkthrough-step>

Click the buttons on the radio itself. They are objects in the scene with a `Button` component, using the pointer methods from [step 07](#07-pointer-input). `onPointerDown` presses one into the case, `onPointerUp` releases it, and `onPointerClick` calls the matching method on the radio. Each button gets its action as an init object, so one class covers all three.

One `AudioSource` plays every track. `play()` takes a clip, so changing track is a single call.

`Radio` adds the `AudioSource` itself with `getOrAddComponent`. Attaching `Radio` is therefore all you need, and an object that already has an `AudioSource` keeps the one it has.

`spatialBlend: 1` makes the sound positional. It comes from wherever the object is and fades as you orbit away. Set it to `0` for flat audio at constant volume, which is what you want for music or narration covering the whole scene.

Browsers block audio until the visitor interacts with the page. The engine handles that: it waits for the first interaction and starts playback then. `playOnAwake` works as you would expect, for audio and for video, with nothing to write yourself. It is off here only because the buttons decide when playback starts.

The bars follow the actual sound. `AudioSource.Sound` is the underlying three.js audio object, and `audioContext` is the Web Audio context. `Visualiser` connects an `AnalyserNode` to the output and reads the frequency data each frame. That is the standard Web Audio API, reachable because the engine does not hide it.

The analyser is created on first use rather than in `start`, because the audio object doesn't exist until something plays.

→ [Spatial Audio sample](https://engine.needle.tools/samples/spatial-audio) · [AudioSource API](https://engine.needle.tools/docs/api/AudioSource)

---

## 18 · Post-processing

<walkthrough-tags symbols="BloomEffect, DepthOfField, ScreenSpaceAmbientOcclusionN8, VolumeParameter" />

<walkthrough-takeaway>

Bloom, depth of field, ambient occlusion and the rest ship as components. Add one to the scene to switch it on, remove it to switch it off — the same as any other component.

</walkthrough-takeaway>

<walkthrough-step
  src="/docs/code-samples/walkthrough-18-postprocessing.html"
  title="Glowing orbs receding into the distance, with bloom and depth of field"
  :actions='[
    { "name": "bloom", "code": "bloom.enabled = !bloom.enabled", "label": "Toggle bloom" },
    { "name": "dof",   "code": "dof.enabled = !dof.enabled",     "label": "Toggle depth of field" },
    { "name": "ao",    "code": "ao.enabled = !ao.enabled",       "label": "Toggle ambient occlusion" }
  ]'>

```js
import {
  Behaviour,
  onStart,
  BloomEffect,
  Gizmos,
  DepthOfField,
  ScreenSpaceAmbientOcclusionN8,
  OrbitControls,
} from '@needle-tools/engine';
import * as THREE from 'three';
import { configureDemoScene } from './walkthrough-base.js';

configureDemoScene({ showGrid: false, autoFrame: false });

// The viewpoint the example opens on.
const CAMERA_POSITION = new THREE.Vector3(-1.642094, 0.709587, 4.062127);
const CAMERA_ROTATION = new THREE.Quaternion(-0.053509, -0.188422, -0.010282, 0.980575);

onStart(context => {
  /*
    An effect is a component. Add it to any object in the scene and it
    registers itself — there is no manager or Volume to set up first.
    `context.postprocessing` is the subsystem they register with, if you
    need to reach it directly.
  */
  const bloom = context.scene.addComponent(BloomEffect);
  // Every setting is a VolumeParameter, so values go through `.value`.
  bloom.threshold.value = 1.05;
  bloom.intensity.value = 2;
  bloom.scatter.value = .9;

  const dof = context.scene.addComponent(DepthOfField);
  // focalLength is how wide the sharp band is, in metres. 
  // aperture reads like an f-stop: a bigger number is a smaller opening and less blur.
  dof.focalLength.value = 4;
  dof.aperture.value = 5;
  /*
    Render the blur at full resolution. It defaults to 1 / devicePixelRatio,
    which halves it on a retina screen — cheaper, but the upscale leaves
    coloured fringes along high-contrast edges.
  */
  dof.resolutionScale.value = 1;

  // Ambient occlusion darkens the creases a light can't reach into.
  const ao = context.scene.addComponent(ScreenSpaceAmbientOcclusionN8);
  ao.aoRadius.value = 0.6;
  ao.intensity.value = 4;

  window.addEventListener('message', event => {
    // Effects are components, so switching one off is the same `enabled`
    // flag every other component has.
    if (event.data === 'bloom') bloom.enabled = !bloom.enabled;
    if (event.data === 'dof') dof.enabled = !dof.enabled;
    if (event.data === 'ao') ao.enabled = !ao.enabled;
  });

  // The scene the effects run on. Built at the bottom of this file.
  const props = buildDemoScene(context);

  const orbit = context.mainCamera.getComponent(OrbitControls);
  if (orbit) {
    // Take the shot, rather than letting the camera fit itself to the scene
    // and frame the far blocks along with everything else.
    orbit.autoFit = false;
    orbit.setCameraTargetPosition(CAMERA_POSITION, true);

    /*
      OrbitControls aims the camera at its look target, so the orientation is
      given as a point to look at rather than a rotation. This one sits on the
      view axis, so it sets the framing without changing where the camera
      points; AutoFocus moves it onto the focal point on its first reading.
    */
    const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(CAMERA_ROTATION);
    orbit.setLookTargetPosition(CAMERA_POSITION.clone().addScaledVector(forward, 3), true);

    /*
      Polar angle is measured from straight up, in radians. Stopping at 90°
      keeps the camera level with its target at the lowest, so dragging can't
      swing it under the ground and look up through the floor.
    */
    orbit.minPolarAngle = 0.35;
    orbit.maxPolarAngle = Math.PI / 2;
  }

  // Focus on whatever is in the middle of the view, and keep checking.
  context.scene.addComponent(AutoFocus, { effect: dof, orbit });
});

/*
  Autofocus, the way a camera does it: fire a ray through the middle of the
  view and focus on whatever it hits.

  focusDistance is a distance from the camera in metres, not a point in the
  scene, so it has to be recalculated whenever the view changes. Orbit the
  scene and the focus follows what you point at.
*/
const SCREEN_CENTRE = new THREE.Vector2(0, 0);
// Reused each frame, so the gizmo doesn't allocate a vector per draw.
const FORWARD = new THREE.Vector3();

class AutoFocus extends Behaviour {
  effect = null;
  /** Optional. Its pivot is moved onto the focal point once, at startup. */
  orbit = null;
  /** Seconds between raycasts. Focus doesn't need to be measured per frame. */
  interval = 0.2;
  /** Used when the ray hits nothing, so the focus doesn't jump to zero. */
  fallback = 6;
  /** How quickly focus travels to a new subject. */
  speed = 3;

  awake() {
    this._timer = 0;
    this._distance = this.fallback;
  }

  update() {
    if (!this.effect) return;

    this._timer -= this.context.time.deltaTime;
    if (this._timer <= 0) {
      this._timer = this.interval;

      /*
        screenPoint is in normalized device coordinates, so (0, 0) is the
        centre of the view. Hits come back sorted, nearest first.
      */
      const hits = this.context.physics.raycast({ screenPoint: SCREEN_CENTRE });
      this._distance = hits.length > 0 ? hits[0].distance : this.fallback;

      /*
        Put the orbit pivot on the first thing focused, so dragging turns
        around what the ring is sitting on. The point is on the view axis, so
        moving the pivot there reframes without turning the camera.
      */
      if (this.orbit && !this._pivotSet && hits.length > 0) {
        this._pivotSet = true;
        this.orbit.setLookTargetPosition(this.focalPoint(this._distance), true);
      }
    }

    // Ease towards it, so changing subject racks focus instead of snapping.
    const current = this.effect.focusDistance.value;
    const t = Math.min(1, this.context.time.deltaTime * this.speed);
    const focus = current + (this._distance - current) * t;
    this.effect.focusDistance.value = focus;

    this.drawFocusRing(focus);
  }

  /*
    A reticle on the focal plane, so you can see where the focus actually
    landed. Gizmos are drawn per frame and never end up in an export, which
    makes them a good fit for showing what a component is doing.
  */
  drawFocusRing(distance) {
    const forward = this.viewDirection();
    Gizmos.DrawCircle(this.focalPoint(distance), forward, 0.06, 0xff3366, 0, false);
  }

  /** Where the camera is pointing, from its rotation. */
  viewDirection() {
    return FORWARD.set(0, 0, -1).applyQuaternion(this.context.mainCamera.worldQuaternion);
  }

  /** The point on the focal plane, straight ahead of the camera. */
  focalPoint(distance) {
    return this.context.mainCamera.worldPosition
      .clone()
      .addScaledVector(this.viewDirection(), distance);
  }
}

/* ------------------------------------------------------------------------
   Everything below is scenery — a ground, a sun and props for the effects
   to act on. None of it is specific to post-processing.

   The one part worth knowing: depth of field needs a scene with depth, so
   the props run from near the camera far into the background rather than
   sitting in a line across the view. `glow` marks the ones bright enough
   for bloom to catch, and `h` makes a block taller than it is wide.
   ------------------------------------------------------------------------ */

const GREY = '#98a49c';
const PALE = '#e8e4dc';

const PROPS = [
  // Foreground, closest to the camera.
  { shape: 'cone',  pos: [-0.95, 1.5],  size: 0.34, color: '#6aa9e8' },
  { shape: 'box',   pos: [1.15, 1.35],  size: 0.32, color: PALE },
  { shape: 'lamp',  pos: [0.35, 1.0],   size: 0.10, color: '#f2c14e', glow: true },
  { shape: 'box',   pos: [-1.75, 0.9],  size: 0.4,  color: GREY, h: 1.8 },

  // Middle ground. The cylinder stands centre stage, where the focus lands.
  { shape: 'cyl',   pos: [-0.2, 0.45],  size: 0.34, color: '#e86a9b', h: 1.3 },
  { shape: 'box',   pos: [1.5, 0.2],    size: 0.44, color: GREY, h: 1.2 },
  { shape: 'ico',   pos: [0.55, -0.25], size: 0.32, color: '#7dd3a0' },
  { shape: 'lamp',  pos: [-1.0, -0.4],  size: 0.09, color: '#e86a9b', glow: true },
  { shape: 'cyl',   pos: [-2.1, -0.6],  size: 0.5,  color: PALE },
  { shape: 'box',   pos: [0.05, -1.05], size: 0.5,  color: GREY, h: 2.4 },
  { shape: 'oct',   pos: [1.95, -1.2],  size: 0.34, color: '#6aa9e8' },

  // Background — taller and sparser, so the far blur has something to sit on.
  { shape: 'box',   pos: [-1.5, -1.9],  size: 0.55, color: GREY, h: 3.2 },
  { shape: 'lamp',  pos: [0.9, -2.0],   size: 0.10, color: '#7dd3a0', glow: true },
  // The ring sits well back, where the blur has something to bite on.
  { shape: 'torus', pos: [-0.35, -2.6], size: 0.55, color: '#e86a9b' },
  { shape: 'box',   pos: [2.6, -2.4],   size: 0.6,  color: GREY, h: 2.0 },
  { shape: 'cone',  pos: [-2.7, -2.7],  size: 0.7,  color: '#e86a9b' },
  { shape: 'box',   pos: [0.4, -3.3],   size: 0.7,  color: GREY, h: 2.8 },
  { shape: 'cyl',   pos: [-0.9, -3.8],  size: 0.8,  color: PALE, h: 2.2 },
  { shape: 'box',   pos: [2.2, -4.2],   size: 0.75, color: GREY, h: 2.8 },
  { shape: 'lamp',  pos: [-2.2, -4.6],  size: 0.12, color: '#9d7dea', glow: true },
  { shape: 'box',   pos: [0.8, -5.4],   size: 0.9,  color: GREY, h: 2.6 },
  { shape: 'box',   pos: [-1.9, -6.2],  size: 1.0,  color: GREY, h: 2.4 },
  { shape: 'box',   pos: [3.1, -6.6],   size: 0.85, color: GREY, h: 3.0 },
];

// Each prop stands on the ground, so every shape is raised by half its height.
function buildProp({ shape, size, color, glow, h = 1 }) {
  const material = glow
    // Bloom only picks up what is brighter than its threshold. Emissive is
    // what pushes these past it — a plain colour would stay below it.
    ? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 2.5 })
    : new THREE.MeshStandardMaterial({ color, roughness: 0.6 });

  switch (shape) {
    case 'box':
      return raise(new THREE.Mesh(new THREE.BoxGeometry(size, size * h, size), material), size * h / 2);
    case 'cyl':
      return raise(new THREE.Mesh(new THREE.CylinderGeometry(size / 2.8, size / 2.8, size * h, 24), material), size * h / 2);
    case 'cone':
      return raise(new THREE.Mesh(new THREE.ConeGeometry(size / 2, size * h, 24), material), size * h / 2);
    case 'ico':
      return raise(new THREE.Mesh(new THREE.IcosahedronGeometry(size / 2, 0), material), size / 2);
    case 'oct':
      return raise(new THREE.Mesh(new THREE.OctahedronGeometry(size / 2, 0), material), size / 2);
    case 'torus':
      return raise(new THREE.Mesh(new THREE.TorusGeometry(size / 2, size / 6, 18, 44), material), size / 2 + size / 6);
    // A glowing ball on a thin post, so the light sits above the ground.
    default: {
      const post = raise(
        new THREE.Mesh(
          new THREE.CylinderGeometry(0.014, 0.02, 0.55, 10),
          new THREE.MeshStandardMaterial({ color: '#7b837e', roughness: 0.5 })
        ),
        0.275
      );
      const bulb = new THREE.Mesh(new THREE.IcosahedronGeometry(size, 3), material);
      bulb.position.y = 0.275 + size * 0.8;
      post.add(bulb);
      return post;
    }
  }
}

function raise(mesh, y) {
  mesh.position.y = y;
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

function buildDemoScene(context) {
  const ground = new THREE.Mesh(
    new THREE.PlaneGeometry(60, 60),
    new THREE.MeshStandardMaterial({ color: '#d9d9d4', roughness: 1 })
  );
  ground.rotation.x = -Math.PI / 2;
  ground.receiveShadow = true;
  context.scene.add(ground);

  const sun = new THREE.DirectionalLight('#fff6e8', 1.6);
  sun.position.set(4, 7, 5);
  sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  // The shadow camera has to cover the props, or their shadows are clipped.
  sun.shadow.camera.near = 1;
  sun.shadow.camera.far = 30;
  sun.shadow.camera.left = -8;
  sun.shadow.camera.right = 8;
  sun.shadow.camera.top = 8;
  sun.shadow.camera.bottom = -8;
  sun.shadow.bias = -0.0006;
  context.scene.add(sun);

  return PROPS.map(prop => {
    const object = buildProp(prop);
    object.position.x = prop.pos[0];
    object.position.z = prop.pos[1];
    // Turn each one a little, so repeated shapes don't line up.
    object.rotation.y = prop.pos[0] + prop.pos[1];
    context.scene.add(object);
    return object;
  });
}
```
</walkthrough-step>

An effect registers itself when you add it. There is no manager or profile asset to set up first — `addComponent` is the whole setup. Turn all three buttons off to see the scene underneath.

`context.postprocessing` is the subsystem effects register with, if you ever need to reach it directly.

Effect settings are `VolumeParameter` objects rather than plain numbers, so values go through `.value`. That extra step is what lets a setting be animated, or blended between one set of values and another.

Bloom only affects what is already brighter than its `threshold`. The lamps use an emissive material to get there. A plain colour stays below the line and never glows, however high you push the intensity.

`focusDistance` is a distance from the camera in metres, not a point in the scene, so it follows nothing on its own. `AutoFocus` does what a camera does: it raycasts through the middle of the view and focuses on whatever it hits.

`screenPoint` is in normalized device coordinates, so `(0, 0)` is the centre. Hits come back sorted, nearest first.

The raycast runs on a timer rather than every frame, because focus does not need measuring 60 times a second. Each new distance is eased into, which is what makes it rack focus instead of snapping. Orbit the scene and watch the focus follow what you point at.

::: tip Needle Engine only downloads what you use
The post-processing library is a separate chunk, fetched the first time an effect component is added. A project with no effects never downloads that code at all — it isn't shipped in the page and left unused. The same is true of physics, in [step 10](#10-physics-and-collisions).
:::

→ [Post-Processing Effects](/docs/how-to-guides/rendering/postprocessing) · [Postprocessing components](/docs/reference/components#postprocessing) · [Postprocessing sample](https://samples.needle.tools/postprocessing)

---

## What's next

That is the whole idea: components on objects, a lifecycle, and a context they share. Everything else in Needle Engine works the same way, so a component you meet later will look like the ones on this page.

**Set up a project.** These examples run from a CDN to keep them copyable, but a project adds hot reload, TypeScript, and the editor integrations. [Getting Started](/docs/getting-started/) — pick Unity, Blender, or code.

**Open components up to the editor.** [Create Components](/docs/how-to-guides/scripting/create-components) covers `@serializable`, which lets fields be set per object in Unity and Blender. It also covers where component files live in a project.

**Look things up.** [Scripting Examples](/docs/reference/scripting-examples) is snippets by topic. [Component Reference](/docs/reference/components) lists every built-in component — a lot of what you might write by hand already exists.

**See it at scale.** The [samples gallery](https://engine.needle.tools/samples?utm_source=needle_docs&utm_content=walkthrough_next) has 150+ finished projects to pull apart, from configurators to multiplayer games. In Unity and Blender you can install them from the Samples window and open any scene directly.

**Keep the code from this page.** Every step is one HTML page and one JS file, with no build step and nothing to install. Take both from [the docs repository](https://github.com/needle-tools/needle-engine-support/tree/main/documentation/.vuepress/public/code-samples) — the script alone won't run, since the page is what loads the engine. Save the pair side by side, open the HTML, and it works.

Something missing or unclear on this page? [Open an issue](https://github.com/needle-tools/needle-engine-support/issues) or ask in [Discord](https://discord.needle.tools).

