@unboxy/phaser-sdk 0.2.5 → 0.2.7

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
@@ -185,7 +185,12 @@ Rule of thumb: if two different players would write to the same key, it's `gameD
185
185
 
186
186
  ### Multiplayer rooms — `unboxy.rooms`
187
187
 
188
- Realtime rooms backed by unboxy-realtime-service. The server is authoritativeclients send intents; the server updates state; every client receives the delta. This layer is for *live* shared play; persistent leaderboards/scores still belong in `gameData` or `saves`.
188
+ Realtime rooms backed by unboxy-realtime-service. The platform is **game-agnostic**the server tracks who's in the room and relays data, but does not know what kind of game you're building. Two opaque primitives are your building blocks:
189
+
190
+ 1. **Delta-synced KV state** (`room.player.*`, `room.data.*`) — server-maintained maps. Values are JSON-serializable. Every client sees changes via `onStateChange`. State is preserved while the room exists; new joiners see the current snapshot.
191
+ 2. **Transient message relay** (`room.send` + `room.on`) — fire-and-forget. Payloads are stamped with `from: senderSessionId` and broadcast to every other client in the room. Not persisted.
192
+
193
+ #### Join a room
189
194
 
190
195
  ```ts
191
196
  // Join or create a room scoped to this game. The SDK fetches a short-lived
@@ -194,37 +199,101 @@ const room = await unboxy.rooms.joinOrCreate('lobby', {
194
199
  displayName: unboxy.user?.name ?? 'guest',
195
200
  });
196
201
 
197
- // Read server state (Colyseus Schema proxy — access fields directly)
198
- room.state; // e.g. { players: Map, gameId, ... } depending on room type
199
- room.id; // room identifier
200
- room.sessionId; // this connection's session id within the room
202
+ room.id; // room identifier
203
+ room.sessionId; // this connection's session id within the room
204
+ room.state; // proxy of the server-authoritative schema
205
+ ```
206
+
207
+ #### Delta-synced state — `room.player` and `room.data`
208
+
209
+ Use this for anything that represents the *current world*: positions, hp, score, turn state, scoreboard, etc. The server delta-syncs diffs to every client.
210
+
211
+ ```ts
212
+ // Per-player (only you can write your own entry)
213
+ room.player.set('pos', { x: 120, y: 340 });
214
+ room.player.set('hp', 80);
215
+ room.player.delete('pos');
216
+
217
+ // Read another player's data at any time
218
+ const otherPos = room.player.get<{x:number; y:number}>(otherSessionId, 'pos');
219
+
220
+ // Per-room (any client can write)
221
+ room.data.set('currentTurn', playerSessionId);
222
+ room.data.set('scoreboard', [{ sid: 's1', score: 12 }, ...]);
223
+ const board = room.data.get<Entry[]>('scoreboard') ?? [];
224
+
225
+ // React to any state change
226
+ const offState = room.onStateChange(() => {
227
+ room.state.players.forEach((p, sid) => {
228
+ const pos = room.player.get<{x:number;y:number}>(sid, 'pos');
229
+ // ... re-render
230
+ });
231
+ });
232
+ ```
233
+
234
+ Values are JSON-stringified under the hood. Use for anything JSON-serializable.
235
+
236
+ #### Transient events — `room.send` and `room.on`
237
+
238
+ Use this for ephemeral actions: fires, hits, emotes, explosions, chat. Not persisted.
239
+
240
+ ```ts
241
+ // Sender
242
+ room.send('fire', { x: 100, y: 200, angle: 1.5 });
201
243
 
202
- // React to server updates
203
- const offState = room.onStateChange((state) => {
204
- // re-render player list, scores, etc.
244
+ // Every other client (sender does NOT receive their own)
245
+ const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angle: number }) => {
246
+ spawnProjectile(msg.x, msg.y, msg.angle);
205
247
  });
248
+ ```
249
+
250
+ #### Server-tracked membership
251
+
252
+ `room.state.players` is server-owned. Each entry exposes:
253
+ - `userId` — Unboxy identity (from the JWT)
254
+ - `displayName` — set by the joining client
255
+ - `joinedAt` — ms timestamp
256
+ - `data` — the MapSchema<string> the player writes to via `room.player.set`
206
257
 
207
- // Messages (server client, typed by name)
208
- const offHit = room.on('hit', (payload) => { /* apply a hit effect */ });
209
- room.send('move', { x: 120, y: 340 });
258
+ Games never mutate these directly; read them from `room.state.players`.
210
259
 
211
- // Connection lifecycle
260
+ #### Connection lifecycle
261
+
262
+ ```ts
212
263
  room.onLeave((code) => console.log('disconnected', code));
213
264
  room.onError((code, message) => console.warn('room error', code, message));
214
265
 
215
- // Clean up when the scene is destroyed
266
+ // Clean up on scene shutdown
216
267
  offState();
217
- offHit();
268
+ offFire();
218
269
  await room.leave();
219
270
  ```
220
271
 
221
- **Availability**
272
+ #### Picking the right primitive
273
+
274
+ | Need | Use | Why |
275
+ |---|---|---|
276
+ | Smooth position sync of a player | `room.player.set('pos', {x,y})` at ~10-20 Hz + `onStateChange` | Delta-compressed, survives reconnect, server is source of truth. |
277
+ | Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
278
+ | Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
279
+ | Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
280
+ | Chat message | `room.send('chat', text)` + `room.on('chat', ...)` | Same — ephemeral relay. |
281
+ | Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
282
+
283
+ Rules of thumb:
284
+ - **If someone joining mid-game should see it, it's state** (`room.player.*` or `room.data.*`).
285
+ - **If it's a one-shot action, it's a message** (`room.send` / `room.on`).
286
+ - **Do not implement position sync via messages.** You will reinvent delta compression badly and spend more bandwidth. Use `room.player.set('pos', {x,y})`.
287
+ - **Server is authoritative but permissive** — it doesn't validate what you put in `data`. Treat inbound values from other players as untrusted (don't eval, validate shape before reading).
288
+
289
+ #### Availability
290
+
222
291
  - Requires sign-in. Anonymous players get `RpcError('UNAUTHENTICATED')`.
223
292
  - Requires a host with multiplayer enabled. Standalone games (no host) get `RpcError('REALTIME_UNAVAILABLE')` — guard with `if (unboxy.host === 'unboxy-home-ui')` if your game can run both hosted and standalone.
224
293
 
225
- **Available room types (server-owned)**
226
- - `lobby` — simple player list with `ready` flag and chat broadcast. Use for pre-match staging.
227
- - (more coming: `TurnBasedRoom`, `RealtimeSyncRoom`)
294
+ #### Available room types
295
+
296
+ - `lobby` the generic room described above. Use for any multiplayer gameplay.
228
297
 
229
298
  **Rules of the road**
230
299
  - The server is the source of truth. Never trust `send()` payloads coming from other clients — the server validates.
@@ -245,6 +314,8 @@ await room.leave();
245
314
 
246
315
  ## Changelog
247
316
 
317
+ - **0.2.7** — fixed handshake race on slow-mounting hosts. `PostMessageTransport.connect` now retries `unboxy:hello` every 200 ms until `unboxy:init` arrives (previously one-shot → lost on Android/mobile where React mounted after the iframe first fired hello). Default handshake timeout bumped from 2 s → 5 s.
318
+ - **0.2.6** — redesigned `unboxy.rooms` around two generic primitives: delta-synced KV state (`room.player.set/get/delete`, `room.data.set/get/delete`) + transient relay (`room.send` / `room.on`). Server no longer bakes in game-specific fields like x/y/color/ready or a `move` handler — games define their own shapes via opaque JSON values, same contract as `gameData` / `saves`.
248
319
  - **0.2.5** — added `unboxy.rooms` module (server-authoritative multiplayer rooms backed by Colyseus on unboxy-realtime-service). Requires sign-in. Host must advertise `realtime` capability. Colyseus client loaded lazily — single-player games do not pay for the dependency.
249
320
  - **0.2.4** — added `unboxy.gameData` module (game-scope key-value store for scoreboards, shared state)
250
321
  - **0.2.3** — anonymous users now use localStorage even inside a host (was throwing UNAUTHENTICATED when calling `saves` in home-ui without login)
@@ -4,7 +4,7 @@ import { GameDataModule } from '../gamedata/GameDataModule.js';
4
4
  import { RealtimeModule } from '../realtime/RealtimeModule.js';
5
5
  import type { UnboxyUser } from '../protocol.js';
6
6
  export interface UnboxyInitOptions {
7
- /** Handshake timeout in ms when running inside a host. Default 2000. */
7
+ /** Handshake timeout in ms when running inside a host. Default 5000. */
8
8
  handshakeTimeoutMs?: number;
9
9
  /**
10
10
  * Game ID used for the localStorage fallback. Ignored when connected to a
@@ -4,7 +4,7 @@ import { SavesModule } from '../saves/SavesModule.js';
4
4
  import { GameDataModule } from '../gamedata/GameDataModule.js';
5
5
  import { RealtimeModule } from '../realtime/RealtimeModule.js';
6
6
  // Kept in sync with package.json on each publish.
7
- const SDK_VERSION = '0.2.5';
7
+ const SDK_VERSION = '0.2.7';
8
8
  /**
9
9
  * Unboxy platform services bound to the current (game, user).
10
10
  *
@@ -50,7 +50,7 @@ export class Unboxy {
50
50
  * Discord Activity support is designed for but not shipped in this version.
51
51
  */
52
52
  static async init(options = {}) {
53
- const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 2000;
53
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5000;
54
54
  const standaloneGameId = options.standaloneGameId ?? 'standalone';
55
55
  const pm = await PostMessageTransport.connect(handshakeTimeoutMs, SDK_VERSION);
56
56
  if (pm)
@@ -15,9 +15,14 @@ export declare class PostMessageTransport implements Transport {
15
15
  private constructor();
16
16
  /**
17
17
  * Perform the handshake. Resolves once parent has replied with `unboxy:init`.
18
- * Rejects if no response within `timeoutMs`.
18
+ * Resolves to null if no response within `timeoutMs`.
19
+ *
20
+ * The hello is retried at `helloRetryMs` intervals because the parent's
21
+ * RPC-host message listener may not be mounted yet when the iframe first
22
+ * fires hello — especially on slower mobile devices where React takes
23
+ * longer to boot. One-shot hello was losing the handshake on Android.
19
24
  */
20
- static connect(timeoutMs?: number, sdkVersion?: string): Promise<PostMessageTransport | null>;
25
+ static connect(timeoutMs?: number, sdkVersion?: string, helloRetryMs?: number): Promise<PostMessageTransport | null>;
21
26
  private installResultListener;
22
27
  call<T = unknown>(method: string, params?: unknown): Promise<T>;
23
28
  }
@@ -15,35 +15,51 @@ export class PostMessageTransport {
15
15
  }
16
16
  /**
17
17
  * Perform the handshake. Resolves once parent has replied with `unboxy:init`.
18
- * Rejects if no response within `timeoutMs`.
18
+ * Resolves to null if no response within `timeoutMs`.
19
+ *
20
+ * The hello is retried at `helloRetryMs` intervals because the parent's
21
+ * RPC-host message listener may not be mounted yet when the iframe first
22
+ * fires hello — especially on slower mobile devices where React takes
23
+ * longer to boot. One-shot hello was losing the handshake on Android.
19
24
  */
20
- static async connect(timeoutMs = 2000, sdkVersion = '0.0.0') {
25
+ static async connect(timeoutMs = 5000, sdkVersion = '0.0.0', helloRetryMs = 200) {
21
26
  if (typeof window === 'undefined' || window.parent === window)
22
27
  return null;
23
28
  const transport = new PostMessageTransport(window.parent);
24
- const initPromise = new Promise((resolve) => {
29
+ const hello = {
30
+ type: 'unboxy:hello',
31
+ protocolVersion: PROTOCOL_VERSION,
32
+ sdkVersion,
33
+ };
34
+ const init = await new Promise((resolve) => {
35
+ let settled = false;
36
+ const finish = (value) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ window.removeEventListener('message', onMessage);
41
+ clearInterval(helloInterval);
42
+ clearTimeout(timeoutHandle);
43
+ resolve(value);
44
+ };
25
45
  const onMessage = (event) => {
26
46
  if (event.source !== window.parent)
27
47
  return;
28
48
  const data = event.data;
29
49
  if (!data || data.type !== 'unboxy:init')
30
50
  return;
31
- window.removeEventListener('message', onMessage);
32
- resolve(data);
51
+ finish(data);
33
52
  };
34
53
  window.addEventListener('message', onMessage);
35
- setTimeout(() => {
36
- window.removeEventListener('message', onMessage);
37
- resolve(null);
38
- }, timeoutMs);
54
+ // Fire the first hello immediately, then keep retrying until init
55
+ // arrives or timeout fires. Retries are cheap (a postMessage with a
56
+ // small payload); the parent's RPC host replies on first valid hello.
57
+ transport.parent.postMessage(hello, '*');
58
+ const helloInterval = setInterval(() => {
59
+ transport.parent.postMessage(hello, '*');
60
+ }, helloRetryMs);
61
+ const timeoutHandle = setTimeout(() => finish(null), timeoutMs);
39
62
  });
40
- const hello = {
41
- type: 'unboxy:hello',
42
- protocolVersion: PROTOCOL_VERSION,
43
- sdkVersion,
44
- };
45
- transport.parent.postMessage(hello, '*');
46
- const init = await initPromise;
47
63
  if (!init)
48
64
  return null;
49
65
  if (init.protocolVersion !== PROTOCOL_VERSION) {
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ 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 } from './realtime/RealtimeModule.js';
12
- export { UnboxyRoom } from './realtime/UnboxyRoom.js';
12
+ export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
13
13
  export { RpcError } from './core/Transport.js';
14
14
  export type { Transport, TransportKind } from './core/Transport.js';
15
15
  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 } from './realtime/UnboxyRoom.js';
13
+ export { UnboxyRoom, PlayerDataFacade, RoomDataFacade } from './realtime/UnboxyRoom.js';
14
14
  export { RpcError } from './core/Transport.js';
15
15
  export { PROTOCOL_VERSION, } from './protocol.js';
@@ -1,5 +1,34 @@
1
1
  import type { Room } from 'colyseus.js';
2
2
  type Unsubscribe = () => void;
3
+ /**
4
+ * Facade for per-player state — one entry per player, only the owner can write
5
+ * to their own entry. Delta-synced via Colyseus schema.
6
+ */
7
+ export declare class PlayerDataFacade {
8
+ private readonly room;
9
+ constructor(room: Room<unknown>);
10
+ /** Write a JSON-serializable value to the caller's own player data. */
11
+ set(key: string, value: unknown): void;
12
+ /** Remove a key from the caller's own player data. */
13
+ delete(key: string): void;
14
+ /** Read a player's data value. Returns null if the player or key is absent. */
15
+ get<T = unknown>(sessionId: string, key: string): T | null;
16
+ }
17
+ /**
18
+ * Facade for room-scope shared state — one map per room, any client may write.
19
+ * Delta-synced via Colyseus schema. Use for shared game state like current
20
+ * turn, scoreboard, shared level-of-the-day, etc.
21
+ */
22
+ export declare class RoomDataFacade {
23
+ private readonly room;
24
+ constructor(room: Room<unknown>);
25
+ /** Write a JSON-serializable value into the room's shared data map. */
26
+ set(key: string, value: unknown): void;
27
+ /** Remove a key from the room's shared data map. */
28
+ delete(key: string): void;
29
+ /** Read a shared room value. Returns null if the key is absent. */
30
+ get<T = unknown>(key: string): T | null;
31
+ }
3
32
  /**
4
33
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
5
34
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -7,6 +36,8 @@ type Unsubscribe = () => void;
7
36
  */
8
37
  export declare class UnboxyRoom<State = unknown> {
9
38
  private readonly room;
39
+ readonly player: PlayerDataFacade;
40
+ readonly data: RoomDataFacade;
10
41
  constructor(room: Room<State>);
11
42
  get id(): string;
12
43
  get name(): string;
@@ -17,7 +48,8 @@ export declare class UnboxyRoom<State = unknown> {
17
48
  * may change state.
18
49
  */
19
50
  get state(): State;
20
- /** Send a typed message to the server. */
51
+ /** Send a transient message. Relayed to every other client in the room
52
+ * with `from: sessionId` stamped onto the payload. Not persisted in state. */
21
53
  send(type: string, payload?: unknown): void;
22
54
  /** Register a handler for a server-sent message type. Returns unsubscribe. */
23
55
  on(type: string, handler: (payload: unknown) => void): Unsubscribe;
@@ -1,3 +1,59 @@
1
+ function parseOrNull(raw) {
2
+ if (raw == null)
3
+ return null;
4
+ try {
5
+ return JSON.parse(raw);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ /**
12
+ * Facade for per-player state — one entry per player, only the owner can write
13
+ * to their own entry. Delta-synced via Colyseus schema.
14
+ */
15
+ export class PlayerDataFacade {
16
+ constructor(room) {
17
+ this.room = room;
18
+ }
19
+ /** Write a JSON-serializable value to the caller's own player data. */
20
+ set(key, value) {
21
+ this.room.send('player.set', { key, value });
22
+ }
23
+ /** Remove a key from the caller's own player data. */
24
+ delete(key) {
25
+ this.room.send('player.delete', { key });
26
+ }
27
+ /** Read a player's data value. Returns null if the player or key is absent. */
28
+ get(sessionId, key) {
29
+ const state = this.room.state;
30
+ const player = state?.players?.get?.(sessionId);
31
+ return parseOrNull(player?.data?.get?.(key));
32
+ }
33
+ }
34
+ /**
35
+ * Facade for room-scope shared state — one map per room, any client may write.
36
+ * Delta-synced via Colyseus schema. Use for shared game state like current
37
+ * turn, scoreboard, shared level-of-the-day, etc.
38
+ */
39
+ export class RoomDataFacade {
40
+ constructor(room) {
41
+ this.room = room;
42
+ }
43
+ /** Write a JSON-serializable value into the room's shared data map. */
44
+ set(key, value) {
45
+ this.room.send('room.set', { key, value });
46
+ }
47
+ /** Remove a key from the room's shared data map. */
48
+ delete(key) {
49
+ this.room.send('room.delete', { key });
50
+ }
51
+ /** Read a shared room value. Returns null if the key is absent. */
52
+ get(key) {
53
+ const state = this.room.state;
54
+ return parseOrNull(state?.data?.get?.(key));
55
+ }
56
+ }
1
57
  /**
2
58
  * Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
3
59
  * documented in SDK-GUIDE.md so games are portable to a different realtime
@@ -6,6 +62,8 @@
6
62
  export class UnboxyRoom {
7
63
  constructor(room) {
8
64
  this.room = room;
65
+ this.player = new PlayerDataFacade(room);
66
+ this.data = new RoomDataFacade(room);
9
67
  }
10
68
  get id() { return this.room.roomId; }
11
69
  get name() { return this.room.name; }
@@ -16,7 +74,8 @@ export class UnboxyRoom {
16
74
  * may change state.
17
75
  */
18
76
  get state() { return this.room.state; }
19
- /** Send a typed message to the server. */
77
+ /** Send a transient message. Relayed to every other client in the room
78
+ * with `from: sessionId` stamped onto the payload. Not persisted in state. */
20
79
  send(type, payload) {
21
80
  this.room.send(type, payload);
22
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",