Skip to content

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 frame
events.toArray() // collect all events into an array

Multiple systems can read the same event type. Each reader sees all events from the previous frame.

Event Lifecycle

Events use a double-buffered design:

  1. Frame start: buffers swap — events written last frame become readable, old read buffer is cleared
  2. During frame: writers push to the write buffer, readers read from the read buffer
  3. 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 gone

Practical 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 event
interface DamageEvent {
target: Entity;
amount: number;
}
const Damage = defineEvent<DamageEvent>('Damage');
// 2. Combat system — detects hits and sends damage events
const 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 damage
const 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 EventsUse Queries
One-shot notifications (hit, died, scored)Continuous state (position, health)
Multiple consumers need the same dataSingle system owns the logic
Decoupled systems across different modulesTightly related systems
Timing matters (happened this frame)Current value matters

Next Steps