@unboxy/phaser-sdk 0.2.13 → 0.2.14

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.
package/SDK-GUIDE.md CHANGED
@@ -544,6 +544,7 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
544
544
 
545
545
  ## Changelog
546
546
 
547
+ - **0.2.14** — fix: `ChatFacade` `system.joined` / `system.left` events were silently no-op'ing in production. The lifecycle subscription used `state.players.onAdd` / `.onRemove` instance methods that Colyseus 0.16 removed in favour of `getStateCallbacks(room)`; the runtime check `typeof players.onAdd === 'function'` evaluated false, so neither system message fired. Now uses `room.onStateChange` + a known-names diff — robust to future Colyseus API changes. Tests updated to match. Patch-only; no API change for game code.
547
548
  - **0.2.13** — added `room.chat` helper for in-game chat. New API: `room.chat.send(text)` (returns `Promise<void>`) + `room.chat.onMessage(handler)`, plus exports `ChatMessage`, `ChatMessageKind`, and `MAX_CHAT_TEXT_LEN`. Wraps the existing `'chat'` wildcard relay with local echo (sender sees own message synchronously), stamped `displayName` on every payload, trim + 500-char silent truncate, and auto-emitted `kind: 'system.joined'` / `'system.left'` messages computed locally from `room.state.players` add/remove events (no wire traffic). Replaces the raw `room.send('chat', text)` pattern in the Multiplayer chapter — raw still works but loses local echo, length cap, and join/leave events.
548
549
  - **0.2.12** — added `unboxy.rooms.list(options?)` — returns the open rooms for the caller's game right now, scoped server-side to the caller's gameId. Use this to build a lobby browser ("show me open rooms, click to join") without needing an out-of-band room code. SDK-GUIDE adds a "Browsing open rooms" section under Multiplayer covering the `RoomListEntry` shape, the poll-on-timer pattern, and the `{ roomCode }` filter for "is room X up yet?" probes.
549
550
  - **0.2.11** — added `roomCode` + `maxClients` to `JoinOptions`. SDK-GUIDE's Multiplayer chapter now documents matchmaking: room type is always `'lobby'`; use `roomCode` to bucket independent rooms per gameId (create-with-fresh-code, join-with-shared-code, quick-match with empty code). Fixes a real bug where agents were passing a gameId-derived string as the room type (e.g. `joinOrCreate('blokus-<code>', ...)`) and failing with "no handler" server-side.
@@ -83,10 +83,13 @@ export declare class ChatFacade {
83
83
  private readonly room;
84
84
  private readonly handlers;
85
85
  private remoteOff;
86
- /** Sids that were already in `state.players` when this facade was constructed
87
- * we suppress "joined" system messages for them so the joiner doesn't see
88
- * a flood of "X joined" for everyone already in the room. */
89
- private readonly seen;
86
+ /** Sids known to this facade, mapped to the displayName they had at the
87
+ * time we last observed them. Populated at construction-time snapshot
88
+ * (so we don't emit "X joined" for everyone already present when the local
89
+ * user joins) and on every subsequent diff. We track the displayName here
90
+ * because by the time a player is detected as "left" they're already gone
91
+ * from `state.players` and we can't read it from there. */
92
+ private readonly knownNames;
90
93
  constructor(room: Room<unknown>);
91
94
  /**
92
95
  * Send a chat line. Empty-after-trim input is dropped silently. Text longer
@@ -108,13 +111,27 @@ export declare class ChatFacade {
108
111
  */
109
112
  onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
110
113
  /**
111
- * Snapshot the current player list and subscribe to add/remove for system
112
- * messages. By the time `client.joinOrCreate` resolves (and UnboxyRoom is
113
- * constructed), Colyseus has delivered the initial state — so the snapshot
114
- * here covers everyone already in the room, and we skip emission for them.
115
- * Adds and removes after this point are real events.
114
+ * Snapshot the current player list and hook `onStateChange` to detect
115
+ * subsequent joins / leaves. By the time `client.joinOrCreate` resolves
116
+ * (and UnboxyRoom is constructed), Colyseus has delivered the initial
117
+ * state — so the snapshot covers everyone already in the room and we skip
118
+ * emission for them.
119
+ *
120
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
121
+ * 0.16 dropped those instance methods in favour of a separate
122
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
123
+ * `players` keys ourselves works the same in any Colyseus version, keeps
124
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
125
+ * of work per state change for typical room sizes (max 16 players). The
126
+ * earlier 0.2.13 implementation tried `players.onAdd` directly — the
127
+ * conditional silently no-op'd in 0.16 and BOTH `system.joined` /
128
+ * `system.left` events stopped firing in production. Surfaced via a Blokus
129
+ * chat game on 2026-04-27.
116
130
  */
117
131
  private subscribeToPlayers;
132
+ /** Compare current `players` membership against `knownNames`, emit
133
+ * `system.joined` for new sids and `system.left` for vanished sids. */
134
+ private diffPlayers;
118
135
  private dispatch;
119
136
  }
120
137
  /**
@@ -76,10 +76,13 @@ export class ChatFacade {
76
76
  this.room = room;
77
77
  this.handlers = new Set();
78
78
  this.remoteOff = null;
79
- /** Sids that were already in `state.players` when this facade was constructed
80
- * we suppress "joined" system messages for them so the joiner doesn't see
81
- * a flood of "X joined" for everyone already in the room. */
82
- this.seen = new Set();
79
+ /** Sids known to this facade, mapped to the displayName they had at the
80
+ * time we last observed them. Populated at construction-time snapshot
81
+ * (so we don't emit "X joined" for everyone already present when the local
82
+ * user joins) and on every subsequent diff. We track the displayName here
83
+ * because by the time a player is detected as "left" they're already gone
84
+ * from `state.players` and we can't read it from there. */
85
+ this.knownNames = new Map();
83
86
  this.subscribeToPlayers();
84
87
  }
85
88
  /**
@@ -155,11 +158,22 @@ export class ChatFacade {
155
158
  };
156
159
  }
157
160
  /**
158
- * Snapshot the current player list and subscribe to add/remove for system
159
- * messages. By the time `client.joinOrCreate` resolves (and UnboxyRoom is
160
- * constructed), Colyseus has delivered the initial state — so the snapshot
161
- * here covers everyone already in the room, and we skip emission for them.
162
- * Adds and removes after this point are real events.
161
+ * Snapshot the current player list and hook `onStateChange` to detect
162
+ * subsequent joins / leaves. By the time `client.joinOrCreate` resolves
163
+ * (and UnboxyRoom is constructed), Colyseus has delivered the initial
164
+ * state — so the snapshot covers everyone already in the room and we skip
165
+ * emission for them.
166
+ *
167
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
168
+ * 0.16 dropped those instance methods in favour of a separate
169
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
170
+ * `players` keys ourselves works the same in any Colyseus version, keeps
171
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
172
+ * of work per state change for typical room sizes (max 16 players). The
173
+ * earlier 0.2.13 implementation tried `players.onAdd` directly — the
174
+ * conditional silently no-op'd in 0.16 and BOTH `system.joined` /
175
+ * `system.left` events stopped firing in production. Surfaced via a Blokus
176
+ * chat game on 2026-04-27.
163
177
  */
164
178
  subscribeToPlayers() {
165
179
  const players = this.room.state?.players;
@@ -167,49 +181,74 @@ export class ChatFacade {
167
181
  return;
168
182
  if (typeof players.forEach === 'function') {
169
183
  try {
170
- players.forEach((_p, sid) => { this.seen.add(sid); });
184
+ players.forEach((p, sid) => {
185
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
186
+ this.knownNames.set(sid, name);
187
+ });
171
188
  }
172
189
  catch {
173
- // forEach can throw on an unsynced state; the seen set stays empty,
190
+ // forEach can throw on an unsynced state; knownNames stays empty,
174
191
  // which means we'll emit "joined" for the initial hydration. That's
175
192
  // a degraded but non-broken behavior — the chat log gets an extra
176
193
  // line per existing player. Log once for visibility.
177
194
  console.warn('[unboxy.chat] could not snapshot players on subscribe');
178
195
  }
179
196
  }
180
- if (typeof players.onAdd === 'function') {
181
- players.onAdd((player, sid) => {
182
- if (this.seen.has(sid))
183
- return;
184
- this.seen.add(sid);
185
- // Don't announce yourself joining your own room.
186
- if (sid === this.room.sessionId)
187
- return;
188
- const name = typeof player?.displayName === 'string' ? player.displayName : '';
189
- const who = name || 'A player';
190
- this.dispatch({
191
- kind: 'system.joined',
192
- from: sid,
193
- displayName: name,
194
- text: `${who} joined`,
195
- ts: Date.now(),
197
+ // Diff on every state change. Cheap for any reasonable room size
198
+ // (Colyseus caps maxClients at 16 server-side).
199
+ this.room.onStateChange(() => this.diffPlayers());
200
+ }
201
+ /** Compare current `players` membership against `knownNames`, emit
202
+ * `system.joined` for new sids and `system.left` for vanished sids. */
203
+ diffPlayers() {
204
+ const state = this.room.state;
205
+ const players = state?.players;
206
+ if (!players)
207
+ return;
208
+ const nowSids = new Set();
209
+ if (typeof players.forEach === 'function') {
210
+ try {
211
+ players.forEach((p, sid) => {
212
+ nowSids.add(sid);
213
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
214
+ if (this.knownNames.has(sid)) {
215
+ // Refresh the cached name in case Colyseus updated it post-join
216
+ // (e.g. delayed state sync); but don't re-emit for an existing sid.
217
+ this.knownNames.set(sid, name);
218
+ return;
219
+ }
220
+ this.knownNames.set(sid, name);
221
+ if (sid === this.room.sessionId)
222
+ return; // don't announce self-join
223
+ const who = name || 'A player';
224
+ this.dispatch({
225
+ kind: 'system.joined',
226
+ from: sid,
227
+ displayName: name,
228
+ text: `${who} joined`,
229
+ ts: Date.now(),
230
+ });
196
231
  });
197
- });
232
+ }
233
+ catch {
234
+ // Iteration shouldn't throw post-snapshot, but if it does we just
235
+ // skip this diff cycle rather than break the chat stream.
236
+ return;
237
+ }
198
238
  }
199
- if (typeof players.onRemove === 'function') {
200
- players.onRemove((player, sid) => {
201
- if (!this.seen.has(sid))
202
- return;
203
- this.seen.delete(sid);
204
- const name = typeof player?.displayName === 'string' ? player.displayName : '';
205
- const who = name || 'A player';
206
- this.dispatch({
207
- kind: 'system.left',
208
- from: sid,
209
- displayName: name,
210
- text: `${who} left`,
211
- ts: Date.now(),
212
- });
239
+ // Anyone in knownNames but not in nowSids has left.
240
+ for (const sid of [...this.knownNames.keys()]) {
241
+ if (nowSids.has(sid))
242
+ continue;
243
+ const name = this.knownNames.get(sid) ?? '';
244
+ this.knownNames.delete(sid);
245
+ const who = name || 'A player';
246
+ this.dispatch({
247
+ kind: 'system.left',
248
+ from: sid,
249
+ displayName: name,
250
+ text: `${who} left`,
251
+ ts: Date.now(),
213
252
  });
214
253
  }
215
254
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Unboxy Phaser 3 SDK — game infrastructure for the Unboxy platform",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",