Skip to content

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.

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 payload
const OnDeath = Define.event('OnDeath');
// Typed event payload
const OnDamage = Define.event<{ amount: number; source: string }>('OnDamage');

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' }));
});

The builder returned by Define.event() supports chaining:

MethodDescription
.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 data
events.trigger(OnDamage.with({ amount: 10 }));
// Attach metadata
events.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 }));

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}`);
}
});
PropertyTypeDescription
namestringEvent name.
dataTTyped payload set via .with().
metaMTyped metadata set via .withMeta().
ticknumberThe tick number when the event was triggered.
systemIndexnumberThe system index within the tick that triggered it.

Each event instance independently follows full-circle semantics:

  1. Triggered mid-tick — visible to all remaining systems in the current tick.
  2. Wraps around — visible to systems that run before the trigger in the next tick.
  3. 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.

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; // 20

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.

const ApplyDamage = Define.system().update(({ events }) => {
const e = events.get(OnDamage);
if (e) {
console.log('Damage:', e.data.amount);
}
});
// Register a single observer
world.observe(OnDamage, ApplyDamage);
// Register multiple observers at once
world.observe(OnDamage, [ApplyDamage, LogDamage]);

Observers can use all normal system features: queries, locals, run conditions, and .before() / .after() ordering.

Observers run inline with the triggering system:

System A triggers OnDamage
→ DamageObserver runs immediately
→ System B runs next

Observers 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.

world.unobserve(OnDamage, ApplyDamage);

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.

world.get('events').clear();

This removes all pending events immediately. Usually not needed — events are cleaned up automatically.

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().

import { Define, Schedule } from '@shell/ecs';
// Define events
const OnDamage = Define.event<{ amount: number; source: string }>('OnDamage');
const OnDeath = Define.event('OnDeath');
// Systems
const 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.');
}
});
// Register
world.registerSystem(Schedule.Update, AttackSystem);
world.observe(OnDamage, DamageObserver);
world.observe(OnDeath, DeathObserver);