Skip to content

Behaviour Data Binding

The ScriptComponent.data field is the bridge between the editor inspector and your Behaviour subclass. Anything you place on data is:

  1. Saved with the project.
  2. Rendered as a live inspector control in the editor.
  3. Available at runtime as this.data inside the script.

The type annotation on each field tells the editor which control to render. Import the input types from @relu-interactives/spatial-ecs and use them as as casts on the initial values in your data object.

Input types

TypeInspector controlRuntime value
FloatInputNumber inputnumber
TextInputText inputstring
ColorInputColor pickerstring (hex, e.g. "#ff0000")
Vector2InputXY number pair{ x: number; y: number }
Vector3InputXYZ number triple{ x: number; y: number; z: number }
SliderInputRange slider{ min, max, step, value } — read field.value at runtime
DropdownInputSelect dropdown{ options: DropdownOption[]; value: string } — read field.value
CheckboxInputCheckbox{ type: "checkbox"; value: boolean } — read .value
ToggleInputToggle switchboolean
AssetInputAsset file picker{ path: string; type: string } — read field.path
EntityInputEntity picker{ type: "entity"; value: WorldEntityView | null } — read field.value
ComponentInputEntity + component picker{ type: "component"; componentKind?: ComponentKind; value: Component | null } — read field.value

Declaring inspector fields

Import the input types you need alongside Behaviour, then declare them directly on the data class property using as casts:

ts
import {
  Behaviour,
  type FloatInput,
  type TextInput,
  type ColorInput,
  type Vector2Input,
  type Vector3Input,
  type SliderInput,
  type DropdownInput,
  type CheckboxInput,
  type ToggleInput,
  type AssetInput,
  type EntityInput,
  type ComponentInput,
} from "@relu-interactives/spatial-ecs";

export default class Demo extends Behaviour {
  data = {
    speed:   1 as FloatInput,
    label:   "Hello" as TextInput,
    tint:    "#ffffff" as ColorInput,
    offset:  { x: 0, y: 0 } as Vector2Input,
    origin:  { x: 0, y: 0, z: 0 } as Vector3Input,
    range:   { min: 0, max: 10, step: 0.1, value: 5 } as SliderInput,
    axis: {
      value: "y",
      options: [
        { label: "X", value: "x" },
        { label: "Y", value: "y" },
        { label: "Z", value: "z" },
      ],
    } as DropdownInput,
    active:  true as ToggleInput,
    visible: { type: "checkbox", value: true } as CheckboxInput,
    texture: { path: "", type: "image" }   as AssetInput,
    target:  { type: "entity", value: null } as EntityInput,
    camera:  { type: "component", componentKind: "Camera", value: null } as ComponentInput,
  };
}

Each field in data maps directly to a live inspector control. The script above produces the following inspector panel:

Behaviour inspector controls

Reading values at runtime

Most types map directly to their underlying primitive. The exceptions are SliderInput, DropdownInput, AssetInput, EntityInput, and ComponentInput — read the nested value:

ts
onUpdate() {
  // Primitives — use directly
  this.transform.rotation.y += this.data.speed * this.deltaTime;

  // SliderInput → read .value
  const volume = this.data.range.value;

  // DropdownInput → read .value
  const axis = this.data.axis.value as "x" | "y" | "z";

  // AssetInput → read .path
  const src = this.data.texture.path;

  // EntityInput → cast to WorldEntityView | null
  const entity = this.data.target.value as WorldEntityView | null;

  // ComponentInput → cast to the concrete component type
  const cam = this.data.camera.value as CameraComponent | null;
}

Practical example — spinner with inspector controls

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

export default class Spinner extends Behaviour {
  data = {
    speed:   1 as FloatInput,
    color:   "#00aaff" as ColorInput,
    enabled: true as ToggleInput,
    axis: {
      value: "y",
      options: [
        { label: "X", value: "x" },
        { label: "Y", value: "y" },
        { label: "Z", value: "z" },
      ],
    } as DropdownInput,
  };

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

Defaulting fields

Defaults are set inline on the data property. When a saved project reloads, saved values take precedence — the inline defaults only apply when a field has never been saved. This means:

  • Adding a new field with an inline default is safe — existing saves will pick it up on next load.
  • The inspector always has a valid initial value when the script is first attached.
  • For SliderInput and DropdownInput, the full object (including options) must be present in the inline default — the editor reads options to build the control.

Persistence

ScriptComponent.data is serialized and saved with the project. On reload, your data payload is restored verbatim. If you rename or restructure a field, migrate it inside init():

ts
init() {
  // migrate renamed field
  if ((this.data as any).oldSpeed !== undefined) {
    this.data.speed = (this.data as any).oldSpeed;
    delete (this.data as any).oldSpeed;
  }
}