@smoregg/sdk 0.6.1 → 1.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 (70) hide show
  1. package/README.md +29 -38
  2. package/dist/cjs/controller.cjs +299 -144
  3. package/dist/cjs/controller.cjs.map +1 -1
  4. package/dist/cjs/errors.cjs +36 -0
  5. package/dist/cjs/errors.cjs.map +1 -0
  6. package/dist/cjs/events.cjs +40 -19
  7. package/dist/cjs/events.cjs.map +1 -1
  8. package/dist/cjs/index.cjs +3 -8
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/logger.cjs +75 -0
  11. package/dist/cjs/logger.cjs.map +1 -0
  12. package/dist/cjs/screen.cjs +302 -215
  13. package/dist/cjs/screen.cjs.map +1 -1
  14. package/dist/cjs/testing.cjs +265 -22
  15. package/dist/cjs/testing.cjs.map +1 -1
  16. package/dist/cjs/transport/DirectTransport.cjs.map +1 -1
  17. package/dist/cjs/transport/PostMessageTransport.cjs +11 -6
  18. package/dist/cjs/transport/PostMessageTransport.cjs.map +1 -1
  19. package/dist/cjs/transport/protocol.cjs +25 -5
  20. package/dist/cjs/transport/protocol.cjs.map +1 -1
  21. package/dist/esm/controller.js +292 -136
  22. package/dist/esm/controller.js.map +1 -1
  23. package/dist/esm/errors.js +34 -0
  24. package/dist/esm/errors.js.map +1 -0
  25. package/dist/esm/events.js +38 -18
  26. package/dist/esm/events.js.map +1 -1
  27. package/dist/esm/index.js +3 -4
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/logger.js +73 -0
  30. package/dist/esm/logger.js.map +1 -0
  31. package/dist/esm/screen.js +290 -202
  32. package/dist/esm/screen.js.map +1 -1
  33. package/dist/esm/testing.js +265 -22
  34. package/dist/esm/testing.js.map +1 -1
  35. package/dist/esm/transport/DirectTransport.js.map +1 -1
  36. package/dist/esm/transport/PostMessageTransport.js +12 -7
  37. package/dist/esm/transport/PostMessageTransport.js.map +1 -1
  38. package/dist/esm/transport/protocol.js +23 -4
  39. package/dist/esm/transport/protocol.js.map +1 -1
  40. package/dist/types/controller.d.ts +1 -14
  41. package/dist/types/controller.d.ts.map +1 -1
  42. package/dist/types/errors.d.ts +45 -0
  43. package/dist/types/errors.d.ts.map +1 -0
  44. package/dist/types/events.d.ts +52 -12
  45. package/dist/types/events.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +4 -6
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/logger.d.ts +35 -0
  49. package/dist/types/logger.d.ts.map +1 -0
  50. package/dist/types/screen.d.ts +1 -14
  51. package/dist/types/screen.d.ts.map +1 -1
  52. package/dist/types/testing.d.ts +0 -1
  53. package/dist/types/testing.d.ts.map +1 -1
  54. package/dist/types/transport/DirectTransport.d.ts +2 -1
  55. package/dist/types/transport/DirectTransport.d.ts.map +1 -1
  56. package/dist/types/transport/PostMessageTransport.d.ts +17 -2
  57. package/dist/types/transport/PostMessageTransport.d.ts.map +1 -1
  58. package/dist/types/transport/index.d.ts +2 -2
  59. package/dist/types/transport/index.d.ts.map +1 -1
  60. package/dist/types/transport/protocol.d.ts +71 -23
  61. package/dist/types/transport/protocol.d.ts.map +1 -1
  62. package/dist/types/transport/types.d.ts +24 -2
  63. package/dist/types/transport/types.d.ts.map +1 -1
  64. package/dist/types/types.d.ts +298 -212
  65. package/dist/types/types.d.ts.map +1 -1
  66. package/dist/umd/smore-sdk.umd.js +950 -349
  67. package/dist/umd/smore-sdk.umd.js.map +1 -1
  68. package/dist/umd/smore-sdk.umd.min.js +1 -1
  69. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  70. package/package.json +8 -13
@@ -4,9 +4,28 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SmoreSDK = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
- const SMORE_MSG_PREFIX = "smore:";
8
- function isSmoreMessage(data) {
9
- return data && typeof data === "object" && typeof data.type === "string" && data.type.startsWith(SMORE_MSG_PREFIX);
7
+ const BRIDGE_MSG_PREFIX = "_bridge:";
8
+ function isBridgeMessage(data) {
9
+ return data !== null && typeof data === "object" && "type" in data && typeof data.type === "string" && data.type.startsWith(BRIDGE_MSG_PREFIX);
10
+ }
11
+ function validateInitPayload(payload) {
12
+ if (!payload || typeof payload !== "object") {
13
+ throw new Error("[SDK] _bridge:init payload must be an object");
14
+ }
15
+ const p = payload;
16
+ if (typeof p.side !== "string" || !["host", "player"].includes(p.side)) {
17
+ throw new Error(`[SDK] _bridge:init payload.side must be "host" or "player", got: ${p.side}`);
18
+ }
19
+ if (typeof p.roomCode !== "string" || p.roomCode.length === 0) {
20
+ throw new Error("[SDK] _bridge:init payload.roomCode must be a non-empty string");
21
+ }
22
+ if (!Array.isArray(p.players)) {
23
+ throw new Error("[SDK] _bridge:init payload.players must be an array");
24
+ }
25
+ if (p.myIndex !== void 0 && typeof p.myIndex !== "number") {
26
+ throw new Error("[SDK] _bridge:init payload.myIndex must be a number if provided");
27
+ }
28
+ return true;
10
29
  }
11
30
 
12
31
  class PostMessageTransport {
@@ -23,14 +42,19 @@
23
42
  emit(event, ...args) {
24
43
  let data = args[0];
25
44
  let ackId;
26
- if (args.length >= 2 && typeof args[args.length - 1] === "function") {
27
- data = args.length === 2 ? args[0] : args[0];
45
+ if (args.length === 1 && typeof args[0] === "function") {
46
+ data = void 0;
47
+ const callback = args[0];
48
+ ackId = `ack_${++this.ackCounter}`;
49
+ this.ackCallbacks.set(ackId, callback);
50
+ } else if (args.length >= 2 && typeof args[args.length - 1] === "function") {
51
+ data = args[0];
28
52
  const callback = args[args.length - 1];
29
53
  ackId = `ack_${++this.ackCounter}`;
30
54
  this.ackCallbacks.set(ackId, callback);
31
55
  }
32
56
  window.parent.postMessage(
33
- { type: "smore:emit", payload: { event, data, ackId } },
57
+ { type: "_bridge:emit", payload: { event, data, ackId } },
34
58
  this.parentOrigin
35
59
  );
36
60
  }
@@ -57,14 +81,14 @@
57
81
  handleMessage(e) {
58
82
  if (this.parentOrigin !== "*" && e.origin !== this.parentOrigin) return;
59
83
  const msg = e.data;
60
- if (!isSmoreMessage(msg)) return;
61
- if (msg.type === "smore:event") {
84
+ if (!isBridgeMessage(msg)) return;
85
+ if (msg.type === "_bridge:event") {
62
86
  const { event, data } = msg.payload;
63
87
  const set = this.handlers.get(event);
64
88
  if (set) {
65
89
  set.forEach((handler) => handler(data));
66
90
  }
67
- } else if (msg.type === "smore:ack") {
91
+ } else if (msg.type === "_bridge:ack") {
68
92
  const { ackId, data } = msg.payload;
69
93
  const cb = this.ackCallbacks.get(ackId);
70
94
  if (cb) {
@@ -75,23 +99,22 @@
75
99
  }
76
100
  }
77
101
 
78
- const SYSTEM_PREFIX$1 = "smore:";
79
- const SYSTEM_EVENTS$1 = {
80
- PLAYER_JOIN: `${SYSTEM_PREFIX$1}player-join`,
81
- PLAYER_LEAVE: `${SYSTEM_PREFIX$1}player-leave`,
82
- PLAYER_RECONNECT: `${SYSTEM_PREFIX$1}player-reconnect`,
83
- GAME_OVER: `${SYSTEM_PREFIX$1}game-over`
84
- };
85
- const DEFAULT_TIMEOUT$1 = 1e4;
86
- let SmoreSDKError$1 = class SmoreSDKError extends Error {
102
+ class SmoreSDKError extends Error {
87
103
  code;
104
+ /**
105
+ * The original error that caused this error.
106
+ *
107
+ * **Note:** This field intentionally shadows the native `Error.cause` (ES2022).
108
+ * Both this class field and the native property (set via `super()` options bag)
109
+ * are assigned the same value, so there is no behavioral difference.
110
+ * The explicit field provides TypeScript type narrowing to `Error` instead of `unknown`.
111
+ */
88
112
  cause;
89
113
  details;
90
114
  constructor(code, message, options) {
91
- super(message);
115
+ super(message, options?.cause ? { cause: options.cause } : void 0);
92
116
  this.name = "SmoreSDKError";
93
117
  this.code = code;
94
- this.cause = options?.cause;
95
118
  this.details = options?.details;
96
119
  const ErrorWithCapture = Error;
97
120
  if (typeof ErrorWithCapture.captureStackTrace === "function") {
@@ -106,32 +129,51 @@
106
129
  details: this.details
107
130
  };
108
131
  }
132
+ }
133
+
134
+ const SMORE_EVENTS = {
135
+ // Game lifecycle
136
+ GAME_OVER: "smore:game-over",
137
+ RETURN_TO_LOBBY: "smore:return-to-lobby",
138
+ // Used internally by platform, not handled by SDK
139
+ // Player management
140
+ PLAYER_JOINED: "smore:player-joined",
141
+ PLAYER_LEFT: "smore:player-left",
142
+ PLAYER_DISCONNECTED: "smore:player-disconnected",
143
+ PLAYER_RECONNECTED: "smore:player-reconnected",
144
+ // Character change
145
+ PLAYER_CHARACTER_UPDATED: "smore:player-character-updated",
146
+ // Rate limiting
147
+ RATE_LIMITED: "smore:rate-limited",
148
+ // Send to specific player (internal use)
149
+ SEND_TO_PLAYER: "smore:send-to-player"
150
+ // Used internally by platform, not handled by SDK
109
151
  };
110
- const EVENT_NAME_REGEX$1 = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
111
- function validateEventName$1(event) {
152
+ new Set(
153
+ Object.values(SMORE_EVENTS)
154
+ );
155
+ const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
156
+ const EVENT_NAME_MAX_LENGTH = 128;
157
+ function validateEventName(event) {
112
158
  if (!event || typeof event !== "string") {
113
- throw new SmoreSDKError$1("INVALID_EVENT", "Event name must be a non-empty string");
159
+ throw new SmoreSDKError("INVALID_EVENT", "Event name must be a non-empty string");
114
160
  }
115
- if (!EVENT_NAME_REGEX$1.test(event)) {
116
- throw new SmoreSDKError$1(
161
+ if (event.length > EVENT_NAME_MAX_LENGTH) {
162
+ throw new SmoreSDKError(
117
163
  "INVALID_EVENT",
118
- `Invalid event name "${event}". Event names must start with a letter, contain only letters, numbers, hyphens, or underscores, and end with a letter or number.`,
119
- { details: { event } }
164
+ `Event name exceeds maximum length of ${EVENT_NAME_MAX_LENGTH} characters (got ${event.length}).`,
165
+ { details: { event: event.slice(0, 50) + "..." } }
120
166
  );
121
167
  }
122
- }
123
- function validatePlayerIndex(playerIndex, controllersCount) {
124
- if (typeof playerIndex !== "number" || !Number.isInteger(playerIndex)) {
125
- throw new SmoreSDKError$1("INVALID_PLAYER", "Player index must be an integer");
126
- }
127
- if (playerIndex < 0 || playerIndex >= controllersCount) {
128
- throw new SmoreSDKError$1(
129
- "INVALID_PLAYER",
130
- `Invalid player index ${playerIndex}. Valid range: 0-${controllersCount - 1}`,
131
- { details: { playerIndex, controllersCount } }
168
+ if (!EVENT_NAME_REGEX.test(event)) {
169
+ throw new SmoreSDKError(
170
+ "INVALID_EVENT",
171
+ `Invalid event name "${event}". Event names must start with a letter, contain only letters, numbers, hyphens, or underscores, and end with a letter or number.`,
172
+ { details: { event } }
132
173
  );
133
174
  }
134
175
  }
176
+
135
177
  class DebugLogger {
136
178
  enabled;
137
179
  level;
@@ -146,30 +188,15 @@
146
188
  warn: 2,
147
189
  error: 3
148
190
  };
149
- constructor(options) {
150
- if (typeof options === "boolean") {
151
- this.enabled = options;
152
- this.level = "debug";
153
- this.prefix = "[SmoreScreen]";
154
- this.logSend = true;
155
- this.logReceive = true;
156
- this.logLifecycle = true;
157
- } else if (options) {
158
- this.enabled = options.enabled ?? false;
159
- this.level = options.level ?? "debug";
160
- this.prefix = options.prefix ?? "[SmoreScreen]";
161
- this.logSend = options.logSend ?? true;
162
- this.logReceive = options.logReceive ?? true;
163
- this.logLifecycle = options.logLifecycle ?? true;
164
- this.customLogger = options.logger;
165
- } else {
166
- this.enabled = false;
167
- this.level = "debug";
168
- this.prefix = "[SmoreScreen]";
169
- this.logSend = true;
170
- this.logReceive = true;
171
- this.logLifecycle = true;
172
- }
191
+ constructor(options, defaultPrefix = "[Smore]") {
192
+ const opts = typeof options === "boolean" ? { enabled: options } : options;
193
+ this.enabled = opts?.enabled ?? false;
194
+ this.level = opts?.level ?? "debug";
195
+ this.prefix = opts?.prefix ?? defaultPrefix;
196
+ this.logSend = opts?.logSend ?? true;
197
+ this.logReceive = opts?.logReceive ?? true;
198
+ this.logLifecycle = opts?.logLifecycle ?? true;
199
+ this.customLogger = opts?.logger;
173
200
  }
174
201
  shouldLog(level) {
175
202
  return this.enabled && DebugLogger.levelOrder[level] >= DebugLogger.levelOrder[this.level];
@@ -180,7 +207,7 @@
180
207
  this.customLogger(level, `${this.prefix} ${message}`, data);
181
208
  return;
182
209
  }
183
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
210
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : level === "debug" ? "debug" : "info";
184
211
  if (data !== void 0) {
185
212
  console[consoleMethod](`${this.prefix} ${message}`, data);
186
213
  } else {
@@ -200,11 +227,13 @@
200
227
  this.log("error", message, data);
201
228
  }
202
229
  send(event, data) {
230
+ if (!this.enabled) return;
203
231
  if (this.logSend) {
204
232
  this.debug(`-> SEND: ${event}`, data);
205
233
  }
206
234
  }
207
235
  receive(event, data) {
236
+ if (!this.enabled) return;
208
237
  if (this.logReceive) {
209
238
  this.debug(`<- RECV: ${event}`, data);
210
239
  }
@@ -215,24 +244,41 @@
215
244
  }
216
245
  }
217
246
  }
247
+
248
+ const DEFAULT_TIMEOUT$1 = 1e4;
249
+ function validatePlayerIndex(playerIndex, controllers) {
250
+ if (typeof playerIndex !== "number" || !Number.isInteger(playerIndex)) {
251
+ throw new SmoreSDKError("INVALID_PLAYER", "Player index must be an integer");
252
+ }
253
+ if (!controllers.some((c) => c.playerIndex === playerIndex)) {
254
+ throw new SmoreSDKError(
255
+ "INVALID_PLAYER",
256
+ `No controller found with player index ${playerIndex}`,
257
+ { details: { playerIndex } }
258
+ );
259
+ }
260
+ }
218
261
  class ScreenImpl {
219
262
  transport = null;
220
263
  config;
221
264
  logger;
222
265
  _controllers = [];
223
266
  _roomCode = "";
224
- _leaderIndex = -1;
225
267
  _isReady = false;
226
268
  _isDestroyed = false;
227
269
  eventHandlers = /* @__PURE__ */ new Map();
228
270
  registeredTransportHandlers = [];
229
271
  boundMessageHandler = null;
272
+ // Maps user-facing handler → transport wrappedHandler for proper cleanup in on()/off()
273
+ handlerToTransport = /* @__PURE__ */ new Map();
274
+ // Tracks event names registered via config.listeners so off(event) without handler won't remove them
275
+ _configListenerEvents = /* @__PURE__ */ new Set();
230
276
  constructor(config = {}) {
231
277
  this.config = config;
232
- this.logger = new DebugLogger(config.debug);
278
+ this.logger = new DebugLogger(config.debug, "[SmoreScreen]");
233
279
  if (config.listeners) {
234
280
  for (const event of Object.keys(config.listeners)) {
235
- validateEventName$1(event);
281
+ validateEventName(event);
236
282
  }
237
283
  }
238
284
  }
@@ -246,9 +292,9 @@
246
292
  return new Promise((resolve, reject) => {
247
293
  const timeoutId = setTimeout(() => {
248
294
  this.cleanup();
249
- const error = new SmoreSDKError$1(
295
+ const error = new SmoreSDKError(
250
296
  "TIMEOUT",
251
- `Screen initialization timed out after ${timeout}ms. Make sure the parent frame sends smore:init.`,
297
+ `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).`,
252
298
  { details: { timeout } }
253
299
  );
254
300
  this.handleError(error);
@@ -257,12 +303,26 @@
257
303
  this.boundMessageHandler = (e) => {
258
304
  if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
259
305
  const msg = e.data;
260
- if (!isSmoreMessage(msg)) return;
261
- if (msg.type === "smore:init") {
306
+ if (!isBridgeMessage(msg)) return;
307
+ if (msg.type === "_bridge:init") {
262
308
  clearTimeout(timeoutId);
263
- const initData = msg.payload;
309
+ const initPayload = msg.payload;
310
+ try {
311
+ validateInitPayload(initPayload);
312
+ } catch (err) {
313
+ const error = new SmoreSDKError(
314
+ "INIT_FAILED",
315
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
316
+ { details: { payload: initPayload } }
317
+ );
318
+ this.logger.warn("_bridge:init validation failed", error);
319
+ this.handleError(error);
320
+ reject(error);
321
+ return;
322
+ }
323
+ const initData = initPayload;
264
324
  if (initData.side !== "host") {
265
- const error = new SmoreSDKError$1(
325
+ const error = new SmoreSDKError(
266
326
  "INIT_FAILED",
267
327
  `Received init for wrong side: ${initData.side}. Expected "host".`,
268
328
  { details: { side: initData.side } }
@@ -274,103 +334,155 @@
274
334
  this.transport = new PostMessageTransport(parentOrigin);
275
335
  this._roomCode = initData.roomCode;
276
336
  this._controllers = this.mapControllersFromInit(initData.players);
277
- this._leaderIndex = this.findLeaderIndex(initData.players, initData.leaderId);
337
+ if (this._controllers.length === 0) {
338
+ this.logger.warn("Screen initialized with zero controllers");
339
+ }
278
340
  this.setupEventHandlers();
279
341
  this._isReady = true;
280
342
  this.logger.lifecycle("Screen ready", {
281
343
  roomCode: this._roomCode,
282
- controllers: this._controllers.length,
283
- leaderIndex: this._leaderIndex
344
+ controllers: this._controllers.length
284
345
  });
285
346
  this.config.onReady?.();
286
347
  resolve();
287
- } else if (msg.type === "smore:update") {
288
- const updateData = msg.payload;
289
- if (updateData.players) {
290
- this._controllers = this.mapControllersFromInit(updateData.players);
348
+ } else if (msg.type === "_bridge:update") {
349
+ if (!this._isReady) {
350
+ this.logger.debug("Ignoring _bridge:update before init completes");
351
+ return;
291
352
  }
292
- if (updateData.leaderId !== void 0) {
293
- this._leaderIndex = this.findLeaderIndex(
294
- updateData.players ?? [],
295
- updateData.leaderId
296
- );
353
+ const updateData = msg.payload;
354
+ if (updateData.players && Array.isArray(updateData.players)) {
355
+ const oldControllers = this._controllers;
356
+ const newControllers = this.mapControllersFromInit(updateData.players);
357
+ this._controllers = newControllers;
358
+ for (const nc of newControllers) {
359
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
360
+ this.logger.lifecycle("Controller joined (via update)", { playerIndex: nc.playerIndex });
361
+ this.config.onControllerJoin?.(nc.playerIndex, nc);
362
+ }
363
+ }
364
+ for (const oc of oldControllers) {
365
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
366
+ this.logger.lifecycle("Controller left (via update)", { playerIndex: oc.playerIndex });
367
+ this.config.onControllerLeave?.(oc.playerIndex);
368
+ }
369
+ }
297
370
  }
298
371
  this.logger.lifecycle("Room updated", {
299
- controllers: this._controllers.length,
300
- leaderIndex: this._leaderIndex
372
+ controllers: this._controllers.length
301
373
  });
302
374
  }
303
375
  };
304
376
  window.addEventListener("message", this.boundMessageHandler);
305
- window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
306
- this.logger.lifecycle("Sent smore:ready to parent");
377
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
378
+ this.logger.lifecycle("Sent _bridge:ready to parent");
307
379
  });
308
380
  }
309
381
  mapControllersFromInit(players) {
310
382
  return players.map((p, index) => ({
311
383
  playerIndex: p.playerIndex ?? index,
312
- nickname: p.nickname || `Player ${index + 1}`,
384
+ // Fallback to `nickname` for defensive compatibility (server currently always sends `name`)
385
+ nickname: p.nickname || p.name || `Player ${index + 1}`,
313
386
  connected: p.connected !== false,
314
- appearance: p.appearance
387
+ // Fallback to `appearance` for defensive compatibility (server currently always sends `character`)
388
+ appearance: p.appearance ?? p.character
315
389
  }));
316
390
  }
317
- findLeaderIndex(players, leaderId) {
318
- if (!leaderId) return -1;
319
- const idx = players.findIndex(
320
- (p) => p.sessionId === leaderId
321
- );
322
- return idx >= 0 ? idx : -1;
323
- }
324
391
  setupEventHandlers() {
325
392
  if (!this.transport) return;
326
- this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_JOIN, (data) => {
393
+ this.registerTransportHandler(SMORE_EVENTS.PLAYER_JOINED, (data) => {
327
394
  const payload = data;
328
- const player = payload?.player;
329
- if (player && typeof player.playerIndex === "number") {
330
- this.logger.lifecycle("Controller joined", { playerIndex: player.playerIndex });
331
- this.config.onControllerJoin?.(player.playerIndex, player);
395
+ const playerData = payload?.player;
396
+ if (playerData && typeof playerData.playerIndex === "number") {
397
+ const controllerInfo = {
398
+ playerIndex: playerData.playerIndex,
399
+ nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
400
+ connected: playerData.connected !== false,
401
+ appearance: playerData.appearance ?? playerData.character
402
+ };
403
+ if (this._controllers.some((c) => c.playerIndex === controllerInfo.playerIndex)) return;
404
+ this._controllers = [...this._controllers, controllerInfo];
405
+ this.logger.lifecycle("Controller joined", { playerIndex: controllerInfo.playerIndex });
406
+ this.config.onControllerJoin?.(controllerInfo.playerIndex, controllerInfo);
332
407
  }
333
408
  });
334
- this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_LEAVE, (data) => {
409
+ this.registerTransportHandler(SMORE_EVENTS.PLAYER_LEFT, (data) => {
335
410
  const payload = data;
336
- if (typeof payload?.playerIndex === "number") {
337
- this.logger.lifecycle("Controller left", { playerIndex: payload.playerIndex });
338
- this.config.onControllerLeave?.(payload.playerIndex);
411
+ const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
412
+ if (typeof playerIndex === "number") {
413
+ if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
414
+ this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
415
+ this.logger.lifecycle("Controller left", { playerIndex });
416
+ this.config.onControllerLeave?.(playerIndex);
339
417
  }
340
418
  });
341
- this.registerTransportHandler(SYSTEM_EVENTS$1.PLAYER_RECONNECT, (data) => {
419
+ this.registerTransportHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (data) => {
342
420
  const payload = data;
343
- const player = payload?.player;
344
- if (player && typeof player.playerIndex === "number") {
345
- this.logger.lifecycle("Controller reconnected", { playerIndex: player.playerIndex });
346
- this.config.onControllerReconnect?.(player.playerIndex, player);
421
+ const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
422
+ if (typeof playerIndex === "number") {
423
+ this._controllers = this._controllers.map(
424
+ (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
425
+ );
426
+ this.logger.lifecycle("Controller disconnected", { playerIndex });
427
+ this.config.onControllerDisconnect?.(playerIndex);
347
428
  }
348
429
  });
349
- this.registerTransportHandler("room:player-joined", (data) => {
430
+ this.registerTransportHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (data) => {
350
431
  const payload = data;
351
- const playerIndex = payload?.player?.playerIndex ?? payload?.playerIndex;
352
- if (typeof playerIndex === "number") {
353
- this.config.onControllerJoin?.(playerIndex, {
354
- playerIndex,
355
- nickname: `Player ${playerIndex + 1}`,
356
- connected: true
357
- });
432
+ const playerData = payload?.player;
433
+ if (playerData && typeof playerData.playerIndex === "number") {
434
+ const controllerInfo = {
435
+ playerIndex: playerData.playerIndex,
436
+ nickname: playerData.nickname || playerData.name || `Player ${playerData.playerIndex + 1}`,
437
+ connected: true,
438
+ appearance: playerData.appearance ?? playerData.character
439
+ };
440
+ this._controllers = this._controllers.map(
441
+ (c) => c.playerIndex === controllerInfo.playerIndex ? controllerInfo : c
442
+ );
443
+ this.logger.lifecycle("Controller reconnected", { playerIndex: controllerInfo.playerIndex });
444
+ this.config.onControllerReconnect?.(controllerInfo.playerIndex, controllerInfo);
358
445
  }
359
446
  });
360
- this.registerTransportHandler("room:player-left", (data) => {
447
+ this.registerTransportHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (data) => {
361
448
  const payload = data;
362
- const playerIndex = payload?.playerIndex ?? payload?.player?.playerIndex;
363
- if (typeof playerIndex === "number") {
364
- this.config.onControllerLeave?.(playerIndex);
449
+ const playerData = payload?.player;
450
+ if (playerData && typeof playerData.playerIndex === "number") {
451
+ const appearance = playerData.character ?? null;
452
+ this._controllers = this._controllers.map(
453
+ (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
454
+ );
455
+ this.logger.lifecycle("Player character updated", { playerIndex: playerData.playerIndex });
456
+ this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
365
457
  }
366
458
  });
459
+ this.registerTransportHandler(SMORE_EVENTS.RATE_LIMITED, (data) => {
460
+ const payload = data;
461
+ const event = payload?.event ?? "unknown";
462
+ this.logger.warn(`Rate limited: ${event}`);
463
+ this.config.onRateLimited?.(event);
464
+ });
367
465
  if (this.config.listeners) {
368
466
  for (const [event, handler] of Object.entries(this.config.listeners)) {
369
467
  if (!handler) continue;
468
+ this._configListenerEvents.add(event);
370
469
  this.setupUserEventHandler(event, handler);
371
470
  }
372
471
  }
373
472
  }
473
+ /**
474
+ * Sets up a user event handler with playerIndex extraction.
475
+ *
476
+ * Events received from controllers are dropped if they lack a playerIndex field.
477
+ * This is a security measure to prevent controller impersonation - the relay server
478
+ * automatically attaches playerIndex based on the sender's authenticated session,
479
+ * ensuring controllers cannot forge events as other players.
480
+ *
481
+ * Note: `playerIndex` is a reserved field name in event payloads.
482
+ * It is automatically extracted by the SDK and passed as the first argument
483
+ * to Screen event handlers. Game developers must NOT use `playerIndex` as
484
+ * a custom data field name -- it will be stripped from the data object.
485
+ */
374
486
  setupUserEventHandler(event, handler) {
375
487
  const wrappedHandler = (data) => {
376
488
  this.logger.receive(event, data);
@@ -381,12 +493,14 @@
381
493
  handler(playerIndex, rest);
382
494
  } catch (err) {
383
495
  this.handleError(
384
- new SmoreSDKError$1("UNKNOWN", `Error in handler for event "${event}"`, {
496
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
385
497
  cause: err instanceof Error ? err : void 0,
386
498
  details: { event, playerIndex }
387
499
  })
388
500
  );
389
501
  }
502
+ } else {
503
+ this.logger.debug(`Dropping event "${event}" without playerIndex`, data);
390
504
  }
391
505
  };
392
506
  this.registerTransportHandler(event, wrappedHandler);
@@ -405,15 +519,16 @@
405
519
  // ---------------------------------------------------------------------------
406
520
  // Properties (readonly)
407
521
  // ---------------------------------------------------------------------------
522
+ /**
523
+ * Returns a new shallow copy of the controllers array on every access.
524
+ * Cache the result if accessing repeatedly in the same frame/tick.
525
+ */
408
526
  get controllers() {
409
527
  return [...this._controllers];
410
528
  }
411
529
  get roomCode() {
412
530
  return this._roomCode;
413
531
  }
414
- get leaderIndex() {
415
- return this._leaderIndex;
416
- }
417
532
  get isReady() {
418
533
  return this._isReady;
419
534
  }
@@ -423,22 +538,70 @@
423
538
  // ---------------------------------------------------------------------------
424
539
  // Communication Methods
425
540
  // ---------------------------------------------------------------------------
541
+ /**
542
+ * Send type-safe events to all controllers.
543
+ *
544
+ * Uses EventMap generic for compile-time type checking of event names and data payloads.
545
+ * Runtime behavior is identical to broadcastRaw - both call validateEventName at runtime.
546
+ *
547
+ * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
548
+ * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
549
+ * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
550
+ * Use the onError callback or smore:rate-limited event to detect rate limiting.
551
+ *
552
+ * Warning: Avoid sending primitive values directly (string, number, boolean).
553
+ * Wrap in an object: broadcast('event', { value: 42 }) instead of broadcast('event', 42)
554
+ *
555
+ * @see broadcastRaw for bypassing TypeScript type checking (runtime behavior identical)
556
+ */
426
557
  broadcast(event, data) {
427
558
  this.ensureReady("broadcast");
428
- validateEventName$1(event);
559
+ validateEventName(event);
429
560
  this.logger.send(event, data);
430
561
  this.transport.emit(event, data);
431
562
  }
563
+ /**
564
+ * Send events to all controllers without TypeScript type checking.
565
+ *
566
+ * Bypasses EventMap generic type checks at compile time.
567
+ * Runtime behavior is identical to broadcast - both call validateEventName at runtime.
568
+ *
569
+ * Use this when you need dynamic event names or when working without a predefined EventMap.
570
+ *
571
+ * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
572
+ * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
573
+ *
574
+ * @see broadcast for type-safe version using EventMap generic
575
+ */
432
576
  broadcastRaw(event, data) {
433
577
  this.ensureReady("broadcastRaw");
434
- validateEventName$1(event);
578
+ validateEventName(event);
435
579
  this.logger.send(event, data);
436
580
  this.transport.emit(event, data);
437
581
  }
582
+ /**
583
+ * Send an event to a specific controller.
584
+ *
585
+ * **Reserved field:** `targetPlayerIndex` is automatically merged into the data payload
586
+ * to route the event to the specified controller. Game developers should avoid using
587
+ * `targetPlayerIndex` as a custom data field name to prevent conflicts.
588
+ *
589
+ * @note Data should be an object. Primitive values will be wrapped as `{ data: value }` by the relay server.
590
+ * @note Maximum payload size is 64KB. Data exceeding this limit will be silently dropped by the server.
591
+ *
592
+ * @param playerIndex - Target controller's player index
593
+ * @param event - Event name
594
+ * @param data - Event data payload
595
+ */
438
596
  sendToController(playerIndex, event, data) {
439
597
  this.ensureReady("sendToController");
440
- validateEventName$1(event);
441
- validatePlayerIndex(playerIndex, this._controllers.length);
598
+ validateEventName(event);
599
+ validatePlayerIndex(playerIndex, this._controllers);
600
+ if (data && typeof data === "object" && "targetPlayerIndex" in data) {
601
+ this.logger.warn(
602
+ `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
603
+ );
604
+ }
442
605
  this.logger.send(`${event} -> Player ${playerIndex}`, data);
443
606
  this.transport.emit(event, {
444
607
  targetPlayerIndex: playerIndex,
@@ -447,8 +610,13 @@
447
610
  }
448
611
  sendToControllerRaw(playerIndex, event, data) {
449
612
  this.ensureReady("sendToControllerRaw");
450
- validateEventName$1(event);
451
- validatePlayerIndex(playerIndex, this._controllers.length);
613
+ validateEventName(event);
614
+ validatePlayerIndex(playerIndex, this._controllers);
615
+ if (data && typeof data === "object" && "targetPlayerIndex" in data) {
616
+ this.logger.warn(
617
+ `Event "${event}" data contains reserved field "targetPlayerIndex" which will be overwritten for routing.`
618
+ );
619
+ }
452
620
  this.logger.send(`${event} -> Player ${playerIndex}`, data);
453
621
  this.transport.emit(event, {
454
622
  targetPlayerIndex: playerIndex,
@@ -461,21 +629,31 @@
461
629
  gameOver(results) {
462
630
  this.ensureReady("gameOver");
463
631
  this.logger.lifecycle("Game over", results);
464
- this.transport.emit(SYSTEM_EVENTS$1.GAME_OVER, { results });
632
+ this.transport.emit(SMORE_EVENTS.GAME_OVER, { results });
465
633
  }
466
634
  // ---------------------------------------------------------------------------
467
635
  // Event Subscription
468
636
  // ---------------------------------------------------------------------------
637
+ /**
638
+ * Register an event handler for messages from controllers.
639
+ *
640
+ * **Important:** If called before the Screen is ready (i.e., before `await createScreen()`
641
+ * resolves or before the `onReady` callback fires), the handler is stored locally but
642
+ * will NOT be registered with the transport layer. This means the handler will never
643
+ * fire for events received during the pre-ready window. Always call `on()` after
644
+ * initialization completes, or use `config.listeners` for handlers needed from the start.
645
+ */
469
646
  on(event, handler) {
470
- validateEventName$1(event);
647
+ validateEventName(event);
471
648
  let handlers = this.eventHandlers.get(event);
472
649
  if (!handlers) {
473
650
  handlers = /* @__PURE__ */ new Set();
474
651
  this.eventHandlers.set(event, handlers);
475
652
  }
476
653
  handlers.add(handler);
654
+ let wrappedHandler = null;
477
655
  if (this.transport) {
478
- const wrappedHandler = (data) => {
656
+ wrappedHandler = (data) => {
479
657
  this.logger.receive(event, data);
480
658
  const payload = data;
481
659
  const { playerIndex, ...rest } = payload;
@@ -484,22 +662,54 @@
484
662
  handler(playerIndex, rest);
485
663
  } catch (err) {
486
664
  this.handleError(
487
- new SmoreSDKError$1("UNKNOWN", `Error in handler for event "${event}"`, {
665
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
488
666
  cause: err instanceof Error ? err : void 0
489
667
  })
490
668
  );
491
669
  }
670
+ } else {
671
+ this.logger.debug(`Dropping event "${event}" without playerIndex`, data);
492
672
  }
493
673
  };
494
674
  this.registerTransportHandler(event, wrappedHandler);
675
+ this.handlerToTransport.set(handler, { event, transportHandler: wrappedHandler });
495
676
  }
496
677
  return () => {
497
678
  handlers?.delete(handler);
498
679
  if (handlers?.size === 0) {
499
680
  this.eventHandlers.delete(event);
500
681
  }
682
+ if (wrappedHandler) {
683
+ this.transport?.off(event, wrappedHandler);
684
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
685
+ (h) => h.handler !== wrappedHandler
686
+ );
687
+ this.handlerToTransport.delete(handler);
688
+ }
501
689
  };
502
690
  }
691
+ /**
692
+ * Register an event handler that will be called only once.
693
+ *
694
+ * The handler is automatically removed after the first invocation.
695
+ *
696
+ * **Important:** The wrapped handler cannot be removed via `off(event, originalHandler)`.
697
+ * Use the returned unsubscribe function instead.
698
+ *
699
+ * @param event - Event name to listen for
700
+ * @param handler - Handler function to call once
701
+ * @returns Unsubscribe function to remove the handler before it fires
702
+ *
703
+ * @example
704
+ * ```ts
705
+ * const unsubscribe = screen.once('ready', (playerIndex, data) => {
706
+ * console.log('Ready event received');
707
+ * });
708
+ *
709
+ * // To remove before the event fires:
710
+ * unsubscribe();
711
+ * ```
712
+ */
503
713
  once(event, handler) {
504
714
  const wrappedHandler = (playerIndex, data) => {
505
715
  unsubscribe();
@@ -510,14 +720,38 @@
510
720
  }
511
721
  off(event, handler) {
512
722
  if (!handler) {
513
- this.eventHandlers.delete(event);
514
- this.transport?.off(event);
723
+ if (this._configListenerEvents.has(event)) {
724
+ for (const [key, val] of this.handlerToTransport) {
725
+ if (val.event === event) {
726
+ this.transport?.off(event, val.transportHandler);
727
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
728
+ (h) => h.handler !== val.transportHandler
729
+ );
730
+ this.handlerToTransport.delete(key);
731
+ }
732
+ }
733
+ } else {
734
+ this.eventHandlers.delete(event);
735
+ this.transport?.off(event);
736
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter((h) => h.event !== event);
737
+ for (const [key, val] of this.handlerToTransport) {
738
+ if (val.event === event) this.handlerToTransport.delete(key);
739
+ }
740
+ }
515
741
  } else {
516
742
  const handlers = this.eventHandlers.get(event);
517
743
  handlers?.delete(handler);
518
744
  if (handlers?.size === 0) {
519
745
  this.eventHandlers.delete(event);
520
746
  }
747
+ const entry = this.handlerToTransport.get(handler);
748
+ if (entry) {
749
+ this.transport?.off(event, entry.transportHandler);
750
+ this.registeredTransportHandlers = this.registeredTransportHandlers.filter(
751
+ (h) => h.handler !== entry.transportHandler
752
+ );
753
+ this.handlerToTransport.delete(handler);
754
+ }
521
755
  }
522
756
  }
523
757
  // ---------------------------------------------------------------------------
@@ -526,12 +760,31 @@
526
760
  getController(playerIndex) {
527
761
  return this._controllers.find((c) => c.playerIndex === playerIndex);
528
762
  }
529
- isLeader(playerIndex) {
530
- return playerIndex === this._leaderIndex;
531
- }
532
763
  getControllerCount() {
533
764
  return this._controllers.filter((c) => c.connected).length;
534
765
  }
766
+ /**
767
+ * Check if there is at least one connected controller.
768
+ * Useful for detecting when all players have disconnected
769
+ * (e.g., to pause the game or show a waiting screen).
770
+ *
771
+ * Use this in onControllerDisconnect callback to detect when all controllers have disconnected.
772
+ *
773
+ * @example
774
+ * ```ts
775
+ * const screen = await createScreen<MyEvents>({
776
+ * onControllerDisconnect: (playerIndex) => {
777
+ * if (!screen.hasAnyConnectedControllers()) {
778
+ * console.log('All controllers disconnected!');
779
+ * screen.broadcast('waiting-for-players', {});
780
+ * }
781
+ * },
782
+ * });
783
+ * ```
784
+ */
785
+ hasAnyConnectedControllers() {
786
+ return this._controllers.some((c) => c.connected);
787
+ }
535
788
  // ---------------------------------------------------------------------------
536
789
  // Cleanup
537
790
  // ---------------------------------------------------------------------------
@@ -549,6 +802,7 @@
549
802
  }
550
803
  this.registeredTransportHandlers = [];
551
804
  this.eventHandlers.clear();
805
+ this.handlerToTransport.clear();
552
806
  if (this.transport instanceof PostMessageTransport) {
553
807
  this.transport.destroy();
554
808
  }
@@ -562,6 +816,7 @@
562
816
  // Error Handling
563
817
  // ---------------------------------------------------------------------------
564
818
  handleError(error) {
819
+ this.logger.warn(`Error in handler: ${error.message}`);
565
820
  const smoreError = error.toSmoreError();
566
821
  if (this.config.onError) {
567
822
  this.config.onError(smoreError);
@@ -571,14 +826,14 @@
571
826
  }
572
827
  ensureReady(method) {
573
828
  if (this._isDestroyed) {
574
- throw new SmoreSDKError$1(
829
+ throw new SmoreSDKError(
575
830
  "DESTROYED",
576
831
  `Cannot call ${method}() after destroy()`,
577
832
  { details: { method } }
578
833
  );
579
834
  }
580
835
  if (!this._isReady || !this.transport) {
581
- throw new SmoreSDKError$1(
836
+ throw new SmoreSDKError(
582
837
  "NOT_READY",
583
838
  `Cannot call ${method}() before screen is ready. Use await createScreen() or onReady callback.`,
584
839
  { details: { method } }
@@ -593,103 +848,26 @@
593
848
  return promise;
594
849
  }
595
850
 
596
- const SYSTEM_PREFIX = "smore:";
597
- const SYSTEM_EVENTS = {
598
- PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
599
- PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`
600
- };
601
851
  const DEFAULT_TIMEOUT = 1e4;
602
- class SmoreSDKError extends Error {
603
- code;
604
- cause;
605
- details;
606
- constructor(code, message, options) {
607
- super(message);
608
- this.name = "SmoreSDKError";
609
- this.code = code;
610
- this.cause = options?.cause;
611
- this.details = options?.details;
612
- const ErrorWithCapture = Error;
613
- if (typeof ErrorWithCapture.captureStackTrace === "function") {
614
- ErrorWithCapture.captureStackTrace(this, SmoreSDKError);
615
- }
616
- }
617
- toSmoreError() {
618
- return {
619
- code: this.code,
620
- message: this.message,
621
- cause: this.cause,
622
- details: this.details
623
- };
624
- }
625
- }
626
- const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
627
- function validateEventName(event) {
628
- if (!event || typeof event !== "string") {
629
- throw new SmoreSDKError("INVALID_EVENT", "Event name must be a non-empty string");
630
- }
631
- if (!EVENT_NAME_REGEX.test(event)) {
632
- throw new SmoreSDKError(
633
- "INVALID_EVENT",
634
- `Invalid event name "${event}". Event names must:
635
- - Start with a letter (a-z, A-Z)
636
- - Only contain letters, numbers, hyphens (-), and underscores (_)
637
- - End with a letter or number`,
638
- { details: { event } }
639
- );
640
- }
641
- }
642
- function createLogger(options) {
643
- const enabled = typeof options === "boolean" ? options : options?.enabled ?? false;
644
- const level = (typeof options === "object" ? options.level : void 0) ?? "debug";
645
- const prefix = (typeof options === "object" ? options.prefix : void 0) ?? "[SmoreController]";
646
- const customLogger = typeof options === "object" ? options.logger : void 0;
647
- const levelPriority = {
648
- debug: 0,
649
- info: 1,
650
- warn: 2,
651
- error: 3
652
- };
653
- const shouldLog = (msgLevel) => {
654
- if (!enabled) return false;
655
- return levelPriority[msgLevel] >= levelPriority[level];
656
- };
657
- const log = (msgLevel, message, data) => {
658
- if (!shouldLog(msgLevel)) return;
659
- if (customLogger) {
660
- customLogger(msgLevel, message, data);
661
- return;
662
- }
663
- const fullMessage = `${prefix} ${message}`;
664
- const consoleFn = console[msgLevel] ?? console.log;
665
- if (data !== void 0) {
666
- consoleFn(fullMessage, data);
667
- } else {
668
- consoleFn(fullMessage);
669
- }
670
- };
671
- return {
672
- debug: (msg, data) => log("debug", msg, data),
673
- info: (msg, data) => log("info", msg, data),
674
- warn: (msg, data) => log("warn", msg, data),
675
- error: (msg, data) => log("error", msg, data)
676
- };
677
- }
678
852
  class ControllerImpl {
679
853
  transport = null;
680
854
  config;
681
855
  logger;
682
856
  _roomCode = "";
683
857
  _myIndex = -1;
684
- _isLeader = false;
685
858
  _isReady = false;
686
859
  _isDestroyed = false;
687
860
  boundMessageHandler = null;
688
861
  registeredHandlers = [];
689
862
  eventListeners = /* @__PURE__ */ new Map();
863
+ // Maps user-facing handler -> transport wrappedHandler for proper cleanup in on()/off()
864
+ handlerToTransport = /* @__PURE__ */ new Map();
865
+ _controllers = [];
866
+ // Tracks event names registered via config.listeners so off(event) without handler won't remove them
867
+ _configListenerEvents = /* @__PURE__ */ new Set();
690
868
  constructor(config = {}) {
691
869
  this.config = config;
692
- this.logger = createLogger(config.debug);
870
+ this.logger = new DebugLogger(config.debug, "[SmoreController]");
693
871
  if (config.listeners) {
694
872
  for (const event of Object.keys(config.listeners)) {
695
873
  validateEventName(event);
@@ -702,9 +880,6 @@
702
880
  get myIndex() {
703
881
  return this._myIndex;
704
882
  }
705
- get isLeader() {
706
- return this._isLeader;
707
- }
708
883
  get roomCode() {
709
884
  return this._roomCode;
710
885
  }
@@ -714,19 +889,35 @@
714
889
  get isDestroyed() {
715
890
  return this._isDestroyed;
716
891
  }
892
+ /**
893
+ * Read-only list of all known controllers (players) in the room.
894
+ * Returns full ControllerInfo including playerIndex, nickname, connected status, and appearance.
895
+ *
896
+ * Returns a new shallow copy on every access. Cache the result if accessing
897
+ * repeatedly in the same frame/tick.
898
+ */
899
+ get controllers() {
900
+ return [...this._controllers];
901
+ }
902
+ /**
903
+ * Returns the number of currently connected players.
904
+ */
905
+ getControllerCount() {
906
+ return this._controllers.filter((c) => c.connected).length;
907
+ }
717
908
  // ---------------------------------------------------------------------------
718
909
  // Initialization
719
910
  // ---------------------------------------------------------------------------
720
911
  async initialize() {
721
912
  const parentOrigin = this.config.parentOrigin ?? "*";
722
913
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;
723
- this.logger.debug("Initializing controller...", { parentOrigin, timeout });
914
+ this.logger.lifecycle("Initializing controller...", { parentOrigin, timeout });
724
915
  return new Promise((resolve, reject) => {
725
916
  const timeoutId = setTimeout(() => {
726
917
  this.cleanup();
727
918
  const error = new SmoreSDKError(
728
919
  "TIMEOUT",
729
- `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends smore:init message.`,
920
+ `Controller initialization timed out after ${timeout}ms. Make sure the parent window sends _bridge:init message. Check that the iframe has correct sandbox attributes (allow-scripts required) and same-origin/cross-origin settings. Create a new Controller instance to retry (this instance has been cleaned up).`,
730
921
  { details: { timeout } }
731
922
  );
732
923
  this.handleError(error);
@@ -735,22 +926,36 @@
735
926
  this.boundMessageHandler = (e) => {
736
927
  if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
737
928
  const msg = e.data;
738
- if (!isSmoreMessage(msg)) return;
739
- if (msg.type === "smore:init") {
929
+ if (!isBridgeMessage(msg)) return;
930
+ if (msg.type === "_bridge:init") {
740
931
  clearTimeout(timeoutId);
741
932
  this.handleInit(msg, parentOrigin, resolve, reject);
742
- } else if (msg.type === "smore:update") {
933
+ } else if (msg.type === "_bridge:update") {
743
934
  this.handleUpdate(msg);
744
935
  }
745
936
  };
746
937
  window.addEventListener("message", this.boundMessageHandler);
747
- this.logger.debug("Sending smore:ready to parent");
748
- window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
938
+ this.logger.lifecycle("Sending _bridge:ready to parent");
939
+ window.parent.postMessage({ type: "_bridge:ready" }, parentOrigin);
749
940
  });
750
941
  }
751
942
  handleInit(msg, parentOrigin, resolve, reject) {
752
- const initData = msg.payload;
753
- this.logger.debug("Received smore:init", initData);
943
+ const initPayload = msg.payload;
944
+ this.logger.debug("Received _bridge:init", initPayload);
945
+ try {
946
+ validateInitPayload(initPayload);
947
+ } catch (err) {
948
+ const error = new SmoreSDKError(
949
+ "INIT_FAILED",
950
+ `Invalid _bridge:init payload: ${err instanceof Error ? err.message : String(err)}`,
951
+ { details: { payload: initPayload } }
952
+ );
953
+ this.logger.warn("_bridge:init validation failed", error);
954
+ this.handleError(error);
955
+ reject(error);
956
+ return;
957
+ }
958
+ const initData = initPayload;
754
959
  if (initData.side !== "player") {
755
960
  const error = new SmoreSDKError(
756
961
  "INIT_FAILED",
@@ -774,49 +979,171 @@
774
979
  this.transport = new PostMessageTransport(parentOrigin);
775
980
  this._roomCode = initData.roomCode;
776
981
  this._myIndex = initData.myIndex;
777
- this._isLeader = initData.isLeader ?? false;
982
+ const initPlayers = initData.players;
983
+ this._controllers = initPlayers.filter((p) => typeof p.playerIndex === "number").map((p) => ({
984
+ playerIndex: p.playerIndex,
985
+ nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
986
+ connected: p.connected !== false,
987
+ appearance: p.appearance ?? p.character
988
+ }));
778
989
  this.setupEventHandlers();
779
990
  this._isReady = true;
780
- this.logger.info("Controller ready", {
991
+ this.logger.lifecycle("Controller ready", {
781
992
  roomCode: this._roomCode,
782
- myIndex: this._myIndex,
783
- isLeader: this._isLeader
993
+ myIndex: this._myIndex
784
994
  });
785
995
  this.config.onReady?.();
786
996
  resolve();
787
997
  }
788
998
  handleUpdate(msg) {
999
+ if (!this._isReady) {
1000
+ this.logger.debug("Ignoring _bridge:update before init completes");
1001
+ return;
1002
+ }
789
1003
  const updateData = msg.payload;
790
- this.logger.debug("Received smore:update", updateData);
1004
+ this.logger.debug("Received _bridge:update", updateData);
1005
+ if (updateData.players && Array.isArray(updateData.players)) {
1006
+ const players = updateData.players;
1007
+ const newControllers = players.filter((p) => typeof p.playerIndex === "number").map((p) => ({
1008
+ playerIndex: p.playerIndex,
1009
+ nickname: p.nickname || p.name || `Player ${p.playerIndex + 1}`,
1010
+ connected: p.connected !== false,
1011
+ appearance: p.appearance ?? p.character
1012
+ }));
1013
+ const oldControllers = this._controllers;
1014
+ for (const nc of newControllers) {
1015
+ if (!oldControllers.some((oc) => oc.playerIndex === nc.playerIndex)) {
1016
+ this.config.onControllerJoin?.(nc.playerIndex, nc);
1017
+ }
1018
+ }
1019
+ for (const oc of oldControllers) {
1020
+ if (!newControllers.some((nc) => nc.playerIndex === oc.playerIndex)) {
1021
+ this.config.onControllerLeave?.(oc.playerIndex);
1022
+ }
1023
+ }
1024
+ for (const nc of newControllers) {
1025
+ const oc = oldControllers.find((c) => c.playerIndex === nc.playerIndex);
1026
+ if (oc) {
1027
+ if (oc.connected && !nc.connected) {
1028
+ this.logger.debug("Player disconnected (via update)", { playerIndex: nc.playerIndex });
1029
+ this.config.onControllerDisconnect?.(nc.playerIndex);
1030
+ }
1031
+ if (!oc.connected && nc.connected) {
1032
+ this.logger.debug("Player reconnected (via update)", { playerIndex: nc.playerIndex });
1033
+ this.config.onControllerReconnect?.(nc.playerIndex, nc);
1034
+ }
1035
+ }
1036
+ }
1037
+ this._controllers = newControllers;
1038
+ }
791
1039
  }
792
1040
  setupEventHandlers() {
793
1041
  if (!this.transport) return;
794
- this.registerHandler(
795
- SYSTEM_EVENTS.PLAYER_JOIN,
796
- (data) => {
797
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
798
- if (playerIndex !== void 0) {
799
- this.logger.debug("Player joined", { playerIndex });
800
- this.config.onControllerJoin?.(playerIndex, data.player);
801
- }
1042
+ this.registerHandler(SMORE_EVENTS.PLAYER_JOINED, (raw) => {
1043
+ const data = raw;
1044
+ const playerInfo = data.player;
1045
+ const playerIndex = playerInfo?.playerIndex ?? data.playerIndex;
1046
+ if (playerIndex !== void 0) {
1047
+ if (this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1048
+ const controllerInfo = playerInfo ? {
1049
+ playerIndex,
1050
+ nickname: playerInfo.nickname || playerInfo.name || `Player ${playerIndex + 1}`,
1051
+ connected: playerInfo.connected !== false,
1052
+ appearance: playerInfo.appearance ?? playerInfo.character
1053
+ } : {
1054
+ playerIndex,
1055
+ nickname: `Player ${playerIndex + 1}`,
1056
+ connected: true
1057
+ };
1058
+ this._controllers = [...this._controllers, controllerInfo];
1059
+ this.logger.debug("Player joined", { playerIndex });
1060
+ this.config.onControllerJoin?.(playerIndex, controllerInfo);
802
1061
  }
803
- );
804
- this.registerHandler(
805
- SYSTEM_EVENTS.PLAYER_LEAVE,
806
- (data) => {
807
- const playerIndex = data.player?.playerIndex ?? data.playerIndex;
808
- if (playerIndex !== void 0) {
809
- this.logger.debug("Player left", { playerIndex });
810
- this.config.onControllerLeave?.(playerIndex);
811
- }
1062
+ });
1063
+ this.registerHandler(SMORE_EVENTS.PLAYER_LEFT, (raw) => {
1064
+ const data = raw;
1065
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
1066
+ if (playerIndex !== void 0) {
1067
+ if (!this._controllers.some((c) => c.playerIndex === playerIndex)) return;
1068
+ this._controllers = this._controllers.filter((c) => c.playerIndex !== playerIndex);
1069
+ this.logger.debug("Player left", { playerIndex });
1070
+ this.config.onControllerLeave?.(playerIndex);
812
1071
  }
813
- );
1072
+ });
1073
+ this.registerHandler(SMORE_EVENTS.PLAYER_DISCONNECTED, (raw) => {
1074
+ const data = raw;
1075
+ const playerData = data.player;
1076
+ const playerIndex = playerData?.playerIndex ?? data.playerIndex;
1077
+ if (playerIndex !== void 0) {
1078
+ this._controllers = this._controllers.map(
1079
+ (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1080
+ );
1081
+ this.logger.debug("Player disconnected", { playerIndex });
1082
+ this.config.onControllerDisconnect?.(playerIndex);
1083
+ }
1084
+ });
1085
+ this.registerHandler(SMORE_EVENTS.PLAYER_RECONNECTED, (raw) => {
1086
+ const data = raw;
1087
+ const playerData = data.player;
1088
+ const playerIndex = playerData?.playerIndex ?? data.playerIndex;
1089
+ if (playerIndex !== void 0) {
1090
+ const controllerInfo = playerData ? {
1091
+ playerIndex,
1092
+ nickname: playerData.nickname || playerData.name || `Player ${playerIndex + 1}`,
1093
+ connected: true,
1094
+ appearance: playerData.appearance ?? playerData.character
1095
+ } : {
1096
+ playerIndex,
1097
+ nickname: `Player ${playerIndex + 1}`,
1098
+ connected: true
1099
+ };
1100
+ this._controllers = this._controllers.map(
1101
+ (c) => c.playerIndex === playerIndex ? controllerInfo : c
1102
+ );
1103
+ this.logger.debug("Player reconnected", { playerIndex });
1104
+ this.config.onControllerReconnect?.(playerIndex, controllerInfo);
1105
+ }
1106
+ });
1107
+ this.registerHandler(SMORE_EVENTS.PLAYER_CHARACTER_UPDATED, (raw) => {
1108
+ const payload = raw;
1109
+ const playerData = payload?.player;
1110
+ if (playerData && typeof playerData.playerIndex === "number") {
1111
+ const appearance = playerData.character ?? null;
1112
+ this._controllers = this._controllers.map(
1113
+ (c) => c.playerIndex === playerData.playerIndex ? { ...c, appearance } : c
1114
+ );
1115
+ this.logger.debug("Player character updated", { playerIndex: playerData.playerIndex });
1116
+ this.config.onCharacterUpdated?.(playerData.playerIndex, appearance ?? null);
1117
+ }
1118
+ });
1119
+ this.registerHandler(SMORE_EVENTS.RATE_LIMITED, (raw) => {
1120
+ const data = raw;
1121
+ const event = data?.event ?? "unknown";
1122
+ this.logger.warn(`Rate limited: ${event}`);
1123
+ this.config.onRateLimited?.(event);
1124
+ });
1125
+ if (this.config.onHostDisconnect) {
1126
+ this.logger.warn("onHostDisconnect is reserved for future use and currently non-functional");
1127
+ }
1128
+ if (this.config.onHostReconnect) {
1129
+ this.logger.warn("onHostReconnect is reserved for future use and currently non-functional");
1130
+ }
814
1131
  if (this.config.listeners) {
815
1132
  for (const [event, handler] of Object.entries(this.config.listeners)) {
816
1133
  if (!handler) continue;
1134
+ this._configListenerEvents.add(event);
817
1135
  this.registerHandler(event, (data) => {
818
1136
  this.logReceive(event, data);
819
- handler(data);
1137
+ try {
1138
+ handler(data);
1139
+ } catch (err) {
1140
+ this.handleError(
1141
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1142
+ cause: err instanceof Error ? err : void 0,
1143
+ details: { event }
1144
+ })
1145
+ );
1146
+ }
820
1147
  });
821
1148
  }
822
1149
  }
@@ -829,9 +1156,24 @@
829
1156
  // ---------------------------------------------------------------------------
830
1157
  // Communication Methods
831
1158
  // ---------------------------------------------------------------------------
1159
+ /**
1160
+ * Send an event to the Screen. Controller-to-Controller direct communication
1161
+ * is not supported; all messages must go through the Screen.
1162
+ *
1163
+ * Data is sent to the Screen only (not to other controllers). For Screen→Controller communication,
1164
+ * Screen uses broadcast() or sendToController().
1165
+ *
1166
+ * @note Fire-and-forget sends (no callback) will silently fail if rate-limited.
1167
+ * Use the onError callback or smore:rate-limited event to detect rate limiting.
1168
+ */
832
1169
  send(event, data) {
833
1170
  this.ensureReady("send");
834
1171
  validateEventName(event);
1172
+ if (typeof data !== "object" || data === null) {
1173
+ this.logger.warn(
1174
+ 'Event data should be an object. Primitive values will be wrapped as { data: value } by the relay server. To avoid confusion, wrap explicitly: send("event", { value: 42 }) instead of send("event", 42).'
1175
+ );
1176
+ }
835
1177
  this.logSend(event, data);
836
1178
  this.transport.emit(event, data);
837
1179
  }
@@ -844,6 +1186,28 @@
844
1186
  // ---------------------------------------------------------------------------
845
1187
  // Event Subscription
846
1188
  // ---------------------------------------------------------------------------
1189
+ /**
1190
+ * Register a handler for custom events.
1191
+ *
1192
+ * When receiving events from Screen's `broadcast()`:
1193
+ * handler receives `(data)` — no playerIndex included.
1194
+ *
1195
+ * When receiving events from Screen's `sendToController()`:
1196
+ * handler receives `(data)` — targeted to this specific controller.
1197
+ *
1198
+ * @note Unlike Screen's `on()` which receives `(playerIndex, data)`,
1199
+ * Controller's `on()` receives only `(data)` since there's only one player per controller.
1200
+ *
1201
+ * Controller's on() handler signature: (data) => void
1202
+ * Unlike Screen's (playerIndex, data) => void, Controller doesn't receive playerIndex
1203
+ * because Controller only receives events from Screen, not from other controllers.
1204
+ * The sender is always the Screen, so playerIndex is not applicable.
1205
+ *
1206
+ * **Important:** If called before the Controller is ready (i.e., before `await createController()`
1207
+ * resolves or before the `onReady` callback fires), the handler is stored locally but
1208
+ * will NOT receive events until the transport is initialized. Always call `on()` after
1209
+ * initialization completes, or use `config.listeners` for handlers needed from the start.
1210
+ */
847
1211
  on(event, handler) {
848
1212
  validateEventName(event);
849
1213
  let listeners = this.eventListeners.get(event);
@@ -854,11 +1218,21 @@
854
1218
  listeners.add(handler);
855
1219
  const transportHandler = (data) => {
856
1220
  this.logReceive(event, data);
857
- handler(data);
1221
+ try {
1222
+ handler(data);
1223
+ } catch (err) {
1224
+ this.handleError(
1225
+ new SmoreSDKError("UNKNOWN", `Error in handler for event "${event}"`, {
1226
+ cause: err instanceof Error ? err : void 0,
1227
+ details: { event }
1228
+ })
1229
+ );
1230
+ }
858
1231
  };
859
1232
  if (this.transport) {
860
1233
  this.transport.on(event, transportHandler);
861
1234
  this.registeredHandlers.push({ event, handler: transportHandler });
1235
+ this.handlerToTransport.set(handler, { event, transportHandler });
862
1236
  }
863
1237
  return () => {
864
1238
  listeners?.delete(handler);
@@ -869,8 +1243,25 @@
869
1243
  this.registeredHandlers = this.registeredHandlers.filter(
870
1244
  (h) => h.handler !== transportHandler
871
1245
  );
1246
+ this.handlerToTransport.delete(handler);
872
1247
  };
873
1248
  }
1249
+ /**
1250
+ * Add a one-time listener that auto-removes after first call.
1251
+ *
1252
+ * @note The handler is internally wrapped, so it cannot be removed via
1253
+ * `off(event, originalHandler)`. Use the returned unsubscribe function instead.
1254
+ *
1255
+ * @example
1256
+ * ```ts
1257
+ * const unsubscribe = controller.once('game-start', (data) => {
1258
+ * console.log('Game started!', data);
1259
+ * });
1260
+ *
1261
+ * // To cancel before it fires:
1262
+ * unsubscribe();
1263
+ * ```
1264
+ */
874
1265
  once(event, handler) {
875
1266
  const unsubscribe = this.on(event, ((data) => {
876
1267
  unsubscribe();
@@ -880,15 +1271,38 @@
880
1271
  }
881
1272
  off(event, handler) {
882
1273
  if (!handler) {
883
- this.eventListeners.delete(event);
884
- this.transport?.off(event);
885
- this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1274
+ if (this._configListenerEvents.has(event)) {
1275
+ for (const [key, val] of this.handlerToTransport) {
1276
+ if (val.event === event) {
1277
+ this.transport?.off(event, val.transportHandler);
1278
+ this.registeredHandlers = this.registeredHandlers.filter(
1279
+ (h) => h.handler !== val.transportHandler
1280
+ );
1281
+ this.handlerToTransport.delete(key);
1282
+ }
1283
+ }
1284
+ } else {
1285
+ this.eventListeners.delete(event);
1286
+ this.transport?.off(event);
1287
+ this.registeredHandlers = this.registeredHandlers.filter((h) => h.event !== event);
1288
+ for (const [key, val] of this.handlerToTransport) {
1289
+ if (val.event === event) this.handlerToTransport.delete(key);
1290
+ }
1291
+ }
886
1292
  } else {
887
1293
  const listeners = this.eventListeners.get(event);
888
1294
  listeners?.delete(handler);
889
1295
  if (listeners?.size === 0) {
890
1296
  this.eventListeners.delete(event);
891
1297
  }
1298
+ const entry = this.handlerToTransport.get(handler);
1299
+ if (entry) {
1300
+ this.transport?.off(event, entry.transportHandler);
1301
+ this.registeredHandlers = this.registeredHandlers.filter(
1302
+ (h) => h.handler !== entry.transportHandler
1303
+ );
1304
+ this.handlerToTransport.delete(handler);
1305
+ }
892
1306
  }
893
1307
  }
894
1308
  // ---------------------------------------------------------------------------
@@ -896,7 +1310,7 @@
896
1310
  // ---------------------------------------------------------------------------
897
1311
  destroy() {
898
1312
  if (this._isDestroyed) return;
899
- this.logger.info("Destroying controller");
1313
+ this.logger.lifecycle("Destroying controller");
900
1314
  this.cleanup();
901
1315
  this._isDestroyed = true;
902
1316
  }
@@ -907,6 +1321,7 @@
907
1321
  }
908
1322
  this.registeredHandlers = [];
909
1323
  this.eventListeners.clear();
1324
+ this.handlerToTransport.clear();
910
1325
  if (this.transport) {
911
1326
  this.transport.destroy();
912
1327
  this.transport = null;
@@ -936,6 +1351,7 @@
936
1351
  }
937
1352
  }
938
1353
  handleError(error) {
1354
+ this.logger.warn(`Error in handler: ${error.message}`);
939
1355
  if (this.config.onError) {
940
1356
  this.config.onError(error.toSmoreError());
941
1357
  } else {
@@ -943,18 +1359,10 @@
943
1359
  }
944
1360
  }
945
1361
  logSend(event, data) {
946
- const options = this.config.debug;
947
- const shouldLog = typeof options === "object" ? options.logSend ?? true : Boolean(options);
948
- if (shouldLog) {
949
- this.logger.debug(`\u2192 SEND [${event}]`, data);
950
- }
1362
+ this.logger.send(event, data);
951
1363
  }
952
1364
  logReceive(event, data) {
953
- const options = this.config.debug;
954
- const shouldLog = typeof options === "object" ? options.logReceive ?? true : Boolean(options);
955
- if (shouldLog) {
956
- this.logger.debug(`\u2190 RECV [${event}]`, data);
957
- }
1365
+ this.logger.receive(event, data);
958
1366
  }
959
1367
  }
960
1368
  function createController(config) {
@@ -964,61 +1372,40 @@
964
1372
  return promise;
965
1373
  }
966
1374
 
967
- class DirectTransport {
968
- constructor(socket) {
969
- this.socket = socket;
970
- }
971
- emit(event, ...args) {
972
- this.socket.emit(event, ...args);
973
- }
974
- on(event, handler) {
975
- this.socket.on(event, handler);
976
- }
977
- off(event, handler) {
978
- if (handler) {
979
- this.socket.off(event, handler);
980
- } else {
981
- this.socket.off(event);
982
- }
983
- }
984
- }
985
-
986
- const SMORE_EVENTS = {
987
- // 게임 lifecycle
988
- READY: "smore:ready",
989
- GAME_OVER: "smore:game-over",
990
- RETURN_TO_LOBBY: "smore:return-to-lobby",
991
- // 플레이어 관리
992
- PLAYER_JOIN: "smore:player-join",
993
- PLAYER_LEAVE: "smore:player-leave",
994
- // 특정 플레이어에게 전송 (내부용)
995
- SEND_TO_PLAYER: "smore:send-to-player",
996
- // 초기화
997
- INIT: "smore:init",
998
- UPDATE: "smore:update"
999
- };
1000
- function validateUserEvent(event) {
1001
- if (event.includes(":")) {
1002
- throw new Error(
1003
- `Invalid event name "${event}": User events cannot contain ':'. Use '_' or '-' instead. System events use 'smore:' prefix.`
1004
- );
1005
- }
1006
- }
1007
- function isSystemEvent(event) {
1008
- return event.startsWith("smore:");
1009
- }
1010
-
1011
1375
  function createMockScreen(options = {}) {
1012
1376
  const {
1013
1377
  roomCode = "TEST",
1014
1378
  controllers: initialControllers = [],
1015
- autoReady = true
1379
+ autoReady = true,
1380
+ onReady: onReadyCb,
1381
+ onControllerJoin: onJoinCb,
1382
+ onControllerLeave: onLeaveCb,
1383
+ onControllerDisconnect: onDisconnectCb,
1384
+ onControllerReconnect: onReconnectCb,
1385
+ onCharacterUpdated: onCharacterUpdatedCb,
1386
+ onRateLimited: onRateLimitedCb,
1387
+ onError: onErrorCb
1016
1388
  } = options;
1017
1389
  let _controllers = [...initialControllers];
1018
1390
  let _isReady = false;
1019
1391
  let _isDestroyed = false;
1020
- let _leaderIndex = initialControllers[0]?.playerIndex ?? -1;
1021
1392
  const listeners = /* @__PURE__ */ new Map();
1393
+ let onReadyCallback;
1394
+ let onControllerJoinCallback;
1395
+ let onControllerLeaveCallback;
1396
+ let onControllerDisconnectCallback;
1397
+ let onControllerReconnectCallback;
1398
+ let onCharacterUpdatedCallback;
1399
+ let onRateLimitedCallback;
1400
+ let onErrorCallback;
1401
+ onReadyCallback = onReadyCb;
1402
+ onControllerJoinCallback = onJoinCb;
1403
+ onControllerLeaveCallback = onLeaveCb;
1404
+ onControllerDisconnectCallback = onDisconnectCb;
1405
+ onControllerReconnectCallback = onReconnectCb;
1406
+ onCharacterUpdatedCallback = onCharacterUpdatedCb;
1407
+ onRateLimitedCallback = onRateLimitedCb;
1408
+ onErrorCallback = onErrorCb;
1022
1409
  const broadcasts = [];
1023
1410
  const sends = [];
1024
1411
  const screen = {
@@ -1029,9 +1416,6 @@
1029
1416
  get roomCode() {
1030
1417
  return roomCode;
1031
1418
  },
1032
- get leaderIndex() {
1033
- return _leaderIndex;
1034
- },
1035
1419
  get isReady() {
1036
1420
  return _isReady;
1037
1421
  },
@@ -1043,18 +1427,30 @@
1043
1427
  if (_isDestroyed) {
1044
1428
  throw new Error("Cannot broadcast: screen is destroyed");
1045
1429
  }
1430
+ if (!_isReady) {
1431
+ throw new Error("Cannot broadcast: screen is not ready");
1432
+ }
1433
+ validateEventName(event);
1046
1434
  broadcasts.push({ event, data });
1047
1435
  },
1048
1436
  broadcastRaw(event, data) {
1049
1437
  if (_isDestroyed) {
1050
1438
  throw new Error("Cannot broadcast: screen is destroyed");
1051
1439
  }
1440
+ if (!_isReady) {
1441
+ throw new Error("Cannot broadcast: screen is not ready");
1442
+ }
1443
+ validateEventName(event);
1052
1444
  broadcasts.push({ event, data });
1053
1445
  },
1054
1446
  sendToController(playerIndex, event, data) {
1055
1447
  if (_isDestroyed) {
1056
1448
  throw new Error("Cannot send: screen is destroyed");
1057
1449
  }
1450
+ if (!_isReady) {
1451
+ throw new Error("Cannot send: screen is not ready");
1452
+ }
1453
+ validateEventName(event);
1058
1454
  if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1059
1455
  throw new Error(`Invalid player index: ${playerIndex}`);
1060
1456
  }
@@ -1064,6 +1460,10 @@
1064
1460
  if (_isDestroyed) {
1065
1461
  throw new Error("Cannot send: screen is destroyed");
1066
1462
  }
1463
+ if (!_isReady) {
1464
+ throw new Error("Cannot send: screen is not ready");
1465
+ }
1466
+ validateEventName(event);
1067
1467
  if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1068
1468
  throw new Error(`Invalid player index: ${playerIndex}`);
1069
1469
  }
@@ -1071,10 +1471,17 @@
1071
1471
  },
1072
1472
  // === Game Lifecycle ===
1073
1473
  gameOver(results) {
1074
- screen.broadcastRaw("game-over", results);
1474
+ if (_isDestroyed) {
1475
+ throw new Error("Cannot call gameOver: screen is destroyed");
1476
+ }
1477
+ if (!_isReady) {
1478
+ throw new Error("Cannot call gameOver: screen is not ready");
1479
+ }
1480
+ broadcasts.push({ event: "smore:game-over", data: { results } });
1075
1481
  },
1076
1482
  // === Event Subscription ===
1077
1483
  on(event, handler) {
1484
+ validateEventName(event);
1078
1485
  const eventStr = event;
1079
1486
  if (!listeners.has(eventStr)) {
1080
1487
  listeners.set(eventStr, /* @__PURE__ */ new Set());
@@ -1085,6 +1492,7 @@
1085
1492
  };
1086
1493
  },
1087
1494
  once(event, handler) {
1495
+ validateEventName(event);
1088
1496
  const wrapper = (playerIndex, data) => {
1089
1497
  handler(playerIndex, data);
1090
1498
  screen.off(event, wrapper);
@@ -1092,6 +1500,7 @@
1092
1500
  return screen.on(event, wrapper);
1093
1501
  },
1094
1502
  off(event, handler) {
1503
+ validateEventName(event);
1095
1504
  const eventStr = event;
1096
1505
  if (!handler) {
1097
1506
  listeners.delete(eventStr);
@@ -1103,13 +1512,16 @@
1103
1512
  getController(playerIndex) {
1104
1513
  return _controllers.find((c) => c.playerIndex === playerIndex);
1105
1514
  },
1106
- isLeader(playerIndex) {
1107
- return playerIndex === _leaderIndex;
1108
- },
1109
1515
  getControllerCount() {
1110
- return _controllers.length;
1516
+ return _controllers.filter((c) => c.connected).length;
1517
+ },
1518
+ hasAnyConnectedControllers() {
1519
+ return _controllers.some((c) => c.connected);
1111
1520
  },
1112
1521
  // === Cleanup ===
1522
+ /**
1523
+ * Note: destroy() clears recorded broadcast/event arrays. Call getBroadcasts() before destroy() if assertions are needed.
1524
+ */
1113
1525
  destroy() {
1114
1526
  _isDestroyed = true;
1115
1527
  listeners.clear();
@@ -1118,6 +1530,7 @@
1118
1530
  },
1119
1531
  // === Mock-specific methods ===
1120
1532
  simulateEvent(playerIndex, event, data) {
1533
+ validateEventName(event);
1121
1534
  const eventStr = event;
1122
1535
  const handlers = listeners.get(eventStr);
1123
1536
  if (handlers) {
@@ -1128,14 +1541,80 @@
1128
1541
  },
1129
1542
  simulateControllerJoin(info) {
1130
1543
  _controllers.push(info);
1131
- if (_leaderIndex === -1) {
1132
- _leaderIndex = info.playerIndex;
1544
+ if (onControllerJoinCallback) {
1545
+ onControllerJoinCallback(info.playerIndex, info);
1133
1546
  }
1134
1547
  },
1135
1548
  simulateControllerLeave(playerIndex) {
1136
1549
  _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1137
- if (_leaderIndex === playerIndex) {
1138
- _leaderIndex = _controllers[0]?.playerIndex ?? -1;
1550
+ if (onControllerLeaveCallback) {
1551
+ onControllerLeaveCallback(playerIndex);
1552
+ }
1553
+ },
1554
+ /**
1555
+ * Simulate a controller network disconnect (player still in room but unreachable).
1556
+ *
1557
+ * @example
1558
+ * ```ts
1559
+ * screen.simulateControllerDisconnect(0);
1560
+ * expect(screen.getController(0)?.connected).toBe(false);
1561
+ * ```
1562
+ */
1563
+ simulateControllerDisconnect(playerIndex) {
1564
+ const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1565
+ if (!controller) {
1566
+ throw new Error(`Controller ${playerIndex} not found`);
1567
+ }
1568
+ _controllers = _controllers.map(
1569
+ (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1570
+ );
1571
+ if (onControllerDisconnectCallback) {
1572
+ onControllerDisconnectCallback(playerIndex);
1573
+ }
1574
+ },
1575
+ /**
1576
+ * Simulate a controller network reconnect after disconnect.
1577
+ *
1578
+ * @example
1579
+ * ```ts
1580
+ * screen.simulateControllerDisconnect(0);
1581
+ * screen.simulateControllerReconnect(0);
1582
+ * expect(screen.getController(0)?.connected).toBe(true);
1583
+ * ```
1584
+ */
1585
+ simulateControllerReconnect(playerIndex) {
1586
+ const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1587
+ if (!controller) {
1588
+ throw new Error(`Controller ${playerIndex} not found`);
1589
+ }
1590
+ const reconnectedController = { ...controller, connected: true };
1591
+ _controllers = _controllers.map(
1592
+ (c) => c.playerIndex === playerIndex ? reconnectedController : c
1593
+ );
1594
+ if (onControllerReconnectCallback) {
1595
+ onControllerReconnectCallback(playerIndex, reconnectedController);
1596
+ }
1597
+ },
1598
+ simulateCharacterUpdate(playerIndex, appearance) {
1599
+ const controller = _controllers.find((c) => c.playerIndex === playerIndex);
1600
+ if (!controller) {
1601
+ throw new Error(`Controller ${playerIndex} not found`);
1602
+ }
1603
+ _controllers = _controllers.map(
1604
+ (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
1605
+ );
1606
+ if (onCharacterUpdatedCallback) {
1607
+ onCharacterUpdatedCallback(playerIndex, appearance);
1608
+ }
1609
+ },
1610
+ simulateRateLimited(event) {
1611
+ if (onRateLimitedCallback) {
1612
+ onRateLimitedCallback(event);
1613
+ }
1614
+ },
1615
+ simulateError(error) {
1616
+ if (onErrorCallback) {
1617
+ onErrorCallback(error);
1139
1618
  }
1140
1619
  },
1141
1620
  getBroadcasts() {
@@ -1150,6 +1629,9 @@
1150
1629
  },
1151
1630
  triggerReady() {
1152
1631
  _isReady = true;
1632
+ if (onReadyCallback) {
1633
+ onReadyCallback();
1634
+ }
1153
1635
  }
1154
1636
  };
1155
1637
  if (autoReady) {
@@ -1161,22 +1643,42 @@
1161
1643
  const {
1162
1644
  roomCode = "TEST",
1163
1645
  myIndex = 0,
1164
- isLeader: initialIsLeader = false,
1165
- autoReady = true
1646
+ autoReady = true,
1647
+ onReady: onReadyCb,
1648
+ onControllerJoin: onJoinCb,
1649
+ onControllerLeave: onLeaveCb,
1650
+ onControllerDisconnect: onDisconnectCb,
1651
+ onControllerReconnect: onReconnectCb,
1652
+ onCharacterUpdated: onCharacterUpdatedCb,
1653
+ onRateLimited: onRateLimitedCb,
1654
+ onError: onErrorCb
1166
1655
  } = options;
1167
1656
  let _isReady = false;
1168
1657
  let _isDestroyed = false;
1169
- let _isLeader = initialIsLeader;
1658
+ let _controllers = options.controllers ?? [];
1170
1659
  const listeners = /* @__PURE__ */ new Map();
1660
+ let onReadyCallback;
1661
+ let onControllerJoinCallback;
1662
+ let onControllerLeaveCallback;
1663
+ let onControllerDisconnectCallback;
1664
+ let onControllerReconnectCallback;
1665
+ let onCharacterUpdatedCallback;
1666
+ let onRateLimitedCallback;
1667
+ let onErrorCallback;
1668
+ onReadyCallback = onReadyCb;
1669
+ onControllerJoinCallback = onJoinCb;
1670
+ onControllerLeaveCallback = onLeaveCb;
1671
+ onControllerDisconnectCallback = onDisconnectCb;
1672
+ onControllerReconnectCallback = onReconnectCb;
1673
+ onCharacterUpdatedCallback = onCharacterUpdatedCb;
1674
+ onRateLimitedCallback = onRateLimitedCb;
1675
+ onErrorCallback = onErrorCb;
1171
1676
  const sentEvents = [];
1172
1677
  const controller = {
1173
1678
  // === Properties ===
1174
1679
  get myIndex() {
1175
1680
  return myIndex;
1176
1681
  },
1177
- get isLeader() {
1178
- return _isLeader;
1179
- },
1180
1682
  get roomCode() {
1181
1683
  return roomCode;
1182
1684
  },
@@ -1186,21 +1688,30 @@
1186
1688
  get isDestroyed() {
1187
1689
  return _isDestroyed;
1188
1690
  },
1691
+ get controllers() {
1692
+ return [..._controllers];
1693
+ },
1694
+ getControllerCount() {
1695
+ return _controllers.filter((c) => c.connected).length;
1696
+ },
1189
1697
  // === Communication Methods ===
1190
1698
  send(event, data) {
1191
1699
  if (_isDestroyed) {
1192
1700
  throw new Error("Cannot send: controller is destroyed");
1193
1701
  }
1702
+ validateEventName(event);
1194
1703
  sentEvents.push({ event, data });
1195
1704
  },
1196
1705
  sendRaw(event, data) {
1197
1706
  if (_isDestroyed) {
1198
1707
  throw new Error("Cannot send: controller is destroyed");
1199
1708
  }
1709
+ validateEventName(event);
1200
1710
  sentEvents.push({ event, data });
1201
1711
  },
1202
1712
  // === Event Subscription ===
1203
1713
  on(event, handler) {
1714
+ validateEventName(event);
1204
1715
  const eventStr = event;
1205
1716
  if (!listeners.has(eventStr)) {
1206
1717
  listeners.set(eventStr, /* @__PURE__ */ new Set());
@@ -1211,6 +1722,7 @@
1211
1722
  };
1212
1723
  },
1213
1724
  once(event, handler) {
1725
+ validateEventName(event);
1214
1726
  const wrapper = (data) => {
1215
1727
  handler(data);
1216
1728
  controller.off(event, wrapper);
@@ -1218,6 +1730,7 @@
1218
1730
  return controller.on(event, wrapper);
1219
1731
  },
1220
1732
  off(event, handler) {
1733
+ validateEventName(event);
1221
1734
  const eventStr = event;
1222
1735
  if (!handler) {
1223
1736
  listeners.delete(eventStr);
@@ -1233,6 +1746,7 @@
1233
1746
  },
1234
1747
  // === Mock-specific methods ===
1235
1748
  simulateEvent(event, data) {
1749
+ validateEventName(event);
1236
1750
  const eventStr = event;
1237
1751
  const handlers = listeners.get(eventStr);
1238
1752
  if (handlers) {
@@ -1249,9 +1763,100 @@
1249
1763
  },
1250
1764
  triggerReady() {
1251
1765
  _isReady = true;
1766
+ if (onReadyCallback) {
1767
+ onReadyCallback();
1768
+ }
1769
+ },
1770
+ /**
1771
+ * Simulate a new player joining the room.
1772
+ * Stores full ControllerInfo (nickname, appearance, etc.) for later retrieval.
1773
+ *
1774
+ * @example
1775
+ * ```ts
1776
+ * controller.simulatePlayerJoin(2, { playerIndex: 2, nickname: 'Alice', connected: true });
1777
+ * expect(controller.getControllerCount()).toBe(3);
1778
+ * expect(controller.controllers.find(c => c.playerIndex === 2)?.nickname).toBe('Alice');
1779
+ * ```
1780
+ */
1781
+ simulatePlayerJoin(playerIndex, info) {
1782
+ if (!_controllers.some((c) => c.playerIndex === playerIndex)) {
1783
+ _controllers = [..._controllers, { ...info, connected: info.connected ?? true }];
1784
+ }
1785
+ if (onControllerJoinCallback) {
1786
+ onControllerJoinCallback(playerIndex, info);
1787
+ }
1788
+ },
1789
+ /**
1790
+ * Simulate a player leaving the room (fully removed).
1791
+ *
1792
+ * @example
1793
+ * ```ts
1794
+ * controller.simulatePlayerLeave(1);
1795
+ * expect(controller.controllers.some(c => c.playerIndex === 1)).toBe(false);
1796
+ * ```
1797
+ */
1798
+ simulatePlayerLeave(playerIndex) {
1799
+ _controllers = _controllers.filter((c) => c.playerIndex !== playerIndex);
1800
+ if (onControllerLeaveCallback) {
1801
+ onControllerLeaveCallback(playerIndex);
1802
+ }
1252
1803
  },
1253
- setLeader(isLeader) {
1254
- _isLeader = isLeader;
1804
+ /**
1805
+ * Simulate a player network disconnect (player still in room but unreachable).
1806
+ *
1807
+ * @example
1808
+ * ```ts
1809
+ * controller.simulatePlayerDisconnect(1);
1810
+ * expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(false);
1811
+ * ```
1812
+ */
1813
+ simulatePlayerDisconnect(playerIndex) {
1814
+ _controllers = _controllers.map(
1815
+ (c) => c.playerIndex === playerIndex ? { ...c, connected: false } : c
1816
+ );
1817
+ if (onControllerDisconnectCallback) {
1818
+ onControllerDisconnectCallback(playerIndex);
1819
+ }
1820
+ },
1821
+ /**
1822
+ * Simulate a player network reconnect after disconnect.
1823
+ *
1824
+ * @example
1825
+ * ```ts
1826
+ * controller.simulatePlayerDisconnect(1);
1827
+ * controller.simulatePlayerReconnect(1, { playerIndex: 1, nickname: 'Bob', connected: true });
1828
+ * expect(controller.controllers.find(c => c.playerIndex === 1)?.connected).toBe(true);
1829
+ * ```
1830
+ */
1831
+ simulatePlayerReconnect(playerIndex, info) {
1832
+ _controllers = _controllers.map(
1833
+ (c) => c.playerIndex === playerIndex ? { ...info, connected: true } : c
1834
+ );
1835
+ if (onControllerReconnectCallback) {
1836
+ onControllerReconnectCallback(playerIndex, info);
1837
+ }
1838
+ },
1839
+ simulateCharacterUpdate(playerIndex, appearance) {
1840
+ const controller2 = _controllers.find((c) => c.playerIndex === playerIndex);
1841
+ if (!controller2) {
1842
+ throw new Error(`Controller ${playerIndex} not found`);
1843
+ }
1844
+ _controllers = _controllers.map(
1845
+ (c) => c.playerIndex === playerIndex ? { ...c, appearance } : c
1846
+ );
1847
+ if (onCharacterUpdatedCallback) {
1848
+ onCharacterUpdatedCallback(playerIndex, appearance);
1849
+ }
1850
+ },
1851
+ simulateRateLimited(event) {
1852
+ if (onRateLimitedCallback) {
1853
+ onRateLimitedCallback(event);
1854
+ }
1855
+ },
1856
+ simulateError(error) {
1857
+ if (onErrorCallback) {
1858
+ onErrorCallback(error);
1859
+ }
1255
1860
  }
1256
1861
  };
1257
1862
  if (autoReady) {
@@ -1260,16 +1865,12 @@
1260
1865
  return controller;
1261
1866
  }
1262
1867
 
1263
- exports.DirectTransport = DirectTransport;
1264
- exports.PostMessageTransport = PostMessageTransport;
1265
- exports.SMORE_EVENTS = SMORE_EVENTS;
1266
- exports.SmoreSDKError = SmoreSDKError$1;
1868
+ exports.SmoreSDKError = SmoreSDKError;
1267
1869
  exports.createController = createController;
1268
1870
  exports.createMockController = createMockController;
1269
1871
  exports.createMockScreen = createMockScreen;
1270
1872
  exports.createScreen = createScreen;
1271
- exports.isSystemEvent = isSystemEvent;
1272
- exports.validateUserEvent = validateUserEvent;
1873
+ exports.validateEventName = validateEventName;
1273
1874
 
1274
1875
  }));
1275
1876
  //# sourceMappingURL=smore-sdk.umd.js.map