@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
@@ -1,7 +1,7 @@
1
1
  import { PostMessageTransport } from './transport/PostMessageTransport.js';
2
2
  import { isBridgeMessage, validateInitPayload } from './transport/protocol.js';
3
3
  import { SmoreSDKError } from './errors.js';
4
- import { validateEventName, SMORE_EVENTS } from './events.js';
4
+ import { SMORE_EVENTS, validateEventName } from './events.js';
5
5
  import { DebugLogger } from './logger.js';
6
6
  import { getGlobalConfig } from './config.js';
7
7
 
@@ -14,22 +14,38 @@ class ControllerImpl {
14
14
  _myIndex = -1;
15
15
  _isReady = false;
16
16
  _isDestroyed = false;
17
+ _initTimeoutId = null;
17
18
  boundMessageHandler = null;
18
19
  registeredHandlers = [];
19
20
  eventListeners = /* @__PURE__ */ new Map();
20
21
  // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
21
22
  handlerToTransport = /* @__PURE__ */ new Map();
22
23
  _controllers = [];
23
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
24
- _configListenerEvents = /* @__PURE__ */ new Set();
24
+ // Pending handlers registered via on() before transport is ready
25
+ _pendingHandlers = [];
26
+ // Lifecycle callback arrays
27
+ _onAllReadyCallbacks = /* @__PURE__ */ new Set();
28
+ _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
29
+ _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
30
+ _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
31
+ _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
32
+ _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
33
+ _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
34
+ _onErrorCallbacks = /* @__PURE__ */ new Set();
35
+ // Whether all-ready has fired
36
+ _allReadyFired = false;
37
+ // Ready promise
38
+ _readyResolve;
39
+ _readyReject;
40
+ ready;
25
41
  constructor(config = {}) {
26
42
  this.config = config;
27
43
  this.logger = new DebugLogger(config.debug, "[SmoreController]");
28
- if (config.listeners) {
29
- for (const event of Object.keys(config.listeners)) {
30
- validateEventName(event);
31
- }
32
- }
44
+ this.ready = new Promise((resolve, reject) => {
45
+ this._readyResolve = resolve;
46
+ this._readyReject = reject;
47
+ });
48
+ this.startInitialization();
33
49
  }
34
50
  // ---------------------------------------------------------------------------
35
51
  // Properties (readonly)
@@ -65,38 +81,36 @@ class ControllerImpl {
65
81
  // ---------------------------------------------------------------------------
66
82
  // Initialization
67
83
  // ---------------------------------------------------------------------------
68
- async initialize() {
84
+ startInitialization() {
69
85
  const parentOrigin = this.config.parentOrigin ?? "*";
70
86
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
71
87
  this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
72
- return new Promise((resolve, reject) => {
73
- const timeoutId = setTimeout(() => {
74
- this.cleanup();
75
- const error = new SmoreSDKError(
76
- "TIMEOUT",
77
- `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).`,
78
- { details: { timeout } }
79
- );
80
- this.handleError(error);
81
- reject(error);
82
- }, timeout);
83
- this.boundMessageHandler = (e) => {
84
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
85
- const msg = e.data;
86
- if (!isBridgeMessage(msg)) return;
87
- if (msg.type === "_bridge:init") {
88
- clearTimeout(timeoutId);
89
- this.handleInit(msg, parentOrigin, resolve, reject);
90
- } else if (msg.type === "_bridge:update") {
91
- this.handleUpdate(msg);
92
- }
93
- };
94
- window.addEventListener("message", this.boundMessageHandler);
95
- this.logger.lifecycle("Sending _bridge:ready to parent");
96
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
97
- });
88
+ this._initTimeoutId = setTimeout(() => {
89
+ this.cleanup();
90
+ const error = new SmoreSDKError(
91
+ "TIMEOUT",
92
+ `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).`,
93
+ { details: { timeout } }
94
+ );
95
+ this.handleError(error);
96
+ this._readyReject(error);
97
+ }, timeout);
98
+ this.boundMessageHandler = (e) => {
99
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
100
+ const msg = e.data;
101
+ if (!isBridgeMessage(msg)) return;
102
+ if (msg.type === "_bridge:init") {
103
+ clearTimeout(this._initTimeoutId);
104
+ this.handleInit(msg, parentOrigin);
105
+ } else if (msg.type === "_bridge:update") {
106
+ this.handleUpdate(msg);
107
+ }
108
+ };
109
+ window.addEventListener("message", this.boundMessageHandler);
110
+ this.logger.lifecycle("Sending _bridge:ready to parent");
111
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
98
112
  }
99
- handleInit(msg, parentOrigin, resolve, reject) {
113
+ handleInit(msg, parentOrigin) {
100
114
  const initPayload = msg.payload;
101
115
  this.logger.debug("Received _bridge:init", initPayload);
102
116
  try {
@@ -109,7 +123,7 @@ class ControllerImpl {
109
123
  );
110
124
  this.logger.warn("_bridge:init validation failed", error);
111
125
  this.handleError(error);
112
- reject(error);
126
+ this._readyReject(error);
113
127
  return;
114
128
  }
115
129
  const initData = initPayload;
@@ -120,7 +134,7 @@ class ControllerImpl {
120
134
  { details: { side: initData.side } }
121
135
  );
122
136
  this.handleError(error);
123
- reject(error);
137
+ this._readyReject(error);
124
138
  return;
125
139
  }
126
140
  if (initData.myIndex === void 0) {
@@ -130,7 +144,7 @@ class ControllerImpl {
130
144
  { details: initData }
131
145
  );
132
146
  this.handleError(error);
133
- reject(error);
147
+ this._readyReject(error);
134
148
  return;
135
149
  }
136
150
  this.transport = new PostMessageTransport(parentOrigin);
@@ -144,18 +158,21 @@ class ControllerImpl {
144
158
  appearance: p.appearance ?? p.character
145
159
  }));
146
160
  this.setupEventHandlers();
161
+ for (const { event, handler } of this._pendingHandlers) {
162
+ this.setupUserEventHandler(event, handler);
163
+ }
164
+ this._pendingHandlers = [];
147
165
  this._isReady = true;
148
166
  this.logger.lifecycle("Controller ready", {
149
167
  roomCode: this._roomCode,
150
168
  myIndex: this._myIndex
151
169
  });
152
- this.config.onReady?.();
153
- const autoReady = this.config.autoReady ?? getGlobalConfig().autoReady ?? true;
170
+ const autoReady = getGlobalConfig().autoReady ?? true;
154
171
  if (autoReady) {
155
172
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
156
173
  this.signalReady();
157
174
  }
158
- resolve();
175
+ this._readyResolve();
159
176
  }
160
177
  handleUpdate(msg) {
161
178
  if (!this._isReady) {
@@ -175,12 +192,12 @@ class ControllerImpl {
175
192
  const oldControllers = this._controllers;
176
193
  for (const nc of newControllers) {
177
194
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
178
- this.config.onControllerJoin?.(nc.playerIndex, nc);
195
+ this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
179
196
  }
180
197
  }
181
198
  for (const oc of oldControllers) {
182
199
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
183
- this.config.onControllerLeave?.(oc.playerIndex);
200
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
184
201
  }
185
202
  }
186
203
  for (const nc of newControllers) {
@@ -188,11 +205,11 @@ class ControllerImpl {
188
205
  if (oc) {
189
206
  if (oc.connected && !nc.connected) {
190
207
  this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
191
- this.config.onControllerDisconnect?.(nc.playerIndex);
208
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(nc.playerIndex));
192
209
  }
193
210
  if (!oc.connected && nc.connected) {
194
211
  this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
195
- this.config.onControllerReconnect?.(nc.playerIndex, nc);
212
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
196
213
  }
197
214
  }
198
215
  }
@@ -219,7 +236,7 @@ class ControllerImpl {
219
236
  };
220
237
  this._controllers = [...this._controllers, controllerInfo];
221
238
  this.logger.debug("Player joined", { playerIndex });
222
- this.config.onControllerJoin?.(playerIndex, controllerInfo);
239
+ this._onControllerJoinCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
223
240
  }
224
241
  });
225
242
  this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
@@ -229,7 +246,7 @@ class ControllerImpl {
229
246
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
230
247
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
231
248
  this.logger.debug("Player left", { playerIndex });
232
- this.config.onControllerLeave?.(playerIndex);
249
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
233
250
  }
234
251
  });
235
252
  this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
@@ -241,7 +258,7 @@ class ControllerImpl {
241
258
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
242
259
  );
243
260
  this.logger.debug("Player disconnected", { playerIndex });
244
- this.config.onControllerDisconnect?.(playerIndex);
261
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
245
262
  }
246
263
  });
247
264
  this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
@@ -263,56 +280,63 @@ class ControllerImpl {
263
280
  (c) => c.playerIndex === playerIndex ? controllerInfo : c
264
281
  );
265
282
  this.logger.debug("Player reconnected", { playerIndex });
266
- this.config.onControllerReconnect?.(playerIndex, controllerInfo);
283
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(playerIndex, controllerInfo));
267
284
  }
268
285
  });
269
286
  this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
270
287
  const payload = raw;
271
288
  const playerData = payload?.player;
272
289
  if (playerData && typeof playerData.playerIndex === "number") {
290
+ const pi = playerData.playerIndex;
273
291
  const appearance = playerData.character ?? null;
274
292
  this._controllers = this._controllers.map(
275
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
293
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
276
294
  );
277
- this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
278
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
295
+ this.logger.debug("Player character updated", { playerIndex: pi });
296
+ this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
279
297
  }
280
298
  });
281
299
  this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
282
300
  const data = raw;
283
301
  const event = data?.event ?? "unknown";
284
302
  this.logger.warn(`Rate limited: ${event}`);
285
- this.config.onRateLimited?.(event);
303
+ this._onRateLimitedCallbacks.forEach((cb) => cb(event));
286
304
  });
287
305
  this.registerHandler(SMORE_EVENTS.ALL_READY, () => {
288
306
  this.logger.lifecycle("All participants ready");
289
- this.config.onAllReady?.();
307
+ this._allReadyFired = true;
308
+ this._onAllReadyCallbacks.forEach((cb) => cb());
290
309
  });
291
- if (this.config.onHostDisconnect) {
292
- this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
293
- }
294
- if (this.config.onHostReconnect) {
295
- this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
296
- }
297
- if (this.config.listeners) {
298
- for (const [event, handler] of Object.entries(this.config.listeners)) {
299
- if (!handler) continue;
300
- this._configListenerEvents.add(event);
301
- this.registerHandler(event, (data) => {
302
- this.logReceive(event, data);
303
- try {
304
- handler(data);
305
- } catch (err) {
306
- this.handleError(
307
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
308
- cause: err instanceof Error ? err : void 0,
309
- details: { event }
310
- })
311
- );
312
- }
313
- });
310
+ }
311
+ /**
312
+ * Sets up a user event handler for controller events.
313
+ * Used for registering pending handlers after transport becomes available.
314
+ */
315
+ setupUserEventHandler(event, handler) {
316
+ const transportHandler = (data) => {
317
+ this.logReceive(event, data);
318
+ try {
319
+ handler(data);
320
+ } catch (err) {
321
+ this.handleError(
322
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
323
+ cause: err instanceof Error ? err : void 0,
324
+ details: { event }
325
+ })
326
+ );
314
327
  }
328
+ };
329
+ if (this.transport) {
330
+ this.transport.on(event, transportHandler);
331
+ this.registeredHandlers.push({ event, handler: transportHandler });
332
+ this.handlerToTransport.set(handler, { event, transportHandler });
315
333
  }
334
+ let listeners = this.eventListeners.get(event);
335
+ if (!listeners) {
336
+ listeners = /* @__PURE__ */ new Set();
337
+ this.eventListeners.set(event, listeners);
338
+ }
339
+ listeners.add(handler);
316
340
  }
317
341
  registerHandler(event, handler) {
318
342
  if (!this.transport) return;
@@ -320,13 +344,67 @@ class ControllerImpl {
320
344
  this.registeredHandlers.push({ event, handler });
321
345
  }
322
346
  // ---------------------------------------------------------------------------
347
+ // Lifecycle Methods
348
+ // ---------------------------------------------------------------------------
349
+ onAllReady(callback) {
350
+ if (this._allReadyFired) {
351
+ callback();
352
+ }
353
+ this._onAllReadyCallbacks.add(callback);
354
+ return () => {
355
+ this._onAllReadyCallbacks.delete(callback);
356
+ };
357
+ }
358
+ onControllerJoin(callback) {
359
+ this._onControllerJoinCallbacks.add(callback);
360
+ return () => {
361
+ this._onControllerJoinCallbacks.delete(callback);
362
+ };
363
+ }
364
+ onControllerLeave(callback) {
365
+ this._onControllerLeaveCallbacks.add(callback);
366
+ return () => {
367
+ this._onControllerLeaveCallbacks.delete(callback);
368
+ };
369
+ }
370
+ onControllerDisconnect(callback) {
371
+ this._onControllerDisconnectCallbacks.add(callback);
372
+ return () => {
373
+ this._onControllerDisconnectCallbacks.delete(callback);
374
+ };
375
+ }
376
+ onControllerReconnect(callback) {
377
+ this._onControllerReconnectCallbacks.add(callback);
378
+ return () => {
379
+ this._onControllerReconnectCallbacks.delete(callback);
380
+ };
381
+ }
382
+ onCharacterUpdated(callback) {
383
+ this._onCharacterUpdatedCallbacks.add(callback);
384
+ return () => {
385
+ this._onCharacterUpdatedCallbacks.delete(callback);
386
+ };
387
+ }
388
+ onRateLimited(callback) {
389
+ this._onRateLimitedCallbacks.add(callback);
390
+ return () => {
391
+ this._onRateLimitedCallbacks.delete(callback);
392
+ };
393
+ }
394
+ onError(callback) {
395
+ this._onErrorCallbacks.add(callback);
396
+ return () => {
397
+ this._onErrorCallbacks.delete(callback);
398
+ };
399
+ }
400
+ // ---------------------------------------------------------------------------
323
401
  // Communication Methods
324
402
  // ---------------------------------------------------------------------------
325
403
  /**
326
404
  * Send an event to the Screen. Controller-to-Controller direct communication
327
405
  * is not supported; all messages must go through the Screen.
328
406
  *
329
- * Data is sent to the Screen only (not to other controllers). For ScreenController communication,
407
+ * Data is sent to the Screen only (not to other controllers). For Screen->Controller communication,
330
408
  * Screen uses broadcast() or sendToController().
331
409
  *
332
410
  * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
@@ -360,24 +438,14 @@ class ControllerImpl {
360
438
  /**
361
439
  * Register a handler for custom events.
362
440
  *
441
+ * Can be called before the Controller is ready. Handlers registered before ready
442
+ * are queued and activated when the transport becomes available.
443
+ *
363
444
  * When receiving events from Screen's `broadcast()`:
364
- * handler receives `(data)` no playerIndex included.
445
+ * handler receives `(data)` -- no playerIndex included.
365
446
  *
366
447
  * When receiving events from Screen's `sendToController()`:
367
- * handler receives `(data)` targeted to this specific controller.
368
- *
369
- * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
370
- * Controller's `on()` receives only `(data)` since there's only one player per controller.
371
- *
372
- * Controller's on() handler signature: (data) => void
373
- * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
374
- * because Controller only receives events from Screen, not from other controllers.
375
- * The sender is always the Screen, so playerIndex is not applicable.
376
- *
377
- * **Important:** If called before the Controller is ready (i.e., before `await createController()`
378
- * resolves or before the `onReady` callback fires), the handler is stored locally but
379
- * will NOT receive events until the transport is initialized. Always call `on()` after
380
- * initialization completes, or use `config.listeners` for handlers needed from the start.
448
+ * handler receives `(data)` -- targeted to this specific controller.
381
449
  */
382
450
  on(event, handler) {
383
451
  validateEventName(event);
@@ -387,34 +455,42 @@ class ControllerImpl {
387
455
  this.eventListeners.set(event, listeners);
388
456
  }
389
457
  listeners.add(handler);
390
- const transportHandler = (data) => {
391
- this.logReceive(event, data);
392
- try {
393
- handler(data);
394
- } catch (err) {
395
- this.handleError(
396
- new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
397
- cause: err instanceof Error ? err : void 0,
398
- details: { event }
399
- })
400
- );
401
- }
402
- };
403
458
  if (this.transport) {
459
+ const transportHandler = (data) => {
460
+ this.logReceive(event, data);
461
+ try {
462
+ handler(data);
463
+ } catch (err) {
464
+ this.handleError(
465
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
466
+ cause: err instanceof Error ? err : void 0,
467
+ details: { event }
468
+ })
469
+ );
470
+ }
471
+ };
404
472
  this.transport.on(event, transportHandler);
405
473
  this.registeredHandlers.push({ event, handler: transportHandler });
406
474
  this.handlerToTransport.set(handler, { event, transportHandler });
475
+ } else {
476
+ this._pendingHandlers.push({ event, handler });
407
477
  }
408
478
  return () => {
409
479
  listeners?.delete(handler);
410
480
  if (listeners?.size === 0) {
411
481
  this.eventListeners.delete(event);
412
482
  }
413
- this.transport?.off(event, transportHandler);
414
- this.registeredHandlers = this.registeredHandlers.filter(
415
- (h) => h.handler !== transportHandler
483
+ this._pendingHandlers = this._pendingHandlers.filter(
484
+ (p) => !(p.event === event && p.handler === handler)
416
485
  );
417
- this.handlerToTransport.delete(handler);
486
+ const entry = this.handlerToTransport.get(handler);
487
+ if (entry) {
488
+ this.transport?.off(event, entry.transportHandler);
489
+ this.registeredHandlers = this.registeredHandlers.filter(
490
+ (h) => h.handler !== entry.transportHandler
491
+ );
492
+ this.handlerToTransport.delete(handler);
493
+ }
418
494
  };
419
495
  }
420
496
  /**
@@ -422,16 +498,6 @@ class ControllerImpl {
422
498
  *
423
499
  * @note The handler is internally wrapped, so it cannot be removed via
424
500
  * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
425
- *
426
- * @example
427
- * ```ts
428
- * const unsubscribe = controller.once('game-start', (data) => {
429
- * console.log('Game started!', data);
430
- * });
431
- *
432
- * // To cancel before it fires:
433
- * unsubscribe();
434
- * ```
435
501
  */
436
502
  once(event, handler) {
437
503
  const unsubscribe = this.on(event, ((data) => {
@@ -442,24 +508,13 @@ class ControllerImpl {
442
508
  }
443
509
  off(event, handler) {
444
510
  if (!handler) {
445
- if (this._configListenerEvents.has(event)) {
446
- for (const [key, val] of this.handlerToTransport) {
447
- if (val.event === event) {
448
- this.transport?.off(event, val.transportHandler);
449
- this.registeredHandlers = this.registeredHandlers.filter(
450
- (h) => h.handler !== val.transportHandler
451
- );
452
- this.handlerToTransport.delete(key);
453
- }
454
- }
455
- } else {
456
- this.eventListeners.delete(event);
457
- this.transport?.off(event);
458
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
459
- for (const [key, val] of this.handlerToTransport) {
460
- if (val.event === event) this.handlerToTransport.delete(key);
461
- }
511
+ this.eventListeners.delete(event);
512
+ this.transport?.off(event);
513
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
514
+ for (const [key, val] of this.handlerToTransport) {
515
+ if (val.event === event) this.handlerToTransport.delete(key);
462
516
  }
517
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
463
518
  } else {
464
519
  const listeners = this.eventListeners.get(event);
465
520
  listeners?.delete(handler);
@@ -474,6 +529,9 @@ class ControllerImpl {
474
529
  );
475
530
  this.handlerToTransport.delete(handler);
476
531
  }
532
+ this._pendingHandlers = this._pendingHandlers.filter(
533
+ (p) => !(p.event === event && p.handler === handler)
534
+ );
477
535
  }
478
536
  }
479
537
  // ---------------------------------------------------------------------------
@@ -482,10 +540,15 @@ class ControllerImpl {
482
540
  destroy() {
483
541
  if (this._isDestroyed) return;
484
542
  this.logger.lifecycle("Destroying controller");
485
- this.cleanup();
486
543
  this._isDestroyed = true;
544
+ this._isReady = false;
545
+ this.cleanup();
487
546
  }
488
547
  cleanup() {
548
+ if (this._initTimeoutId) {
549
+ clearTimeout(this._initTimeoutId);
550
+ this._initTimeoutId = null;
551
+ }
489
552
  this._isReady = false;
490
553
  for (const { event, handler } of this.registeredHandlers) {
491
554
  this.transport?.off(event, handler);
@@ -493,6 +556,15 @@ class ControllerImpl {
493
556
  this.registeredHandlers = [];
494
557
  this.eventListeners.clear();
495
558
  this.handlerToTransport.clear();
559
+ this._pendingHandlers = [];
560
+ this._onAllReadyCallbacks.clear();
561
+ this._onControllerJoinCallbacks.clear();
562
+ this._onControllerLeaveCallbacks.clear();
563
+ this._onControllerDisconnectCallbacks.clear();
564
+ this._onControllerReconnectCallbacks.clear();
565
+ this._onCharacterUpdatedCallbacks.clear();
566
+ this._onRateLimitedCallbacks.clear();
567
+ this._onErrorCallbacks.clear();
496
568
  if (this.transport) {
497
569
  this.transport.destroy();
498
570
  this.transport = null;
@@ -516,15 +588,16 @@ class ControllerImpl {
516
588
  if (!this._isReady || !this.transport) {
517
589
  throw new SmoreSDKError(
518
590
  "NOT_READY",
519
- `Cannot call ${method}() before controller is ready. Use await createController() or wait for onReady callback.`,
591
+ `Cannot call ${method}() before controller is ready. Use await controller.ready.`,
520
592
  { details: { method, isReady: this._isReady } }
521
593
  );
522
594
  }
523
595
  }
524
596
  handleError(error) {
525
597
  this.logger.warn(`Error in handler: ${error.message}`);
526
- if (this.config.onError) {
527
- this.config.onError(error.toSmoreError());
598
+ const smoreError = error.toSmoreError();
599
+ if (this._onErrorCallbacks.size > 0) {
600
+ this._onErrorCallbacks.forEach((cb) => cb(smoreError));
528
601
  } else {
529
602
  this.logger.error(error.message, error.details);
530
603
  }
@@ -537,10 +610,7 @@ class ControllerImpl {
537
610
  }
538
611
  }
539
612
  function createController(config) {
540
- const controller = new ControllerImpl(config ?? {});
541
- const promise = controller.initialize().then(() => controller);
542
- promise.instance = controller;
543
- return promise;
613
+ return new ControllerImpl(config ?? {});
544
614
  }
545
615
 
546
616
  export { createController };