Skip to content

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.

MemberSignatureDescription
emit(event, payload?): voidFire a named event; every registered handler receives the payload.
on(event, cb): voidRegister 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 in onUpdate would 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 emit or 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

MemberSignatureDescription
after(seconds, cb): () => voidRun cb once after seconds. Returns a cancel function.
every(seconds, cb): () => voidRun 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 after never 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 onStart re-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

Awaken — browser-native WebGPU game engine.