@unboxy/phaser-sdk 0.2.14 → 0.2.16

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
@@ -15,16 +15,28 @@ Reference for AI agents building games on the Unboxy platform. Tracks the **inst
15
15
 
16
16
  | field | type | note |
17
17
  |-------|------|------|
18
- | `width` | `number` | required |
19
- | `height` | `number` | required |
18
+ | `orientation` | `'portrait' \| 'landscape'` | mutually exclusive with `width`/`height`. Resolves to preset dims from `ORIENTATION_DIMENSIONS`. **Since 0.2.16.** |
19
+ | `width` | `number` | required if `orientation` is omitted |
20
+ | `height` | `number` | required if `orientation` is omitted |
20
21
  | `scenes` | `Phaser.Scene class[]` | required |
21
22
  | `backgroundColor` | `string` | defaults to `'#1a1a2e'` |
22
23
  | `pixelArt` | `boolean` | defaults to `false` |
23
24
  | `physics` | `Phaser.Types.Core.PhysicsConfig` | defaults to arcade physics, no gravity |
24
25
  | `plugins` | `Phaser.Types.Core.PluginObject` | forwarded as-is into the Phaser `GameConfig`. **Since 0.2.9.** Earlier versions silently dropped this field. |
25
26
 
27
+ `orientation` and explicit `width`/`height` are a TypeScript union — pass one or the other, not both. The compiler rejects mixed usage.
28
+
26
29
  Any field not listed is ignored. If you need something else (e.g. `callbacks`, custom `render` options), use plain `new Phaser.Game(config)` instead of `createUnboxyGame`.
27
30
 
31
+ ### Orientation presets
32
+
33
+ ```ts
34
+ import { ORIENTATION_DIMENSIONS } from '@unboxy/phaser-sdk';
35
+ // { landscape: { width: 1280, height: 720 }, portrait: { width: 720, height: 1280 } }
36
+ ```
37
+
38
+ The orientation a game uses is decided **once at game creation** and baked into the template's `src/config.ts`. There is no runtime API to flip orientation — switching mid-development would invalidate every screen layout.
39
+
28
40
  ### Registering third-party Phaser plugins (e.g. rexUI)
29
41
 
30
42
  Pass them via the `plugins` option — do NOT try to register them inside a scene's `preload`:
@@ -544,6 +556,8 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
544
556
 
545
557
  ## Changelog
546
558
 
559
+ - **0.2.16** — added orientation presets. `createUnboxyGame` now accepts an `orientation: 'portrait' | 'landscape'` option as an alternative to explicit `width`/`height` (TS union — pass one or the other). New exports: `Orientation` type and `ORIENTATION_DIMENSIONS` map (`landscape: 1280×720`, `portrait: 720×1280`). Lets games declare orientation once and have a single source of truth for canvas dimensions.
560
+ - **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
561
  - **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
562
  - **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
563
  - **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.
@@ -1,9 +1,6 @@
1
1
  import Phaser from 'phaser';
2
- export interface UnboxyGameOptions {
3
- /** Game width in pixels */
4
- width: number;
5
- /** Game height in pixels */
6
- height: number;
2
+ import { type Orientation } from '../orientation.js';
3
+ interface UnboxyGameBaseOptions {
7
4
  /** Phaser scene classes to register */
8
5
  scenes: (new (...args: any[]) => Phaser.Scene)[];
9
6
  /** Background color (default: '#1a1a2e') */
@@ -15,8 +12,24 @@ export interface UnboxyGameOptions {
15
12
  /** Phaser plugin registrations (e.g. `phaser3-rex-plugins` virtual joystick) */
16
13
  plugins?: Phaser.Types.Core.PluginObject;
17
14
  }
15
+ /**
16
+ * Either pass `orientation` (preset dims from ORIENTATION_DIMENSIONS) OR
17
+ * explicit `width`/`height` — not both. The TS union enforces this at
18
+ * compile time so games can't drift out of the orientation presets by
19
+ * accident.
20
+ */
21
+ export type UnboxyGameOptions = (UnboxyGameBaseOptions & {
22
+ orientation: Orientation;
23
+ width?: never;
24
+ height?: never;
25
+ }) | (UnboxyGameBaseOptions & {
26
+ width: number;
27
+ height: number;
28
+ orientation?: never;
29
+ });
18
30
  /**
19
31
  * Create an Unboxy-enhanced Phaser game instance.
20
32
  * Includes built-in integrations: screenshot capture, preserveDrawingBuffer, etc.
21
33
  */
22
34
  export declare function createUnboxyGame(options: UnboxyGameOptions): Phaser.Game;
35
+ export {};
@@ -1,19 +1,23 @@
1
1
  import Phaser from 'phaser';
2
2
  import { setupScreenshotListener } from '../screenshot/ScreenshotManager.js';
3
3
  import { setupRecordingListener } from '../recording/RecordingManager.js';
4
+ import { ORIENTATION_DIMENSIONS } from '../orientation.js';
4
5
  /**
5
6
  * Create an Unboxy-enhanced Phaser game instance.
6
7
  * Includes built-in integrations: screenshot capture, preserveDrawingBuffer, etc.
7
8
  */
8
9
  export function createUnboxyGame(options) {
10
+ const { width, height } = 'orientation' in options && options.orientation
11
+ ? ORIENTATION_DIMENSIONS[options.orientation]
12
+ : { width: options.width, height: options.height };
9
13
  const config = {
10
14
  type: Phaser.AUTO,
11
15
  backgroundColor: options.backgroundColor ?? '#1a1a2e',
12
16
  scale: {
13
17
  mode: Phaser.Scale.FIT,
14
18
  autoCenter: Phaser.Scale.CENTER_BOTH,
15
- width: options.width,
16
- height: options.height,
19
+ width,
20
+ height,
17
21
  },
18
22
  render: {
19
23
  preserveDrawingBuffer: true,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { createUnboxyGame } from './core/UnboxyGame.js';
2
2
  export type { UnboxyGameOptions } from './core/UnboxyGame.js';
3
3
  export { UnboxyScene } from './core/UnboxyScene.js';
4
+ export { ORIENTATION_DIMENSIONS } from './orientation.js';
5
+ export type { Orientation } from './orientation.js';
4
6
  export { setupScreenshotListener, takeScreenshot } from './screenshot/ScreenshotManager.js';
5
7
  export { setupRecordingListener } from './recording/RecordingManager.js';
6
8
  export { Unboxy } from './core/Unboxy.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Core
2
2
  export { createUnboxyGame } from './core/UnboxyGame.js';
3
3
  export { UnboxyScene } from './core/UnboxyScene.js';
4
+ export { ORIENTATION_DIMENSIONS } from './orientation.js';
4
5
  // Screenshot
5
6
  export { setupScreenshotListener, takeScreenshot } from './screenshot/ScreenshotManager.js';
6
7
  // Recording
@@ -0,0 +1,5 @@
1
+ export type Orientation = 'portrait' | 'landscape';
2
+ export declare const ORIENTATION_DIMENSIONS: Record<Orientation, {
3
+ width: number;
4
+ height: number;
5
+ }>;
@@ -0,0 +1,4 @@
1
+ export const ORIENTATION_DIMENSIONS = {
2
+ landscape: { width: 1280, height: 720 },
3
+ portrait: { width: 720, height: 1280 },
4
+ };
@@ -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.16",
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",