@smoregg/sdk 2.0.0 → 2.1.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 (70) hide show
  1. package/dist/cjs/controller.cjs +193 -115
  2. package/dist/cjs/controller.cjs.map +1 -1
  3. package/dist/cjs/errors.cjs +1 -0
  4. package/dist/cjs/errors.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +19 -2
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/index.cjs +2 -7
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/screen.cjs +185 -130
  10. package/dist/cjs/screen.cjs.map +1 -1
  11. package/dist/cjs/shared.cjs +34 -0
  12. package/dist/cjs/shared.cjs.map +1 -0
  13. package/dist/cjs/testing.cjs +125 -74
  14. package/dist/cjs/testing.cjs.map +1 -1
  15. package/dist/cjs/transport/PostMessageTransport.cjs +12 -0
  16. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/protocol.cjs +2 -0
  18. package/dist/cjs/transport/protocol.cjs.map +1 -1
  19. package/dist/cjs/types.cjs +16 -0
  20. package/dist/cjs/types.cjs.map +1 -0
  21. package/dist/esm/controller.js +195 -117
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +1 -0
  24. package/dist/esm/errors.js.map +1 -1
  25. package/dist/esm/events.js +18 -3
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +1 -3
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/screen.js +187 -132
  30. package/dist/esm/screen.js.map +1 -1
  31. package/dist/esm/shared.js +30 -0
  32. package/dist/esm/shared.js.map +1 -0
  33. package/dist/esm/testing.js +125 -74
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/PostMessageTransport.js +12 -0
  36. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  37. package/dist/esm/transport/protocol.js +2 -1
  38. package/dist/esm/transport/protocol.js.map +1 -1
  39. package/dist/esm/types.js +14 -0
  40. package/dist/esm/types.js.map +1 -0
  41. package/dist/types/controller.d.ts +1 -1
  42. package/dist/types/controller.d.ts.map +1 -1
  43. package/dist/types/errors.d.ts.map +1 -1
  44. package/dist/types/events.d.ts +10 -1
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -8
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/screen.d.ts +3 -3
  49. package/dist/types/screen.d.ts.map +1 -1
  50. package/dist/types/shared.d.ts +21 -0
  51. package/dist/types/shared.d.ts.map +1 -0
  52. package/dist/types/testing.d.ts +63 -4
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/PostMessageTransport.d.ts +1 -0
  55. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  56. package/dist/types/transport/protocol.d.ts +4 -0
  57. package/dist/types/transport/protocol.d.ts.map +1 -1
  58. package/dist/types/types.d.ts +215 -347
  59. package/dist/types/types.d.ts.map +1 -1
  60. package/dist/umd/smore-sdk.umd.js +442 -787
  61. package/dist/umd/smore-sdk.umd.js.map +1 -1
  62. package/dist/umd/smore-sdk.umd.min.js +1 -1
  63. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  64. package/package.json +7 -1
  65. package/dist/cjs/config.cjs +0 -13
  66. package/dist/cjs/config.cjs.map +0 -1
  67. package/dist/esm/config.js +0 -10
  68. package/dist/esm/config.js.map +0 -1
  69. package/dist/types/config.d.ts +0 -35
  70. package/dist/types/config.d.ts.map +0 -1
@@ -1,9 +1,9 @@
1
1
  import { PostMessageTransport } from './transport/PostMessageTransport.js';
2
- import { isBridgeMessage, validateInitPayload } from './transport/protocol.js';
2
+ import { PROTOCOL_VERSION, isBridgeMessage, validateInitPayload } from './transport/protocol.js';
3
3
  import { SmoreSDKError } from './errors.js';
4
- import { SMORE_EVENTS, validateEventName } from './events.js';
4
+ import { SMORE_EVENTS, validateEventName, SCREEN_LIFECYCLE_EVENTS } from './events.js';
5
5
  import { DebugLogger } from './logger.js';
6
- import { getGlobalConfig } from './config.js';
6
+ import { mapPlayerDTO, validatePayloadSize } from './shared.js';
7
7
 
8
8
  const DEFAULT_TIMEOUT = 1e4;
9
9
  function validatePlayerIndex(playerIndex, controllers) {
@@ -34,17 +34,16 @@ class ScreenImpl {
34
34
  handlerToTransport = /* @__PURE__ */ new Map();
35
35
  // Pending handlers registered via on() before transport is ready
36
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();
37
+ // Unified lifecycle listener map (supports both onXxx() and on('$xxx') patterns)
38
+ _lifecycleListeners = /* @__PURE__ */ new Map();
39
+ // Outbound message buffer (messages sent before ready)
40
+ _outboundBuffer = [];
46
41
  // Whether all-ready has fired
47
42
  _allReadyFired = false;
43
+ // Self-connection awareness
44
+ _isConnected = false;
45
+ // Protocol versioning
46
+ _protocolVersion = PROTOCOL_VERSION;
48
47
  // Ready promise
49
48
  _readyResolve;
50
49
  _readyReject;
@@ -106,8 +105,17 @@ class ScreenImpl {
106
105
  this._readyReject(error);
107
106
  return;
108
107
  }
109
- this.transport = new PostMessageTransport(parentOrigin);
108
+ this.transport = this.config.transport ?? new PostMessageTransport(parentOrigin);
110
109
  this._roomCode = initData.roomCode;
110
+ const serverProtocolVersion = initData.protocolVersion;
111
+ if (serverProtocolVersion !== void 0) {
112
+ this._protocolVersion = serverProtocolVersion;
113
+ if (serverProtocolVersion !== PROTOCOL_VERSION) {
114
+ this.logger.warn(
115
+ `Protocol version mismatch: SDK v${PROTOCOL_VERSION}, server v${serverProtocolVersion}. Some features may not work correctly.`
116
+ );
117
+ }
118
+ }
111
119
  this._controllers = this.mapControllersFromInit(initData.players);
112
120
  if (this._controllers.length === 0) {
113
121
  this.logger.warn("Screen initialized with zero controllers");
@@ -117,13 +125,28 @@ class ScreenImpl {
117
125
  this.setupUserEventHandler(event, handler);
118
126
  }
119
127
  this._pendingHandlers = [];
128
+ this._isConnected = true;
120
129
  this._isReady = true;
130
+ for (const buffered of this._outboundBuffer) {
131
+ try {
132
+ switch (buffered.method) {
133
+ case "broadcast":
134
+ this.broadcast(buffered.args[0], buffered.args[1]);
135
+ break;
136
+ case "sendToController":
137
+ this.sendToController(buffered.args[0], buffered.args[1], buffered.args[2]);
138
+ break;
139
+ }
140
+ } catch (err) {
141
+ this.handleError(err instanceof SmoreSDKError ? err : new SmoreSDKError("UNKNOWN", "Failed to flush buffered message"));
142
+ }
143
+ }
144
+ this._outboundBuffer = [];
121
145
  this.logger.lifecycle("Screen ready", {
122
146
  roomCode: this._roomCode,
123
147
  controllers: this._controllers.length
124
148
  });
125
- const autoReady = getGlobalConfig().autoReady ?? true;
126
- if (autoReady) {
149
+ if (this.config.autoReady !== false) {
127
150
  this.logger.lifecycle("Auto-signaling ready (autoReady enabled)");
128
151
  this.signalReady();
129
152
  }
@@ -141,13 +164,13 @@ class ScreenImpl {
141
164
  for (const nc of newControllers) {
142
165
  if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
143
166
  this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
144
- this._onControllerJoinCallbacks.forEach((cb) => cb(nc.playerIndex, nc));
167
+ this._emitLifecycle("$controller-join", nc.playerIndex, nc);
145
168
  }
146
169
  }
147
170
  for (const oc of oldControllers) {
148
171
  if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
149
172
  this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
150
- this._onControllerLeaveCallbacks.forEach((cb) => cb(oc.playerIndex));
173
+ this._emitLifecycle("$controller-leave", oc.playerIndex);
151
174
  }
152
175
  }
153
176
  }
@@ -157,18 +180,11 @@ class ScreenImpl {
157
180
  }
158
181
  };
159
182
  window.addEventListener("message", this.boundMessageHandler);
160
- window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
183
+ window.parent.postMessage({ type: "_bridge:ready", protocolVersion: PROTOCOL_VERSION }, parentOrigin);
161
184
  this.logger.lifecycle("Sent _bridge:ready to parent");
162
185
  }
163
186
  mapControllersFromInit(players) {
164
- return players.map((p, index) => ({
165
- playerIndex: p.playerIndex ?? index,
166
- // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
167
- nickname: p.nickname || p.name || `Player ${index + 1}`,
168
- connected: p.connected !== false,
169
- // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
170
- appearance: p.appearance ?? p.character
171
- }));
187
+ return players.map((p, index) => mapPlayerDTO(p, index));
172
188
  }
173
189
  setupEventHandlers() {
174
190
  if (!this.transport) return;
@@ -176,16 +192,11 @@ class ScreenImpl {
176
192
  const payload = data;
177
193
  const playerData = payload?.player;
178
194
  if (playerData && typeof playerData.playerIndex === "number") {
179
- const controllerInfo = {
180
- playerIndex: playerData.playerIndex,
181
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
182
- connected: playerData.connected !== false,
183
- appearance: playerData.appearance ?? playerData.character
184
- };
195
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
185
196
  if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
186
197
  this._controllers = [...this._controllers, controllerInfo];
187
198
  this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
188
- this._onControllerJoinCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
199
+ this._emitLifecycle("$controller-join", controllerInfo.playerIndex, controllerInfo);
189
200
  }
190
201
  });
191
202
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
@@ -195,7 +206,7 @@ class ScreenImpl {
195
206
  if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
196
207
  this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
197
208
  this.logger.lifecycle("Controller left", { playerIndex });
198
- this._onControllerLeaveCallbacks.forEach((cb) => cb(playerIndex));
209
+ this._emitLifecycle("$controller-leave", playerIndex);
199
210
  }
200
211
  });
201
212
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
@@ -206,24 +217,19 @@ class ScreenImpl {
206
217
  (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
207
218
  );
208
219
  this.logger.lifecycle("Controller disconnected", { playerIndex });
209
- this._onControllerDisconnectCallbacks.forEach((cb) => cb(playerIndex));
220
+ this._emitLifecycle("$controller-disconnect", playerIndex);
210
221
  }
211
222
  });
212
223
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
213
224
  const payload = data;
214
225
  const playerData = payload?.player;
215
226
  if (playerData && typeof playerData.playerIndex === "number") {
216
- const controllerInfo = {
217
- playerIndex: playerData.playerIndex,
218
- nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
219
- connected: true,
220
- appearance: playerData.appearance ?? playerData.character
221
- };
227
+ const controllerInfo = mapPlayerDTO(playerData, playerData.playerIndex);
222
228
  this._controllers = this._controllers.map(
223
229
  (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
224
230
  );
225
231
  this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
226
- this._onControllerReconnectCallbacks.forEach((cb) => cb(controllerInfo.playerIndex, controllerInfo));
232
+ this._emitLifecycle("$controller-reconnect", controllerInfo.playerIndex, controllerInfo);
227
233
  }
228
234
  });
229
235
  this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
@@ -236,19 +242,32 @@ class ScreenImpl {
236
242
  (c) => c.playerIndex === pi ? { ...c, appearance } : c
237
243
  );
238
244
  this.logger.lifecycle("Player character updated", { playerIndex: pi });
239
- this._onCharacterUpdatedCallbacks.forEach((cb) => cb(pi, appearance ?? null));
245
+ this._emitLifecycle("$character-updated", pi, appearance ?? null);
240
246
  }
241
247
  });
242
248
  this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
243
249
  const payload = data;
244
- const event = payload?.event ?? "unknown";
245
- this.logger.warn(`Rate limited: ${event}`);
246
- this._onRateLimitedCallbacks.forEach((cb) => cb(event));
250
+ const eventName = payload?.event ?? "unknown";
251
+ this.handleError(
252
+ new SmoreSDKError("RATE_LIMITED", `Server rate-limited event: ${eventName}`, {
253
+ details: { event: eventName }
254
+ })
255
+ );
247
256
  });
248
257
  this.registerTransportHandler(SMORE_EVENTS.ALL_READY, () => {
249
258
  this.logger.lifecycle("All participants ready");
250
259
  this._allReadyFired = true;
251
- this._onAllReadyCallbacks.forEach((cb) => cb());
260
+ this._emitLifecycle("$all-ready");
261
+ });
262
+ this.registerTransportHandler(SMORE_EVENTS.SELF_DISCONNECTED, () => {
263
+ this._isConnected = false;
264
+ this.logger.lifecycle("Connection lost");
265
+ this._emitLifecycle("$connection-change", false);
266
+ });
267
+ this.registerTransportHandler(SMORE_EVENTS.SELF_RECONNECTED, () => {
268
+ this._isConnected = true;
269
+ this.logger.lifecycle("Connection restored");
270
+ this._emitLifecycle("$connection-change", true);
252
271
  });
253
272
  }
254
273
  /**
@@ -317,6 +336,45 @@ class ScreenImpl {
317
336
  get isDestroyed() {
318
337
  return this._isDestroyed;
319
338
  }
339
+ get isConnected() {
340
+ return this._isConnected;
341
+ }
342
+ get protocolVersion() {
343
+ return this._protocolVersion;
344
+ }
345
+ // ---------------------------------------------------------------------------
346
+ // Lifecycle Listener Helpers
347
+ // ---------------------------------------------------------------------------
348
+ _addLifecycleListener(event, listener) {
349
+ let set = this._lifecycleListeners.get(event);
350
+ if (!set) {
351
+ set = /* @__PURE__ */ new Set();
352
+ this._lifecycleListeners.set(event, set);
353
+ }
354
+ set.add(listener);
355
+ return () => {
356
+ set.delete(listener);
357
+ if (set.size === 0) this._lifecycleListeners.delete(event);
358
+ };
359
+ }
360
+ _emitLifecycle(event, ...args) {
361
+ this._lifecycleListeners.get(event)?.forEach((cb) => {
362
+ try {
363
+ cb(...args);
364
+ } catch (err) {
365
+ this.handleError(
366
+ new SmoreSDKError("UNKNOWN", `Error in lifecycle handler for "${event}"`, {
367
+ cause: err instanceof Error ? err : void 0,
368
+ details: { event }
369
+ })
370
+ );
371
+ }
372
+ });
373
+ }
374
+ _hasLifecycleListeners(event) {
375
+ const set = this._lifecycleListeners.get(event);
376
+ return set !== void 0 && set.size > 0;
377
+ }
320
378
  // ---------------------------------------------------------------------------
321
379
  // Lifecycle Methods
322
380
  // ---------------------------------------------------------------------------
@@ -324,52 +382,28 @@ class ScreenImpl {
324
382
  if (this._allReadyFired) {
325
383
  callback();
326
384
  }
327
- this._onAllReadyCallbacks.add(callback);
328
- return () => {
329
- this._onAllReadyCallbacks.delete(callback);
330
- };
385
+ return this._addLifecycleListener("$all-ready", callback);
331
386
  }
332
387
  onControllerJoin(callback) {
333
- this._onControllerJoinCallbacks.add(callback);
334
- return () => {
335
- this._onControllerJoinCallbacks.delete(callback);
336
- };
388
+ return this._addLifecycleListener("$controller-join", callback);
337
389
  }
338
390
  onControllerLeave(callback) {
339
- this._onControllerLeaveCallbacks.add(callback);
340
- return () => {
341
- this._onControllerLeaveCallbacks.delete(callback);
342
- };
391
+ return this._addLifecycleListener("$controller-leave", callback);
343
392
  }
344
393
  onControllerDisconnect(callback) {
345
- this._onControllerDisconnectCallbacks.add(callback);
346
- return () => {
347
- this._onControllerDisconnectCallbacks.delete(callback);
348
- };
394
+ return this._addLifecycleListener("$controller-disconnect", callback);
349
395
  }
350
396
  onControllerReconnect(callback) {
351
- this._onControllerReconnectCallbacks.add(callback);
352
- return () => {
353
- this._onControllerReconnectCallbacks.delete(callback);
354
- };
397
+ return this._addLifecycleListener("$controller-reconnect", callback);
355
398
  }
356
399
  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
- };
400
+ return this._addLifecycleListener("$character-updated", callback);
367
401
  }
368
402
  onError(callback) {
369
- this._onErrorCallbacks.add(callback);
370
- return () => {
371
- this._onErrorCallbacks.delete(callback);
372
- };
403
+ return this._addLifecycleListener("$error", callback);
404
+ }
405
+ onConnectionChange(callback) {
406
+ return this._addLifecycleListener("$connection-change", callback);
373
407
  }
374
408
  // ---------------------------------------------------------------------------
375
409
  // Communication Methods
@@ -378,7 +412,6 @@ class ScreenImpl {
378
412
  * Send type-safe events to all controllers.
379
413
  *
380
414
  * Uses EventMap generic for compile-time type checking of event names and data payloads.
381
- * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
382
415
  *
383
416
  * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
384
417
  * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
@@ -387,31 +420,18 @@ class ScreenImpl {
387
420
  *
388
421
  * Warning: Avoid sending primitive values directly (string, number, boolean).
389
422
  * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
390
- *
391
- * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
392
423
  */
393
424
  broadcast(event, data) {
394
- this.ensureReady("broadcast");
395
- validateEventName(event);
396
- this.logger.send(event, data);
397
- this.transport.emit(event, data);
398
- }
399
- /**
400
- * Send events to all controllers without TypeScript type checking.
401
- *
402
- * Bypasses EventMap generic type checks at compile time.
403
- * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
404
- *
405
- * Use this when you need dynamic event names or when working without a predefined EventMap.
406
- *
407
- * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
408
- * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
409
- *
410
- * @see broadcast for type-safe version using EventMap generic
411
- */
412
- broadcastRaw(event, data) {
413
- this.ensureReady("broadcastRaw");
425
+ if (this._isDestroyed) {
426
+ throw new SmoreSDKError("DESTROYED", "Cannot call broadcast() after destroy()");
427
+ }
428
+ if (!this._isReady || !this.transport) {
429
+ this._outboundBuffer.push({ method: "broadcast", args: [event, data] });
430
+ this.logger.debug(`Buffered broadcast "${event}" (screen not ready yet)`);
431
+ return;
432
+ }
414
433
  validateEventName(event);
434
+ validatePayloadSize(data);
415
435
  this.logger.send(event, data);
416
436
  this.transport.emit(event, data);
417
437
  }
@@ -430,24 +450,17 @@ class ScreenImpl {
430
450
  * @param data - Event data payload
431
451
  */
432
452
  sendToController(playerIndex, event, data) {
433
- this.ensureReady("sendToController");
434
- validateEventName(event);
435
- validatePlayerIndex(playerIndex, this._controllers);
436
- if (data && typeof data === "object" && "targetPlayerIndex" in data) {
437
- this.logger.warn(
438
- `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
439
- );
453
+ if (this._isDestroyed) {
454
+ throw new SmoreSDKError("DESTROYED", "Cannot call sendToController() after destroy()");
455
+ }
456
+ if (!this._isReady || !this.transport) {
457
+ this._outboundBuffer.push({ method: "sendToController", args: [playerIndex, event, data] });
458
+ this.logger.debug(`Buffered sendToController "${event}" -> Player ${playerIndex} (screen not ready yet)`);
459
+ return;
440
460
  }
441
- this.logger.send(`${event} -> Player ${playerIndex}`, data);
442
- this.transport.emit(event, {
443
- targetPlayerIndex: playerIndex,
444
- ...data && typeof data === "object" ? data : { data }
445
- });
446
- }
447
- sendToControllerRaw(playerIndex, event, data) {
448
- this.ensureReady("sendToControllerRaw");
449
461
  validateEventName(event);
450
462
  validatePlayerIndex(playerIndex, this._controllers);
463
+ validatePayloadSize(data);
451
464
  if (data && typeof data === "object" && "targetPlayerIndex" in data) {
452
465
  this.logger.warn(
453
466
  `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
@@ -482,6 +495,16 @@ class ScreenImpl {
482
495
  * are queued and activated when the transport becomes available.
483
496
  */
484
497
  on(event, handler) {
498
+ if (typeof event === "string" && event.startsWith("$")) {
499
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
500
+ if (!validEvents.has(event)) {
501
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}". Valid lifecycle events: ${Array.from(validEvents).join(", ")}`);
502
+ }
503
+ if (event === "$all-ready" && this._allReadyFired) {
504
+ handler();
505
+ }
506
+ return this._addLifecycleListener(event, handler);
507
+ }
485
508
  validateEventName(event);
486
509
  let handlers = this.eventHandlers.get(event);
487
510
  if (!handlers) {
@@ -544,6 +567,23 @@ class ScreenImpl {
544
567
  * @returns Unsubscribe function to remove the handler before it fires
545
568
  */
546
569
  once(event, handler) {
570
+ if (typeof event === "string" && event.startsWith("$")) {
571
+ const validEvents = SCREEN_LIFECYCLE_EVENTS;
572
+ if (!validEvents.has(event)) {
573
+ throw new SmoreSDKError("INVALID_EVENT", `Unknown lifecycle event: "${event}"`);
574
+ }
575
+ if (event === "$all-ready" && this._allReadyFired) {
576
+ handler();
577
+ return () => {
578
+ };
579
+ }
580
+ const wrapper = (...args) => {
581
+ unsub();
582
+ handler(...args);
583
+ };
584
+ const unsub = this._addLifecycleListener(event, wrapper);
585
+ return unsub;
586
+ }
547
587
  const wrappedHandler = (playerIndex, data) => {
548
588
  unsubscribe();
549
589
  handler(playerIndex, data);
@@ -552,6 +592,14 @@ class ScreenImpl {
552
592
  return unsubscribe;
553
593
  }
554
594
  off(event, handler) {
595
+ if (typeof event === "string" && event.startsWith("$")) {
596
+ if (!handler) {
597
+ this._lifecycleListeners.delete(event);
598
+ } else {
599
+ this._lifecycleListeners.get(event)?.delete(handler);
600
+ }
601
+ return;
602
+ }
555
603
  if (!handler) {
556
604
  this.eventHandlers.delete(event);
557
605
  this.transport?.off(event);
@@ -579,6 +627,21 @@ class ScreenImpl {
579
627
  );
580
628
  }
581
629
  }
630
+ removeAllListeners(event) {
631
+ if (event) {
632
+ this.eventHandlers.delete(event);
633
+ this.transport?.off(event);
634
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
635
+ for (const [key, val] of this.handlerToTransport) {
636
+ if (val.event === event) this.handlerToTransport.delete(key);
637
+ }
638
+ this._pendingHandlers = this._pendingHandlers.filter((p) => p.event !== event);
639
+ } else {
640
+ for (const evt of [...this.eventHandlers.keys()]) {
641
+ this.removeAllListeners(evt);
642
+ }
643
+ }
644
+ }
582
645
  // ---------------------------------------------------------------------------
583
646
  // Utilities
584
647
  // ---------------------------------------------------------------------------
@@ -588,9 +651,6 @@ class ScreenImpl {
588
651
  getControllerCount() {
589
652
  return this._controllers.filter((c) => c.connected).length;
590
653
  }
591
- hasAnyConnectedControllers() {
592
- return this._controllers.some((c) => c.connected);
593
- }
594
654
  // ---------------------------------------------------------------------------
595
655
  // Cleanup
596
656
  // ---------------------------------------------------------------------------
@@ -614,14 +674,9 @@ class ScreenImpl {
614
674
  this.eventHandlers.clear();
615
675
  this.handlerToTransport.clear();
616
676
  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();
677
+ this._lifecycleListeners.clear();
678
+ this._isConnected = false;
679
+ this._outboundBuffer = [];
625
680
  if (this.transport instanceof PostMessageTransport) {
626
681
  this.transport.destroy();
627
682
  }
@@ -637,8 +692,8 @@ class ScreenImpl {
637
692
  handleError(error) {
638
693
  this.logger.warn(`Error in handler: ${error.message}`);
639
694
  const smoreError = error.toSmoreError();
640
- if (this._onErrorCallbacks.size > 0) {
641
- this._onErrorCallbacks.forEach((cb) => cb(smoreError));
695
+ if (this._hasLifecycleListeners("$error")) {
696
+ this._emitLifecycle("$error", smoreError);
642
697
  } else {
643
698
  this.logger.error(error.message, error.details);
644
699
  }