Authoring a Script Object
A Script Object is an entity whose THREE.js visual is built entirely by your code, for example custom shaders, procedural geometry, particle systems, anything the editor's object palette can't produce. Unlike a Behaviour, a Script Object runs in the editor AND at runtime: build() and update() execute identically in both, so what you see while authoring is exactly what ships.
IMPORTANT
Do NOT gate anything on world.isEditorMode(). Running everywhere is the whole point of a Script Object. Use a Behaviour for gameplay logic that must NOT run in the editor.
The contract
Subclass ScriptObject and export it (named export matching the class, or default export):
import { ScriptObject, type ColorInput } from "@relu-interactives/spatial-ecs";
import * as THREE from "three";
export default class WavePlane extends ScriptObject {
// Inspector fields: an INLINE `data = { … }` literal (same envelopes as
// Behaviours). NEVER `declare data` that is a type-only annotation with no
// runtime object, so the editor can't discover your fields and the inspector
// shows nothing to edit.
data = { color: "#3aa0ff" as ColorInput };
private mat!: THREE.ShaderMaterial;
// REQUIRED build and return the visual root.
build(): THREE.Object3D {
this.mat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(this.data.color) },
},
vertexShader: /* glsl */ `
uniform float uTime;
varying float vH;
void main() {
vec3 p = position;
p.z += sin(p.x * 4.0 + uTime) * 0.1;
vH = p.z;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
varying float vH;
void main() { gl_FragColor = vec4(uColor * (0.6 + vH), 1.0); }`,
});
const geo = new THREE.PlaneGeometry(2, 2, 64, 64);
geo.rotateX(-Math.PI / 2);
return new THREE.Mesh(geo, this.mat);
}
// OPTIONAL — per-frame, runs in editor AND runtime.
update(deltaTime: number): void {
this.mat.uniforms.uTime.value += deltaTime;
}
// OPTIONAL — free GPU resources you created.
dispose(): void {
this.mat.dispose();
}
}ScriptObjectSystem calls build() once, attaches the returned object under the entity's Object3D (so it inherits the entity's transform, selection, and visibility), then calls update(deltaTime) every frame. dispose() runs on removal, disable, or hot-reload.
Members available
| Member | Type | Notes |
|---|---|---|
this.world | World | The owning ECS world. |
this.entityId | number | The entity this script is bound to. |
this.data | Record<string, unknown> | Inspector-bound payload. Declare it with an INLINE literal (data = { … }), never use declare data (a type-only annotation with no runtime object → no inspector fields). |
this.root | THREE.Object3D | null | The object build() returned (set after build). |
Data fields (inspector)
Declare data as an inline literal (data = { … }) with the same input envelopes as Behaviours. Never use declare data it is a type-only annotation that produces no runtime object, so the editor can't discover the fields and the inspector shows nothing to edit. (Likewise, don't set defaults with this.data.x ??= … inside build() put the default in the literal.)
Simple scalars take a value plus an as <Input> cast and are read directly:
data = {
color: "#3aa0ff" as ColorInput, // color swatch → this.data.color
speed: 1.0 as FloatInput, // number field → this.data.speed
enabled: true as ToggleInput, // toggle → this.data.enabled
};Rich inputs that carry metadata are authored as inline objects and read via .value (or the resolved view for entity/component pickers):
data = {
amplitude: { type: "slider", min: 0, max: 1, value: 0.5 }, // → this.data.amplitude.value
target: { type: "entity", value: null }, // → this.data.target.value
};See Data binding for the full envelope catalog. Edits made in the inspector are pushed to the live instance's this.data between frames.
Rules
build()must return aTHREE.Object3D, aGroup,Mesh,Points, etc. Returning anything else marks the object as errored.- Do NOT gate on
world.isEditorMode(). Build and animate unconditionally. dispose()must free what you allocated, geometries, materials, textures, render targets. THREEdispose()is idempotent, so it's safe to over-dispose. The engine removes the built subtree from the scene for you; freeing GPU memory is yours.- Keep per-frame work in local THREE state / uniforms. Do NOT write ECS components every frame from
update(), that churns the editor's undo history. - Pivot at the origin, meters for scale. The built object rides the entity's transform, so build it centered on
(0,0,0)at real-world scale. - Imports: only
three(andthree/examples/jsm/*) and@relu-interactives/spatial-ecs. Other bare imports won't resolve.
Behaviour vs Script Object
| Behaviour | Script Object | |
|---|---|---|
| Runs in editor | ❌ preview/runtime only | ✅ editor AND runtime |
| Purpose | gameplay logic, input, physics | visuals: shaders, procedural geometry, particles |
| Produces | side effects on the world | a THREE.Object3D (the entity's visual) |
| Hooks | init / onUpdate / dispose | build / update / dispose |

