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
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:
| Field | Description |
|---|---|
this.world | The owning World. |
this.entityId | Numeric entity id. |
this.entity | A WorldEntityView for the entity (id + components). |
this.transform | Mutable TransformComponent (position/rotation/scale). |
this.threeRef | The Object3DRef wrapping the live three.js object. |
this.data | The ScriptComponent.data payload (your inspector fields). |
this.deltaTime | Seconds since last frame. |
Helper methods
Behaviour exposes a small ergonomics surface so scripts rarely need to touch three.js directly:
- World-space math:
getWorldPosition,getWorldDirection,directionTo,distanceTo. - Smooth motion:
lerpTo,slerpTo,lookAt. - Component access:
getComponents,getComponent. - Hierarchy:
getEntity,getParent,getChildren.
See the full Behaviour API.
Lifecycle hooks
| Hook | When |
|---|---|
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:
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 (init → onUpdate) 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.
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:
| Accessor | Returns |
|---|---|
this.world.getScene() | THREE.Scene |
this.world.getCamera() | THREE.PerspectiveCamera |
this.world.getRenderer() | THREE.WebGLRenderer |

