Skip to content

Scripting

The @web-engine-dev/scripting package provides a Unity MonoBehaviour-style scripting layer on top of the ECS. While pure ECS systems are optimal for performance-critical logic (physics, rendering, AI), scripts are ideal for per-entity gameplay logic that is easier to read, author, and iterate on.

Scripts vs. ECS Systems - Editor Workflow

This is the most important distinction for editor users:

  • Scripts (classes implementing Script) are attached to individual entities in the editor inspector. This is the primary way gameplay logic is authored in the editor. Write scripts in .ts files and they are discovered and exposed automatically.
  • ECS Systems (functions registered with world.addSystem()) process all matching entities at once. Systems live in project bootstrap code, not in editor script files.

The editor supports scripts, not raw ECS systems. If you need a system, register it in your project setup code outside the editor.

Use a Script when...Use a System when...
Logic is entity-specific (a particular boss, a door)Logic processes many entities of the same type
Rapid design iteration matters more than throughputPerformance is critical (1000+ entities)
Design team authors logic in the editorEngine team writes core simulation
You want inspector-visible propertiesYou want minimal per-entity overhead

Defining a Script

Implement the Script interface and use @RegisterScript to make it discoverable in the editor. Use @EditorProperty to expose fields in the inspector:

typescript
import {
  type Script,
  type ScriptContext,
  type ScriptInstanceId,
  createScriptTypeId,
  RegisterScript,
  EditorProperty,
} from '@web-engine-dev/scripting';
import { type InputState, InputStateResource } from '@web-engine-dev/input';

@RegisterScript({
  typeId: 'PlayerController',
  name: 'Player Controller',
  category: 'Gameplay',
  tags: ['player', 'movement'],
})
export class PlayerController implements Script {
  // Required identity fields
  readonly typeId = createScriptTypeId('PlayerController');
  readonly instanceId = 0 as unknown as ScriptInstanceId;
  enabled = true;

  // @EditorProperty exposes fields in the editor inspector
  @EditorProperty({ type: 'number', min: 0, max: 20, step: 0.5, label: 'Move Speed' })
  speed = 5;

  @EditorProperty({ type: 'number', min: 1, max: 1000, label: 'Max Health' })
  maxHealth = 100;

  private health = this.maxHealth;

  // --- Lifecycle Hooks (all optional) ---

  onFirst(ctx: ScriptContext): void {
    // Runs on the very first ECS tick (CoreSchedule.First), before onStart.
    // Use for one-time global setup that must precede all initialization.
  }

  onStart(ctx: ScriptContext): void {
    // Called once when the script first becomes active (after onFirst).
    console.log(`Player on entity ${ctx.entity}`);
    this.health = this.maxHealth;
  }

  onUpdate(ctx: ScriptContext): void {
    // Called every frame in CoreSchedule.Update.
    const { deltaTime } = ctx;

    // Resources are accessed via ctx.world
    const input = ctx.world?.getResource(InputStateResource) as InputState | undefined;
    if (!input) return;

    // getComponent returns a COPY of the data - always write it back
    const transform = ctx.getComponent<{ px: number; py: number; pz: number }>('transform');
    if (!transform) return;

    // Input uses browser KeyboardEvent.code values
    if (input.pressedKeys.has('KeyW')) transform.pz -= this.speed * deltaTime;
    if (input.pressedKeys.has('KeyS')) transform.pz += this.speed * deltaTime;
    if (input.pressedKeys.has('KeyA')) transform.px -= this.speed * deltaTime;
    if (input.pressedKeys.has('KeyD')) transform.px += this.speed * deltaTime;

    // justPressedKeys is true only on the first frame the key is pressed
    if (input.justPressedKeys.has('Space')) {
      // trigger jump / action
    }

    // REQUIRED: write the modified copy back to persist changes
    ctx.setComponent('transform', transform);
  }

  onFixedUpdate(ctx: ScriptContext): void {
    // Called at a fixed timestep (CoreSchedule.FixedUpdate).
    // Use for physics-coupled logic.
  }

  onLateUpdate(ctx: ScriptContext): void {
    // Called after all onUpdate calls (CoreSchedule.PostUpdate).
    // Use for camera follow, IK, etc.
  }

  onDestroy(ctx: ScriptContext): void {
    // Called when the entity is despawned.
    console.log('Player destroyed');
  }

  onEnable(ctx: ScriptContext): void {}
  onDisable(ctx: ScriptContext): void {}
}

Lifecycle Phases and Scheduling

Script hooks map directly to ECS CoreSchedule phases:

HookSchedule PhaseNotes
onFirstCoreSchedule.FirstVery first tick only, before everything else
onStartCoreSchedule.PreUpdateOnce, when script first becomes active
onUpdateCoreSchedule.UpdateEvery frame
onFixedUpdateCoreSchedule.FixedUpdateFixed timestep (physics)
onLateUpdateCoreSchedule.PostUpdateAfter all updates, per-frame
onEnable/onDisableAny phaseOn enabled-state change
onDestroyAny phaseOn detach/despawn

Registering Without Decorators

@RegisterScript handles registration automatically. When you can't use decorators, use defineScript:

typescript
import {
  defineScript,
  createScriptTypeId,
  type Script,
  type ScriptContext,
  type ScriptInstanceId,
} from '@web-engine-dev/scripting';

export const EnemyPatrol = defineScript(
  'EnemyPatrol',
  class implements Script {
    readonly typeId = createScriptTypeId('EnemyPatrol');
    readonly instanceId = 0 as unknown as ScriptInstanceId;
    enabled = true;

    speed = 100;

    onUpdate(ctx: ScriptContext): void {
      // patrol logic
    }
  },
  { name: 'Enemy Patrol', category: 'AI' }
);

Attaching Scripts to Entities

In the editor, scripts are attached to entities via the inspector panel. Programmatically:

typescript
import {
  ScriptManager,
  ScriptManagerResource,
  createScriptTypeId,
} from '@web-engine-dev/scripting';

const mgr = world.getResource(ScriptManagerResource) as ScriptManager;

// Attach by typeId string
mgr.attach(playerEntity, createScriptTypeId('PlayerController'));

// Detach
mgr.detach(playerEntity, createScriptTypeId('PlayerController'));

The ScriptContext API

ctx is passed to every lifecycle hook and provides access to the entity, timing, components, and the ECS world:

typescript
onUpdate(ctx: ScriptContext): void {
  // --- Identity & timing ---
  const myEntity = ctx.entity;   // Entity (numeric ID)
  const dt = ctx.deltaTime;      // seconds since last frame
  const t  = ctx.time;           // total elapsed seconds

  // --- Component access ---
  // Components are keyed by string typeId, NOT by component descriptor
  // getComponent returns a COPY (SoA storage) - always write it back
  const transform = ctx.getComponent<{ px: number; py: number; pz: number }>('transform');
  if (!transform) return;

  transform.px += this.speed * ctx.deltaTime; // mutate the copy
  ctx.setComponent('transform', transform);   // persist the change

  // --- Script references ---
  const animator = ctx.getScript<AnimatorScript>('Animator');  // same entity
  if (animator) animator.play('walk');

  const allEnemies = ctx.findScriptsOfType<EnemyController>('EnemyController');

  // --- World / resource access ---
  // Access ECS resources through ctx.world
  const input = ctx.world?.getResource(InputStateResource) as InputState;
  const audio = ctx.world?.getResource(AudioManagerResource);

  // --- Spawning & destroying ---
  const bullet = ctx.spawn?.('bullet-prefab'); // spawn prefab by registered ID
  // ctx.destroy?.();                          // destroy this entity
  // ctx.destroy?.(otherEntity);              // destroy another entity
}

Component Access Uses String IDs

ctx.getComponent<T>('transform') and ctx.setComponent('transform', data) use string type IDs, not component descriptor objects. This is different from the raw ECS API (world.get(entity, Transform)).

Component Data is Copied

ctx.getComponent() returns a copy of the data. Mutations are not persisted unless you call ctx.setComponent(typeId, modified). Forgetting this is the most common source of "changes disappear next frame" bugs.

Script Communication

Scripts can reference other scripts attached to the same entity by typeId string:

typescript
onStart(ctx: ScriptContext): void {
  // Find another script on this entity
  const animator = ctx.getScript<AnimatorScript>('Animator');
  if (animator) animator.play('idle');
}

onUpdate(ctx: ScriptContext): void {
  // Find all scripts of a type across the entire scene
  const allEnemies = ctx.findScriptsOfType<EnemyController>('EnemyController');
  for (const enemy of allEnemies) {
    if (enemy.isAggressive) { /* respond */ }
  }
}

Editor Property Metadata

Use @EditorProperty to expose script fields in the editor inspector with type hints, ranges and categories:

typescript
import {
  RegisterScript,
  EditorProperty,
  createScriptTypeId,
  type Script,
  type ScriptContext,
  type ScriptInstanceId,
} from '@web-engine-dev/scripting';

@RegisterScript({ typeId: 'EnemyController', name: 'Enemy Controller', category: 'AI' })
export class EnemyController implements Script {
  readonly typeId = createScriptTypeId('EnemyController');
  readonly instanceId = 0 as unknown as ScriptInstanceId;
  enabled = true;

  @EditorProperty({ type: 'number', min: 0, max: 500, step: 10, label: 'Move Speed' })
  speed = 100;

  @EditorProperty({ type: 'number', min: 1, max: 1000, label: 'Health' })
  health = 100;

  @EditorProperty({ type: 'number', min: 0, max: 200, label: 'Detection Radius' })
  detectionRadius = 150;

  @EditorProperty({ type: 'boolean', label: 'Elite Enemy' })
  isElite = false;

  @EditorProperty({ type: 'string', label: 'Faction', tooltip: 'hostile, neutral, or friendly' })
  faction = 'hostile';

  @EditorProperty({
    type: 'number',
    min: 0,
    max: 30,
    label: 'Attack Damage',
    category: 'Combat', // groups related properties in the inspector
    order: 10, // display order within the category
  })
  attackDamage = 25;

  onUpdate(ctx: ScriptContext): void {
    /* ... */
  }
}

Supported type values: 'number', 'boolean', 'string'. Use min, max, step for numeric sliders and tooltip for inline help text.

Save / Load

To serialize script state, use the ScriptSerializer utilities exported from the package:

typescript
import { serializeManagerToJSON, deserializeManagerFromJSON } from '@web-engine-dev/scripting';

// Serialize all managed scripts to JSON (for save files)
const json = serializeManagerToJSON(scriptManager);
localStorage.setItem('save', json);

// Restore on load
deserializeManagerFromJSON(localStorage.getItem('save')!, scriptManager, undefined, (entityId) =>
  world.getEntity(Number(entityId))
);

Hot Reload

In development, scripts support hot module replacement (HMR). When you edit a script file, the engine:

  1. Re-registers the new script class
  2. Detaches old instances from all entities
  3. Re-attaches the new version, restoring serialized property state
  4. Calls onStart on the new instance
typescript
import { ScriptHotReloader } from '@web-engine-dev/scripting';

const reloader = new ScriptHotReloader({ registry, manager });
reloader.reload('PlayerController', UpdatedPlayerController);

Scripting System Integration

Add ScriptingSystem to the appropriate schedule phases to drive all hooks automatically:

typescript
import { ScriptingSystem } from '@web-engine-dev/scripting';
import { CoreSchedule } from '@web-engine-dev/ecs';

// ScriptingSystem dispatches to the right hooks internally
world.addSystem(ScriptingSystem);

// Or add to a specific schedule for precise ordering
world.addSystemToSchedule(CoreSchedule.Update, updateSystem);
world.addSystemToSchedule(CoreSchedule.FixedUpdate, fixedPhysicsSystem);
world.addSystemToSchedule(CoreSchedule.PostUpdate, lateSystem);

For manual ECS integration (without the built-in ScriptingSystem):

typescript
import { ECSScriptManager } from '@web-engine-dev/scripting';

const scripting = new ECSScriptManager();

// Wire up ECS component access
scripting.setComponentAccessor((entity, typeId) => world.get(entity, componentTypes[typeId]));

// In your game loop
scripting.first(deltaTime, time); // dispatches onFirst (once)
scripting.processStart(deltaTime, time);
scripting.update(deltaTime, time); // dispatches onUpdate
scripting.fixedUpdate(0.02, time); // dispatches onFixedUpdate
scripting.lateUpdate(deltaTime, time); // dispatches onLateUpdate

Transform3D Field Reference

The built-in Transform3D component uses these field names (position + quaternion rotation):

typescript
interface Transform3D {
  px: number;
  py: number;
  pz: number; // world position
  rx: number;
  ry: number;
  rz: number;
  rw: number; // quaternion (rw=1 = no rotation)
}

Rotation is a quaternion, not Euler angles. Use a math library to convert.

Best Practices

  • One script per behavior, keep them small and focused
  • ECS systems for hot paths: if 500+ entities need the same logic, use a system instead
  • Always write back components: ctx.getComponent() returns a copy; call ctx.setComponent() to persist changes
  • Use browser key codes for input: 'KeyW', 'Space', 'ArrowLeft' (not 'w' or display names)
  • Access resources via ctx.world: there is no ctx.getResource() shorthand on ScriptContext
  • Never store entity IDs as raw numbers, use the Entity branded type

Next Steps

Proprietary software. All rights reserved.