@unboxy/phaser-sdk 0.2.12 → 0.2.13

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,7 @@ 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.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
548
  - **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
549
  - **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
550
  - **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,94 @@ 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 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;
90
+ constructor(room: Room<unknown>);
91
+ /**
92
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
93
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
94
+ * synchronously before the wire send, so the input box can be cleared on
95
+ * the same tick.
96
+ *
97
+ * Returns a Promise that resolves once dispatch is complete (local echo +
98
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
99
+ * the connection has dropped). Note: resolution does NOT confirm remote
100
+ * delivery — the underlying transient relay is fire-and-forget. Use this
101
+ * for catching local-side errors; do not treat it as an ack.
102
+ */
103
+ send(text: string): Promise<void>;
104
+ /**
105
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
106
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
107
+ * unsubscribe function. Call it on scene shutdown.
108
+ */
109
+ onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
110
+ /**
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.
116
+ */
117
+ private subscribeToPlayers;
118
+ private dispatch;
119
+ }
32
120
  /**
33
121
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
34
122
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -38,6 +126,7 @@ export declare class UnboxyRoom<State = unknown> {
38
126
  private readonly room;
39
127
  readonly player: PlayerDataFacade;
40
128
  readonly data: RoomDataFacade;
129
+ readonly chat: ChatFacade;
41
130
  constructor(room: Room<State>);
42
131
  get id(): string;
43
132
  get name(): string;
@@ -54,6 +54,176 @@ 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 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();
83
+ this.subscribeToPlayers();
84
+ }
85
+ /**
86
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
87
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
88
+ * synchronously before the wire send, so the input box can be cleared on
89
+ * the same tick.
90
+ *
91
+ * Returns a Promise that resolves once dispatch is complete (local echo +
92
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
93
+ * the connection has dropped). Note: resolution does NOT confirm remote
94
+ * delivery — the underlying transient relay is fire-and-forget. Use this
95
+ * for catching local-side errors; do not treat it as an ack.
96
+ */
97
+ send(text) {
98
+ if (typeof text !== 'string')
99
+ return Promise.resolve();
100
+ const trimmed = text.trim();
101
+ if (trimmed.length === 0)
102
+ return Promise.resolve();
103
+ const capped = trimmed.length > MAX_CHAT_TEXT_LEN ? trimmed.slice(0, MAX_CHAT_TEXT_LEN) : trimmed;
104
+ const state = this.room.state;
105
+ const me = state?.players?.get?.(this.room.sessionId);
106
+ const displayName = typeof me?.displayName === 'string' ? me.displayName : '';
107
+ const msg = {
108
+ kind: 'user',
109
+ from: this.room.sessionId,
110
+ displayName,
111
+ text: capped,
112
+ ts: Date.now(),
113
+ };
114
+ // Local echo first, synchronously, so callers can clear input on the same
115
+ // tick the message lands in their chat log.
116
+ this.dispatch(msg);
117
+ // Wire send. The server's wildcard relay rebroadcasts to every other
118
+ // client (`except: senderClient`) with `from: client.sessionId` stamped.
119
+ try {
120
+ this.room.send('chat', { kind: 'user', text: capped, ts: msg.ts, displayName });
121
+ }
122
+ catch (err) {
123
+ return Promise.reject(err);
124
+ }
125
+ return Promise.resolve();
126
+ }
127
+ /**
128
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
129
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
130
+ * unsubscribe function. Call it on scene shutdown.
131
+ */
132
+ onMessage(handler) {
133
+ this.handlers.add(handler);
134
+ if (!this.remoteOff) {
135
+ const cb = (raw) => {
136
+ const p = (raw && typeof raw === 'object' ? raw : {});
137
+ if (typeof p.text !== 'string')
138
+ return; // defensive: drop legacy raw-string sends
139
+ // System messages are always computed locally — clamp wire kind to 'user'.
140
+ const msg = {
141
+ kind: 'user',
142
+ from: typeof p.from === 'string' ? p.from : '',
143
+ displayName: typeof p.displayName === 'string' ? p.displayName : '',
144
+ text: p.text,
145
+ ts: typeof p.ts === 'number' ? p.ts : Date.now(),
146
+ };
147
+ // Server uses `except: senderClient`, so a remote 'chat' is never the
148
+ // sender's own — no de-dupe with local echo needed.
149
+ this.dispatch(msg);
150
+ };
151
+ this.remoteOff = this.room.onMessage('chat', cb);
152
+ }
153
+ return () => {
154
+ this.handlers.delete(handler);
155
+ };
156
+ }
157
+ /**
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.
163
+ */
164
+ subscribeToPlayers() {
165
+ const players = this.room.state?.players;
166
+ if (!players)
167
+ return;
168
+ if (typeof players.forEach === 'function') {
169
+ try {
170
+ players.forEach((_p, sid) => { this.seen.add(sid); });
171
+ }
172
+ 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');
178
+ }
179
+ }
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(),
196
+ });
197
+ });
198
+ }
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
+ });
213
+ });
214
+ }
215
+ }
216
+ dispatch(msg) {
217
+ for (const h of this.handlers) {
218
+ try {
219
+ h(msg);
220
+ }
221
+ catch (err) {
222
+ console.error('[unboxy.chat] handler threw', err);
223
+ }
224
+ }
225
+ }
226
+ }
57
227
  /**
58
228
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
59
229
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -64,6 +234,7 @@ export class UnboxyRoom {
64
234
  this.room = room;
65
235
  this.player = new PlayerDataFacade(room);
66
236
  this.data = new RoomDataFacade(room);
237
+ this.chat = new ChatFacade(room);
67
238
  }
68
239
  get id() { return this.room.roomId; }
69
240
  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.13",
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": {