Skip to content

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):

ts
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 ​

MemberTypeNotes
this.worldWorldThe owning ECS world.
this.entityIdnumberThe entity this script is bound to.
this.dataRecord<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.rootTHREE.Object3D | nullThe 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:

ts
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):

ts
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 a THREE.Object3D, a Group, 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. THREE dispose() 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 (and three/examples/jsm/*) and @relu-interactives/spatial-ecs. Other bare imports won't resolve.

Behaviour vs Script Object ​

BehaviourScript Object
Runs in editor❌ preview/runtime only✅ editor AND runtime
Purposegameplay logic, input, physicsvisuals: shaders, procedural geometry, particles
Producesside effects on the worlda THREE.Object3D (the entity's visual)
Hooksinit / onUpdate / disposebuild / update / dispose