@unboxy/phaser-sdk 0.2.13 → 0.2.15

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,8 @@ 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.15** — fix: `ChatFacade.subscribeToPlayers` early-returned when `state.players` was `undefined` at construction time, which it always is on a fresh `joinOrCreate` (Colyseus delivers initial state as a separate message a few ms later). The early return meant `onStateChange` never subscribed and `system.joined` / `system.left` stayed silent in production despite the 0.2.14 callback-API fix. Now: always subscribe; treat the first state change as initial hydration (silent), subsequent changes do real diff. Three regression tests cover the deferred-hydration flow.
548
+ - **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
549
  - **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
550
  - **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
551
  - **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,19 @@ 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 either at construction (if state
88
+ * was already synced) or on the first `onStateChange` (initial hydration
89
+ * — silent, no system messages). Subsequent state changes diff against
90
+ * this map to emit `system.joined` / `system.left`. We track displayName
91
+ * here because by the time a player is detected as "left" they're already
92
+ * gone from `state.players` and we can't read it from there. */
93
+ private readonly knownNames;
94
+ /** Becomes true the first time we observe a usable `state.players` map.
95
+ * The very first observation is treated as "initial state arrived" and
96
+ * must NOT fire `system.joined` for the players already in the room when
97
+ * the local user joined — only subsequent diffs are real lifecycle events. */
98
+ private hydrated;
90
99
  constructor(room: Room<unknown>);
91
100
  /**
92
101
  * Send a chat line. Empty-after-trim input is dropped silently. Text longer
@@ -108,13 +117,44 @@ export declare class ChatFacade {
108
117
  */
109
118
  onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
110
119
  /**
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.
120
+ * Hook `onStateChange` to detect joins / leaves and try an eager snapshot
121
+ * if `state.players` is already populated.
122
+ *
123
+ * **The onStateChange subscription must always be set up, even when
124
+ * `state.players` is `undefined` at construction.** Colyseus 0.16 (and
125
+ * earlier) deliver the initial state as a *separate* message that arrives
126
+ * a few ms after `client.joinOrCreate` resolves — so right when UnboxyRoom
127
+ * is constructed, `room.state.players` is typically `undefined`. An earlier
128
+ * 0.2.14 implementation early-returned in that case and never subscribed,
129
+ * which meant the diff machinery never armed and BOTH `system.joined` /
130
+ * `system.left` silently dead-stopped in production (Blokus chat game,
131
+ * 2026-04-27).
132
+ *
133
+ * Eager snapshot if `state.players` is already there sets `hydrated=true`
134
+ * so the first state change does a real diff. Otherwise the first state
135
+ * change is treated as initial hydration: populate `knownNames` silently,
136
+ * skip system messages, set `hydrated=true`. Subsequent state changes do
137
+ * the real lifecycle diff.
138
+ *
139
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
140
+ * 0.16 dropped those instance methods in favour of a separate
141
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
142
+ * `players` keys ourselves works the same in any Colyseus version, keeps
143
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
144
+ * of work per state change for typical room sizes (max 16 players).
116
145
  */
117
146
  private subscribeToPlayers;
147
+ /** Populate `knownNames` from `state.players` if it's already available
148
+ * and mark hydrated. Silent — never emits system messages. A successful
149
+ * `forEach` (even iterating zero items) means the schema is synced and
150
+ * this is "what the room looked like when we joined" — safe to hydrate. */
151
+ private tryEagerSnapshot;
152
+ /** Compare current `players` membership against `knownNames`. On the very
153
+ * first invocation (post-construction) when `hydrated=false`, this is
154
+ * the initial-hydration call: populate `knownNames` silently and bail.
155
+ * Subsequent invocations diff against `knownNames` and emit
156
+ * `system.joined` for new sids, `system.left` for vanished sids. */
157
+ private diffPlayers;
118
158
  private dispatch;
119
159
  }
120
160
  /**
@@ -76,10 +76,19 @@ 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 either at construction (if state
81
+ * was already synced) or on the first `onStateChange` (initial hydration
82
+ * — silent, no system messages). Subsequent state changes diff against
83
+ * this map to emit `system.joined` / `system.left`. We track displayName
84
+ * here because by the time a player is detected as "left" they're already
85
+ * gone from `state.players` and we can't read it from there. */
86
+ this.knownNames = new Map();
87
+ /** Becomes true the first time we observe a usable `state.players` map.
88
+ * The very first observation is treated as "initial state arrived" and
89
+ * must NOT fire `system.joined` for the players already in the room when
90
+ * the local user joined — only subsequent diffs are real lifecycle events. */
91
+ this.hydrated = false;
83
92
  this.subscribeToPlayers();
84
93
  }
85
94
  /**
@@ -155,37 +164,97 @@ export class ChatFacade {
155
164
  };
156
165
  }
157
166
  /**
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.
167
+ * Hook `onStateChange` to detect joins / leaves and try an eager snapshot
168
+ * if `state.players` is already populated.
169
+ *
170
+ * **The onStateChange subscription must always be set up, even when
171
+ * `state.players` is `undefined` at construction.** Colyseus 0.16 (and
172
+ * earlier) deliver the initial state as a *separate* message that arrives
173
+ * a few ms after `client.joinOrCreate` resolves — so right when UnboxyRoom
174
+ * is constructed, `room.state.players` is typically `undefined`. An earlier
175
+ * 0.2.14 implementation early-returned in that case and never subscribed,
176
+ * which meant the diff machinery never armed and BOTH `system.joined` /
177
+ * `system.left` silently dead-stopped in production (Blokus chat game,
178
+ * 2026-04-27).
179
+ *
180
+ * Eager snapshot if `state.players` is already there sets `hydrated=true`
181
+ * so the first state change does a real diff. Otherwise the first state
182
+ * change is treated as initial hydration: populate `knownNames` silently,
183
+ * skip system messages, set `hydrated=true`. Subsequent state changes do
184
+ * the real lifecycle diff.
185
+ *
186
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
187
+ * 0.16 dropped those instance methods in favour of a separate
188
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
189
+ * `players` keys ourselves works the same in any Colyseus version, keeps
190
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
191
+ * of work per state change for typical room sizes (max 16 players).
163
192
  */
164
193
  subscribeToPlayers() {
194
+ // Always subscribe — regardless of whether `state.players` is populated yet.
195
+ this.room.onStateChange(() => this.diffPlayers());
196
+ // Best-effort eager snapshot. If state is already synced (rare — usually
197
+ // only in tests or on a reconnect to a hot room), this lets us mark
198
+ // hydrated immediately and treat the first state change as a real diff.
199
+ this.tryEagerSnapshot();
200
+ }
201
+ /** Populate `knownNames` from `state.players` if it's already available
202
+ * and mark hydrated. Silent — never emits system messages. A successful
203
+ * `forEach` (even iterating zero items) means the schema is synced and
204
+ * this is "what the room looked like when we joined" — safe to hydrate. */
205
+ tryEagerSnapshot() {
165
206
  const players = this.room.state?.players;
166
- if (!players)
207
+ if (!players || typeof players.forEach !== 'function')
208
+ return;
209
+ try {
210
+ players.forEach((p, sid) => {
211
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
212
+ this.knownNames.set(sid, name);
213
+ });
214
+ this.hydrated = true;
215
+ }
216
+ catch {
217
+ // State not iterable yet — leave it for the first onStateChange.
218
+ }
219
+ }
220
+ /** Compare current `players` membership against `knownNames`. On the very
221
+ * first invocation (post-construction) when `hydrated=false`, this is
222
+ * the initial-hydration call: populate `knownNames` silently and bail.
223
+ * Subsequent invocations diff against `knownNames` and emit
224
+ * `system.joined` for new sids, `system.left` for vanished sids. */
225
+ diffPlayers() {
226
+ const state = this.room.state;
227
+ const players = state?.players;
228
+ if (!players || typeof players.forEach !== 'function')
167
229
  return;
168
- if (typeof players.forEach === 'function') {
230
+ if (!this.hydrated) {
231
+ // Initial hydration — silently absorb whoever's in the room when we
232
+ // joined. Don't say "X joined" for everyone already there.
169
233
  try {
170
- players.forEach((_p, sid) => { this.seen.add(sid); });
234
+ players.forEach((p, sid) => {
235
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
236
+ this.knownNames.set(sid, name);
237
+ });
171
238
  }
172
239
  catch {
173
- // forEach can throw on an unsynced state; the seen set stays empty,
174
- // which means we'll emit "joined" for the initial hydration. That's
175
- // a degraded but non-broken behavior — the chat log gets an extra
176
- // line per existing player. Log once for visibility.
177
- console.warn('[unboxy.chat] could not snapshot players on subscribe');
240
+ return;
178
241
  }
242
+ this.hydrated = true;
243
+ return;
179
244
  }
180
- if (typeof players.onAdd === 'function') {
181
- players.onAdd((player, sid) => {
182
- if (this.seen.has(sid))
245
+ const nowSids = new Set();
246
+ try {
247
+ players.forEach((p, sid) => {
248
+ nowSids.add(sid);
249
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
250
+ if (this.knownNames.has(sid)) {
251
+ // Refresh cached name in case Colyseus updated it post-join.
252
+ this.knownNames.set(sid, name);
183
253
  return;
184
- this.seen.add(sid);
185
- // Don't announce yourself joining your own room.
254
+ }
255
+ this.knownNames.set(sid, name);
186
256
  if (sid === this.room.sessionId)
187
- return;
188
- const name = typeof player?.displayName === 'string' ? player.displayName : '';
257
+ return; // don't announce self-join
189
258
  const who = name || 'A player';
190
259
  this.dispatch({
191
260
  kind: 'system.joined',
@@ -196,20 +265,22 @@ export class ChatFacade {
196
265
  });
197
266
  });
198
267
  }
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
- });
268
+ catch {
269
+ return;
270
+ }
271
+ // Anyone in knownNames but not in nowSids has left.
272
+ for (const sid of [...this.knownNames.keys()]) {
273
+ if (nowSids.has(sid))
274
+ continue;
275
+ const name = this.knownNames.get(sid) ?? '';
276
+ this.knownNames.delete(sid);
277
+ const who = name || 'A player';
278
+ this.dispatch({
279
+ kind: 'system.left',
280
+ from: sid,
281
+ displayName: name,
282
+ text: `${who} left`,
283
+ ts: Date.now(),
213
284
  });
214
285
  }
215
286
  }
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.15",
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",