Skip to content

Custom Behaviours

A Behaviour is a user-authored TypeScript class that runs per-frame logic on a single entity. Behaviours are attached to entities through the ScriptComponent, and are dispatched each frame by the ScriptBehaviourSystem.

Anatomy of a script

ts
import {
  Behaviour,
  type FloatInput,
  type DropdownInput,
} from "@relu-interactives/spatial-ecs";

export default class Spinner extends Behaviour {
  data = {
    speed: 1 as FloatInput,
    axis: {
      value: "y",
      options: [
        { label: "X", value: "x" },
        { label: "Y", value: "y" },
        { label: "Z", value: "z" },
      ],
    } as DropdownInput,
  };

  init() {
    // Called once before the first update().
  }

  onUpdate() {
    // Called every frame while the script is enabled.
    const axis = this.data.axis.value as "x" | "y" | "z";
    this.transform.rotation[axis] += this.data.speed * this.deltaTime;
  }

  dispose() {
    // Optional cleanup when the entity is destroyed
    // or the script is detached.
  }
}

IMPORTANT

For the runtime to recognize the class make sure the default export of the class subclasses Behaviour.

Available context

When init() / onUpdate() runs, the following fields are populated:

FieldDescription
this.worldThe owning World.
this.entityIdNumeric entity id.
this.entityA WorldEntityView for the entity (id + components).
this.transformMutable TransformComponent (position/rotation/scale).
this.threeRefThe Object3DRef wrapping the live three.js object.
this.dataThe ScriptComponent.data payload (your inspector fields).
this.deltaTimeSeconds since last frame.

Helper methods

Behaviour exposes a small ergonomics surface so scripts rarely need to touch three.js directly:

See the full Behaviour API.

Lifecycle hooks

HookWhen
init()Once, before the first onUpdate().
onUpdate()Every frame while enabled.
dispose()When the script is detached or the entity destroyed.
onSelect() / onDeselect()Events fired when a user clicks or taps an object in the scene.
onTargetFound() / onTargetLost()Image-tracking target visibility changed.

update(deltaTime) is implemented by the base class, do not override it. Override onUpdate() instead.

Where to put scripts

In a Relu Spatial project, scripts live under the project's assets/ directory. The editor compiles them through esbuild and exposes them via the script registry.

Inspector data binding

The data object is the bridge between the editor inspector and your script. Use typed input annotations — FloatInput, ColorInput, DropdownInput, etc. — to control which inspector control renders for each field:

ts
import {
  Behaviour,
  type FloatInput,
  type DropdownInput,
} from "@relu-interactives/spatial-ecs";

export default class Spinner extends Behaviour {
  data = {
    speed: 1 as FloatInput,
    axis: {
      value: "y",
      options: [
        { label: "X", value: "x" },
        { label: "Y", value: "y" },
        { label: "Z", value: "z" },
      ],
    } as DropdownInput,
  };

  onUpdate() {
    const axis = this.data.axis.value as "x" | "y" | "z";
    this.transform.rotation[axis] += this.data.speed * this.deltaTime;
  }
}

See Behaviour Data Binding for the full reference of all input types (TextInput, ColorInput, Vector2Input, Vector3Input, SliderInput, CheckboxInput, ToggleInput, AssetInput, and more).

See Accessing Components to read and mutate built-in components such as PhysicsComponent, AudioComponent, AnimationComponent, LightComponent, and MaterialComponent from script.

See Reading Input for the full keyboard and mouse input API, KeyboardKeys reference, and scripting examples.

Running scripts in the editor

By default, Behaviour scripts only execute at preview or runtime. Setting Execute in editor to true on a script entry (via the inspector toggle) enables the full lifecycle (initonUpdate) while the editor is open.

This is useful for Three.js functionality the editor's object palette does not expose — custom shaders, procedural geometry, grass, post-processing passes, and similar.

ts
import { Behaviour } from "@relu-interactives/spatial-ecs";
import * as THREE from "three";

export default class GrassPlane extends Behaviour {
  private mesh: THREE.Mesh | null = null;

  protected init() {
    // Access the live Three.js scene directly.
    const scene = this.world.getScene();

    const geo = new THREE.PlaneGeometry(10, 10, 32, 32);
    const mat = new THREE.MeshStandardMaterial({ color: 0x228b22 });
    this.mesh = new THREE.Mesh(geo, mat);
    scene.add(this.mesh);
  }

  protected dispose() {
    if (this.mesh) {
      this.world.getScene().remove(this.mesh);
      this.mesh = null;
    }
  }
}

WARNING

Undo-history churn — avoid writing to ECS components (e.g. TransformComponent) on every frame when executeInEditor is true. The editor's change-detection system records a new undo entry each time a component snapshot differs, which will flood the undo stack and cause performance degradation. Keep per-frame mutations in local Three.js state (objects, materials, uniforms) rather than in ECS component fields.

Available world accessors in editor mode:

AccessorReturns
this.world.getScene()THREE.Scene
this.world.getCamera()THREE.PerspectiveCamera
this.world.getRenderer()THREE.WebGLRenderer