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.