Scripting System
The @web-engine-dev/scripting package provides a runtime scripting layer for gameplay logic, similar to Unity's MonoBehaviour pattern. Scripts are classes with lifecycle hooks (onStart, onUpdate, onDestroy, etc.) that attach to entities. The system includes a type-safe registry, ECS integration, state serialization, hot reload support, and editor property metadata.
Architecture Overview
ScriptRegistry ScriptManager
| |
| register() | attach/detach
| create() | lifecycle calls
v v
ScriptDefinition -------> Script Instance
(metadata + constructor) (bound to entity)- ScriptRegistry -- Stores script type definitions (constructor + metadata). Supports global and per-manager registries.
- ScriptManager -- Manages script instances, their lifecycle, and entity associations.
- ECSScriptManager / ScriptingSystem -- ECS integration layer that bridges scripts with the entity component system.
Defining Scripts
A script implements the Script interface with a unique type ID and optional lifecycle methods:
import {
type Script,
type ScriptContext,
createScriptTypeId,
defineScript,
} from '@web-engine-dev/scripting';
class PlayerController implements Script {
readonly typeId = createScriptTypeId('PlayerController');
readonly instanceId = 0 as unknown as ScriptInstanceId; // Set by manager
enabled = true;
// Custom properties
speed = 5;
jumpForce = 10;
onStart(ctx: ScriptContext) {
console.log('Player initialized for entity', ctx.entity);
}
onUpdate(ctx: ScriptContext) {
const movement = this.speed * ctx.deltaTime;
// Move player using ctx.getComponent(), ctx.setComponent(), etc.
}
onDestroy(ctx: ScriptContext) {
console.log('Player destroyed');
}
}
// Register globally
defineScript('PlayerController', PlayerController, {
name: 'Player Controller',
category: 'Character',
tags: ['player', 'movement'],
});Decorator-Based Registration
For projects using TypeScript decorators:
import { RegisterScript, type Script, createScriptTypeId } from '@web-engine-dev/scripting';
@RegisterScript({
typeId: 'EnemyAI',
name: 'Enemy AI',
category: 'AI',
tags: ['enemy', 'ai'],
})
class EnemyAI implements Script {
readonly typeId = createScriptTypeId('EnemyAI');
readonly instanceId = 0 as unknown as ScriptInstanceId;
enabled = true;
onUpdate(ctx: ScriptContext) {
// AI logic...
}
}Lifecycle
Scripts follow a well-defined lifecycle:
attach() --> pendingStart --> onStart() --> hasStarted = true
|
v
+---------------------------------+
| Game Loop |
| onUpdate() -> onFixedUpdate() |
| -> onLateUpdate() |
+---------------------------------+
|
enable() --------> onEnable()
disable() --------> onDisable()
|
detach() --------> onDestroy()| Method | When Called | Use Case |
|---|---|---|
onStart | Once, when script first becomes active | Initialization, caching references |
onUpdate | Every frame after onStart completes | Main game logic |
onFixedUpdate | At fixed timestep intervals | Physics-related logic |
onLateUpdate | After all onUpdate calls complete | Camera follow, dependent calculations |
onEnable | When enabled changes to true | Resume logic |
onDisable | When enabled changes to false | Pause logic |
onDestroy | When script is detached | Cleanup, release resources |
All lifecycle methods are optional. The system uses hasLifecycleMethod() to skip scripts that don't implement a given method, avoiding unnecessary calls.
Script Context
Every lifecycle method receives a ScriptContext providing access to the game world:
interface ScriptContext {
entity: unknown; // The entity this script is attached to
deltaTime: number; // Time since last frame (seconds)
time: number; // Total elapsed time (seconds)
// Cross-script communication
getScript<T>(typeId: ScriptTypeId): T | undefined;
findScriptsOfType<T>(typeId: ScriptTypeId): readonly T[];
// Component access (configured via ECS integration)
getComponent<T>(typeId: string): T | undefined;
setComponent?(typeId: string, data: Record<string, unknown>): void;
// Entity lifecycle (configured via ECS integration)
spawn?(prefabId: string): unknown;
destroy?(entity: unknown): void;
// Direct ECS world access
world?: unknown;
}Copy-on-Get ECS Pattern
When using the ECS, getComponent() returns a copy of component data. Use setComponent() to write changes back. See ECS Concepts for details.
Using ScriptManager
The ScriptManager handles script instance lifecycle:
import { ScriptManager, ScriptRegistry, createScriptTypeId } from '@web-engine-dev/scripting';
// Create manager (uses global registry by default, or pass a custom one)
const registry = new ScriptRegistry();
const manager = new ScriptManager({ registry });
// Attach script to entity
const entity = world.spawn();
const script = manager.attach(entity, createScriptTypeId('PlayerController'));
// Game loop
function gameLoop(deltaTime: number, time: number) {
manager.processStart(deltaTime, time); // Call onStart for new scripts
manager.fixedUpdate(0.02, time); // Fixed timestep
manager.update(deltaTime, time); // Per-frame update
manager.lateUpdate(deltaTime, time); // After all updates
}
// Script management
manager.enable(entity, typeId); // Enable a script
manager.disable(entity, typeId); // Disable a script
manager.detach(entity, typeId); // Remove a specific script
manager.detachAll(entity); // Remove all scripts from entityScript Registry
The registry stores script definitions and supports querying by category and tags:
const registry = new ScriptRegistry();
registry.register(
{
typeId: createScriptTypeId('MyScript'),
name: 'My Script',
description: 'Does something useful',
category: 'Utility',
tags: ['helper', 'tool'],
},
MyScript,
);
// Query by category or tag
const aiScripts = registry.getByCategory('AI');
const movementScripts = registry.getByTag('movement');
// Enumerate
const categories = registry.getCategories();
const tags = registry.getTags();
const all = registry.getAll();The globalScriptRegistry is a singleton used by defineScript() and @RegisterScript when no explicit registry is provided.
Error Handling
By default, errors in lifecycle methods are caught and emitted as events, preventing one broken script from crashing the entire game:
const manager = new ScriptManager({
catchErrors: true, // Default
onError: (error, script, lifecycle) => {
console.error(`Error in ${script.typeId}.${lifecycle}:`, error);
},
});
manager.on('scriptError', ({ instanceId, typeId, error, lifecycle }) => {
// Handle error
});For development, strict mode propagates errors immediately:
const manager = new ScriptManager({ catchErrors: false });Events
Subscribe to script lifecycle events:
manager.on('scriptCreated', ({ instanceId, typeId, entity }) => { /* ... */ });
manager.on('scriptDestroyed', ({ instanceId, typeId, entity }) => { /* ... */ });
manager.on('scriptEnabled', ({ instanceId, typeId }) => { /* ... */ });
manager.on('scriptDisabled', ({ instanceId, typeId }) => { /* ... */ });
manager.on('scriptError', ({ instanceId, typeId, error, lifecycle }) => { /* ... */ });ECS Integration
The ECSScriptManager and ScriptingSystem bridge the scripting system with the ECS:
import { ScriptingSystem, ECSScriptManager } from '@web-engine-dev/scripting';
const scripting = new ScriptingSystem();
// Enable component access from scripts
scripting.setComponentAccessor((entity, typeId) => {
return world.get(entity, componentTypes[typeId]);
});
// Enable entity lifecycle from scripts
scripting.setSpawnFunction((prefabId) => world.spawn(prefabs[prefabId]));
scripting.setDestroyFunction((entity) => world.despawn(entity));
// In your game loop
function gameLoop(deltaTime: number, time: number) {
scripting.processStart(deltaTime, time);
scripting.update(deltaTime, time);
scripting.fixedUpdate(0.02, time);
scripting.lateUpdate(deltaTime, time);
}The package also exports ECS scene registration helpers (registerScriptComponents, HasScripts, ScriptManagerResource) for integrating with the scene system.
Editor Property Metadata
Scripts can annotate properties for editor integration using the @EditorProperty decorator or the programmatic defineScriptProperty() API:
import { EditorProperty } from '@web-engine-dev/scripting';
class PlayerController implements Script {
// ... typeId, instanceId, enabled ...
@EditorProperty({ type: 'number', min: 0, max: 20, step: 0.5 })
speed = 5;
@EditorProperty({ type: 'string', category: 'Display' })
displayName = 'Player';
@EditorProperty({ type: 'boolean' })
canJump = true;
}Query property metadata:
import { getEditorProperties, getEditorPropertiesByCategory } from '@web-engine-dev/scripting';
const props = getEditorProperties(PlayerController);
const displayProps = getEditorPropertiesByCategory(PlayerController, 'Display');Serialization
Save and restore script state for save/load functionality:
import {
serializeManagerToJSON,
deserializeManagerFromJSON,
} from '@web-engine-dev/scripting';
// Serialize all scripts to JSON
const json = serializeManagerToJSON(manager);
localStorage.setItem('scripts', json);
// Deserialize and restore
const restored = deserializeManagerFromJSON(
localStorage.getItem('scripts')!,
manager,
undefined,
(entityId) => world.getEntity(Number(entityId)),
);The ScriptSerializer class provides finer control with configurable property filters and entity ID serialization.
Hot Reload
During development, scripts can be hot-reloaded without restarting the game. The system preserves script state across reloads:
import { ScriptHotReloader, createScriptModuleHandler } from '@web-engine-dev/scripting';
const reloader = new ScriptHotReloader({
registry,
manager,
onReload: (result) => {
console.log(`Reloaded ${result.typeId}: ${result.restoredCount} instances`);
},
});
// Manually reload a script type with a new implementation
reloader.reload('PlayerController', NewPlayerController);
// Or use the module handler for automatic HMR integration
const handler = createScriptModuleHandler({
registry,
manager,
patterns: ['src/scripts/**/*.ts'],
});The hot reload process:
- Serializes state of all instances of the script type
- Detaches all existing instances
- Re-registers the new constructor in the registry
- Re-attaches and deserializes state into new instances
Type Safety
The scripting system uses branded types for compile-time safety:
type ScriptTypeId = string & { readonly __brand: 'ScriptTypeId' };
type ScriptInstanceId = number & { readonly __brand: 'ScriptInstanceId' };Create them with the factory functions:
const typeId = createScriptTypeId('PlayerController');
const instanceId = generateScriptInstanceId();This prevents accidentally passing a plain string where a ScriptTypeId is expected.
Related Packages
@web-engine-dev/ecs-- Entity Component System that scripts integrate with@web-engine-dev/serialization-- Serialization primitives used for script state persistence