Events
The ECS event system provides a lightweight, tick-based pub/sub mechanism for communicating between systems. Every trigger creates an event instance that lives for at most one full tick and can optionally drive observers that run immediately after the triggering system.
Defining events
Section titled “Defining events”Use Define.event(name) to create an event type. Each definition has its own symbol identity, so definitions with the same name remain independent.
import { Define } from '@shell/ecs';
// Simple event with no payloadconst OnDeath = Define.event('OnDeath');
// Typed event payloadconst OnDamage = Define.event<{ amount: number; source: string }>('OnDamage');Triggering events
Section titled “Triggering events”Inside any system, access events from the update params and call trigger().
const AttackSystem = Define.system().update(({ events }) => { events.trigger(OnDamage.with({ amount: 25, source: 'sword' }));});Event builder methods
Section titled “Event builder methods”The builder returned by Define.event() supports chaining:
| Method | Description |
|---|---|
.with(data) | Attach typed payload data. Merges with any defaults. |
.withMeta(meta) | Attach metadata separate from the main payload. |
.networked(options?) | Mark the event for networking replication. Pass { relay: true } to relay. |
const OnDamage = Define.event<{ amount: number }>('OnDamage');
// Attach dataevents.trigger(OnDamage.with({ amount: 10 }));
// Attach metadataevents.trigger(OnDamage.with({ amount: 10 }).withMeta({ timestamp: Date.now() }));
// Mark for networking (WIP)events.trigger(OnDamage.with({ amount: 10 }).networked());events.trigger(OnDamage.with({ amount: 10 }).networked({ relay: true }));Reading events
Section titled “Reading events”Use events.get(event) to read the latest visible instance. It returns an EventInstance while at
least one instance is inside its full-circle visibility window, or undefined after every instance
leaves that window.
const DamageSystem = Define.system().update(({ events }) => { const e = events.get(OnDamage); if (e) { console.log(`Took ${e.data.amount} damage from ${e.data.source}`); }});Use events.getAll(event) to read every visible instance in trigger order:
const DamageSystem = Define.system().update(({ events }) => { for (const event of events.getAll(OnDamage)) { console.log(`Took ${event.data.amount} damage from ${event.data.source}`); }});EventInstance properties
Section titled “EventInstance properties”| Property | Type | Description |
|---|---|---|
name | string | Event name. |
data | T | Typed payload set via .with(). |
meta | M | Typed metadata set via .withMeta(). |
tick | number | The tick number when the event was triggered. |
systemIndex | number | The system index within the tick that triggered it. |
Event lifecycle
Section titled “Event lifecycle”Each event instance independently follows full-circle semantics:
- Triggered mid-tick — visible to all remaining systems in the current tick.
- Wraps around — visible to systems that run before the trigger in the next tick.
- Cleaned up — removed after the full circle completes.
This means an event triggered in Update by system 3 will be seen by systems 4, 5, etc. on the same tick, and by systems 1, 2 on the next tick, then disappear.
Events triggered outside a system context (e.g. between ticks) survive the entire next tick and are cleaned up before the following tick.
Accumulation
Section titled “Accumulation”Repeated triggers of the same event type accumulate in FIFO order. getAll() returns every visible
instance, while get() returns the latest visible instance outside observer dispatch.
events.trigger(OnDamage.with({ amount: 10 }));events.trigger(OnDamage.with({ amount: 20 }));
events.getAll(OnDamage).map((event) => event.data.amount); // [10, 20]events.get(OnDamage)?.data.amount; // 20Observers
Section titled “Observers”Observers are special systems that run once per trigger, immediately after the system that triggered their event and before the next scheduled system in the tick. They are registered on the world, not on the scheduler.
Registering observers
Section titled “Registering observers”const ApplyDamage = Define.system().update(({ events }) => { const e = events.get(OnDamage); if (e) { console.log('Damage:', e.data.amount); }});
// Register a single observerworld.observe(OnDamage, ApplyDamage);
// Register multiple observers at onceworld.observe(OnDamage, [ApplyDamage, LogDamage]);Observers can use all normal system features: queries, locals, run conditions, and .before() / .after() ordering.
Observer execution timing
Section titled “Observer execution timing”Observers run inline with the triggering system:
System A triggers OnDamage → DamageObserver runs immediately → System B runs nextObservers for the same event honor normal system .before() and .after() constraints. Unconstrained
observers retain registration order. If an observer triggers another event, its observers run before
continuing to the next scheduled system.
Registration and unregistration during dispatch take effect at the next dispatch boundary. A newly registered observer does not receive the active event, and an observer removed during dispatch still finishes the active dispatch. Queued mutations are applied even if an observer throws.
When several instances are pending, each observer dispatch sees its corresponding instance through
events.get(event). Retriggering the active event from one of its observers schedules another FIFO
dispatch. Observer cascades are limited to 1,000 dispatches by default; configure
scheduler.configure({ observerCascadeLimit }) to select another positive limit. Exceeding the limit
throws deterministically.
Observers use the active system’s delta and time, including fixed-step timing. A paused or non-reset-unmounted child scene has an inactive scheduler owner, so its observers do not execute until the scene continues or mounts again. Permanent disposal removes the scene’s observer registrations.
Unregistering observers
Section titled “Unregistering observers”world.unobserve(OnDamage, ApplyDamage);Scenes and events
Section titled “Scenes and events”Events are shared between parent worlds and child scenes. Triggering an event in a scene is the same as triggering it in the parent — all observers and listeners in both worlds see it.
Clearing events manually
Section titled “Clearing events manually”world.get('events').clear();This removes all pending events immediately. Usually not needed — events are cleaned up automatically.
TypeScript augmentation
Section titled “TypeScript augmentation”No registry augmentation is required for events. The type parameter on Define.event<T, M>() carries the payload and metadata types through events.get() and events.getAll().
Complete example
Section titled “Complete example”import { Define, Schedule } from '@shell/ecs';
// Define eventsconst OnDamage = Define.event<{ amount: number; source: string }>('OnDamage');const OnDeath = Define.event('OnDeath');
// Systemsconst AttackSystem = Define.system().update(({ events }) => { // Simulate an attack events.trigger(OnDamage.with({ amount: 25, source: 'sword' }));});
const DamageObserver = Define.system().update(({ events }) => { const e = events.get(OnDamage); if (e && e.data.amount >= 20) { console.log(`Fatal blow from ${e.data.source}!`); events.trigger(OnDeath); }});
const DeathObserver = Define.system().update(({ events }) => { if (events.get(OnDeath)) { console.log('Entity died.'); }});
// Registerworld.registerSystem(Schedule.Update, AttackSystem);world.observe(OnDamage, DamageObserver);world.observe(OnDeath, DeathObserver);