@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
@@ -28,122 +28,139 @@ class ScreenImpl {
28
28
  _roomCode = "";
29
29
  _isReady = false;
30
30
  _isDestroyed = false;
31
+ _initTimeoutId = null;
31
32
  eventHandlers = /* @__PURE__ */ new Map();
32
33
  registeredTransportHandlers = [];
33
34
  boundMessageHandler = null;
34
- // Maps user-facing handler transport wrappedHandler for proper cleanup in on()/off()
35
+ // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
35
36
  handlerToTransport = /* @__PURE__ */ new Map();
36
- // Tracks event names registered via config.listeners so off(event) without handler won't remove them
37
- _configListenerEvents = /* @__PURE__ */ new Set();
37
+ // Pending handlers registered via on() before transport is ready
38
+ _pendingHandlers = [];
39
+ // Lifecycle callback arrays
40
+ _onAllReadyCallbacks = /* @__PURE__ */ new Set();
41
+ _onControllerJoinCallbacks = /* @__PURE__ */ new Set();
42
+ _onControllerLeaveCallbacks = /* @__PURE__ */ new Set();
43
+ _onControllerDisconnectCallbacks = /* @__PURE__ */ new Set();
44
+ _onControllerReconnectCallbacks = /* @__PURE__ */ new Set();
45
+ _onCharacterUpdatedCallbacks = /* @__PURE__ */ new Set();
46
+ _onRateLimitedCallbacks = /* @__PURE__ */ new Set();
47
+ _onErrorCallbacks = /* @__PURE__ */ new Set();
48
+ // Whether all-ready has fired
49
+ _allReadyFired = false;
50
+ // Ready promise
51
+ _readyResolve;
52
+ _readyReject;
53
+ ready;
38
54
  constructor(config = {}) {
39
55
  this.config = config;
40
56
  this.logger = new logger.DebugLogger(config.debug, "[SmoreScreen]");
41
- if (config.listeners) {
42
- for (const event of Object.keys(config.listeners)) {
43
- events.validateEventName(event);
44
- }
45
- }
57
+ this.ready = new Promise((resolve, reject) => {
58
+ this._readyResolve = resolve;
59
+ this._readyReject = reject;
60
+ });
61
+ this.startInitialization();
46
62
  }
47
63
  // ---------------------------------------------------------------------------
48
- // Initialization (called by factory)
64
+ // Initialization (called in constructor)
49
65
  // ---------------------------------------------------------------------------
50
- async initialize() {
66
+ startInitialization() {
51
67
  this.logger.lifecycle("Initializing screen...");
52
68
  const parentOrigin = this.config.parentOrigin ?? "*";
53
69
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
54
- return new Promise((resolve, reject) => {
55
- const timeoutId = setTimeout(() => {
56
- this.cleanup();
57
- const error = new errors.SmoreSDKError(
58
- "TIMEOUT",
59
- `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).`,
60
- { details: { timeout } }
61
- );
62
- this.handleError(error);
63
- reject(error);
64
- }, timeout);
65
- this.boundMessageHandler = (e) => {
66
- if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
67
- const msg = e.data;
68
- if (!protocol.isBridgeMessage(msg)) return;
69
- if (msg.type === "_bridge:init") {
70
- clearTimeout(timeoutId);
71
- const initPayload = msg.payload;
72
- try {
73
- protocol.validateInitPayload(initPayload);
74
- } catch (err) {
75
- const error = new errors.SmoreSDKError(
76
- "INIT_FAILED",
77
- `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
78
- { details: { payload: initPayload } }
79
- );
80
- this.logger.warn("_bridge:init validation failed", error);
81
- this.handleError(error);
82
- reject(error);
83
- return;
84
- }
85
- const initData = initPayload;
86
- if (initData.side !== "host") {
87
- const error = new errors.SmoreSDKError(
88
- "INIT_FAILED",
89
- `Received init for wrong side: ${initData.side}. Expected "host".`,
90
- { details: { side: initData.side } }
91
- );
92
- this.handleError(error);
93
- reject(error);
94
- return;
95
- }
96
- this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
97
- this._roomCode = initData.roomCode;
98
- this._controllers = this.mapControllersFromInit(initData.players);
99
- if (this._controllers.length === 0) {
100
- this.logger.warn("Screen initialized with zero controllers");
101
- }
102
- this.setupEventHandlers();
103
- this._isReady = true;
104
- this.logger.lifecycle("Screen ready", {
105
- roomCode: this._roomCode,
106
- controllers: this._controllers.length
107
- });
108
- this.config.onReady?.();
109
- const autoReady = this.config.autoReady ?? config.getGlobalConfig().autoReady ?? true;
110
- if (autoReady) {
111
- this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
112
- this.signalReady();
113
- }
114
- resolve();
115
- } else if (msg.type === "_bridge:update") {
116
- if (!this._isReady) {
117
- this.logger.debug("Ignoring _bridge:update before init completes");
118
- return;
119
- }
120
- const updateData = msg.payload;
121
- if (updateData.players && Array.isArray(updateData.players)) {
122
- const oldControllers = this._controllers;
123
- const newControllers = this.mapControllersFromInit(updateData.players);
124
- this._controllers = newControllers;
125
- for (const nc of newControllers) {
126
- if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
127
- this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
128
- this.config.onControllerJoin?.(nc.playerIndex, nc);
129
- }
70
+ this._initTimeoutId = setTimeout(() => {
71
+ this.cleanup();
72
+ const error = new errors.SmoreSDKError(
73
+ "TIMEOUT",
74
+ `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).`,
75
+ { details: { timeout } }
76
+ );
77
+ this.handleError(error);
78
+ this._readyReject(error);
79
+ }, timeout);
80
+ this.boundMessageHandler = (e) => {
81
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
82
+ const msg = e.data;
83
+ if (!protocol.isBridgeMessage(msg)) return;
84
+ if (msg.type === "_bridge:init") {
85
+ clearTimeout(this._initTimeoutId);
86
+ const initPayload = msg.payload;
87
+ try {
88
+ protocol.validateInitPayload(initPayload);
89
+ } catch (err) {
90
+ const error = new errors.SmoreSDKError(
91
+ "INIT_FAILED",
92
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
93
+ { details: { payload: initPayload } }
94
+ );
95
+ this.logger.warn("_bridge:init validation failed", error);
96
+ this.handleError(error);
97
+ this._readyReject(error);
98
+ return;
99
+ }
100
+ const initData = initPayload;
101
+ if (initData.side !== "host") {
102
+ const error = new errors.SmoreSDKError(
103
+ "INIT_FAILED",
104
+ `Received init for wrong side: ${initData.side}. Expected "host".`,
105
+ { details: { side: initData.side } }
106
+ );
107
+ this.handleError(error);
108
+ this._readyReject(error);
109
+ return;
110
+ }
111
+ this.transport = new PostMessageTransport.PostMessageTransport(parentOrigin);
112
+ this._roomCode = initData.roomCode;
113
+ this._controllers = this.mapControllersFromInit(initData.players);
114
+ if (this._controllers.length === 0) {
115
+ this.logger.warn("Screen initialized with zero controllers");
116
+ }
117
+ this.setupEventHandlers();
118
+ for (const { event, handler } of this._pendingHandlers) {
119
+ this.setupUserEventHandler(event, handler);
120
+ }
121
+ this._pendingHandlers = [];
122
+ this._isReady = true;
123
+ this.logger.lifecycle("Screen ready", {
124
+ roomCode: this._roomCode,
125
+ controllers: this._controllers.length
126
+ });
127
+ const autoReady = config.getGlobalConfig().autoReady ?? true;
128
+ if (autoReady) {
129
+ this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
130
+ this.signalReady();
131
+ }
132
+ this._readyResolve();
133
+ } else if (msg.type === "_bridge:update") {
134
+ if (!this._isReady) {
135
+ this.logger.debug("Ignoring _bridge:update before init completes");
136
+ return;
137
+ }
138
+ const updateData = msg.payload;
139
+ if (updateData.players && Array.isArray(updateData.players)) {
140
+ const oldControllers = this._controllers;
141
+ const newControllers = this.mapControllersFromInit(updateData.players);
142
+ this._controllers = newControllers;
143
+ for (const nc of newControllers) {
144
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
145
+ this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
146
+ this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
130
147
  }
131
- for (const oc of oldControllers) {
132
- if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
133
- this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
134
- this.config.onControllerLeave?.(oc.playerIndex);
135
- }
148
+ }
149
+ for (const oc of oldControllers) {
150
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
151
+ this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
152
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
136
153
  }
137
154
  }
138
- this.logger.lifecycle("Room updated", {
139
- controllers: this._controllers.length
140
- });
141
155
  }
142
- };
143
- window.addEventListener("message", this.boundMessageHandler);
144
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
145
- this.logger.lifecycle("Sent _bridge:ready to parent");
146
- });
156
+ this.logger.lifecycle("Room updated", {
157
+ controllers: this._controllers.length
158
+ });
159
+ }
160
+ };
161
+ window.addEventListener("message", this.boundMessageHandler);
162
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
163
+ this.logger.lifecycle("Sent _bridge:ready to parent");
147
164
  }
148
165
  mapControllersFromInit(players) {
149
166
  return players.map((p, index) => ({
@@ -170,7 +187,7 @@ class ScreenImpl {
170
187
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
171
188
  this._controllers = [...this._controllers, controllerInfo];
172
189
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
173
- this.config.onControllerJoin?.(controllerInfo.playerIndex, controllerInfo);
190
+ this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
174
191
  }
175
192
  });
176
193
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -180,7 +197,7 @@ class ScreenImpl {
180
197
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
181
198
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
182
199
  this.logger.lifecycle("Controller left", { playerIndex });
183
- this.config.onControllerLeave?.(playerIndex);
200
+ this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
184
201
  }
185
202
  });
186
203
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -191,7 +208,7 @@ class ScreenImpl {
191
208
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
192
209
  );
193
210
  this.logger.lifecycle("Controller disconnected", { playerIndex });
194
- this.config.onControllerDisconnect?.(playerIndex);
211
+ this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
195
212
  }
196
213
  });
197
214
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
@@ -208,38 +225,33 @@ class ScreenImpl {
208
225
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
209
226
  );
210
227
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
211
- this.config.onControllerReconnect?.(controllerInfo.playerIndex, controllerInfo);
228
+ this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
212
229
  }
213
230
  });
214
231
  this.registerTransportHandler(events.SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
215
232
  const payload = data;
216
233
  const playerData = payload?.player;
217
234
  if (playerData && typeof playerData.playerIndex === "number") {
235
+ const pi = playerData.playerIndex;
218
236
  const appearance = playerData.character ?? null;
219
237
  this._controllers = this._controllers.map(
220
- (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
238
+ (c) => c.playerIndex === pi ? { ...c, appearance } : c
221
239
  );
222
- this.logger.lifecycle("Player character updated", { playerIndex: playerData.playerIndex });
223
- this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
240
+ this.logger.lifecycle("Player character updated", { playerIndex: pi });
241
+ this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
224
242
  }
225
243
  });
226
244
  this.registerTransportHandler(events.SMORE_EVENTS.RATE_LIMITED, (data) => {
227
245
  const payload = data;
228
246
  const event = payload?.event ?? "unknown";
229
247
  this.logger.warn(`Rate limited: ${event}`);
230
- this.config.onRateLimited?.(event);
248
+ this._onRateLimitedCallbacks.forEach((cb) => cb(event));
231
249
  });
232
250
  this.registerTransportHandler(events.SMORE_EVENTS.ALL_READY, () => {
233
251
  this.logger.lifecycle("All participants ready");
234
- this.config.onAllReady?.();
252
+ this._allReadyFired = true;
253
+ this._onAllReadyCallbacks.forEach((cb) => cb());
235
254
  });
236
- if (this.config.listeners) {
237
- for (const [event, handler] of Object.entries(this.config.listeners)) {
238
- if (!handler) continue;
239
- this._configListenerEvents.add(event);
240
- this.setupUserEventHandler(event, handler);
241
- }
242
- }
243
255
  }
244
256
  /**
245
257
  * Sets up a user event handler with playerIndex extraction.
@@ -281,6 +293,7 @@ class ScreenImpl {
281
293
  this.eventHandlers.set(event, handlers);
282
294
  }
283
295
  handlers.add(handler);
296
+ this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
284
297
  }
285
298
  registerTransportHandler(event, handler) {
286
299
  if (!this.transport) return;
@@ -307,6 +320,60 @@ class ScreenImpl {
307
320
  return this._isDestroyed;
308
321
  }
309
322
  // ---------------------------------------------------------------------------
323
+ // Lifecycle Methods
324
+ // ---------------------------------------------------------------------------
325
+ onAllReady(callback) {
326
+ if (this._allReadyFired) {
327
+ callback();
328
+ }
329
+ this._onAllReadyCallbacks.add(callback);
330
+ return () => {
331
+ this._onAllReadyCallbacks.delete(callback);
332
+ };
333
+ }
334
+ onControllerJoin(callback) {
335
+ this._onControllerJoinCallbacks.add(callback);
336
+ return () => {
337
+ this._onControllerJoinCallbacks.delete(callback);
338
+ };
339
+ }
340
+ onControllerLeave(callback) {
341
+ this._onControllerLeaveCallbacks.add(callback);
342
+ return () => {
343
+ this._onControllerLeaveCallbacks.delete(callback);
344
+ };
345
+ }
346
+ onControllerDisconnect(callback) {
347
+ this._onControllerDisconnectCallbacks.add(callback);
348
+ return () => {
349
+ this._onControllerDisconnectCallbacks.delete(callback);
350
+ };
351
+ }
352
+ onControllerReconnect(callback) {
353
+ this._onControllerReconnectCallbacks.add(callback);
354
+ return () => {
355
+ this._onControllerReconnectCallbacks.delete(callback);
356
+ };
357
+ }
358
+ onCharacterUpdated(callback) {
359
+ this._onCharacterUpdatedCallbacks.add(callback);
360
+ return () => {
361
+ this._onCharacterUpdatedCallbacks.delete(callback);
362
+ };
363
+ }
364
+ onRateLimited(callback) {
365
+ this._onRateLimitedCallbacks.add(callback);
366
+ return () => {
367
+ this._onRateLimitedCallbacks.delete(callback);
368
+ };
369
+ }
370
+ onError(callback) {
371
+ this._onErrorCallbacks.add(callback);
372
+ return () => {
373
+ this._onErrorCallbacks.delete(callback);
374
+ };
375
+ }
376
+ // ---------------------------------------------------------------------------
310
377
  // Communication Methods
311
378
  // ---------------------------------------------------------------------------
312
379
  /**
@@ -413,11 +480,8 @@ class ScreenImpl {
413
480
  /**
414
481
  * Register an event handler for messages from controllers.
415
482
  *
416
- * **Important:** If called before the Screen is ready (i.e., before `await createScreen()`
417
- * resolves or before the `onReady` callback fires), the handler is stored locally but
418
- * will NOT be registered with the transport layer. This means the handler will never
419
- * fire for events received during the pre-ready window. Always call `on()` after
420
- * initialization completes, or use `config.listeners` for handlers needed from the start.
483
+ * Can be called before the Screen is ready. Handlers registered before ready
484
+ * are queued and activated when the transport becomes available.
421
485
  */
422
486
  on(event, handler) {
423
487
  events.validateEventName(event);
@@ -427,9 +491,8 @@ class ScreenImpl {
427
491
  this.eventHandlers.set(event, handlers);
428
492
  }
429
493
  handlers.add(handler);
430
- let wrappedHandler = null;
431
494
  if (this.transport) {
432
- wrappedHandler = (data) => {
495
+ const wrappedHandler = (data) => {
433
496
  this.logger.receive(event, data);
434
497
  const payload = data;
435
498
  const { playerIndex, ...rest } = payload;
@@ -449,16 +512,22 @@ class ScreenImpl {
449
512
  };
450
513
  this.registerTransportHandler(event, wrappedHandler);
451
514
  this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
515
+ } else {
516
+ this._pendingHandlers.push({ event, handler });
452
517
  }
453
518
  return () => {
454
519
  handlers?.delete(handler);
455
520
  if (handlers?.size === 0) {
456
521
  this.eventHandlers.delete(event);
457
522
  }
458
- if (wrappedHandler) {
459
- this.transport?.off(event, wrappedHandler);
523
+ this._pendingHandlers = this._pendingHandlers.filter(
524
+ (p) => !(p.event === event && p.handler === handler)
525
+ );
526
+ const entry = this.handlerToTransport.get(handler);
527
+ if (entry) {
528
+ this.transport?.off(event, entry.transportHandler);
460
529
  this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
461
- (h) => h.handler !== wrappedHandler
530
+ (h) => h.handler !== entry.transportHandler
462
531
  );
463
532
  this.handlerToTransport.delete(handler);
464
533
  }
@@ -475,16 +544,6 @@ class ScreenImpl {
475
544
  * @param event - Event name to listen for
476
545
  * @param handler - Handler function to call once
477
546
  * @returns Unsubscribe function to remove the handler before it fires
478
- *
479
- * @example
480
- * ```ts
481
- * const unsubscribe = screen.once('ready', (playerIndex, data) => {
482
- * console.log('Ready event received');
483
- * });
484
- *
485
- * // To remove before the event fires:
486
- * unsubscribe();
487
- * ```
488
547
  */
489
548
  once(event, handler) {
490
549
  const wrappedHandler = (playerIndex, data) => {
@@ -496,24 +555,13 @@ class ScreenImpl {
496
555
  }
497
556
  off(event, handler) {
498
557
  if (!handler) {
499
- if (this._configListenerEvents.has(event)) {
500
- for (const [key, val] of this.handlerToTransport) {
501
- if (val.event === event) {
502
- this.transport?.off(event, val.transportHandler);
503
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
504
- (h) => h.handler !== val.transportHandler
505
- );
506
- this.handlerToTransport.delete(key);
507
- }
508
- }
509
- } else {
510
- this.eventHandlers.delete(event);
511
- this.transport?.off(event);
512
- this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
513
- for (const [key, val] of this.handlerToTransport) {
514
- if (val.event === event) this.handlerToTransport.delete(key);
515
- }
558
+ this.eventHandlers.delete(event);
559
+ this.transport?.off(event);
560
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
561
+ for (const [key, val] of this.handlerToTransport) {
562
+ if (val.event === event) this.handlerToTransport.delete(key);
516
563
  }
564
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
517
565
  } else {
518
566
  const handlers = this.eventHandlers.get(event);
519
567
  handlers?.delete(handler);
@@ -528,6 +576,9 @@ class ScreenImpl {
528
576
  );
529
577
  this.handlerToTransport.delete(handler);
530
578
  }
579
+ this._pendingHandlers = this._pendingHandlers.filter(
580
+ (p) => !(p.event === event && p.handler === handler)
581
+ );
531
582
  }
532
583
  }
533
584
  // ---------------------------------------------------------------------------
@@ -539,25 +590,6 @@ class ScreenImpl {
539
590
  getControllerCount() {
540
591
  return this._controllers.filter((c) => c.connected).length;
541
592
  }
542
- /**
543
- * Check if there is at least one connected controller.
544
- * Useful for detecting when all players have disconnected
545
- * (e.g., to pause the game or show a waiting screen).
546
- *
547
- * Use this in onControllerDisconnect callback to detect when all controllers have disconnected.
548
- *
549
- * @example
550
- * ```ts
551
- * const screen = await createScreen<MyEvents>({
552
- * onControllerDisconnect: (playerIndex) => {
553
- * if (!screen.hasAnyConnectedControllers()) {
554
- * console.log('All controllers disconnected!');
555
- * screen.broadcast('waiting-for-players', {});
556
- * }
557
- * },
558
- * });
559
- * ```
560
- */
561
593
  hasAnyConnectedControllers() {
562
594
  return this._controllers.some((c) => c.connected);
563
595
  }
@@ -573,12 +605,25 @@ class ScreenImpl {
573
605
  this.logger.lifecycle("Screen destroyed");
574
606
  }
575
607
  cleanup() {
608
+ if (this._initTimeoutId) {
609
+ clearTimeout(this._initTimeoutId);
610
+ this._initTimeoutId = null;
611
+ }
576
612
  for (const { event, handler } of this.registeredTransportHandlers) {
577
613
  this.transport?.off(event, handler);
578
614
  }
579
615
  this.registeredTransportHandlers = [];
580
616
  this.eventHandlers.clear();
581
617
  this.handlerToTransport.clear();
618
+ this._pendingHandlers = [];
619
+ this._onAllReadyCallbacks.clear();
620
+ this._onControllerJoinCallbacks.clear();
621
+ this._onControllerLeaveCallbacks.clear();
622
+ this._onControllerDisconnectCallbacks.clear();
623
+ this._onControllerReconnectCallbacks.clear();
624
+ this._onCharacterUpdatedCallbacks.clear();
625
+ this._onRateLimitedCallbacks.clear();
626
+ this._onErrorCallbacks.clear();
582
627
  if (this.transport instanceof PostMessageTransport.PostMessageTransport) {
583
628
  this.transport.destroy();
584
629
  }
@@ -594,8 +639,8 @@ class ScreenImpl {
594
639
  handleError(error) {
595
640
  this.logger.warn(`Error in handler: ${error.message}`);
596
641
  const smoreError = error.toSmoreError();
597
- if (this.config.onError) {
598
- this.config.onError(smoreError);
642
+ if (this._onErrorCallbacks.size > 0) {
643
+ this._onErrorCallbacks.forEach((cb) => cb(smoreError));
599
644
  } else {
600
645
  this.logger.error(error.message, error.details);
601
646
  }
@@ -611,17 +656,14 @@ class ScreenImpl {
611
656
  if (!this._isReady || !this.transport) {
612
657
  throw new errors.SmoreSDKError(
613
658
  "NOT_READY",
614
- `Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
659
+ `Cannot call ${method}() before screen is ready. Use await screen.ready.`,
615
660
  { details: { method } }
616
661
  );
617
662
  }
618
663
  }
619
664
  }
620
665
  function createScreen(config) {
621
- const screen = new ScreenImpl(config);
622
- const promise = screen.initialize().then(() => screen);
623
- promise.instance = screen;
624
- return promise;
666
+ return new ScreenImpl(config);
625
667
  }
626
668
 
627
669
  exports.createScreen = createScreen;