Skip to content

Events

Game logic talks through entity events. You declare an event once with defineEvent, emit it on the entity where something happens, and listen for it from a component. An event emitted on an entity also reaches the scene, so a component elsewhere can react to every entity’s events. The engine’s EventBus is separate: it carries engine events such as entity:created and scene:pushed.

Entity and scene events are addressed by an EventToken, not by a string. defineEvent creates one and pairs the event name with its payload type, so every on and emit site is checked against the same shape:

import { Entity, defineEvent } from "@yagejs/core";
// Define once in a shared module
export const PlayerHitEvent = defineEvent<{ damage: number }>("player:hit");
export const WaveStartEvent = defineEvent<{ waveNumber: number }>("wave:started");
class Player extends Entity {}
// Tokens address entity.on / entity.emit and scene.on / scene.emit
const player = scene.spawn(Player);
player.on(PlayerHitEvent, (data) => {
console.log(data.damage);
});
player.emit(PlayerHitEvent, { damage: 30 });
scene.emit(WaveStartEvent, { waveNumber: 2 });

The engine EventBus is keyed by string and does not take tokens: bus.on(PlayerHitEvent, ...) does not compile.

Dispatch is by the token’s name, not by the token object. Two defineEvent calls with the same name are one channel, and their payload types are not checked against each other. Prefix names with the owning module ("inventory:item-added") to keep them distinct. In dev builds the second definition of a name logs a warning; an empty name throws.

Entities have their own lightweight event emitter for local communication between components on the same entity:

import { Component, defineEvent } from "@yagejs/core";
const DamagedEvent = defineEvent<{ amount: number }>("damaged");
class Health extends Component {
current = 100;
takeDamage(amount: number) {
this.current -= amount;
this.entity.emit(DamagedEvent, { amount });
}
}
class DamageFlash extends Component {
onAdd() {
this.listen(this.entity, DamagedEvent, ({ amount }) => {
this.flash(amount > 50 ? "red" : "white");
});
}
private flash(color: "red" | "white") {
// tint the entity's sprite with `color` for a few frames
}
}

A subscription made with this.listen is released when the component is removed or its entity is destroyed. this.listen takes any entity, so a component can follow another entity’s events, and that subscription is still released with the component. A bare entity.on subscription is released only when that entity is destroyed.

A component can listen at the scene level with this.listenScene. It hears both events the scene emits itself and events emitted by any of the scene’s entities. The handler takes an optional second argument: the source Entity for bubbled events, or undefined for scene-emitted ones. A single handler can tell the two cases apart:

import { Component, Entity, defineEvent } from "@yagejs/core";
const DamagedEvent = defineEvent<{ amount: number }>("damaged");
/** Rolls every entity's damage into one feed. */
class DamageFeed extends Component {
onAdd() {
this.listenScene(DamagedEvent, (data, entity?: Entity) => {
if (entity) {
// Bubbled — the source called `entity.emit(DamagedEvent, ...)`
console.log(`${entity.name} took ${data.amount} damage`);
} else {
// Scene-emitted via `scene.emit(DamagedEvent, ...)`
console.log(`scene-wide damage broadcast: ${data.amount}`);
}
});
}
}
class Enemy extends Entity {}
const enemy = scene.spawn(Enemy);
// Triggers the bubbled branch, with `entity = enemy`
enemy.emit(DamagedEvent, { amount: 10 });
// Triggers the `entity = undefined` branch
scene.emit(DamagedEvent, { amount: 5 });

The handler signature is (data, entity?) for every subscription. Check entity to decide whether you can read source state (entity.get(Health), etc.) or you’re handling a scene-wide broadcast.

A subscription made with this.listenScene is released with the component. Outside a component, scene.on(token, handler) subscribes and returns an unsubscribe function. Every scene-level subscription is released when the scene exits, together with its entities. A scene instance that is pushed again starts with no subscriptions.

This is how one component reacts to every entity’s events without each entity knowing who’s listening. When a Health component on any entity emits DamagedEvent, a component such as DamageFeed can roll those into a damage total, trigger a screen shake, or update the HUD. The component that keeps the state is the one that listens, as in State Management.

A scene’s onEnter may connect an event to a component method in one line, such as this.on(PlayerHit, () => player.get(Health).damage(1)). The state and the rule stay in the component.

The engine’s EventBus carries the engine’s own events: entities created and destroyed, scenes pushed and popped, the engine starting and stopping. Resolve it with EventBusKey. Every bus.on() call returns an unsubscribe function; call it to stop listening:

import { EventBusKey } from "@yagejs/core";
const bus = context.resolve(EventBusKey);
// Subscribe — returns an unsubscribe function
const off = bus.on("scene:pushed", ({ scene }) => {
console.log("Scene pushed:", scene.name);
});
// Subscribe for a single occurrence
bus.once("engine:started", () => {
console.log("Engine started");
});
// Unsubscribe when done
off();

The bus is typed with the engine’s event map, EngineEvents, so it accepts only the events below. Game events are defineEvent tokens emitted on an entity or a scene, as described above.

The engine emits these events automatically:

EventPayloadWhen
entity:created{ entity }An entity is spawned
entity:destroyed{ entity, scene }An entity is destroyed, on the end-of-frame flush or on scene teardown
component:added{ entity, component }A component is added to an entity
component:removed{ entity, componentClass }A component is removed from an entity
scene:pushed{ scene }A scene is pushed onto the stack
scene:popped{ scene }A scene is popped from the stack
scene:replaced{ oldScene, newScene }The top scene is replaced
scene:transition:started{ kind, fromScene, toScene }A scene transition begins
scene:transition:ended{ kind, fromScene, toScene }A scene transition finishes
scene:loading:progress{ scene, ratio }A loading scene reports progress (0 to 1)
scene:loading:done{ scene }A loading scene has finished loading
engine:startedundefinedThe engine loop has started
engine:stoppedundefinedThe engine is being destroyed
screen:fullscreen{ active }The canvas host entered or left fullscreen
screen:orientation{ type }The device orientation changed

entity in the entity:* and component:* payloads is the live Entity, so a listener can read its tags and components. The scene fields carry { name }, except in entity:destroyed and the loading events, which carry the Scene.

From a component, subscribe with this.listenBus(event, handler). The subscription is released when the component is removed or its entity is destroyed:

import { Component } from "@yagejs/core";
/** Counts destroyed entities for a debug readout. */
class DestroyedCount extends Component {
count = 0;
onAdd() {
this.listenBus("entity:destroyed", () => {
this.count += 1;
});
}
}

For a rule about a specific kind of entity, such as scoring a defeated enemy, have that entity emit its own event and listen with this.listenScene.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/core-concepts.md.