Events
Events let systems communicate without knowing about each other. One system sends an event, and any number of other systems can read it — with no direct coupling between them.
Defining Events
Define an event with defineEvent and a TypeScript interface for its payload:
import { defineEvent } from 'esengine';
interface DamageEvent { target: Entity; amount: number; source?: Entity;}
const Damage = defineEvent<DamageEvent>('Damage');Sending Events
Use EventWriter as a system parameter to send events:
import { defineSystem, defineComponent, defineTag, addSystem, EventWriter, Query } from 'esengine';
const Health = defineComponent('Health', { current: 100, max: 100 });const Enemy = defineTag('Enemy');
addSystem(defineSystem( [EventWriter(Damage), Query(Health, Enemy)], (writer, query) => { for (const [entity, health] of query) { if (health.current <= 0) { writer.send({ target: entity, amount: 0 }); } } }));You can call writer.send() multiple times in a single system — all events are collected.
Reading Events
Use EventReader as a system parameter to receive events:
import { defineSystem, addSystem, EventReader, Commands } from 'esengine';
addSystem(defineSystem( [EventReader(Damage)], (events) => { for (const event of events) { console.log(`Entity ${event.target} took ${event.amount} damage`); } }));EventReader is iterable — use for...of to process each event. It also provides utility methods:
events.isEmpty() // true if no events this frameevents.toArray() // collect all events into an arrayMultiple systems can read the same event type. Each reader sees all events from the previous frame.
Event Lifecycle
Events use a double-buffered design:
- Frame start: buffers swap — events written last frame become readable, old read buffer is cleared
- During frame: writers push to the write buffer, readers read from the read buffer
- Next frame: swap again
Frame 1: systemA sends Damage ──────────────────┐Frame 2: systemB reads Damage (from frame 1) ◄───┘ systemA sends new Damage ──────────────┐Frame 3: systemB reads Damage (from frame 2) ◄──┘ frame 1 events are gonePractical Example: Damage System
A complete example with three decoupled systems:
import { defineEvent, defineSystem, defineComponent, defineTag, addSystem, EventWriter, EventReader, GetWorld, Query, Mut, Commands, Transform } from 'esengine';
const Health = defineComponent('Health', { current: 100, max: 100 });const Enemy = defineTag('Enemy');const Projectile = defineComponent('Projectile', { radius: 10, power: 25 });
// 1. Define the eventinterface DamageEvent { target: Entity; amount: number;}const Damage = defineEvent<DamageEvent>('Damage');
// 2. Combat system — detects hits and sends damage eventsconst combatSystem = defineSystem( [EventWriter(Damage), Query(Transform, Projectile), Query(Transform, Health, Enemy)], (damage, projectiles, enemies) => { for (const [_, pTransform, proj] of projectiles) { for (const [enemy, eTransform] of enemies) { if (distance(pTransform, eTransform) < proj.radius) { damage.send({ target: enemy, amount: proj.power }); } } } });
// 3. Health system — applies damageconst healthSystem = defineSystem( [EventReader(Damage), GetWorld()], (events, world) => { for (const { target, amount } of events) { const health = world.tryGet(target, Health); if (health) { health.current -= amount; world.set(target, Health, health); } } });
// 4. VFX system — shows hit effects (reads same events)const hitVFXSystem = defineSystem( [EventReader(Damage), GetWorld()], (events, world) => { for (const { target } of events) { const transform = world.tryGet(target, Transform); if (transform) { spawnHitParticle(transform.position); } } });Three systems, one event type, zero direct dependencies between them.
Multiple Event Types
A system can write and read multiple event types:
const PlayerDied = defineEvent<{ entity: Entity }>('PlayerDied');const ScoreChanged = defineEvent<{ delta: number }>('ScoreChanged');
defineSystem( [EventReader(Damage), EventWriter(PlayerDied), EventWriter(ScoreChanged)], (damageEvents, deathWriter, scoreWriter) => { for (const { target, amount } of damageEvents) { if (isPlayer(target) && getHealth(target) <= 0) { deathWriter.send({ entity: target }); scoreWriter.send({ delta: -100 }); } } });When to Use Events vs Direct Queries
| Use Events | Use Queries |
|---|---|
| One-shot notifications (hit, died, scored) | Continuous state (position, health) |
| Multiple consumers need the same data | Single system owns the logic |
| Decoupled systems across different modules | Tightly related systems |
| Timing matters (happened this frame) | Current value matters |
Next Steps
- Change Detection — react to component additions and modifications
- Systems — system parameters and scheduling
- Resources — global singleton data