Events & Timers
Events let scripts talk to each other without holding references; timers let a script schedule work for later. Both are scoped per behaviour and cleaned up automatically.
These two systems solve the two most common "how do I structure this?" questions in gameplay code: how does the enemy tell the scoreboard it died? (events) and how do I do X in three seconds? (timers).
NOTE
This is how runtime scripts sequence work over time - the frame loop ticks onUpdate each frame, and timers schedule the rest. There is no blocking "wait" or coroutine yield in the runtime ScriptApi; that would stall the frame. (The editor's tool API does have api.yield(), but that solves a different problem - keeping the editor responsive during a one-shot batch edit.)
Events: decoupled messaging
An event is a named message. One script fires it with emit; any script listening with on receives it. Neither knows about the other - they only agree on the event name and payload shape.
| Member | Signature | Description |
|---|---|---|
emit | (event, payload?): void | Fire a named event; every registered handler receives the payload. |
on | (event, cb): void | Register a handler. Register it in onStart. |
Example: a kill counter
ts
// enemy.ts - attach to each enemy
export default class implements ScriptBehavior {
onCollisionEnter(api: ScriptApi, other: Entity) {
if (api.world.getName(other) === "Bullet") {
api.emit("enemyKilled", { points: 100 });
api.destroy();
}
}
}ts
// scoreboard.ts - attach to one manager object
export default class implements ScriptBehavior {
onStart(api: ScriptApi) {
api.state.score ??= 0;
api.on("enemyKilled", (p: { points: number }) => {
api.state.score += p.points;
api.setHud(`Score: ${api.state.score}`);
});
}
}The enemy has no idea a scoreboard exists - you can add a sound effect or a particle spawner listening for "enemyKilled" later without touching the enemy code.
Semantics you can rely on
- Register in
onStart. Handlers persist for the life of the scene. Registering inonUpdatewould stack duplicates every frame. - Payloads are anything. Pass a number, an object, or nothing. Type the callback parameter yourself for IntelliSense.
- Dispatch is snapshotted. A handler may itself
emitor register handlers mid-dispatch without corrupting the current dispatch loop. - Dead emitters are skipped. If the object that owns a handler was destroyed this frame, that handler is not called.
- Handlers reset with the scene. Loading a scene or stopping Play clears all handlers - a fresh scene starts with none.
Timers: deferred and repeating work
| Member | Signature | Description |
|---|---|---|
after | (seconds, cb): () => void | Run cb once after seconds. Returns a cancel function. |
every | (seconds, cb): () => void | Run cb every seconds (first fire after one interval). Returns a cancel function. |
ts
export default class implements ScriptBehavior {
private stopSpawning?: () => void;
onStart(api: ScriptApi) {
// Spawn an enemy every 2 seconds…
this.stopSpawning = api.every(2, () => api.spawn("Enemy"));
// …but stop after 30 seconds.
api.after(30, () => this.stopSpawning?.());
}
}Cancelling
Both after and every return a cancel function. Call it to stop the timer early - essential for a repeating every you want to end on a condition:
ts
onStart(api: ScriptApi) {
const tick = api.every(1, () => {
this.count++;
if (this.count >= 5) tick(); // cancel itself after 5 ticks
});
}Timing semantics you can rely on
- Clock is Play time. Timers measure against seconds since Play started, ticked each frame.
- One fire per frame. A repeating timer fires at most once per frame even if the interval is shorter than the frame time.
- A callback closing over an old frame is safe. Scheduling
after(1, …)inside another callback measures from the moment it runs, not from the frame the outer callback was captured. - Auto-cancel on destroy. When the owning entity is destroyed, its timers are cancelled - a projectile's
afternever fires after the projectile is gone.
Scoping and cleanup
This is the part that keeps large games from leaking. Every event handler and every timer records the slot that created it - the `${entity}:${index}` key identifying one behaviour on one object. When that slot tears down, its handlers and timers are dropped.
Concretely:
- Disable one behaviour (untick its entry, or the whole Script component) and its timers stop and its handlers unregister - sibling behaviours on the same object keep theirs. Re-enabling starts fresh, with no duplicate handlers stacked.
- Destroy the entity and all its slots' timers/handlers go with it.
- Edit a param during Play and that slot restarts: its old timers/handlers are released before
onStartre-runs, so you never accumulate duplicates from live editing. - Load a scene or Stop and the whole event/timer set resets - scenes and Play sessions start clean.
This per-slot ownership is why you can freely add timers and listeners in onStart without writing teardown code - onDestroy cleanup for events and timers is automatic. You still use onDestroy for your own resources (a spawned helper object, a HUD reset).
See also
- ScriptApi Reference - the
emit/onandafter/everysignatures - Scripting Overview - slots and the behaviour lifecycle
- Scenes & Persistence - what survives a scene load
- Physics: Queries & Events - collision hooks that pair with events