@smoregg/sdk 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/cjs/config.cjs.map +1 -1
  2. package/dist/cjs/controller.cjs +215 -145
  3. package/dist/cjs/controller.cjs.map +1 -1
  4. package/dist/cjs/screen.cjs +220 -178
  5. package/dist/cjs/screen.cjs.map +1 -1
  6. package/dist/cjs/testing.cjs +160 -151
  7. package/dist/cjs/testing.cjs.map +1 -1
  8. package/dist/esm/config.js.map +1 -1
  9. package/dist/esm/controller.js +216 -146
  10. package/dist/esm/controller.js.map +1 -1
  11. package/dist/esm/screen.js +221 -179
  12. package/dist/esm/screen.js.map +1 -1
  13. package/dist/esm/testing.js +160 -151
  14. package/dist/esm/testing.js.map +1 -1
  15. package/dist/types/config.d.ts +1 -2
  16. package/dist/types/config.d.ts.map +1 -1
  17. package/dist/types/controller.d.ts +22 -43
  18. package/dist/types/controller.d.ts.map +1 -1
  19. package/dist/types/index.d.ts +14 -14
  20. package/dist/types/index.d.ts.map +1 -1
  21. package/dist/types/screen.d.ts +26 -37
  22. package/dist/types/screen.d.ts.map +1 -1
  23. package/dist/types/testing.d.ts +16 -0
  24. package/dist/types/testing.d.ts.map +1 -1
  25. package/dist/types/types.d.ts +244 -338
  26. package/dist/types/types.d.ts.map +1 -1
  27. package/dist/umd/smore-sdk.umd.js +595 -474
  28. package/dist/umd/smore-sdk.umd.js.map +1 -1
  29. package/dist/umd/smore-sdk.umd.min.js +1 -1
  30. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  31. package/package.json +1 -1
  32. package/dist/cjs/SmoreHost.cjs +0 -306
  33. package/dist/cjs/SmoreHost.cjs.map +0 -1
  34. package/dist/cjs/SmorePlayer.cjs +0 -229
  35. package/dist/cjs/SmorePlayer.cjs.map +0 -1
  36. package/dist/cjs/components/DirectionPad.cjs +0 -68
  37. package/dist/cjs/components/DirectionPad.cjs.map +0 -1
  38. package/dist/cjs/components/DirectionPad.module.css.cjs +0 -12
  39. package/dist/cjs/components/DirectionPad.module.css.cjs.map +0 -1
  40. package/dist/cjs/components/HoldButton.cjs +0 -57
  41. package/dist/cjs/components/HoldButton.cjs.map +0 -1
  42. package/dist/cjs/components/HoldButton.module.css.cjs +0 -12
  43. package/dist/cjs/components/HoldButton.module.css.cjs.map +0 -1
  44. package/dist/cjs/components/IframeGameBridge.cjs +0 -115
  45. package/dist/cjs/components/IframeGameBridge.cjs.map +0 -1
  46. package/dist/cjs/components/SwipeArea.cjs +0 -58
  47. package/dist/cjs/components/SwipeArea.cjs.map +0 -1
  48. package/dist/cjs/components/SwipeArea.module.css.cjs +0 -12
  49. package/dist/cjs/components/SwipeArea.module.css.cjs.map +0 -1
  50. package/dist/cjs/components/TapButton.cjs +0 -58
  51. package/dist/cjs/components/TapButton.cjs.map +0 -1
  52. package/dist/cjs/components/TapButton.module.css.cjs +0 -12
  53. package/dist/cjs/components/TapButton.module.css.cjs.map +0 -1
  54. package/dist/cjs/context/RoomProvider.cjs +0 -118
  55. package/dist/cjs/context/RoomProvider.cjs.map +0 -1
  56. package/dist/cjs/hooks/useExternalGames.cjs +0 -49
  57. package/dist/cjs/hooks/useExternalGames.cjs.map +0 -1
  58. package/dist/cjs/hooks/useGameHost.cjs +0 -206
  59. package/dist/cjs/hooks/useGameHost.cjs.map +0 -1
  60. package/dist/cjs/hooks/useGamePlayer.cjs +0 -134
  61. package/dist/cjs/hooks/useGamePlayer.cjs.map +0 -1
  62. package/dist/cjs/iframe/index.cjs +0 -260
  63. package/dist/cjs/iframe/index.cjs.map +0 -1
  64. package/dist/cjs/node_modules/.pnpm/style-inject@0.3.0/node_modules/style-inject/dist/style-inject.es.cjs +0 -33
  65. package/dist/cjs/node_modules/.pnpm/style-inject@0.3.0/node_modules/style-inject/dist/style-inject.es.cjs.map +0 -1
  66. package/dist/cjs/server/index.cjs +0 -45
  67. package/dist/cjs/server/index.cjs.map +0 -1
  68. package/dist/cjs/transport/DirectTransport.cjs +0 -23
  69. package/dist/cjs/transport/DirectTransport.cjs.map +0 -1
  70. package/dist/cjs/utils/connectionMonitor.cjs +0 -77
  71. package/dist/cjs/utils/connectionMonitor.cjs.map +0 -1
  72. package/dist/cjs/utils/preloadAssets.cjs +0 -66
  73. package/dist/cjs/utils/preloadAssets.cjs.map +0 -1
  74. package/dist/cjs/utils/serverTime.cjs +0 -43
  75. package/dist/cjs/utils/serverTime.cjs.map +0 -1
  76. package/dist/esm/SmoreHost.js +0 -304
  77. package/dist/esm/SmoreHost.js.map +0 -1
  78. package/dist/esm/SmorePlayer.js +0 -227
  79. package/dist/esm/SmorePlayer.js.map +0 -1
  80. package/dist/esm/components/DirectionPad.js +0 -66
  81. package/dist/esm/components/DirectionPad.js.map +0 -1
  82. package/dist/esm/components/DirectionPad.module.css.js +0 -8
  83. package/dist/esm/components/DirectionPad.module.css.js.map +0 -1
  84. package/dist/esm/components/HoldButton.js +0 -55
  85. package/dist/esm/components/HoldButton.js.map +0 -1
  86. package/dist/esm/components/HoldButton.module.css.js +0 -8
  87. package/dist/esm/components/HoldButton.module.css.js.map +0 -1
  88. package/dist/esm/components/IframeGameBridge.js +0 -113
  89. package/dist/esm/components/IframeGameBridge.js.map +0 -1
  90. package/dist/esm/components/SwipeArea.js +0 -56
  91. package/dist/esm/components/SwipeArea.js.map +0 -1
  92. package/dist/esm/components/SwipeArea.module.css.js +0 -8
  93. package/dist/esm/components/SwipeArea.module.css.js.map +0 -1
  94. package/dist/esm/components/TapButton.js +0 -56
  95. package/dist/esm/components/TapButton.js.map +0 -1
  96. package/dist/esm/components/TapButton.module.css.js +0 -8
  97. package/dist/esm/components/TapButton.module.css.js.map +0 -1
  98. package/dist/esm/context/RoomProvider.js +0 -109
  99. package/dist/esm/context/RoomProvider.js.map +0 -1
  100. package/dist/esm/hooks/useExternalGames.js +0 -47
  101. package/dist/esm/hooks/useExternalGames.js.map +0 -1
  102. package/dist/esm/hooks/useGameHost.js +0 -204
  103. package/dist/esm/hooks/useGameHost.js.map +0 -1
  104. package/dist/esm/hooks/useGamePlayer.js +0 -132
  105. package/dist/esm/hooks/useGamePlayer.js.map +0 -1
  106. package/dist/esm/iframe/index.js +0 -257
  107. package/dist/esm/iframe/index.js.map +0 -1
  108. package/dist/esm/node_modules/.pnpm/style-inject@0.3.0/node_modules/style-inject/dist/style-inject.es.js +0 -29
  109. package/dist/esm/node_modules/.pnpm/style-inject@0.3.0/node_modules/style-inject/dist/style-inject.es.js.map +0 -1
  110. package/dist/esm/server/index.js +0 -43
  111. package/dist/esm/server/index.js.map +0 -1
  112. package/dist/esm/transport/DirectTransport.js +0 -21
  113. package/dist/esm/transport/DirectTransport.js.map +0 -1
  114. package/dist/esm/utils/connectionMonitor.js +0 -75
  115. package/dist/esm/utils/connectionMonitor.js.map +0 -1
  116. package/dist/esm/utils/preloadAssets.js +0 -63
  117. package/dist/esm/utils/preloadAssets.js.map +0 -1
  118. package/dist/esm/utils/serverTime.js +0 -41
  119. package/dist/esm/utils/serverTime.js.map +0 -1
  120. package/dist/types/SmoreHost.d.ts +0 -187
  121. package/dist/types/SmoreHost.d.ts.map +0 -1
  122. package/dist/types/SmorePlayer.d.ts +0 -146
  123. package/dist/types/SmorePlayer.d.ts.map +0 -1
  124. package/dist/types/components/DirectionPad.d.ts +0 -21
  125. package/dist/types/components/DirectionPad.d.ts.map +0 -1
  126. package/dist/types/components/HoldButton.d.ts +0 -22
  127. package/dist/types/components/HoldButton.d.ts.map +0 -1
  128. package/dist/types/components/IframeGameBridge.d.ts +0 -38
  129. package/dist/types/components/IframeGameBridge.d.ts.map +0 -1
  130. package/dist/types/components/SwipeArea.d.ts +0 -19
  131. package/dist/types/components/SwipeArea.d.ts.map +0 -1
  132. package/dist/types/components/TapButton.d.ts +0 -19
  133. package/dist/types/components/TapButton.d.ts.map +0 -1
  134. package/dist/types/components/index.d.ts +0 -6
  135. package/dist/types/components/index.d.ts.map +0 -1
  136. package/dist/types/context/RoomProvider.d.ts +0 -69
  137. package/dist/types/context/RoomProvider.d.ts.map +0 -1
  138. package/dist/types/context/index.d.ts +0 -3
  139. package/dist/types/context/index.d.ts.map +0 -1
  140. package/dist/types/dev/DevSimulator.d.ts +0 -31
  141. package/dist/types/dev/DevSimulator.d.ts.map +0 -1
  142. package/dist/types/dev/index.d.ts +0 -2
  143. package/dist/types/dev/index.d.ts.map +0 -1
  144. package/dist/types/hooks/index.d.ts +0 -7
  145. package/dist/types/hooks/index.d.ts.map +0 -1
  146. package/dist/types/hooks/useExternalGames.d.ts +0 -32
  147. package/dist/types/hooks/useExternalGames.d.ts.map +0 -1
  148. package/dist/types/hooks/useGameHost.d.ts +0 -67
  149. package/dist/types/hooks/useGameHost.d.ts.map +0 -1
  150. package/dist/types/hooks/useGamePlayer.d.ts +0 -55
  151. package/dist/types/hooks/useGamePlayer.d.ts.map +0 -1
  152. package/dist/types/iframe/IframeRoomProvider.d.ts +0 -31
  153. package/dist/types/iframe/IframeRoomProvider.d.ts.map +0 -1
  154. package/dist/types/iframe/index.d.ts +0 -18
  155. package/dist/types/iframe/index.d.ts.map +0 -1
  156. package/dist/types/iframe/vanilla-entry.d.ts +0 -7
  157. package/dist/types/iframe/vanilla-entry.d.ts.map +0 -1
  158. package/dist/types/iframe/vanilla.d.ts +0 -49
  159. package/dist/types/iframe/vanilla.d.ts.map +0 -1
  160. package/dist/types/server/createGameRelay.d.ts +0 -26
  161. package/dist/types/server/createGameRelay.d.ts.map +0 -1
  162. package/dist/types/server/index.d.ts +0 -3
  163. package/dist/types/server/index.d.ts.map +0 -1
  164. package/dist/types/utils/connectionMonitor.d.ts +0 -57
  165. package/dist/types/utils/connectionMonitor.d.ts.map +0 -1
  166. package/dist/types/utils/index.d.ts +0 -7
  167. package/dist/types/utils/index.d.ts.map +0 -1
  168. package/dist/types/utils/preloadAssets.d.ts +0 -29
  169. package/dist/types/utils/preloadAssets.d.ts.map +0 -1
  170. package/dist/types/utils/serverTime.d.ts +0 -28
  171. package/dist/types/utils/serverTime.d.ts.map +0 -1
  172. package/dist/umd/smore-sdk-iframe.umd.js +0 -266
  173. package/dist/umd/smore-sdk-iframe.umd.js.map +0 -1
  174. package/dist/umd/smore-sdk-iframe.umd.min.js +0 -2
  175. package/dist/umd/smore-sdk-iframe.umd.min.js.map +0 -1
  176. package/dist/umd/smore-sdk-vanilla.umd.js +0 -1275
  177. package/dist/umd/smore-sdk-vanilla.umd.js.map +0 -1
  178. package/dist/umd/smore-sdk-vanilla.umd.min.js +0 -2
  179. package/dist/umd/smore-sdk-vanilla.umd.min.js.map +0 -1
@@ -277,122 +277,139 @@
277
277
  _roomCode = "";
278
278
  _isReady = false;
279
279
  _isDestroyed = false;
280
+ _initTimeoutId = null;
280
281
  eventHandlers = /* @__PURE__ */ new Map();
281
282
  registeredTransportHandlers = [];
282
283
  boundMessageHandler = null;
283
- // Maps user-facing handler transport wrappedHandler for proper cleanup in on()/off()
284
+ // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
284
285
  handlerToTransport = /* @__PURE__ */ new Map();
285
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
286
- _configListenerEvents = /* @__PURE__ */ new Set();
286
+ // Pending handlers registered via on() before transport is ready
287
+ _pendingHandlers = [];
288
+ // Lifecycle callback arrays
289
+ _onAllReadyCallbacks = /* @__PURE__ */ new Set();
290
+ _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
291
+ _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
292
+ _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
293
+ _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
294
+ _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
295
+ _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
296
+ _onErrorCallbacks = /* @__PURE__ */ new Set();
297
+ // Whether all-ready has fired
298
+ _allReadyFired = false;
299
+ // Ready promise
300
+ _readyResolve;
301
+ _readyReject;
302
+ ready;
287
303
  constructor(config = {}) {
288
304
  this.config = config;
289
305
  this.logger = new DebugLogger(config.debug, "[SmoreScreen]");
290
- if (config.listeners) {
291
- for (const event of Object.keys(config.listeners)) {
292
- validateEventName(event);
293
- }
294
- }
306
+ this.ready = new Promise((resolve, reject) => {
307
+ this._readyResolve = resolve;
308
+ this._readyReject = reject;
309
+ });
310
+ this.startInitialization();
295
311
  }
296
312
  // ---------------------------------------------------------------------------
297
- // Initialization (called by factory)
313
+ // Initialization (called in constructor)
298
314
  // ---------------------------------------------------------------------------
299
- async initialize() {
315
+ startInitialization() {
300
316
  this.logger.lifecycle("Initializing screen...");
301
317
  const parentOrigin = this.config.parentOrigin ?? "*";
302
318
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT$1;
303
- return new Promise((resolve, reject) => {
304
- const timeoutId = setTimeout(() => {
305
- this.cleanup();
306
- const error = new SmoreSDKError(
307
- "TIMEOUT",
308
- `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends _bridge:init. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Screen instance to retry (this instance has been cleaned up).`,
309
- { details: { timeout } }
310
- );
311
- this.handleError(error);
312
- reject(error);
313
- }, timeout);
314
- this.boundMessageHandler = (e) => {
315
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
316
- const msg = e.data;
317
- if (!isBridgeMessage(msg)) return;
318
- if (msg.type === "_bridge:init") {
319
- clearTimeout(timeoutId);
320
- const initPayload = msg.payload;
321
- try {
322
- validateInitPayload(initPayload);
323
- } catch (err) {
324
- const error = new SmoreSDKError(
325
- "INIT_FAILED",
326
- `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
327
- { details: { payload: initPayload } }
328
- );
329
- this.logger.warn("_bridge:init validation failed", error);
330
- this.handleError(error);
331
- reject(error);
332
- return;
333
- }
334
- const initData = initPayload;
335
- if (initData.side !== "host") {
336
- const error = new SmoreSDKError(
337
- "INIT_FAILED",
338
- `Received init for wrong side: ${initData.side}. Expected "host".`,
339
- { details: { side: initData.side } }
340
- );
341
- this.handleError(error);
342
- reject(error);
343
- return;
344
- }
345
- this.transport = new PostMessageTransport(parentOrigin);
346
- this._roomCode = initData.roomCode;
347
- this._controllers = this.mapControllersFromInit(initData.players);
348
- if (this._controllers.length === 0) {
349
- this.logger.warn("Screen initialized with zero controllers");
350
- }
351
- this.setupEventHandlers();
352
- this._isReady = true;
353
- this.logger.lifecycle("Screen ready", {
354
- roomCode: this._roomCode,
355
- controllers: this._controllers.length
356
- });
357
- this.config.onReady?.();
358
- const autoReady = this.config.autoReady ?? getGlobalConfig().autoReady ?? true;
359
- if (autoReady) {
360
- this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
361
- this.signalReady();
362
- }
363
- resolve();
364
- } else if (msg.type === "_bridge:update") {
365
- if (!this._isReady) {
366
- this.logger.debug("Ignoring _bridge:update before init completes");
367
- return;
368
- }
369
- const updateData = msg.payload;
370
- if (updateData.players && Array.isArray(updateData.players)) {
371
- const oldControllers = this._controllers;
372
- const newControllers = this.mapControllersFromInit(updateData.players);
373
- this._controllers = newControllers;
374
- for (const nc of newControllers) {
375
- if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
376
- this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
377
- this.config.onControllerJoin?.(nc.playerIndex, nc);
378
- }
319
+ this._initTimeoutId = setTimeout(() => {
320
+ this.cleanup();
321
+ const error = new SmoreSDKError(
322
+ "TIMEOUT",
323
+ `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends _bridge:init. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Screen instance to retry (this instance has been cleaned up).`,
324
+ { details: { timeout } }
325
+ );
326
+ this.handleError(error);
327
+ this._readyReject(error);
328
+ }, timeout);
329
+ this.boundMessageHandler = (e) => {
330
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
331
+ const msg = e.data;
332
+ if (!isBridgeMessage(msg)) return;
333
+ if (msg.type === "_bridge:init") {
334
+ clearTimeout(this._initTimeoutId);
335
+ const initPayload = msg.payload;
336
+ try {
337
+ validateInitPayload(initPayload);
338
+ } catch (err) {
339
+ const error = new SmoreSDKError(
340
+ "INIT_FAILED",
341
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
342
+ { details: { payload: initPayload } }
343
+ );
344
+ this.logger.warn("_bridge:init validation failed", error);
345
+ this.handleError(error);
346
+ this._readyReject(error);
347
+ return;
348
+ }
349
+ const initData = initPayload;
350
+ if (initData.side !== "host") {
351
+ const error = new SmoreSDKError(
352
+ "INIT_FAILED",
353
+ `Received init for wrong side: ${initData.side}. Expected "host".`,
354
+ { details: { side: initData.side } }
355
+ );
356
+ this.handleError(error);
357
+ this._readyReject(error);
358
+ return;
359
+ }
360
+ this.transport = new PostMessageTransport(parentOrigin);
361
+ this._roomCode = initData.roomCode;
362
+ this._controllers = this.mapControllersFromInit(initData.players);
363
+ if (this._controllers.length === 0) {
364
+ this.logger.warn("Screen initialized with zero controllers");
365
+ }
366
+ this.setupEventHandlers();
367
+ for (const { event, handler } of this._pendingHandlers) {
368
+ this.setupUserEventHandler(event, handler);
369
+ }
370
+ this._pendingHandlers = [];
371
+ this._isReady = true;
372
+ this.logger.lifecycle("Screen ready", {
373
+ roomCode: this._roomCode,
374
+ controllers: this._controllers.length
375
+ });
376
+ const autoReady = getGlobalConfig().autoReady ?? true;
377
+ if (autoReady) {
378
+ this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
379
+ this.signalReady();
380
+ }
381
+ this._readyResolve();
382
+ } else if (msg.type === "_bridge:update") {
383
+ if (!this._isReady) {
384
+ this.logger.debug("Ignoring _bridge:update before init completes");
385
+ return;
386
+ }
387
+ const updateData = msg.payload;
388
+ if (updateData.players && Array.isArray(updateData.players)) {
389
+ const oldControllers = this._controllers;
390
+ const newControllers = this.mapControllersFromInit(updateData.players);
391
+ this._controllers = newControllers;
392
+ for (const nc of newControllers) {
393
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
394
+ this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
395
+ this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
379
396
  }
380
- for (const oc of oldControllers) {
381
- if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
382
- this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
383
- this.config.onControllerLeave?.(oc.playerIndex);
384
- }
397
+ }
398
+ for (const oc of oldControllers) {
399
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
400
+ this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
401
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
385
402
  }
386
403
  }
387
- this.logger.lifecycle("Room updated", {
388
- controllers: this._controllers.length
389
- });
390
404
  }
391
- };
392
- window.addEventListener("message", this.boundMessageHandler);
393
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
394
- this.logger.lifecycle("Sent _bridge:ready to parent");
395
- });
405
+ this.logger.lifecycle("Room updated", {
406
+ controllers: this._controllers.length
407
+ });
408
+ }
409
+ };
410
+ window.addEventListener("message", this.boundMessageHandler);
411
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
412
+ this.logger.lifecycle("Sent _bridge:ready to parent");
396
413
  }
397
414
  mapControllersFromInit(players) {
398
415
  return players.map((p, index) => ({
@@ -419,7 +436,7 @@
419
436
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
420
437
  this._controllers = [...this._controllers, controllerInfo];
421
438
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
422
- this.config.onControllerJoin?.(controllerInfo.playerIndex, controllerInfo);
439
+ this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
423
440
  }
424
441
  });
425
442
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -429,7 +446,7 @@
429
446
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
430
447
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
431
448
  this.logger.lifecycle("Controller left", { playerIndex });
432
- this.config.onControllerLeave?.(playerIndex);
449
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
433
450
  }
434
451
  });
435
452
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -440,7 +457,7 @@
440
457
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
441
458
  );
442
459
  this.logger.lifecycle("Controller disconnected", { playerIndex });
443
- this.config.onControllerDisconnect?.(playerIndex);
460
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
444
461
  }
445
462
  });
446
463
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
@@ -457,38 +474,33 @@
457
474
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
458
475
  );
459
476
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
460
- this.config.onControllerReconnect?.(controllerInfo.playerIndex, controllerInfo);
477
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
461
478
  }
462
479
  });
463
480
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
464
481
  const payload = data;
465
482
  const playerData = payload?.player;
466
483
  if (playerData && typeof playerData.playerIndex === "number") {
484
+ const pi = playerData.playerIndex;
467
485
  const appearance = playerData.character ?? null;
468
486
  this._controllers = this._controllers.map(
469
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
487
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
470
488
  );
471
- this.logger.lifecycle("Player character updated", { playerIndex: playerData.playerIndex });
472
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
489
+ this.logger.lifecycle("Player character updated", { playerIndex: pi });
490
+ this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
473
491
  }
474
492
  });
475
493
  this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
476
494
  const payload = data;
477
495
  const event = payload?.event ?? "unknown";
478
496
  this.logger.warn(`Rate limited: ${event}`);
479
- this.config.onRateLimited?.(event);
497
+ this._onRateLimitedCallbacks.forEach((cb) => cb(event));
480
498
  });
481
499
  this.registerTransportHandler(SMORE_EVENTS.ALL_READY, () => {
482
500
  this.logger.lifecycle("All participants ready");
483
- this.config.onAllReady?.();
501
+ this._allReadyFired = true;
502
+ this._onAllReadyCallbacks.forEach((cb) => cb());
484
503
  });
485
- if (this.config.listeners) {
486
- for (const [event, handler] of Object.entries(this.config.listeners)) {
487
- if (!handler) continue;
488
- this._configListenerEvents.add(event);
489
- this.setupUserEventHandler(event, handler);
490
- }
491
- }
492
504
  }
493
505
  /**
494
506
  * Sets up a user event handler with playerIndex extraction.
@@ -530,6 +542,7 @@
530
542
  this.eventHandlers.set(event, handlers);
531
543
  }
532
544
  handlers.add(handler);
545
+ this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
533
546
  }
534
547
  registerTransportHandler(event, handler) {
535
548
  if (!this.transport) return;
@@ -556,6 +569,60 @@
556
569
  return this._isDestroyed;
557
570
  }
558
571
  // ---------------------------------------------------------------------------
572
+ // Lifecycle Methods
573
+ // ---------------------------------------------------------------------------
574
+ onAllReady(callback) {
575
+ if (this._allReadyFired) {
576
+ callback();
577
+ }
578
+ this._onAllReadyCallbacks.add(callback);
579
+ return () => {
580
+ this._onAllReadyCallbacks.delete(callback);
581
+ };
582
+ }
583
+ onControllerJoin(callback) {
584
+ this._onControllerJoinCallbacks.add(callback);
585
+ return () => {
586
+ this._onControllerJoinCallbacks.delete(callback);
587
+ };
588
+ }
589
+ onControllerLeave(callback) {
590
+ this._onControllerLeaveCallbacks.add(callback);
591
+ return () => {
592
+ this._onControllerLeaveCallbacks.delete(callback);
593
+ };
594
+ }
595
+ onControllerDisconnect(callback) {
596
+ this._onControllerDisconnectCallbacks.add(callback);
597
+ return () => {
598
+ this._onControllerDisconnectCallbacks.delete(callback);
599
+ };
600
+ }
601
+ onControllerReconnect(callback) {
602
+ this._onControllerReconnectCallbacks.add(callback);
603
+ return () => {
604
+ this._onControllerReconnectCallbacks.delete(callback);
605
+ };
606
+ }
607
+ onCharacterUpdated(callback) {
608
+ this._onCharacterUpdatedCallbacks.add(callback);
609
+ return () => {
610
+ this._onCharacterUpdatedCallbacks.delete(callback);
611
+ };
612
+ }
613
+ onRateLimited(callback) {
614
+ this._onRateLimitedCallbacks.add(callback);
615
+ return () => {
616
+ this._onRateLimitedCallbacks.delete(callback);
617
+ };
618
+ }
619
+ onError(callback) {
620
+ this._onErrorCallbacks.add(callback);
621
+ return () => {
622
+ this._onErrorCallbacks.delete(callback);
623
+ };
624
+ }
625
+ // ---------------------------------------------------------------------------
559
626
  // Communication Methods
560
627
  // ---------------------------------------------------------------------------
561
628
  /**
@@ -662,11 +729,8 @@
662
729
  /**
663
730
  * Register an event handler for messages from controllers.
664
731
  *
665
- * **Important:** If called before the Screen is ready (i.e., before `await createScreen()`
666
- * resolves or before the `onReady` callback fires), the handler is stored locally but
667
- * will NOT be registered with the transport layer. This means the handler will never
668
- * fire for events received during the pre-ready window. Always call `on()` after
669
- * initialization completes, or use `config.listeners` for handlers needed from the start.
732
+ * Can be called before the Screen is ready. Handlers registered before ready
733
+ * are queued and activated when the transport becomes available.
670
734
  */
671
735
  on(event, handler) {
672
736
  validateEventName(event);
@@ -676,9 +740,8 @@
676
740
  this.eventHandlers.set(event, handlers);
677
741
  }
678
742
  handlers.add(handler);
679
- let wrappedHandler = null;
680
743
  if (this.transport) {
681
- wrappedHandler = (data) => {
744
+ const wrappedHandler = (data) => {
682
745
  this.logger.receive(event, data);
683
746
  const payload = data;
684
747
  const { playerIndex, ...rest } = payload;
@@ -698,16 +761,22 @@
698
761
  };
699
762
  this.registerTransportHandler(event, wrappedHandler);
700
763
  this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
764
+ } else {
765
+ this._pendingHandlers.push({ event, handler });
701
766
  }
702
767
  return () => {
703
768
  handlers?.delete(handler);
704
769
  if (handlers?.size === 0) {
705
770
  this.eventHandlers.delete(event);
706
771
  }
707
- if (wrappedHandler) {
708
- this.transport?.off(event, wrappedHandler);
772
+ this._pendingHandlers = this._pendingHandlers.filter(
773
+ (p) => !(p.event === event && p.handler === handler)
774
+ );
775
+ const entry = this.handlerToTransport.get(handler);
776
+ if (entry) {
777
+ this.transport?.off(event, entry.transportHandler);
709
778
  this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
710
- (h) => h.handler !== wrappedHandler
779
+ (h) => h.handler !== entry.transportHandler
711
780
  );
712
781
  this.handlerToTransport.delete(handler);
713
782
  }
@@ -724,16 +793,6 @@
724
793
  * @param event - Event name to listen for
725
794
  * @param handler - Handler function to call once
726
795
  * @returns Unsubscribe function to remove the handler before it fires
727
- *
728
- * @example
729
- * ```ts
730
- * const unsubscribe = screen.once('ready', (playerIndex, data) => {
731
- * console.log('Ready event received');
732
- * });
733
- *
734
- * // To remove before the event fires:
735
- * unsubscribe();
736
- * ```
737
796
  */
738
797
  once(event, handler) {
739
798
  const wrappedHandler = (playerIndex, data) => {
@@ -745,24 +804,13 @@
745
804
  }
746
805
  off(event, handler) {
747
806
  if (!handler) {
748
- if (this._configListenerEvents.has(event)) {
749
- for (const [key, val] of this.handlerToTransport) {
750
- if (val.event === event) {
751
- this.transport?.off(event, val.transportHandler);
752
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
753
- (h) => h.handler !== val.transportHandler
754
- );
755
- this.handlerToTransport.delete(key);
756
- }
757
- }
758
- } else {
759
- this.eventHandlers.delete(event);
760
- this.transport?.off(event);
761
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
762
- for (const [key, val] of this.handlerToTransport) {
763
- if (val.event === event) this.handlerToTransport.delete(key);
764
- }
807
+ this.eventHandlers.delete(event);
808
+ this.transport?.off(event);
809
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
810
+ for (const [key, val] of this.handlerToTransport) {
811
+ if (val.event === event) this.handlerToTransport.delete(key);
765
812
  }
813
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
766
814
  } else {
767
815
  const handlers = this.eventHandlers.get(event);
768
816
  handlers?.delete(handler);
@@ -777,6 +825,9 @@
777
825
  );
778
826
  this.handlerToTransport.delete(handler);
779
827
  }
828
+ this._pendingHandlers = this._pendingHandlers.filter(
829
+ (p) => !(p.event === event && p.handler === handler)
830
+ );
780
831
  }
781
832
  }
782
833
  // ---------------------------------------------------------------------------
@@ -788,25 +839,6 @@
788
839
  getControllerCount() {
789
840
  return this._controllers.filter((c) => c.connected).length;
790
841
  }
791
- /**
792
- * Check if there is at least one connected controller.
793
- * Useful for detecting when all players have disconnected
794
- * (e.g., to pause the game or show a waiting screen).
795
- *
796
- * Use this in onControllerDisconnect callback to detect when all controllers have disconnected.
797
- *
798
- * @example
799
- * ```ts
800
- * const screen = await createScreen<MyEvents>({
801
- * onControllerDisconnect: (playerIndex) => {
802
- * if (!screen.hasAnyConnectedControllers()) {
803
- * console.log('All controllers disconnected!');
804
- * screen.broadcast('waiting-for-players', {});
805
- * }
806
- * },
807
- * });
808
- * ```
809
- */
810
842
  hasAnyConnectedControllers() {
811
843
  return this._controllers.some((c) => c.connected);
812
844
  }
@@ -822,12 +854,25 @@
822
854
  this.logger.lifecycle("Screen destroyed");
823
855
  }
824
856
  cleanup() {
857
+ if (this._initTimeoutId) {
858
+ clearTimeout(this._initTimeoutId);
859
+ this._initTimeoutId = null;
860
+ }
825
861
  for (const { event, handler } of this.registeredTransportHandlers) {
826
862
  this.transport?.off(event, handler);
827
863
  }
828
864
  this.registeredTransportHandlers = [];
829
865
  this.eventHandlers.clear();
830
866
  this.handlerToTransport.clear();
867
+ this._pendingHandlers = [];
868
+ this._onAllReadyCallbacks.clear();
869
+ this._onControllerJoinCallbacks.clear();
870
+ this._onControllerLeaveCallbacks.clear();
871
+ this._onControllerDisconnectCallbacks.clear();
872
+ this._onControllerReconnectCallbacks.clear();
873
+ this._onCharacterUpdatedCallbacks.clear();
874
+ this._onRateLimitedCallbacks.clear();
875
+ this._onErrorCallbacks.clear();
831
876
  if (this.transport instanceof PostMessageTransport) {
832
877
  this.transport.destroy();
833
878
  }
@@ -843,8 +888,8 @@
843
888
  handleError(error) {
844
889
  this.logger.warn(`Error in handler: ${error.message}`);
845
890
  const smoreError = error.toSmoreError();
846
- if (this.config.onError) {
847
- this.config.onError(smoreError);
891
+ if (this._onErrorCallbacks.size > 0) {
892
+ this._onErrorCallbacks.forEach((cb) => cb(smoreError));
848
893
  } else {
849
894
  this.logger.error(error.message, error.details);
850
895
  }
@@ -860,17 +905,14 @@
860
905
  if (!this._isReady || !this.transport) {
861
906
  throw new SmoreSDKError(
862
907
  "NOT_READY",
863
- `Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
908
+ `Cannot call ${method}() before screen is ready. Use await screen.ready.`,
864
909
  { details: { method } }
865
910
  );
866
911
  }
867
912
  }
868
913
  }
869
914
  function createScreen(config) {
870
- const screen = new ScreenImpl(config);
871
- const promise = screen.initialize().then(() => screen);
872
- promise.instance = screen;
873
- return promise;
915
+ return new ScreenImpl(config);
874
916
  }
875
917
 
876
918
  const DEFAULT_TIMEOUT = 1e4;
@@ -882,22 +924,38 @@
882
924
  _myIndex = -1;
883
925
  _isReady = false;
884
926
  _isDestroyed = false;
927
+ _initTimeoutId = null;
885
928
  boundMessageHandler = null;
886
929
  registeredHandlers = [];
887
930
  eventListeners = /* @__PURE__ */ new Map();
888
931
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
889
932
  handlerToTransport = /* @__PURE__ */ new Map();
890
933
  _controllers = [];
891
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
892
- _configListenerEvents = /* @__PURE__ */ new Set();
934
+ // Pending handlers registered via on() before transport is ready
935
+ _pendingHandlers = [];
936
+ // Lifecycle callback arrays
937
+ _onAllReadyCallbacks = /* @__PURE__ */ new Set();
938
+ _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
939
+ _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
940
+ _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
941
+ _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
942
+ _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
943
+ _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
944
+ _onErrorCallbacks = /* @__PURE__ */ new Set();
945
+ // Whether all-ready has fired
946
+ _allReadyFired = false;
947
+ // Ready promise
948
+ _readyResolve;
949
+ _readyReject;
950
+ ready;
893
951
  constructor(config = {}) {
894
952
  this.config = config;
895
953
  this.logger = new DebugLogger(config.debug, "[SmoreController]");
896
- if (config.listeners) {
897
- for (const event of Object.keys(config.listeners)) {
898
- validateEventName(event);
899
- }
900
- }
954
+ this.ready = new Promise((resolve, reject) => {
955
+ this._readyResolve = resolve;
956
+ this._readyReject = reject;
957
+ });
958
+ this.startInitialization();
901
959
  }
902
960
  // ---------------------------------------------------------------------------
903
961
  // Properties (readonly)
@@ -933,38 +991,36 @@
933
991
  // ---------------------------------------------------------------------------
934
992
  // Initialization
935
993
  // ---------------------------------------------------------------------------
936
- async initialize() {
994
+ startInitialization() {
937
995
  const parentOrigin = this.config.parentOrigin ?? "*";
938
996
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
939
997
  this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
940
- return new Promise((resolve, reject) => {
941
- const timeoutId = setTimeout(() => {
942
- this.cleanup();
943
- const error = new SmoreSDKError(
944
- "TIMEOUT",
945
- `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
946
- { details: { timeout } }
947
- );
948
- this.handleError(error);
949
- reject(error);
950
- }, timeout);
951
- this.boundMessageHandler = (e) => {
952
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
953
- const msg = e.data;
954
- if (!isBridgeMessage(msg)) return;
955
- if (msg.type === "_bridge:init") {
956
- clearTimeout(timeoutId);
957
- this.handleInit(msg, parentOrigin, resolve, reject);
958
- } else if (msg.type === "_bridge:update") {
959
- this.handleUpdate(msg);
960
- }
961
- };
962
- window.addEventListener("message", this.boundMessageHandler);
963
- this.logger.lifecycle("Sending _bridge:ready to parent");
964
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
965
- });
998
+ this._initTimeoutId = setTimeout(() => {
999
+ this.cleanup();
1000
+ const error = new SmoreSDKError(
1001
+ "TIMEOUT",
1002
+ `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
1003
+ { details: { timeout } }
1004
+ );
1005
+ this.handleError(error);
1006
+ this._readyReject(error);
1007
+ }, timeout);
1008
+ this.boundMessageHandler = (e) => {
1009
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
1010
+ const msg = e.data;
1011
+ if (!isBridgeMessage(msg)) return;
1012
+ if (msg.type === "_bridge:init") {
1013
+ clearTimeout(this._initTimeoutId);
1014
+ this.handleInit(msg, parentOrigin);
1015
+ } else if (msg.type === "_bridge:update") {
1016
+ this.handleUpdate(msg);
1017
+ }
1018
+ };
1019
+ window.addEventListener("message", this.boundMessageHandler);
1020
+ this.logger.lifecycle("Sending _bridge:ready to parent");
1021
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
966
1022
  }
967
- handleInit(msg, parentOrigin, resolve, reject) {
1023
+ handleInit(msg, parentOrigin) {
968
1024
  const initPayload = msg.payload;
969
1025
  this.logger.debug("Received _bridge:init", initPayload);
970
1026
  try {
@@ -977,7 +1033,7 @@
977
1033
  );
978
1034
  this.logger.warn("_bridge:init validation failed", error);
979
1035
  this.handleError(error);
980
- reject(error);
1036
+ this._readyReject(error);
981
1037
  return;
982
1038
  }
983
1039
  const initData = initPayload;
@@ -988,7 +1044,7 @@
988
1044
  { details: { side: initData.side } }
989
1045
  );
990
1046
  this.handleError(error);
991
- reject(error);
1047
+ this._readyReject(error);
992
1048
  return;
993
1049
  }
994
1050
  if (initData.myIndex === void 0) {
@@ -998,7 +1054,7 @@
998
1054
  { details: initData }
999
1055
  );
1000
1056
  this.handleError(error);
1001
- reject(error);
1057
+ this._readyReject(error);
1002
1058
  return;
1003
1059
  }
1004
1060
  this.transport = new PostMessageTransport(parentOrigin);
@@ -1012,18 +1068,21 @@
1012
1068
  appearance: p.appearance ?? p.character
1013
1069
  }));
1014
1070
  this.setupEventHandlers();
1071
+ for (const { event, handler } of this._pendingHandlers) {
1072
+ this.setupUserEventHandler(event, handler);
1073
+ }
1074
+ this._pendingHandlers = [];
1015
1075
  this._isReady = true;
1016
1076
  this.logger.lifecycle("Controller ready", {
1017
1077
  roomCode: this._roomCode,
1018
1078
  myIndex: this._myIndex
1019
1079
  });
1020
- this.config.onReady?.();
1021
- const autoReady = this.config.autoReady ?? getGlobalConfig().autoReady ?? true;
1080
+ const autoReady = getGlobalConfig().autoReady ?? true;
1022
1081
  if (autoReady) {
1023
1082
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
1024
1083
  this.signalReady();
1025
1084
  }
1026
- resolve();
1085
+ this._readyResolve();
1027
1086
  }
1028
1087
  handleUpdate(msg) {
1029
1088
  if (!this._isReady) {
@@ -1043,12 +1102,12 @@
1043
1102
  const oldControllers = this._controllers;
1044
1103
  for (const nc of newControllers) {
1045
1104
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
1046
- this.config.onControllerJoin?.(nc.playerIndex, nc);
1105
+ this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1047
1106
  }
1048
1107
  }
1049
1108
  for (const oc of oldControllers) {
1050
1109
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
1051
- this.config.onControllerLeave?.(oc.playerIndex);
1110
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
1052
1111
  }
1053
1112
  }
1054
1113
  for (const nc of newControllers) {
@@ -1056,11 +1115,11 @@
1056
1115
  if (oc) {
1057
1116
  if (oc.connected && !nc.connected) {
1058
1117
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
1059
- this.config.onControllerDisconnect?.(nc.playerIndex);
1118
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
1060
1119
  }
1061
1120
  if (!oc.connected && nc.connected) {
1062
1121
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
1063
- this.config.onControllerReconnect?.(nc.playerIndex, nc);
1122
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
1064
1123
  }
1065
1124
  }
1066
1125
  }
@@ -1087,7 +1146,7 @@
1087
1146
  };
1088
1147
  this._controllers = [...this._controllers, controllerInfo];
1089
1148
  this.logger.debug("Player joined", { playerIndex });
1090
- this.config.onControllerJoin?.(playerIndex, controllerInfo);
1149
+ this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1091
1150
  }
1092
1151
  });
1093
1152
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -1097,7 +1156,7 @@
1097
1156
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1098
1157
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
1099
1158
  this.logger.debug("Player left", { playerIndex });
1100
- this.config.onControllerLeave?.(playerIndex);
1159
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1101
1160
  }
1102
1161
  });
1103
1162
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -1109,7 +1168,7 @@
1109
1168
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1110
1169
  );
1111
1170
  this.logger.debug("Player disconnected", { playerIndex });
1112
- this.config.onControllerDisconnect?.(playerIndex);
1171
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1113
1172
  }
1114
1173
  });
1115
1174
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -1131,56 +1190,63 @@
1131
1190
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
1132
1191
  );
1133
1192
  this.logger.debug("Player reconnected", { playerIndex });
1134
- this.config.onControllerReconnect?.(playerIndex, controllerInfo);
1193
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
1135
1194
  }
1136
1195
  });
1137
1196
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
1138
1197
  const payload = raw;
1139
1198
  const playerData = payload?.player;
1140
1199
  if (playerData && typeof playerData.playerIndex === "number") {
1200
+ const pi = playerData.playerIndex;
1141
1201
  const appearance = playerData.character ?? null;
1142
1202
  this._controllers = this._controllers.map(
1143
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
1203
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
1144
1204
  );
1145
- this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
1146
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
1205
+ this.logger.debug("Player character updated", { playerIndex: pi });
1206
+ this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
1147
1207
  }
1148
1208
  });
1149
1209
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
1150
1210
  const data = raw;
1151
1211
  const event = data?.event ?? "unknown";
1152
1212
  this.logger.warn(`Rate limited: ${event}`);
1153
- this.config.onRateLimited?.(event);
1213
+ this._onRateLimitedCallbacks.forEach((cb) => cb(event));
1154
1214
  });
1155
1215
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
1156
1216
  this.logger.lifecycle("All participants ready");
1157
- this.config.onAllReady?.();
1217
+ this._allReadyFired = true;
1218
+ this._onAllReadyCallbacks.forEach((cb) => cb());
1158
1219
  });
1159
- if (this.config.onHostDisconnect) {
1160
- this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
1161
- }
1162
- if (this.config.onHostReconnect) {
1163
- this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
1164
- }
1165
- if (this.config.listeners) {
1166
- for (const [event, handler] of Object.entries(this.config.listeners)) {
1167
- if (!handler) continue;
1168
- this._configListenerEvents.add(event);
1169
- this.registerHandler(event, (data) => {
1170
- this.logReceive(event, data);
1171
- try {
1172
- handler(data);
1173
- } catch (err) {
1174
- this.handleError(
1175
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1176
- cause: err instanceof Error ? err : void 0,
1177
- details: { event }
1178
- })
1179
- );
1180
- }
1181
- });
1220
+ }
1221
+ /**
1222
+ * Sets up a user event handler for controller events.
1223
+ * Used for registering pending handlers after transport becomes available.
1224
+ */
1225
+ setupUserEventHandler(event, handler) {
1226
+ const transportHandler = (data) => {
1227
+ this.logReceive(event, data);
1228
+ try {
1229
+ handler(data);
1230
+ } catch (err) {
1231
+ this.handleError(
1232
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1233
+ cause: err instanceof Error ? err : void 0,
1234
+ details: { event }
1235
+ })
1236
+ );
1182
1237
  }
1238
+ };
1239
+ if (this.transport) {
1240
+ this.transport.on(event, transportHandler);
1241
+ this.registeredHandlers.push({ event, handler: transportHandler });
1242
+ this.handlerToTransport.set(handler, { event, transportHandler });
1243
+ }
1244
+ let listeners = this.eventListeners.get(event);
1245
+ if (!listeners) {
1246
+ listeners = /* @__PURE__ */ new Set();
1247
+ this.eventListeners.set(event, listeners);
1183
1248
  }
1249
+ listeners.add(handler);
1184
1250
  }
1185
1251
  registerHandler(event, handler) {
1186
1252
  if (!this.transport) return;
@@ -1188,13 +1254,67 @@
1188
1254
  this.registeredHandlers.push({ event, handler });
1189
1255
  }
1190
1256
  // ---------------------------------------------------------------------------
1257
+ // Lifecycle Methods
1258
+ // ---------------------------------------------------------------------------
1259
+ onAllReady(callback) {
1260
+ if (this._allReadyFired) {
1261
+ callback();
1262
+ }
1263
+ this._onAllReadyCallbacks.add(callback);
1264
+ return () => {
1265
+ this._onAllReadyCallbacks.delete(callback);
1266
+ };
1267
+ }
1268
+ onControllerJoin(callback) {
1269
+ this._onControllerJoinCallbacks.add(callback);
1270
+ return () => {
1271
+ this._onControllerJoinCallbacks.delete(callback);
1272
+ };
1273
+ }
1274
+ onControllerLeave(callback) {
1275
+ this._onControllerLeaveCallbacks.add(callback);
1276
+ return () => {
1277
+ this._onControllerLeaveCallbacks.delete(callback);
1278
+ };
1279
+ }
1280
+ onControllerDisconnect(callback) {
1281
+ this._onControllerDisconnectCallbacks.add(callback);
1282
+ return () => {
1283
+ this._onControllerDisconnectCallbacks.delete(callback);
1284
+ };
1285
+ }
1286
+ onControllerReconnect(callback) {
1287
+ this._onControllerReconnectCallbacks.add(callback);
1288
+ return () => {
1289
+ this._onControllerReconnectCallbacks.delete(callback);
1290
+ };
1291
+ }
1292
+ onCharacterUpdated(callback) {
1293
+ this._onCharacterUpdatedCallbacks.add(callback);
1294
+ return () => {
1295
+ this._onCharacterUpdatedCallbacks.delete(callback);
1296
+ };
1297
+ }
1298
+ onRateLimited(callback) {
1299
+ this._onRateLimitedCallbacks.add(callback);
1300
+ return () => {
1301
+ this._onRateLimitedCallbacks.delete(callback);
1302
+ };
1303
+ }
1304
+ onError(callback) {
1305
+ this._onErrorCallbacks.add(callback);
1306
+ return () => {
1307
+ this._onErrorCallbacks.delete(callback);
1308
+ };
1309
+ }
1310
+ // ---------------------------------------------------------------------------
1191
1311
  // Communication Methods
1192
1312
  // ---------------------------------------------------------------------------
1193
1313
  /**
1194
1314
  * Send an event to the Screen. Controller-to-Controller direct communication
1195
1315
  * is not supported; all messages must go through the Screen.
1196
1316
  *
1197
- * Data is sent to the Screen only (not to other controllers). For ScreenController communication,
1317
+ * Data is sent to the Screen only (not to other controllers). For Screen->Controller communication,
1198
1318
  * Screen uses broadcast() or sendToController().
1199
1319
  *
1200
1320
  * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
@@ -1228,24 +1348,14 @@
1228
1348
  /**
1229
1349
  * Register a handler for custom events.
1230
1350
  *
1351
+ * Can be called before the Controller is ready. Handlers registered before ready
1352
+ * are queued and activated when the transport becomes available.
1353
+ *
1231
1354
  * When receiving events from Screen's `broadcast()`:
1232
- * handler receives `(data)` no playerIndex included.
1355
+ * handler receives `(data)` -- no playerIndex included.
1233
1356
  *
1234
1357
  * When receiving events from Screen's `sendToController()`:
1235
- * handler receives `(data)` targeted to this specific controller.
1236
- *
1237
- * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
1238
- * Controller's `on()` receives only `(data)` since there's only one player per controller.
1239
- *
1240
- * Controller's on() handler signature: (data) => void
1241
- * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
1242
- * because Controller only receives events from Screen, not from other controllers.
1243
- * The sender is always the Screen, so playerIndex is not applicable.
1244
- *
1245
- * **Important:** If called before the Controller is ready (i.e., before `await createController()`
1246
- * resolves or before the `onReady` callback fires), the handler is stored locally but
1247
- * will NOT receive events until the transport is initialized. Always call `on()` after
1248
- * initialization completes, or use `config.listeners` for handlers needed from the start.
1358
+ * handler receives `(data)` -- targeted to this specific controller.
1249
1359
  */
1250
1360
  on(event, handler) {
1251
1361
  validateEventName(event);
@@ -1255,34 +1365,42 @@
1255
1365
  this.eventListeners.set(event, listeners);
1256
1366
  }
1257
1367
  listeners.add(handler);
1258
- const transportHandler = (data) => {
1259
- this.logReceive(event, data);
1260
- try {
1261
- handler(data);
1262
- } catch (err) {
1263
- this.handleError(
1264
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1265
- cause: err instanceof Error ? err : void 0,
1266
- details: { event }
1267
- })
1268
- );
1269
- }
1270
- };
1271
1368
  if (this.transport) {
1369
+ const transportHandler = (data) => {
1370
+ this.logReceive(event, data);
1371
+ try {
1372
+ handler(data);
1373
+ } catch (err) {
1374
+ this.handleError(
1375
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1376
+ cause: err instanceof Error ? err : void 0,
1377
+ details: { event }
1378
+ })
1379
+ );
1380
+ }
1381
+ };
1272
1382
  this.transport.on(event, transportHandler);
1273
1383
  this.registeredHandlers.push({ event, handler: transportHandler });
1274
1384
  this.handlerToTransport.set(handler, { event, transportHandler });
1385
+ } else {
1386
+ this._pendingHandlers.push({ event, handler });
1275
1387
  }
1276
1388
  return () => {
1277
1389
  listeners?.delete(handler);
1278
1390
  if (listeners?.size === 0) {
1279
1391
  this.eventListeners.delete(event);
1280
1392
  }
1281
- this.transport?.off(event, transportHandler);
1282
- this.registeredHandlers = this.registeredHandlers.filter(
1283
- (h) => h.handler !== transportHandler
1393
+ this._pendingHandlers = this._pendingHandlers.filter(
1394
+ (p) => !(p.event === event && p.handler === handler)
1284
1395
  );
1285
- this.handlerToTransport.delete(handler);
1396
+ const entry = this.handlerToTransport.get(handler);
1397
+ if (entry) {
1398
+ this.transport?.off(event, entry.transportHandler);
1399
+ this.registeredHandlers = this.registeredHandlers.filter(
1400
+ (h) => h.handler !== entry.transportHandler
1401
+ );
1402
+ this.handlerToTransport.delete(handler);
1403
+ }
1286
1404
  };
1287
1405
  }
1288
1406
  /**
@@ -1290,16 +1408,6 @@
1290
1408
  *
1291
1409
  * @note The handler is internally wrapped, so it cannot be removed via
1292
1410
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
1293
- *
1294
- * @example
1295
- * ```ts
1296
- * const unsubscribe = controller.once('game-start', (data) => {
1297
- * console.log('Game started!', data);
1298
- * });
1299
- *
1300
- * // To cancel before it fires:
1301
- * unsubscribe();
1302
- * ```
1303
1411
  */
1304
1412
  once(event, handler) {
1305
1413
  const unsubscribe = this.on(event, ((data) => {
@@ -1310,24 +1418,13 @@
1310
1418
  }
1311
1419
  off(event, handler) {
1312
1420
  if (!handler) {
1313
- if (this._configListenerEvents.has(event)) {
1314
- for (const [key, val] of this.handlerToTransport) {
1315
- if (val.event === event) {
1316
- this.transport?.off(event, val.transportHandler);
1317
- this.registeredHandlers = this.registeredHandlers.filter(
1318
- (h) => h.handler !== val.transportHandler
1319
- );
1320
- this.handlerToTransport.delete(key);
1321
- }
1322
- }
1323
- } else {
1324
- this.eventListeners.delete(event);
1325
- this.transport?.off(event);
1326
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1327
- for (const [key, val] of this.handlerToTransport) {
1328
- if (val.event === event) this.handlerToTransport.delete(key);
1329
- }
1421
+ this.eventListeners.delete(event);
1422
+ this.transport?.off(event);
1423
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1424
+ for (const [key, val] of this.handlerToTransport) {
1425
+ if (val.event === event) this.handlerToTransport.delete(key);
1330
1426
  }
1427
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
1331
1428
  } else {
1332
1429
  const listeners = this.eventListeners.get(event);
1333
1430
  listeners?.delete(handler);
@@ -1342,6 +1439,9 @@
1342
1439
  );
1343
1440
  this.handlerToTransport.delete(handler);
1344
1441
  }
1442
+ this._pendingHandlers = this._pendingHandlers.filter(
1443
+ (p) => !(p.event === event && p.handler === handler)
1444
+ );
1345
1445
  }
1346
1446
  }
1347
1447
  // ---------------------------------------------------------------------------
@@ -1350,10 +1450,15 @@
1350
1450
  destroy() {
1351
1451
  if (this._isDestroyed) return;
1352
1452
  this.logger.lifecycle("Destroying controller");
1353
- this.cleanup();
1354
1453
  this._isDestroyed = true;
1454
+ this._isReady = false;
1455
+ this.cleanup();
1355
1456
  }
1356
1457
  cleanup() {
1458
+ if (this._initTimeoutId) {
1459
+ clearTimeout(this._initTimeoutId);
1460
+ this._initTimeoutId = null;
1461
+ }
1357
1462
  this._isReady = false;
1358
1463
  for (const { event, handler } of this.registeredHandlers) {
1359
1464
  this.transport?.off(event, handler);
@@ -1361,6 +1466,15 @@
1361
1466
  this.registeredHandlers = [];
1362
1467
  this.eventListeners.clear();
1363
1468
  this.handlerToTransport.clear();
1469
+ this._pendingHandlers = [];
1470
+ this._onAllReadyCallbacks.clear();
1471
+ this._onControllerJoinCallbacks.clear();
1472
+ this._onControllerLeaveCallbacks.clear();
1473
+ this._onControllerDisconnectCallbacks.clear();
1474
+ this._onControllerReconnectCallbacks.clear();
1475
+ this._onCharacterUpdatedCallbacks.clear();
1476
+ this._onRateLimitedCallbacks.clear();
1477
+ this._onErrorCallbacks.clear();
1364
1478
  if (this.transport) {
1365
1479
  this.transport.destroy();
1366
1480
  this.transport = null;
@@ -1384,15 +1498,16 @@
1384
1498
  if (!this._isReady || !this.transport) {
1385
1499
  throw new SmoreSDKError(
1386
1500
  "NOT_READY",
1387
- `Cannot call ${method}() before controller is ready. Use await createController() or wait for onReady callback.`,
1501
+ `Cannot call ${method}() before controller is ready. Use await controller.ready.`,
1388
1502
  { details: { method, isReady: this._isReady } }
1389
1503
  );
1390
1504
  }
1391
1505
  }
1392
1506
  handleError(error) {
1393
1507
  this.logger.warn(`Error in handler: ${error.message}`);
1394
- if (this.config.onError) {
1395
- this.config.onError(error.toSmoreError());
1508
+ const smoreError = error.toSmoreError();
1509
+ if (this._onErrorCallbacks.size > 0) {
1510
+ this._onErrorCallbacks.forEach((cb) => cb(smoreError));
1396
1511
  } else {
1397
1512
  this.logger.error(error.message, error.details);
1398
1513
  }
@@ -1405,49 +1520,32 @@
1405
1520
  }
1406
1521
  }
1407
1522
  function createController(config) {
1408
- const controller = new ControllerImpl(config ?? {});
1409
- const promise = controller.initialize().then(() => controller);
1410
- promise.instance = controller;
1411
- return promise;
1523
+ return new ControllerImpl(config ?? {});
1412
1524
  }
1413
1525
 
1414
1526
  function createMockScreen(options = {}) {
1415
1527
  const {
1416
1528
  roomCode = "TEST",
1417
1529
  controllers: initialControllers = [],
1418
- autoReady = true,
1419
- onReady: onReadyCb,
1420
- onControllerJoin: onJoinCb,
1421
- onControllerLeave: onLeaveCb,
1422
- onControllerDisconnect: onDisconnectCb,
1423
- onControllerReconnect: onReconnectCb,
1424
- onCharacterUpdated: onCharacterUpdatedCb,
1425
- onRateLimited: onRateLimitedCb,
1426
- onAllReady: onAllReadyCb,
1427
- onError: onErrorCb
1530
+ autoReady = true
1428
1531
  } = options;
1429
1532
  let _controllers = [...initialControllers];
1430
1533
  let _isReady = false;
1431
1534
  let _isDestroyed = false;
1432
1535
  const listeners = /* @__PURE__ */ new Map();
1433
- let onReadyCallback;
1434
- let onControllerJoinCallback;
1435
- let onControllerLeaveCallback;
1436
- let onControllerDisconnectCallback;
1437
- let onControllerReconnectCallback;
1438
- let onCharacterUpdatedCallback;
1439
- let onRateLimitedCallback;
1440
- let onAllReadyCallback;
1441
- let onErrorCallback;
1442
- onReadyCallback = onReadyCb;
1443
- onControllerJoinCallback = onJoinCb;
1444
- onControllerLeaveCallback = onLeaveCb;
1445
- onControllerDisconnectCallback = onDisconnectCb;
1446
- onControllerReconnectCallback = onReconnectCb;
1447
- onCharacterUpdatedCallback = onCharacterUpdatedCb;
1448
- onRateLimitedCallback = onRateLimitedCb;
1449
- onAllReadyCallback = onAllReadyCb;
1450
- onErrorCallback = onErrorCb;
1536
+ const _onAllReadyCallbacks = /* @__PURE__ */ new Set();
1537
+ const _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
1538
+ const _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
1539
+ const _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
1540
+ const _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
1541
+ const _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
1542
+ const _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
1543
+ const _onErrorCallbacks = /* @__PURE__ */ new Set();
1544
+ let _allReadyFired = false;
1545
+ let _readyResolve;
1546
+ const _readyPromise = new Promise((resolve) => {
1547
+ _readyResolve = resolve;
1548
+ });
1451
1549
  const broadcasts = [];
1452
1550
  const sends = [];
1453
1551
  const screen = {
@@ -1464,6 +1562,61 @@
1464
1562
  get isDestroyed() {
1465
1563
  return _isDestroyed;
1466
1564
  },
1565
+ get ready() {
1566
+ return _readyPromise;
1567
+ },
1568
+ // === Lifecycle Methods ===
1569
+ onAllReady(callback) {
1570
+ if (_allReadyFired) {
1571
+ callback();
1572
+ }
1573
+ _onAllReadyCallbacks.add(callback);
1574
+ return () => {
1575
+ _onAllReadyCallbacks.delete(callback);
1576
+ };
1577
+ },
1578
+ onControllerJoin(callback) {
1579
+ _onControllerJoinCallbacks.add(callback);
1580
+ return () => {
1581
+ _onControllerJoinCallbacks.delete(callback);
1582
+ };
1583
+ },
1584
+ onControllerLeave(callback) {
1585
+ _onControllerLeaveCallbacks.add(callback);
1586
+ return () => {
1587
+ _onControllerLeaveCallbacks.delete(callback);
1588
+ };
1589
+ },
1590
+ onControllerDisconnect(callback) {
1591
+ _onControllerDisconnectCallbacks.add(callback);
1592
+ return () => {
1593
+ _onControllerDisconnectCallbacks.delete(callback);
1594
+ };
1595
+ },
1596
+ onControllerReconnect(callback) {
1597
+ _onControllerReconnectCallbacks.add(callback);
1598
+ return () => {
1599
+ _onControllerReconnectCallbacks.delete(callback);
1600
+ };
1601
+ },
1602
+ onCharacterUpdated(callback) {
1603
+ _onCharacterUpdatedCallbacks.add(callback);
1604
+ return () => {
1605
+ _onCharacterUpdatedCallbacks.delete(callback);
1606
+ };
1607
+ },
1608
+ onRateLimited(callback) {
1609
+ _onRateLimitedCallbacks.add(callback);
1610
+ return () => {
1611
+ _onRateLimitedCallbacks.delete(callback);
1612
+ };
1613
+ },
1614
+ onError(callback) {
1615
+ _onErrorCallbacks.add(callback);
1616
+ return () => {
1617
+ _onErrorCallbacks.delete(callback);
1618
+ };
1619
+ },
1467
1620
  // === Communication Methods ===
1468
1621
  broadcast(event, data) {
1469
1622
  if (_isDestroyed) {
@@ -1591,24 +1744,14 @@
1591
1744
  },
1592
1745
  simulateControllerJoin(info) {
1593
1746
  _controllers.push(info);
1594
- if (onControllerJoinCallback) {
1595
- onControllerJoinCallback(info.playerIndex, info);
1596
- }
1747
+ _onControllerJoinCallbacks.forEach((cb) => cb(info.playerIndex, info));
1597
1748
  },
1598
1749
  simulateControllerLeave(playerIndex) {
1599
1750
  _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1600
- if (onControllerLeaveCallback) {
1601
- onControllerLeaveCallback(playerIndex);
1602
- }
1751
+ _onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1603
1752
  },
1604
1753
  /**
1605
1754
  * Simulate a controller network disconnect (player still in room but unreachable).
1606
- *
1607
- * @example
1608
- * ```ts
1609
- * screen.simulateControllerDisconnect(0);
1610
- * expect(screen.getController(0)?.connected).toBe(false);
1611
- * ```
1612
1755
  */
1613
1756
  simulateControllerDisconnect(playerIndex) {
1614
1757
  const controller = _controllers.find((c) => c.playerIndex === playerIndex);
@@ -1618,19 +1761,10 @@
1618
1761
  _controllers = _controllers.map(
1619
1762
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1620
1763
  );
1621
- if (onControllerDisconnectCallback) {
1622
- onControllerDisconnectCallback(playerIndex);
1623
- }
1764
+ _onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1624
1765
  },
1625
1766
  /**
1626
1767
  * Simulate a controller network reconnect after disconnect.
1627
- *
1628
- * @example
1629
- * ```ts
1630
- * screen.simulateControllerDisconnect(0);
1631
- * screen.simulateControllerReconnect(0);
1632
- * expect(screen.getController(0)?.connected).toBe(true);
1633
- * ```
1634
1768
  */
1635
1769
  simulateControllerReconnect(playerIndex) {
1636
1770
  const controller = _controllers.find((c) => c.playerIndex === playerIndex);
@@ -1641,9 +1775,7 @@
1641
1775
  _controllers = _controllers.map(
1642
1776
  (c) => c.playerIndex === playerIndex ? reconnectedController : c
1643
1777
  );
1644
- if (onControllerReconnectCallback) {
1645
- onControllerReconnectCallback(playerIndex, reconnectedController);
1646
- }
1778
+ _onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, reconnectedController));
1647
1779
  },
1648
1780
  simulateCharacterUpdate(playerIndex, appearance) {
1649
1781
  const controller = _controllers.find((c) => c.playerIndex === playerIndex);
@@ -1653,24 +1785,17 @@
1653
1785
  _controllers = _controllers.map(
1654
1786
  (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
1655
1787
  );
1656
- if (onCharacterUpdatedCallback) {
1657
- onCharacterUpdatedCallback(playerIndex, appearance);
1658
- }
1788
+ _onCharacterUpdatedCallbacks.forEach((cb) => cb(playerIndex, appearance));
1659
1789
  },
1660
1790
  simulateRateLimited(event) {
1661
- if (onRateLimitedCallback) {
1662
- onRateLimitedCallback(event);
1663
- }
1791
+ _onRateLimitedCallbacks.forEach((cb) => cb(event));
1664
1792
  },
1665
1793
  simulateAllReady() {
1666
- if (onAllReadyCallback) {
1667
- onAllReadyCallback();
1668
- }
1794
+ _allReadyFired = true;
1795
+ _onAllReadyCallbacks.forEach((cb) => cb());
1669
1796
  },
1670
1797
  simulateError(error) {
1671
- if (onErrorCallback) {
1672
- onErrorCallback(error);
1673
- }
1798
+ _onErrorCallbacks.forEach((cb) => cb(error));
1674
1799
  },
1675
1800
  getBroadcasts() {
1676
1801
  return [...broadcasts];
@@ -1684,9 +1809,7 @@
1684
1809
  },
1685
1810
  triggerReady() {
1686
1811
  _isReady = true;
1687
- if (onReadyCallback) {
1688
- onReadyCallback();
1689
- }
1812
+ _readyResolve();
1690
1813
  }
1691
1814
  };
1692
1815
  if (autoReady) {
@@ -1698,39 +1821,25 @@
1698
1821
  const {
1699
1822
  roomCode = "TEST",
1700
1823
  myIndex = 0,
1701
- autoReady = true,
1702
- onReady: onReadyCb,
1703
- onControllerJoin: onJoinCb,
1704
- onControllerLeave: onLeaveCb,
1705
- onControllerDisconnect: onDisconnectCb,
1706
- onControllerReconnect: onReconnectCb,
1707
- onCharacterUpdated: onCharacterUpdatedCb,
1708
- onRateLimited: onRateLimitedCb,
1709
- onAllReady: onAllReadyCb,
1710
- onError: onErrorCb
1824
+ autoReady = true
1711
1825
  } = options;
1712
1826
  let _isReady = false;
1713
1827
  let _isDestroyed = false;
1714
1828
  let _controllers = options.controllers ?? [];
1715
1829
  const listeners = /* @__PURE__ */ new Map();
1716
- let onReadyCallback;
1717
- let onControllerJoinCallback;
1718
- let onControllerLeaveCallback;
1719
- let onControllerDisconnectCallback;
1720
- let onControllerReconnectCallback;
1721
- let onCharacterUpdatedCallback;
1722
- let onRateLimitedCallback;
1723
- let onAllReadyCallback;
1724
- let onErrorCallback;
1725
- onReadyCallback = onReadyCb;
1726
- onControllerJoinCallback = onJoinCb;
1727
- onControllerLeaveCallback = onLeaveCb;
1728
- onControllerDisconnectCallback = onDisconnectCb;
1729
- onControllerReconnectCallback = onReconnectCb;
1730
- onCharacterUpdatedCallback = onCharacterUpdatedCb;
1731
- onRateLimitedCallback = onRateLimitedCb;
1732
- onAllReadyCallback = onAllReadyCb;
1733
- onErrorCallback = onErrorCb;
1830
+ const _onAllReadyCallbacks = /* @__PURE__ */ new Set();
1831
+ const _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
1832
+ const _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
1833
+ const _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
1834
+ const _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
1835
+ const _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
1836
+ const _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
1837
+ const _onErrorCallbacks = /* @__PURE__ */ new Set();
1838
+ let _allReadyFired = false;
1839
+ let _readyResolve;
1840
+ const _readyPromise = new Promise((resolve) => {
1841
+ _readyResolve = resolve;
1842
+ });
1734
1843
  const sentEvents = [];
1735
1844
  const controller = {
1736
1845
  // === Properties ===
@@ -1749,6 +1858,61 @@
1749
1858
  get controllers() {
1750
1859
  return [..._controllers];
1751
1860
  },
1861
+ get ready() {
1862
+ return _readyPromise;
1863
+ },
1864
+ // === Lifecycle Methods ===
1865
+ onAllReady(callback) {
1866
+ if (_allReadyFired) {
1867
+ callback();
1868
+ }
1869
+ _onAllReadyCallbacks.add(callback);
1870
+ return () => {
1871
+ _onAllReadyCallbacks.delete(callback);
1872
+ };
1873
+ },
1874
+ onControllerJoin(callback) {
1875
+ _onControllerJoinCallbacks.add(callback);
1876
+ return () => {
1877
+ _onControllerJoinCallbacks.delete(callback);
1878
+ };
1879
+ },
1880
+ onControllerLeave(callback) {
1881
+ _onControllerLeaveCallbacks.add(callback);
1882
+ return () => {
1883
+ _onControllerLeaveCallbacks.delete(callback);
1884
+ };
1885
+ },
1886
+ onControllerDisconnect(callback) {
1887
+ _onControllerDisconnectCallbacks.add(callback);
1888
+ return () => {
1889
+ _onControllerDisconnectCallbacks.delete(callback);
1890
+ };
1891
+ },
1892
+ onControllerReconnect(callback) {
1893
+ _onControllerReconnectCallbacks.add(callback);
1894
+ return () => {
1895
+ _onControllerReconnectCallbacks.delete(callback);
1896
+ };
1897
+ },
1898
+ onCharacterUpdated(callback) {
1899
+ _onCharacterUpdatedCallbacks.add(callback);
1900
+ return () => {
1901
+ _onCharacterUpdatedCallbacks.delete(callback);
1902
+ };
1903
+ },
1904
+ onRateLimited(callback) {
1905
+ _onRateLimitedCallbacks.add(callback);
1906
+ return () => {
1907
+ _onRateLimitedCallbacks.delete(callback);
1908
+ };
1909
+ },
1910
+ onError(callback) {
1911
+ _onErrorCallbacks.add(callback);
1912
+ return () => {
1913
+ _onErrorCallbacks.delete(callback);
1914
+ };
1915
+ },
1752
1916
  getControllerCount() {
1753
1917
  return _controllers.filter((c) => c.connected).length;
1754
1918
  },
@@ -1829,105 +1993,62 @@
1829
1993
  },
1830
1994
  triggerReady() {
1831
1995
  _isReady = true;
1832
- if (onReadyCallback) {
1833
- onReadyCallback();
1834
- }
1996
+ _readyResolve();
1835
1997
  },
1836
1998
  /**
1837
1999
  * Simulate a new player joining the room.
1838
2000
  * Stores full ControllerInfo (nickname, appearance, etc.) for later retrieval.
1839
- *
1840
- * @example
1841
- * ```ts
1842
- * controller.simulatePlayerJoin(2, { playerIndex: 2, nickname: 'Alice', connected: true });
1843
- * expect(controller.getControllerCount()).toBe(3);
1844
- * expect(controller.controllers.find(c => c.playerIndex === 2)?.nickname).toBe('Alice');
1845
- * ```
1846
2001
  */
1847
2002
  simulatePlayerJoin(playerIndex, info) {
1848
2003
  if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1849
2004
  _controllers = [..._controllers, { ...info, connected: info.connected ?? true }];
1850
2005
  }
1851
- if (onControllerJoinCallback) {
1852
- onControllerJoinCallback(playerIndex, info);
1853
- }
2006
+ _onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, info));
1854
2007
  },
1855
2008
  /**
1856
2009
  * Simulate a player leaving the room (fully removed).
1857
- *
1858
- * @example
1859
- * ```ts
1860
- * controller.simulatePlayerLeave(1);
1861
- * expect(controller.controllers.some(c => c.playerIndex === 1)).toBe(false);
1862
- * ```
1863
2010
  */
1864
2011
  simulatePlayerLeave(playerIndex) {
1865
2012
  _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1866
- if (onControllerLeaveCallback) {
1867
- onControllerLeaveCallback(playerIndex);
1868
- }
2013
+ _onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
1869
2014
  },
1870
2015
  /**
1871
2016
  * Simulate a player network disconnect (player still in room but unreachable).
1872
- *
1873
- * @example
1874
- * ```ts
1875
- * controller.simulatePlayerDisconnect(1);
1876
- * expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(false);
1877
- * ```
1878
2017
  */
1879
2018
  simulatePlayerDisconnect(playerIndex) {
1880
2019
  _controllers = _controllers.map(
1881
2020
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1882
2021
  );
1883
- if (onControllerDisconnectCallback) {
1884
- onControllerDisconnectCallback(playerIndex);
1885
- }
2022
+ _onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
1886
2023
  },
1887
2024
  /**
1888
2025
  * Simulate a player network reconnect after disconnect.
1889
- *
1890
- * @example
1891
- * ```ts
1892
- * controller.simulatePlayerDisconnect(1);
1893
- * controller.simulatePlayerReconnect(1, { playerIndex: 1, nickname: 'Bob', connected: true });
1894
- * expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(true);
1895
- * ```
1896
2026
  */
1897
2027
  simulatePlayerReconnect(playerIndex, info) {
1898
2028
  _controllers = _controllers.map(
1899
2029
  (c) => c.playerIndex === playerIndex ? { ...info, connected: true } : c
1900
2030
  );
1901
- if (onControllerReconnectCallback) {
1902
- onControllerReconnectCallback(playerIndex, info);
1903
- }
2031
+ _onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, info));
1904
2032
  },
1905
2033
  simulateCharacterUpdate(playerIndex, appearance) {
1906
- const controller2 = _controllers.find((c) => c.playerIndex === playerIndex);
1907
- if (!controller2) {
2034
+ const ctrl = _controllers.find((c) => c.playerIndex === playerIndex);
2035
+ if (!ctrl) {
1908
2036
  throw new Error(`Controller ${playerIndex} not found`);
1909
2037
  }
1910
2038
  _controllers = _controllers.map(
1911
2039
  (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
1912
2040
  );
1913
- if (onCharacterUpdatedCallback) {
1914
- onCharacterUpdatedCallback(playerIndex, appearance);
1915
- }
2041
+ _onCharacterUpdatedCallbacks.forEach((cb) => cb(playerIndex, appearance));
1916
2042
  },
1917
2043
  simulateRateLimited(event) {
1918
- if (onRateLimitedCallback) {
1919
- onRateLimitedCallback(event);
1920
- }
2044
+ _onRateLimitedCallbacks.forEach((cb) => cb(event));
1921
2045
  },
1922
2046
  simulateAllReady() {
1923
- if (onAllReadyCallback) {
1924
- onAllReadyCallback();
1925
- }
2047
+ _allReadyFired = true;
2048
+ _onAllReadyCallbacks.forEach((cb) => cb());
1926
2049
  },
1927
2050
  simulateError(error) {
1928
- if (onErrorCallback) {
1929
- onErrorCallback(error);
1930
- }
2051
+ _onErrorCallbacks.forEach((cb) => cb(error));
1931
2052
  }
1932
2053
  };
1933
2054
  if (autoReady) {