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