@unboxy/phaser-sdk 0.2.14 → 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,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.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.
547
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.
548
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.
549
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.
@@ -84,12 +84,18 @@ export declare class ChatFacade {
84
84
  private readonly handlers;
85
85
  private remoteOff;
86
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. */
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. */
92
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;
93
99
  constructor(room: Room<unknown>);
94
100
  /**
95
101
  * Send a chat line. Empty-after-trim input is dropped silently. Text longer
@@ -111,26 +117,43 @@ export declare class ChatFacade {
111
117
  */
112
118
  onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
113
119
  /**
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.
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.
119
138
  *
120
139
  * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
121
140
  * 0.16 dropped those instance methods in favour of a separate
122
141
  * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
123
142
  * `players` keys ourselves works the same in any Colyseus version, keeps
124
143
  * 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.
144
+ * of work per state change for typical room sizes (max 16 players).
130
145
  */
131
146
  private subscribeToPlayers;
132
- /** Compare current `players` membership against `knownNames`, emit
133
- * `system.joined` for new sids and `system.left` for vanished sids. */
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. */
134
157
  private diffPlayers;
135
158
  private dispatch;
136
159
  }
@@ -77,12 +77,18 @@ export class ChatFacade {
77
77
  this.handlers = new Set();
78
78
  this.remoteOff = null;
79
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. */
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. */
85
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;
86
92
  this.subscribeToPlayers();
87
93
  }
88
94
  /**
@@ -158,83 +164,109 @@ export class ChatFacade {
158
164
  };
159
165
  }
160
166
  /**
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.
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.
166
185
  *
167
186
  * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
168
187
  * 0.16 dropped those instance methods in favour of a separate
169
188
  * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
170
189
  * `players` keys ourselves works the same in any Colyseus version, keeps
171
190
  * 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.
191
+ * of work per state change for typical room sizes (max 16 players).
177
192
  */
178
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() {
179
206
  const players = this.room.state?.players;
180
- if (!players)
207
+ if (!players || typeof players.forEach !== 'function')
181
208
  return;
182
- if (typeof players.forEach === 'function') {
183
- try {
184
- players.forEach((p, sid) => {
185
- const name = typeof p?.displayName === 'string' ? p.displayName : '';
186
- this.knownNames.set(sid, name);
187
- });
188
- }
189
- catch {
190
- // forEach can throw on an unsynced state; knownNames stays empty,
191
- // which means we'll emit "joined" for the initial hydration. That's
192
- // a degraded but non-broken behavior — the chat log gets an extra
193
- // line per existing player. Log once for visibility.
194
- console.warn('[unboxy.chat] could not snapshot players on subscribe');
195
- }
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.
196
218
  }
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
219
  }
201
- /** Compare current `players` membership against `knownNames`, emit
202
- * `system.joined` for new sids and `system.left` for vanished sids. */
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. */
203
225
  diffPlayers() {
204
226
  const state = this.room.state;
205
227
  const players = state?.players;
206
- if (!players)
228
+ if (!players || typeof players.forEach !== 'function')
207
229
  return;
208
- const nowSids = new Set();
209
- 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.
210
233
  try {
211
234
  players.forEach((p, sid) => {
212
- nowSids.add(sid);
213
235
  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
236
  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
- });
231
237
  });
232
238
  }
233
239
  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
240
  return;
237
241
  }
242
+ this.hydrated = true;
243
+ return;
244
+ }
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);
253
+ return;
254
+ }
255
+ this.knownNames.set(sid, name);
256
+ if (sid === this.room.sessionId)
257
+ return; // don't announce self-join
258
+ const who = name || 'A player';
259
+ this.dispatch({
260
+ kind: 'system.joined',
261
+ from: sid,
262
+ displayName: name,
263
+ text: `${who} joined`,
264
+ ts: Date.now(),
265
+ });
266
+ });
267
+ }
268
+ catch {
269
+ return;
238
270
  }
239
271
  // Anyone in knownNames but not in nowSids has left.
240
272
  for (const sid of [...this.knownNames.keys()]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.14",
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",