Skip to content

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:

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

typescript
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()
MethodWhen CalledUse Case
onStartOnce, when script first becomes activeInitialization, caching references
onUpdateEvery frame after onStart completesMain game logic
onFixedUpdateAt fixed timestep intervalsPhysics-related logic
onLateUpdateAfter all onUpdate calls completeCamera follow, dependent calculations
onEnableWhen enabled changes to trueResume logic
onDisableWhen enabled changes to falsePause logic
onDestroyWhen script is detachedCleanup, 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:

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

typescript
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 entity

Script Registry

The registry stores script definitions and supports querying by category and tags:

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

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

typescript
const manager = new ScriptManager({ catchErrors: false });

Events

Subscribe to script lifecycle events:

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

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

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

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

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

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

  1. Serializes state of all instances of the script type
  2. Detaches all existing instances
  3. Re-registers the new constructor in the registry
  4. Re-attaches and deserializes state into new instances

Type Safety

The scripting system uses branded types for compile-time safety:

typescript
type ScriptTypeId = string & { readonly __brand: 'ScriptTypeId' };
type ScriptInstanceId = number & { readonly __brand: 'ScriptInstanceId' };

Create them with the factory functions:

typescript
const typeId = createScriptTypeId('PlayerController');
const instanceId = generateScriptInstanceId();

This prevents accidentally passing a plain string where a ScriptTypeId is expected.

Proprietary software. All rights reserved.