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.tsfiles 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 throughput | Performance is critical (1000+ entities) |
| Design team authors logic in the editor | Engine team writes core simulation |
| You want inspector-visible properties | You 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:
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:
| Hook | Schedule Phase | Notes |
|---|---|---|
onFirst | CoreSchedule.First | Very first tick only, before everything else |
onStart | CoreSchedule.PreUpdate | Once, when script first becomes active |
onUpdate | CoreSchedule.Update | Every frame |
onFixedUpdate | CoreSchedule.FixedUpdate | Fixed timestep (physics) |
onLateUpdate | CoreSchedule.PostUpdate | After all updates, per-frame |
onEnable/onDisable | Any phase | On enabled-state change |
onDestroy | Any phase | On detach/despawn |
Registering Without Decorators
@RegisterScript handles registration automatically. When you can't use decorators, use defineScript:
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:
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:
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:
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:
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:
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:
- Re-registers the new script class
- Detaches old instances from all entities
- Re-attaches the new version, restoring serialized property state
- Calls
onStarton the new instance
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:
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):
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 onLateUpdateTransform3D Field Reference
The built-in Transform3D component uses these field names (position + quaternion rotation):
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; callctx.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 noctx.getResource()shorthand onScriptContext - Never store entity IDs as raw numbers, use the
Entitybranded type
Next Steps
- Entities & Components, component design patterns
- Input Handling, reading keyboard/gamepad/touch from scripts
- Save & Load, persisting script state to disk