Skip to content

t1k:cocos:playable:signalbus

FieldValue
Moduleplayable
Version2.14.4
Efforthigh
Tools—

Keywords: event, pub sub, signal, signalbus

/t1k:cocos:playable:signalbus

Singleton event bus using class constructors as keys. Signals are plain TS classes — no base class required. See also: t1k-cocos-playable-lifecycle, t1k-cocos-playable-gameflow.

Import path: db://assets/packages/@playablelabs/game-foundation/signalBus

  • SignalBus.instance — singleton
  • Key = class constructor (no string keys, no magic strings)
  • Duplicate subscriptions are silently ignored (safe to call subscribe multiple times)
  • Callbacks snapshot before firing — safe to modify subscriptions inside callbacks
// Subscribe
SignalBus.instance.subscribe(MySignal, (data: MySignal) => { ... });
// Unsubscribe — store callback reference to unsubscribe later
const cb = (data: MySignal) => { ... };
SignalBus.instance.subscribe(MySignal, cb);
SignalBus.instance.unsubscribe(MySignal, cb);
// Fire — pass an instance, not the constructor
SignalBus.instance.fire(new MySignal(value));
// Async one-shot wait
const result = await SignalBus.instance.waitFor(MySignal);
const result = await SignalBus.instance.waitFor(MySignal, 5000); // 5s timeout, throws on expire
// Cleanup
SignalBus.instance.clear(MySignal); // remove all listeners for one type
SignalBus.instance.clearAll(); // remove everything (use in scene teardown)
SignalBus.instance.hasSubscriptions(MySignal); // bool check

Signals are plain TS classes. Constructor args become typed payload fields.

// No payload
export class FirstInteractionSignal {}
// With payload — use readonly constructor fields
export class TapSignal {
constructor(
public readonly worldPosition: Vec3,
public readonly uiPosition: Vec2
) {}
}
SignalSource filePayload
ParametersReadySignalPlayableParamterTool/PlayableParameterUpdatesnone
AllAsyncParametersReadySignalscripts/parameter/ParameterControllernone
FirstInteractionSignalPLAGameFoundation/inputService/InputSignalsnone
TapSignalPLAGameFoundation/inputService/InputSignalsworldPosition, uiPosition
SwipeSignalPLAGameFoundation/inputService/InputSignalsdirection, velocity
DragSignalPLAGameFoundation/inputService/InputSignalsworldPosition, uiPosition, delta
TouchStartSignalPLAGameFoundation/inputService/InputSignalsworldPosition, uiPosition
TouchEndSignalPLAGameFoundation/inputService/InputSignalsworldPosition, uiPosition
RaycastHitSignalPLAGameFoundation/inputService/InputSignalshitNode, hitPoint

Wait for parameters before initializing scene:

await SignalBus.instance.waitFor(AllAsyncParametersReadySignal);
this.initScene();

Subscribe in onEnable / unsubscribe in onDisable (Cocos component pattern):

private onTap = (s: TapSignal) => { this.handleTap(s); };
onEnable() { SignalBus.instance.subscribe(TapSignal, this.onTap); }
onDisable() { SignalBus.instance.unsubscribe(TapSignal, this.onTap); }

Fire after state change:

SignalBus.instance.fire(new ParametersReadySignal());
Use caseRecommendation
Cross-system communicationSignalBus
Parent → child componentCocos Node events or direct call
One-shot async gate (wait for load)waitFor()
UI button clicksCocos Button event + direct callback
Scene-local tight couplingDirect method call
  • Firing the constructor instead of an instance: fire(MySignal) — wrong. Use fire(new MySignal()).
  • Forgetting to unsubscribe in onDisable/onDestroy — causes ghost callbacks on recycled nodes.
  • Calling clearAll() mid-game — clears every listener globally; reserve for scene resets.
  • Unsubscribe in onDestroy, not onDisable — disabled-but-not-destroyed nodes still receive signals; disable-only unsubscribe leaves dead subscriptions on hot-reload.
  • Signal payloads should be serializable — passing a node reference through a signal that crosses scene-replay = dangling reference.
  • Event names are global — namespace via prefixes (game.start, ui.toast) to avoid silent collisions across modules.
  • fire() DROPS the signal when there are no subscribers yet — it does not queue it. A downstream queue (e.g. an analytics service buffering track() calls until its providers init) protects only that service’s own before-init gap; it cannot protect the SignalBus fire-before-subscribe gap upstream of it. These are two distinct windows needing two distinct guarantees: scene node order (the subscriber’s onLoad must run before the emitter’s) for fire-before-subscribe, and the service’s internal queue for track-before-init. Measured 2026-09-03 (Cocos analytics integration): GameView.onLoad() → startGameFlow() → startGameplay() fires GameStartedSignal synchronously on scene load, so the node holding the subscribing service must precede the node that triggers gameplay in the scene tree — a bootstrap comment crediting the service’s internal queue with catching this signal was wrong and was corrected in review.