@unboxy/phaser-sdk 0.2.12 → 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
@@ -369,7 +369,7 @@ Values are JSON-stringified under the hood. Use for anything JSON-serializable.
369
369
 
370
370
  #### Transient events — `room.send` and `room.on`
371
371
 
372
- Use this for ephemeral actions: fires, hits, emotes, explosions, chat. Not persisted.
372
+ Use this for ephemeral actions: fires, hits, emotes, explosions. Not persisted. (For **chat**, prefer the dedicated `room.chat` helper below — it adds local echo, length capping, and a stamped sender name on top of this same primitive.)
373
373
 
374
374
  ```ts
375
375
  // Sender
@@ -381,6 +381,68 @@ const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angl
381
381
  });
382
382
  ```
383
383
 
384
+ #### Chat — `room.chat.send` and `room.chat.onMessage`
385
+
386
+ A thin wrapper over the transient relay tuned for in-game chat. Use this instead of raw `room.send('chat', text)` — you get four things you'd otherwise re-implement in every game:
387
+
388
+ 1. **Local echo** — the sender's own message hits the same `onMessage` handler stream the moment it's sent, so your UI has one render path for both your messages and remote ones.
389
+ 2. **Stamped sender info** — every `ChatMessage` carries `kind`, `from` (sessionId), `displayName` (sender's human-readable name at send time), `text`, and `ts`. No need to look up the sender in `room.state.players`.
390
+ 3. **Trim + length cap** — text is `.trim()`ed, dropped if empty, and silently truncated to `MAX_CHAT_TEXT_LEN` (500) so a stray paste can't flood the relay.
391
+ 4. **Auto-emitted system messages** — when a remote player joins or leaves, the SDK dispatches a `kind: 'system.joined'` / `'system.left'` message into the same stream. No wire traffic; computed locally on each client from `room.state.players`. Skipped for the local player's own join.
392
+
393
+ ```ts
394
+ import { MAX_CHAT_TEXT_LEN } from '@unboxy/phaser-sdk';
395
+ import type { ChatMessage } from '@unboxy/phaser-sdk';
396
+
397
+ const log: ChatMessage[] = [];
398
+
399
+ const offChat = room.chat.onMessage((msg) => {
400
+ log.push(msg);
401
+ if (msg.kind === 'user') {
402
+ const who = msg.from === room.sessionId ? 'You' : (msg.displayName || 'Player');
403
+ appendChatLine(`${formatTime(msg.ts)} ${who}: ${msg.text}`);
404
+ } else {
405
+ // 'system.joined' or 'system.left' — render however you like.
406
+ // msg.text is already a sensible English fallback ("Alice joined").
407
+ appendSystemLine(`${formatTime(msg.ts)} — ${msg.text}`);
408
+ }
409
+ });
410
+
411
+ inputEl.maxLength = MAX_CHAT_TEXT_LEN; // mirror the cap on the input box
412
+ inputEl.addEventListener('keydown', async (e) => {
413
+ if (e.key !== 'Enter') return;
414
+ try {
415
+ await room.chat.send(inputEl.value); // trim + cap + local echo + wire send
416
+ inputEl.value = ''; // safe: echo already dispatched synchronously
417
+ } catch (err) {
418
+ // Local-side dispatch failed (e.g. connection dropped). Show a UI hint.
419
+ showChatError(err);
420
+ }
421
+ });
422
+
423
+ scene.events.once('shutdown', offChat);
424
+ ```
425
+
426
+ `ChatMessage` discriminates by `kind`:
427
+
428
+ ```ts
429
+ type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
430
+ interface ChatMessage {
431
+ kind: ChatMessageKind;
432
+ from: string; // sessionId of the sender (or join/leave subject)
433
+ displayName: string; // their displayName at the time
434
+ text: string; // user-typed text, or English system fallback
435
+ ts: number; // Date.now() when emitted
436
+ }
437
+ ```
438
+
439
+ Notes:
440
+ - **No history.** A player who joins mid-room sees no prior `'user'` messages. If you need scrollback, write to `room.data.set('chatLog', [...])` yourself — the relay is fire-and-forget.
441
+ - **System messages are local-only.** Each client computes them from `room.state.players` add/remove events. They are NOT broadcast over the wire, and never echo from the wire even if a different client tries to send one.
442
+ - **`send` returns `Promise<void>`.** Resolves once dispatch is complete (echo + wire queued); rejects if the underlying connection blew up. It is **not** an ack — the relay itself is fire-and-forget; the Promise only catches local-side errors.
443
+ - **Order by arrival.** `ts` is `Date.now()`, advisory only. Skew between clients is small in practice but don't sort by `ts` across senders.
444
+ - **Trust model.** `text` and `displayName` come from the sender — no server-side validation. Same as every other transient relay payload.
445
+
384
446
  #### Server-tracked membership
385
447
 
386
448
  `room.state.players` is server-owned. Each entry exposes:
@@ -411,7 +473,7 @@ await room.leave();
411
473
  | Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
412
474
  | Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
413
475
  | Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
414
- | Chat message | `room.send('chat', text)` + `room.on('chat', ...)` | Same ephemeral relay. |
476
+ | Chat message | `room.chat.send(text)` + `room.chat.onMessage(...)` | Helper over the relay: local echo, stamped sender, length cap. |
415
477
  | Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
416
478
 
417
479
  Rules of thumb:
@@ -482,6 +544,8 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
482
544
 
483
545
  ## Changelog
484
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.
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.
485
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.
486
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.
487
551
  - **0.2.10** — docs: added a `createUnboxyGame` options table (including the new `plugins` field) and a third-party Phaser plugin registration example. No code changes; version bumped so existing workspaces pick up the new guidance via `npm update @unboxy/phaser-sdk`.
package/dist/index.d.ts CHANGED
@@ -9,7 +9,8 @@ export { SavesModule } from './saves/SavesModule.js';
9
9
  export { GameDataModule } from './gamedata/GameDataModule.js';
10
10
  export { RealtimeModule } from './realtime/RealtimeModule.js';
11
11
  export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/RealtimeModule.js';
12
- export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
12
+ export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
13
+ export type { ChatMessage, ChatMessageKind } from './realtime/UnboxyRoom.js';
13
14
  export { RpcError } from './core/Transport.js';
14
15
  export type { Transport, TransportKind } from './core/Transport.js';
15
16
  export type { UnboxyUser } from './protocol.js';
package/dist/index.js CHANGED
@@ -10,6 +10,6 @@ export { Unboxy } from './core/Unboxy.js';
10
10
  export { SavesModule } from './saves/SavesModule.js';
11
11
  export { GameDataModule } from './gamedata/GameDataModule.js';
12
12
  export { RealtimeModule } from './realtime/RealtimeModule.js';
13
- export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
13
+ export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
14
14
  export { RpcError } from './core/Transport.js';
15
15
  export { PROTOCOL_VERSION, } from './protocol.js';
@@ -29,6 +29,111 @@ export declare class RoomDataFacade {
29
29
  /** Read a shared room value. Returns null if the key is absent. */
30
30
  get<T = unknown>(key: string): T | null;
31
31
  }
32
+ /** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
33
+ * truncated. Games should mirror this on their input box's `maxLength`. */
34
+ export declare const MAX_CHAT_TEXT_LEN = 500;
35
+ /**
36
+ * Discriminator for `ChatMessage`:
37
+ * - `'user'` — a real player message sent via `ChatFacade.send`.
38
+ * - `'system.joined'` — auto-emitted locally when a remote player enters the room.
39
+ * - `'system.left'` — auto-emitted locally when a player leaves the room.
40
+ *
41
+ * System messages are computed locally on each client from `room.state.players`
42
+ * add/remove events; they never go over the wire. The first joiner does not
43
+ * see "X joined" for players already in the room when they themselves arrived
44
+ * (initial hydration is suppressed).
45
+ */
46
+ export type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
47
+ /** Canonical chat-message shape delivered to `ChatFacade.onMessage` handlers.
48
+ * See unboxy-design/features/multiplayer-chat.md §3 for the rationale on
49
+ * each field. */
50
+ export interface ChatMessage {
51
+ /** What kind of message this is — see `ChatMessageKind`. Default `'user'`. */
52
+ kind: ChatMessageKind;
53
+ /** For user messages: sender's session id (equals `room.sessionId` on the
54
+ * sender's local-echoed copy). For system messages: the joined/left player's
55
+ * session id. */
56
+ from: string;
57
+ /** Sender's (or subject's) display name at the time of the message. Empty if
58
+ * the Player entry carried no displayName. */
59
+ displayName: string;
60
+ /** For user messages: the trimmed, length-capped text. For system messages:
61
+ * a sensible English fallback (e.g. `"Alice joined"`). Games doing their own
62
+ * i18n should switch on `kind` and ignore this. */
63
+ text: string;
64
+ /** Sender-side `Date.now()` for user messages, local `Date.now()` for system
65
+ * messages. Advisory: order chat by arrival, not by ts. */
66
+ ts: number;
67
+ }
68
+ /**
69
+ * Convenience layer over the transient relay for in-game chat. Wraps
70
+ * `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
71
+ * game would otherwise re-implement inconsistently:
72
+ * - Local echo so the sender's own message hits the same handler stream
73
+ * immediately (synchronous, before the network call).
74
+ * - A canonical `ChatMessage` payload shape every chat in every game shares.
75
+ * - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
76
+ * paste cannot flood the relay.
77
+ *
78
+ * `displayName` is read from the sender's Player entry at send time and
79
+ * stamped onto the payload so receivers don't have to chase room.state to
80
+ * resolve a sessionId. See unboxy-design/features/multiplayer-chat.md §3.4.
81
+ */
82
+ export declare class ChatFacade {
83
+ private readonly room;
84
+ private readonly handlers;
85
+ private remoteOff;
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;
93
+ constructor(room: Room<unknown>);
94
+ /**
95
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
96
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
97
+ * synchronously before the wire send, so the input box can be cleared on
98
+ * the same tick.
99
+ *
100
+ * Returns a Promise that resolves once dispatch is complete (local echo +
101
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
102
+ * the connection has dropped). Note: resolution does NOT confirm remote
103
+ * delivery — the underlying transient relay is fire-and-forget. Use this
104
+ * for catching local-side errors; do not treat it as an ack.
105
+ */
106
+ send(text: string): Promise<void>;
107
+ /**
108
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
109
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
110
+ * unsubscribe function. Call it on scene shutdown.
111
+ */
112
+ onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
113
+ /**
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.
130
+ */
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;
135
+ private dispatch;
136
+ }
32
137
  /**
33
138
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
34
139
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -38,6 +143,7 @@ export declare class UnboxyRoom<State = unknown> {
38
143
  private readonly room;
39
144
  readonly player: PlayerDataFacade;
40
145
  readonly data: RoomDataFacade;
146
+ readonly chat: ChatFacade;
41
147
  constructor(room: Room<State>);
42
148
  get id(): string;
43
149
  get name(): string;
@@ -54,6 +54,215 @@ export class RoomDataFacade {
54
54
  return parseOrNull(state?.data?.get?.(key));
55
55
  }
56
56
  }
57
+ /** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
58
+ * truncated. Games should mirror this on their input box's `maxLength`. */
59
+ export const MAX_CHAT_TEXT_LEN = 500;
60
+ /**
61
+ * Convenience layer over the transient relay for in-game chat. Wraps
62
+ * `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
63
+ * game would otherwise re-implement inconsistently:
64
+ * - Local echo so the sender's own message hits the same handler stream
65
+ * immediately (synchronous, before the network call).
66
+ * - A canonical `ChatMessage` payload shape every chat in every game shares.
67
+ * - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
68
+ * paste cannot flood the relay.
69
+ *
70
+ * `displayName` is read from the sender's Player entry at send time and
71
+ * stamped onto the payload so receivers don't have to chase room.state to
72
+ * resolve a sessionId. See unboxy-design/features/multiplayer-chat.md §3.4.
73
+ */
74
+ export class ChatFacade {
75
+ constructor(room) {
76
+ this.room = room;
77
+ this.handlers = new Set();
78
+ this.remoteOff = null;
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();
86
+ this.subscribeToPlayers();
87
+ }
88
+ /**
89
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
90
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
91
+ * synchronously before the wire send, so the input box can be cleared on
92
+ * the same tick.
93
+ *
94
+ * Returns a Promise that resolves once dispatch is complete (local echo +
95
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
96
+ * the connection has dropped). Note: resolution does NOT confirm remote
97
+ * delivery — the underlying transient relay is fire-and-forget. Use this
98
+ * for catching local-side errors; do not treat it as an ack.
99
+ */
100
+ send(text) {
101
+ if (typeof text !== 'string')
102
+ return Promise.resolve();
103
+ const trimmed = text.trim();
104
+ if (trimmed.length === 0)
105
+ return Promise.resolve();
106
+ const capped = trimmed.length > MAX_CHAT_TEXT_LEN ? trimmed.slice(0, MAX_CHAT_TEXT_LEN) : trimmed;
107
+ const state = this.room.state;
108
+ const me = state?.players?.get?.(this.room.sessionId);
109
+ const displayName = typeof me?.displayName === 'string' ? me.displayName : '';
110
+ const msg = {
111
+ kind: 'user',
112
+ from: this.room.sessionId,
113
+ displayName,
114
+ text: capped,
115
+ ts: Date.now(),
116
+ };
117
+ // Local echo first, synchronously, so callers can clear input on the same
118
+ // tick the message lands in their chat log.
119
+ this.dispatch(msg);
120
+ // Wire send. The server's wildcard relay rebroadcasts to every other
121
+ // client (`except: senderClient`) with `from: client.sessionId` stamped.
122
+ try {
123
+ this.room.send('chat', { kind: 'user', text: capped, ts: msg.ts, displayName });
124
+ }
125
+ catch (err) {
126
+ return Promise.reject(err);
127
+ }
128
+ return Promise.resolve();
129
+ }
130
+ /**
131
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
132
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
133
+ * unsubscribe function. Call it on scene shutdown.
134
+ */
135
+ onMessage(handler) {
136
+ this.handlers.add(handler);
137
+ if (!this.remoteOff) {
138
+ const cb = (raw) => {
139
+ const p = (raw && typeof raw === 'object' ? raw : {});
140
+ if (typeof p.text !== 'string')
141
+ return; // defensive: drop legacy raw-string sends
142
+ // System messages are always computed locally — clamp wire kind to 'user'.
143
+ const msg = {
144
+ kind: 'user',
145
+ from: typeof p.from === 'string' ? p.from : '',
146
+ displayName: typeof p.displayName === 'string' ? p.displayName : '',
147
+ text: p.text,
148
+ ts: typeof p.ts === 'number' ? p.ts : Date.now(),
149
+ };
150
+ // Server uses `except: senderClient`, so a remote 'chat' is never the
151
+ // sender's own — no de-dupe with local echo needed.
152
+ this.dispatch(msg);
153
+ };
154
+ this.remoteOff = this.room.onMessage('chat', cb);
155
+ }
156
+ return () => {
157
+ this.handlers.delete(handler);
158
+ };
159
+ }
160
+ /**
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.
177
+ */
178
+ subscribeToPlayers() {
179
+ const players = this.room.state?.players;
180
+ if (!players)
181
+ 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
+ }
196
+ }
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
+ });
231
+ });
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
+ }
238
+ }
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(),
252
+ });
253
+ }
254
+ }
255
+ dispatch(msg) {
256
+ for (const h of this.handlers) {
257
+ try {
258
+ h(msg);
259
+ }
260
+ catch (err) {
261
+ console.error('[unboxy.chat] handler threw', err);
262
+ }
263
+ }
264
+ }
265
+ }
57
266
  /**
58
267
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
59
268
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -64,6 +273,7 @@ export class UnboxyRoom {
64
273
  this.room = room;
65
274
  this.player = new PlayerDataFacade(room);
66
275
  this.data = new RoomDataFacade(room);
276
+ this.chat = new ChatFacade(room);
67
277
  }
68
278
  get id() { return this.room.roomId; }
69
279
  get name() { return this.room.name; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.12",
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",
@@ -10,6 +10,7 @@
10
10
  ],
11
11
  "scripts": {
12
12
  "build": "tsc",
13
+ "test": "npm run build && node --test scripts/test-chat-facade.mjs",
13
14
  "prepublishOnly": "npm run build"
14
15
  },
15
16
  "dependencies": {