@unboxy/phaser-sdk 0.2.3 → 0.2.5
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 +115 -2
- package/dist/core/Transport.d.ts +6 -0
- package/dist/core/Unboxy.d.ts +4 -0
- package/dist/core/Unboxy.js +16 -5
- package/dist/core/transports/LocalStorageTransport.d.ts +6 -5
- package/dist/core/transports/LocalStorageTransport.js +24 -20
- package/dist/core/transports/PostMessageTransport.d.ts +1 -0
- package/dist/core/transports/PostMessageTransport.js +1 -0
- package/dist/gamedata/GameDataModule.d.ts +45 -0
- package/dist/gamedata/GameDataModule.js +59 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/protocol.d.ts +40 -1
- package/dist/realtime/RealtimeModule.d.ts +30 -0
- package/dist/realtime/RealtimeModule.js +54 -0
- package/dist/realtime/UnboxyRoom.d.ts +36 -0
- package/dist/realtime/UnboxyRoom.js +52 -0
- package/package.json +9 -2
package/SDK-GUIDE.md
CHANGED
|
@@ -6,8 +6,10 @@ Reference for AI agents building games on the Unboxy platform. Tracks the **inst
|
|
|
6
6
|
|
|
7
7
|
- `createUnboxyGame(options)` — Phaser.Game factory with platform defaults (screenshot + recording wired up)
|
|
8
8
|
- `UnboxyScene` — optional base scene with `createButton`, `shakeCamera`, `flashCamera` helpers
|
|
9
|
-
- `Unboxy.init()` — platform services
|
|
10
|
-
- (
|
|
9
|
+
- `Unboxy.init()` — platform services entry point
|
|
10
|
+
- `unboxy.saves` — **per-user** key-value store (only the owner sees/writes their data)
|
|
11
|
+
- `unboxy.gameData` — **per-game** key-value store (read by anyone; write requires auth)
|
|
12
|
+
- `unboxy.rooms` — **multiplayer rooms** (server-authoritative state sync, requires sign-in and host support)
|
|
11
13
|
|
|
12
14
|
## Platform services
|
|
13
15
|
|
|
@@ -124,6 +126,113 @@ const saved = await unboxy.saves.get<number>('highScore').catch(() => null);
|
|
|
124
126
|
this.highScore = typeof saved === 'number' ? saved : 0;
|
|
125
127
|
```
|
|
126
128
|
|
|
129
|
+
### Game data — `unboxy.gameData`
|
|
130
|
+
|
|
131
|
+
Same API shape as `saves`, but **game-scope**: one value per key, shared by all players of the game. Use it for things the whole playerbase sees — scoreboards, shared inventories, tournament state, level-of-the-day.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// Read — public. Works for anonymous visitors.
|
|
135
|
+
type Entry = { name: string; score: number; at: number };
|
|
136
|
+
const board = await unboxy.gameData.get<Entry[]>('leaderboard') ?? [];
|
|
137
|
+
|
|
138
|
+
// Write — requires an authenticated user. Throws RpcError('UNAUTHENTICATED') otherwise.
|
|
139
|
+
// Safe append: read + modify + write with ifVersion to avoid clobbering
|
|
140
|
+
// concurrent writers (two players finishing at the same time).
|
|
141
|
+
const { value: current, version } = await unboxy.gameData
|
|
142
|
+
.get<Entry[]>('leaderboard')
|
|
143
|
+
.then((v) => ({ value: v ?? [], version: null as number | null })) // null means "not yet written"
|
|
144
|
+
|
|
145
|
+
async function submitScore(name: string, score: number) {
|
|
146
|
+
if (!unboxy.isAuthenticated) return; // skip for anonymous
|
|
147
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
148
|
+
const raw = await unboxy.gameData.get<Entry[]>('leaderboard');
|
|
149
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
150
|
+
const next = [...list, { name, score, at: Date.now() }]
|
|
151
|
+
.sort((a, b) => b.score - a.score)
|
|
152
|
+
.slice(0, 100); // cap to top 100 so we don't blow the 100 KB limit
|
|
153
|
+
try {
|
|
154
|
+
await unboxy.gameData.set('leaderboard', next);
|
|
155
|
+
return; // success
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
if (err?.code !== 'VERSION_MISMATCH') throw err;
|
|
158
|
+
// another writer landed first — re-read and retry
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Saves vs. gameData — which one?
|
|
165
|
+
|
|
166
|
+
| Need | Use | Why |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| My personal high score | `saves` | Only I write it, only I read it. Anonymous-friendly (localStorage fallback). |
|
|
169
|
+
| Progress through levels | `saves` | Per-user private state. |
|
|
170
|
+
| Character / loadout choice | `saves` | Per-user. |
|
|
171
|
+
| Scoreboard visible to everyone | `gameData` | Multiple users write; anyone reads. |
|
|
172
|
+
| Today's puzzle / daily challenge | `gameData` | Single global value read by all players. |
|
|
173
|
+
| Shared tournament bracket | `gameData` | Shared mutable state across players. |
|
|
174
|
+
| Player name in a chat log | `gameData` | But consider size caps — chat is noisy. |
|
|
175
|
+
| Current frame's enemy positions | *neither* — transient, don't persist | Wastes quota and confuses state after refresh. |
|
|
176
|
+
|
|
177
|
+
Rule of thumb: if two different players would write to the same key, it's `gameData`. If a write from player A should never be visible to player B, it's `saves`.
|
|
178
|
+
|
|
179
|
+
### Concurrency + size (gameData specifics)
|
|
180
|
+
|
|
181
|
+
- **List writes race.** If two players append to `leaderboard` simultaneously, one overwrites the other's work. Use `ifVersion` for read-modify-write. On `VERSION_MISMATCH`, re-read and retry.
|
|
182
|
+
- **Truncate.** A single key caps at 100 KB — roughly 500-1000 compact JSON entries. Keep lists bounded (top N, drop older), or shard across keys.
|
|
183
|
+
- **Reads are cheap and public.** No auth needed — even logged-out players see `gameData`. Don't put anything secret here.
|
|
184
|
+
- **Trust is thin.** The backend stores what you write, verbatim. A player with devtools could submit any JSON. For casual games this is fine; for competitive stakes, a future typed leaderboards API with server-side validation is the right fix.
|
|
185
|
+
|
|
186
|
+
### Multiplayer rooms — `unboxy.rooms`
|
|
187
|
+
|
|
188
|
+
Realtime rooms backed by unboxy-realtime-service. The server is authoritative — clients 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`.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Join or create a room scoped to this game. The SDK fetches a short-lived
|
|
192
|
+
// token from the host and forwards it; the iframe never handles credentials.
|
|
193
|
+
const room = await unboxy.rooms.joinOrCreate('lobby', {
|
|
194
|
+
displayName: unboxy.user?.name ?? 'guest',
|
|
195
|
+
});
|
|
196
|
+
|
|
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
|
|
201
|
+
|
|
202
|
+
// React to server updates
|
|
203
|
+
const offState = room.onStateChange((state) => {
|
|
204
|
+
// re-render player list, scores, etc.
|
|
205
|
+
});
|
|
206
|
+
|
|
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 });
|
|
210
|
+
|
|
211
|
+
// Connection lifecycle
|
|
212
|
+
room.onLeave((code) => console.log('disconnected', code));
|
|
213
|
+
room.onError((code, message) => console.warn('room error', code, message));
|
|
214
|
+
|
|
215
|
+
// Clean up when the scene is destroyed
|
|
216
|
+
offState();
|
|
217
|
+
offHit();
|
|
218
|
+
await room.leave();
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Availability**
|
|
222
|
+
- Requires sign-in. Anonymous players get `RpcError('UNAUTHENTICATED')`.
|
|
223
|
+
- 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
|
+
|
|
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`)
|
|
228
|
+
|
|
229
|
+
**Rules of the road**
|
|
230
|
+
- The server is the source of truth. Never trust `send()` payloads coming from other clients — the server validates.
|
|
231
|
+
- Room state is *ephemeral*. When the last player leaves, it disposes. Persist anything you want to keep via `saves` or `gameData`.
|
|
232
|
+
- Keep messages small. State syncs are delta-compressed; raw `send()` payloads are not.
|
|
233
|
+
- Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
|
|
234
|
+
- One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
|
|
235
|
+
|
|
127
236
|
## Anti-patterns (don't do these)
|
|
128
237
|
|
|
129
238
|
- Do **not** call `Unboxy.init()` inside a scene. Initialize at module load in `main.ts` and export the promise.
|
|
@@ -131,9 +240,13 @@ this.highScore = typeof saved === 'number' ? saved : 0;
|
|
|
131
240
|
- Do **not** save on every frame / every score tick. Debounce or only save on meaningful events (game over, level clear).
|
|
132
241
|
- Do **not** store large binary blobs (images, audio) as save values — use a dedicated blob API when it ships.
|
|
133
242
|
- Do **not** use `localStorage` directly for persistent data when `unboxy.saves` is available — you'll lose cross-device sync when we ship provisional accounts.
|
|
243
|
+
- Do **not** put secrets, private messages, or per-user data in `gameData` — it's public. That's `saves`.
|
|
244
|
+
- Do **not** write to `gameData` on every score tick either — submit once at game-over or level-clear.
|
|
134
245
|
|
|
135
246
|
## Changelog
|
|
136
247
|
|
|
248
|
+
- **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
|
+
- **0.2.4** — added `unboxy.gameData` module (game-scope key-value store for scoreboards, shared state)
|
|
137
250
|
- **0.2.3** — anonymous users now use localStorage even inside a host (was throwing UNAUTHENTICATED when calling `saves` in home-ui without login)
|
|
138
251
|
- **0.2.2** — added `SDK-GUIDE.md`
|
|
139
252
|
- **0.2.1** — added `Unboxy.init`, `unboxy.user`, `unboxy.saves`, Transport abstraction (PostMessage + LocalStorage backends)
|
package/dist/core/Transport.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface Transport {
|
|
|
13
13
|
readonly kind: TransportKind;
|
|
14
14
|
readonly user: UnboxyUser | null;
|
|
15
15
|
readonly gameId: string;
|
|
16
|
+
/**
|
|
17
|
+
* Endpoint for unboxy-realtime-service (WebSocket URL). Set by the host
|
|
18
|
+
* during handshake when multiplayer is enabled. Absent in standalone mode —
|
|
19
|
+
* `unboxy.rooms.*` is unavailable in that case.
|
|
20
|
+
*/
|
|
21
|
+
readonly realtimeUrl?: string;
|
|
16
22
|
call<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
17
23
|
}
|
|
18
24
|
export type TransportKind = 'unboxy-home-ui' | 'standalone' | 'discord';
|
package/dist/core/Unboxy.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TransportKind } from './Transport.js';
|
|
2
2
|
import { SavesModule } from '../saves/SavesModule.js';
|
|
3
|
+
import { GameDataModule } from '../gamedata/GameDataModule.js';
|
|
4
|
+
import { RealtimeModule } from '../realtime/RealtimeModule.js';
|
|
3
5
|
import type { UnboxyUser } from '../protocol.js';
|
|
4
6
|
export interface UnboxyInitOptions {
|
|
5
7
|
/** Handshake timeout in ms when running inside a host. Default 2000. */
|
|
@@ -20,6 +22,8 @@ export interface UnboxyInitOptions {
|
|
|
20
22
|
export declare class Unboxy {
|
|
21
23
|
private transport;
|
|
22
24
|
readonly saves: SavesModule;
|
|
25
|
+
readonly gameData: GameDataModule;
|
|
26
|
+
readonly rooms: RealtimeModule;
|
|
23
27
|
private constructor();
|
|
24
28
|
/** The current authenticated user, or `null` if anonymous / standalone. */
|
|
25
29
|
get user(): UnboxyUser | null;
|
package/dist/core/Unboxy.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { PostMessageTransport } from './transports/PostMessageTransport.js';
|
|
2
2
|
import { LocalStorageTransport } from './transports/LocalStorageTransport.js';
|
|
3
3
|
import { SavesModule } from '../saves/SavesModule.js';
|
|
4
|
+
import { GameDataModule } from '../gamedata/GameDataModule.js';
|
|
5
|
+
import { RealtimeModule } from '../realtime/RealtimeModule.js';
|
|
4
6
|
// Kept in sync with package.json on each publish.
|
|
5
|
-
const SDK_VERSION = '0.2.
|
|
7
|
+
const SDK_VERSION = '0.2.5';
|
|
6
8
|
/**
|
|
7
9
|
* Unboxy platform services bound to the current (game, user).
|
|
8
10
|
*
|
|
@@ -13,14 +15,23 @@ const SDK_VERSION = '0.2.3';
|
|
|
13
15
|
export class Unboxy {
|
|
14
16
|
constructor(transport) {
|
|
15
17
|
this.transport = transport;
|
|
16
|
-
// Saves are per-user. When the viewer is anonymous
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// Saves are per-user. When the viewer is anonymous, route save calls to
|
|
19
|
+
// localStorage even if a host is attached — the backend requires auth,
|
|
20
|
+
// so forwarding would 401. Games see the same `unboxy.saves` API either
|
|
21
|
+
// way and don't have to branch on auth state.
|
|
20
22
|
const savesTransport = transport.user === null
|
|
21
23
|
? new LocalStorageTransport(transport.gameId || 'anonymous')
|
|
22
24
|
: transport;
|
|
23
25
|
this.saves = new SavesModule(savesTransport);
|
|
26
|
+
// gameData is game-scope: reads are public (anonymous OK), only writes
|
|
27
|
+
// need auth. The primary transport handles both; if it's a LocalStorage
|
|
28
|
+
// fallback (standalone mode) that also works — the module speaks the
|
|
29
|
+
// same RPC interface.
|
|
30
|
+
this.gameData = new GameDataModule(transport);
|
|
31
|
+
// Multiplayer requires a realtime endpoint from the host. When absent
|
|
32
|
+
// (standalone or a host without multiplayer), methods throw a clear
|
|
33
|
+
// REALTIME_UNAVAILABLE error rather than silently misbehaving.
|
|
34
|
+
this.rooms = new RealtimeModule(transport, transport.realtimeUrl);
|
|
24
35
|
}
|
|
25
36
|
/** The current authenticated user, or `null` if anonymous / standalone. */
|
|
26
37
|
get user() { return this.transport.user; }
|
|
@@ -11,11 +11,12 @@ export declare class LocalStorageTransport implements Transport {
|
|
|
11
11
|
readonly user: UnboxyUser | null;
|
|
12
12
|
constructor(gameId: string);
|
|
13
13
|
call<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
14
|
-
private
|
|
14
|
+
private savesPrefix;
|
|
15
|
+
private gameDataPrefix;
|
|
15
16
|
private read;
|
|
16
17
|
private write;
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
private
|
|
18
|
+
private kvGet;
|
|
19
|
+
private kvSet;
|
|
20
|
+
private kvDelete;
|
|
21
|
+
private kvList;
|
|
21
22
|
}
|
|
@@ -12,18 +12,23 @@ export class LocalStorageTransport {
|
|
|
12
12
|
}
|
|
13
13
|
async call(method, params) {
|
|
14
14
|
switch (method) {
|
|
15
|
-
case 'saves.get': return this.
|
|
16
|
-
case 'saves.set': return this.
|
|
17
|
-
case 'saves.delete': return this.
|
|
18
|
-
case 'saves.list': return this.
|
|
15
|
+
case 'saves.get': return this.kvGet(this.savesPrefix(), params);
|
|
16
|
+
case 'saves.set': return this.kvSet(this.savesPrefix(), params);
|
|
17
|
+
case 'saves.delete': return this.kvDelete(this.savesPrefix(), params);
|
|
18
|
+
case 'saves.list': return this.kvList(this.savesPrefix());
|
|
19
|
+
case 'gameData.get': return this.kvGet(this.gameDataPrefix(), params);
|
|
20
|
+
case 'gameData.set': return this.kvSet(this.gameDataPrefix(), params);
|
|
21
|
+
case 'gameData.delete': return this.kvDelete(this.gameDataPrefix(), params);
|
|
22
|
+
case 'gameData.list': return this.kvList(this.gameDataPrefix());
|
|
19
23
|
default: throw new RpcError('UNKNOWN_METHOD', `Unknown method: ${method}`);
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
savesPrefix() { return `unboxy:saves:${this.gameId}:`; }
|
|
27
|
+
gameDataPrefix() { return `unboxy:gameData:${this.gameId}:`; }
|
|
28
|
+
read(prefix, key) {
|
|
24
29
|
if (typeof localStorage === 'undefined')
|
|
25
30
|
return null;
|
|
26
|
-
const raw = localStorage.getItem(
|
|
31
|
+
const raw = localStorage.getItem(prefix + key);
|
|
27
32
|
if (!raw)
|
|
28
33
|
return null;
|
|
29
34
|
try {
|
|
@@ -33,41 +38,40 @@ export class LocalStorageTransport {
|
|
|
33
38
|
return null;
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
|
-
write(key, rec) {
|
|
41
|
+
write(prefix, key, rec) {
|
|
37
42
|
if (typeof localStorage === 'undefined')
|
|
38
43
|
throw new RpcError('UNAVAILABLE', 'localStorage unavailable');
|
|
39
|
-
localStorage.setItem(
|
|
44
|
+
localStorage.setItem(prefix + key, JSON.stringify(rec));
|
|
40
45
|
}
|
|
41
|
-
|
|
42
|
-
const rec = this.read(key);
|
|
46
|
+
kvGet(prefix, { key }) {
|
|
47
|
+
const rec = this.read(prefix, key);
|
|
43
48
|
return rec ? { value: rec.value, version: rec.version } : { value: null, version: null };
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
const existing = this.read(key);
|
|
50
|
+
kvSet(prefix, { key, value, ifVersion }) {
|
|
51
|
+
const existing = this.read(prefix, key);
|
|
47
52
|
if (ifVersion !== undefined && existing && existing.version !== ifVersion) {
|
|
48
53
|
throw new RpcError('VERSION_MISMATCH', `Expected version ${ifVersion} but stored is ${existing.version}`);
|
|
49
54
|
}
|
|
50
55
|
const next = { value, version: (existing?.version ?? 0) + 1 };
|
|
51
|
-
this.write(key, next);
|
|
56
|
+
this.write(prefix, key, next);
|
|
52
57
|
return { version: next.version };
|
|
53
58
|
}
|
|
54
|
-
|
|
59
|
+
kvDelete(prefix, { key }) {
|
|
55
60
|
if (typeof localStorage === 'undefined')
|
|
56
61
|
return { deleted: false };
|
|
57
|
-
const full =
|
|
62
|
+
const full = prefix + key;
|
|
58
63
|
const existed = localStorage.getItem(full) !== null;
|
|
59
64
|
localStorage.removeItem(full);
|
|
60
65
|
return { deleted: existed };
|
|
61
66
|
}
|
|
62
|
-
|
|
67
|
+
kvList(prefix) {
|
|
63
68
|
if (typeof localStorage === 'undefined')
|
|
64
69
|
return { keys: [] };
|
|
65
70
|
const keys = [];
|
|
66
|
-
const p = this.prefix();
|
|
67
71
|
for (let i = 0; i < localStorage.length; i++) {
|
|
68
72
|
const k = localStorage.key(i);
|
|
69
|
-
if (k && k.startsWith(
|
|
70
|
-
keys.push(k.slice(
|
|
73
|
+
if (k && k.startsWith(prefix))
|
|
74
|
+
keys.push(k.slice(prefix.length));
|
|
71
75
|
}
|
|
72
76
|
return { keys };
|
|
73
77
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Transport } from '../core/Transport.js';
|
|
2
|
+
/**
|
|
3
|
+
* Game-scope key-value store shared by all players of a game.
|
|
4
|
+
*
|
|
5
|
+
* Unlike `unboxy.saves` (per-user), `gameData` lives at the game level — one
|
|
6
|
+
* value per key, visible to everyone. Reads are public (anonymous players
|
|
7
|
+
* see the same data). Writes require an authenticated user; calling
|
|
8
|
+
* `set()` or `delete()` when `unboxy.user === null` throws an `RpcError`
|
|
9
|
+
* with code `UNAUTHENTICATED`.
|
|
10
|
+
*
|
|
11
|
+
* Typical uses: scoreboards, shared inventories, tournament state, level
|
|
12
|
+
* of the day. Values are opaque JSON — primitive, object, or collection.
|
|
13
|
+
*
|
|
14
|
+
* Trust model (see SDK-GUIDE.md for details):
|
|
15
|
+
* - The backend does not enforce invariants INSIDE a value. If you store
|
|
16
|
+
* a list and append your entry, you own the read-modify-write loop —
|
|
17
|
+
* including not mutating other players' entries, truncating to cap
|
|
18
|
+
* the list size, and retrying on 409 conflicts.
|
|
19
|
+
* - Use `ifVersion` to avoid lost updates when concurrent writes race.
|
|
20
|
+
*
|
|
21
|
+
* Quotas (enforced at the backend):
|
|
22
|
+
* - 100 KB per value
|
|
23
|
+
* - 1 MB total per game
|
|
24
|
+
* - 64 keys per game
|
|
25
|
+
*/
|
|
26
|
+
export declare class GameDataModule {
|
|
27
|
+
private transport;
|
|
28
|
+
constructor(transport: Transport);
|
|
29
|
+
/** Read the value under `key`. Returns `null` if unset. Public — works for anonymous viewers. */
|
|
30
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Write `value` under `key`. Requires an authenticated user.
|
|
33
|
+
* @param options.ifVersion — only succeed if the stored version matches; otherwise throws
|
|
34
|
+
* an RpcError with code `VERSION_MISMATCH`. Use this to implement safe read-modify-write
|
|
35
|
+
* loops for list values (scoreboards, shared lists).
|
|
36
|
+
* @returns the new version number.
|
|
37
|
+
*/
|
|
38
|
+
set(key: string, value: unknown, options?: {
|
|
39
|
+
ifVersion?: number;
|
|
40
|
+
}): Promise<number>;
|
|
41
|
+
/** Delete the value at `key`. Requires an authenticated user. */
|
|
42
|
+
delete(key: string): Promise<boolean>;
|
|
43
|
+
/** List all keys that have a value set for this game. Public. */
|
|
44
|
+
list(): Promise<string[]>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Game-scope key-value store shared by all players of a game.
|
|
3
|
+
*
|
|
4
|
+
* Unlike `unboxy.saves` (per-user), `gameData` lives at the game level — one
|
|
5
|
+
* value per key, visible to everyone. Reads are public (anonymous players
|
|
6
|
+
* see the same data). Writes require an authenticated user; calling
|
|
7
|
+
* `set()` or `delete()` when `unboxy.user === null` throws an `RpcError`
|
|
8
|
+
* with code `UNAUTHENTICATED`.
|
|
9
|
+
*
|
|
10
|
+
* Typical uses: scoreboards, shared inventories, tournament state, level
|
|
11
|
+
* of the day. Values are opaque JSON — primitive, object, or collection.
|
|
12
|
+
*
|
|
13
|
+
* Trust model (see SDK-GUIDE.md for details):
|
|
14
|
+
* - The backend does not enforce invariants INSIDE a value. If you store
|
|
15
|
+
* a list and append your entry, you own the read-modify-write loop —
|
|
16
|
+
* including not mutating other players' entries, truncating to cap
|
|
17
|
+
* the list size, and retrying on 409 conflicts.
|
|
18
|
+
* - Use `ifVersion` to avoid lost updates when concurrent writes race.
|
|
19
|
+
*
|
|
20
|
+
* Quotas (enforced at the backend):
|
|
21
|
+
* - 100 KB per value
|
|
22
|
+
* - 1 MB total per game
|
|
23
|
+
* - 64 keys per game
|
|
24
|
+
*/
|
|
25
|
+
export class GameDataModule {
|
|
26
|
+
constructor(transport) {
|
|
27
|
+
this.transport = transport;
|
|
28
|
+
}
|
|
29
|
+
/** Read the value under `key`. Returns `null` if unset. Public — works for anonymous viewers. */
|
|
30
|
+
async get(key) {
|
|
31
|
+
const res = await this.transport.call('gameData.get', { key });
|
|
32
|
+
return (res?.value ?? null);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Write `value` under `key`. Requires an authenticated user.
|
|
36
|
+
* @param options.ifVersion — only succeed if the stored version matches; otherwise throws
|
|
37
|
+
* an RpcError with code `VERSION_MISMATCH`. Use this to implement safe read-modify-write
|
|
38
|
+
* loops for list values (scoreboards, shared lists).
|
|
39
|
+
* @returns the new version number.
|
|
40
|
+
*/
|
|
41
|
+
async set(key, value, options) {
|
|
42
|
+
const res = await this.transport.call('gameData.set', {
|
|
43
|
+
key,
|
|
44
|
+
value,
|
|
45
|
+
ifVersion: options?.ifVersion,
|
|
46
|
+
});
|
|
47
|
+
return res.version;
|
|
48
|
+
}
|
|
49
|
+
/** Delete the value at `key`. Requires an authenticated user. */
|
|
50
|
+
async delete(key) {
|
|
51
|
+
const res = await this.transport.call('gameData.delete', { key });
|
|
52
|
+
return res.deleted;
|
|
53
|
+
}
|
|
54
|
+
/** List all keys that have a value set for this game. Public. */
|
|
55
|
+
async list() {
|
|
56
|
+
const res = await this.transport.call('gameData.list');
|
|
57
|
+
return res.keys;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,11 @@ export { setupRecordingListener } from './recording/RecordingManager.js';
|
|
|
6
6
|
export { Unboxy } from './core/Unboxy.js';
|
|
7
7
|
export type { UnboxyInitOptions } from './core/Unboxy.js';
|
|
8
8
|
export { SavesModule } from './saves/SavesModule.js';
|
|
9
|
+
export { GameDataModule } from './gamedata/GameDataModule.js';
|
|
10
|
+
export { RealtimeModule } from './realtime/RealtimeModule.js';
|
|
11
|
+
export type { JoinOptions } from './realtime/RealtimeModule.js';
|
|
12
|
+
export { UnboxyRoom } from './realtime/UnboxyRoom.js';
|
|
9
13
|
export { RpcError } from './core/Transport.js';
|
|
10
14
|
export type { Transport, TransportKind } from './core/Transport.js';
|
|
11
15
|
export type { UnboxyUser } from './protocol.js';
|
|
12
|
-
export { PROTOCOL_VERSION, type HelloMessage, type InitMessage, type RpcRequestMessage, type RpcResultOk, type RpcResultError, type HostToSdkMessage, type SdkToHostMessage, type RpcErrorPayload, type RpcMethod, type SavesGetParams, type SavesGetResult, type SavesSetParams, type SavesSetResult, type SavesDeleteParams, type SavesDeleteResult, type SavesListResult, } from './protocol.js';
|
|
16
|
+
export { PROTOCOL_VERSION, type HelloMessage, type InitMessage, type RpcRequestMessage, type RpcResultOk, type RpcResultError, type HostToSdkMessage, type SdkToHostMessage, type RpcErrorPayload, type RpcMethod, type SavesGetParams, type SavesGetResult, type SavesSetParams, type SavesSetResult, type SavesDeleteParams, type SavesDeleteResult, type SavesListResult, type GameDataGetParams, type GameDataGetResult, type GameDataSetParams, type GameDataSetResult, type GameDataDeleteParams, type GameDataDeleteResult, type GameDataListResult, type RealtimeGetTokenParams, type RealtimeGetTokenResult, } from './protocol.js';
|
package/dist/index.js
CHANGED
|
@@ -8,5 +8,8 @@ export { setupRecordingListener } from './recording/RecordingManager.js';
|
|
|
8
8
|
// Platform services (v0.2.1+)
|
|
9
9
|
export { Unboxy } from './core/Unboxy.js';
|
|
10
10
|
export { SavesModule } from './saves/SavesModule.js';
|
|
11
|
+
export { GameDataModule } from './gamedata/GameDataModule.js';
|
|
12
|
+
export { RealtimeModule } from './realtime/RealtimeModule.js';
|
|
13
|
+
export { UnboxyRoom } from './realtime/UnboxyRoom.js';
|
|
11
14
|
export { RpcError } from './core/Transport.js';
|
|
12
15
|
export { PROTOCOL_VERSION, } from './protocol.js';
|
package/dist/protocol.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface InitMessage {
|
|
|
22
22
|
gameId: string;
|
|
23
23
|
user: UnboxyUser | null;
|
|
24
24
|
capabilities: string[];
|
|
25
|
+
/**
|
|
26
|
+
* WebSocket endpoint for unboxy-realtime-service. Present only when the host
|
|
27
|
+
* has multiplayer configured (capabilities includes 'realtime'). SDK connects
|
|
28
|
+
* here after fetching a JWT via the 'realtime.getToken' RPC.
|
|
29
|
+
*/
|
|
30
|
+
realtimeUrl?: string;
|
|
25
31
|
}
|
|
26
32
|
export interface RpcResultOk {
|
|
27
33
|
type: 'unboxy:rpc.result';
|
|
@@ -40,7 +46,7 @@ export interface RpcResultError {
|
|
|
40
46
|
error: RpcErrorPayload;
|
|
41
47
|
}
|
|
42
48
|
export type HostToSdkMessage = InitMessage | RpcResultOk | RpcResultError;
|
|
43
|
-
export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list';
|
|
49
|
+
export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken';
|
|
44
50
|
export interface SavesGetParams {
|
|
45
51
|
key: string;
|
|
46
52
|
}
|
|
@@ -65,3 +71,36 @@ export type SavesDeleteResult = {
|
|
|
65
71
|
export interface SavesListResult {
|
|
66
72
|
keys: string[];
|
|
67
73
|
}
|
|
74
|
+
export interface GameDataGetParams {
|
|
75
|
+
key: string;
|
|
76
|
+
}
|
|
77
|
+
export interface GameDataGetResult {
|
|
78
|
+
value: unknown | null;
|
|
79
|
+
version: number | null;
|
|
80
|
+
}
|
|
81
|
+
export interface GameDataSetParams {
|
|
82
|
+
key: string;
|
|
83
|
+
value: unknown;
|
|
84
|
+
ifVersion?: number;
|
|
85
|
+
}
|
|
86
|
+
export interface GameDataSetResult {
|
|
87
|
+
version: number;
|
|
88
|
+
}
|
|
89
|
+
export interface GameDataDeleteParams {
|
|
90
|
+
key: string;
|
|
91
|
+
}
|
|
92
|
+
export type GameDataDeleteResult = {
|
|
93
|
+
deleted: boolean;
|
|
94
|
+
};
|
|
95
|
+
export interface GameDataListResult {
|
|
96
|
+
keys: string[];
|
|
97
|
+
}
|
|
98
|
+
export interface RealtimeGetTokenParams {
|
|
99
|
+
/** Opaque, forwarded to server-side auth (future use). */
|
|
100
|
+
purpose?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface RealtimeGetTokenResult {
|
|
103
|
+
token: string;
|
|
104
|
+
/** Epoch millis when the token expires. */
|
|
105
|
+
expiresAt: number;
|
|
106
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Transport } from '../core/Transport.js';
|
|
2
|
+
import { UnboxyRoom } from './UnboxyRoom.js';
|
|
3
|
+
export interface JoinOptions extends Record<string, unknown> {
|
|
4
|
+
/** Optional display name passed to the server-side room on join. */
|
|
5
|
+
displayName?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Multiplayer rooms backed by unboxy-realtime-service (Colyseus under the hood).
|
|
9
|
+
*
|
|
10
|
+
* Availability: only when the host provided a `realtimeUrl` during the
|
|
11
|
+
* handshake (i.e. connected to unboxy-home-ui with multiplayer enabled).
|
|
12
|
+
* Unavailable in standalone/anonymous mode — methods throw RpcError
|
|
13
|
+
* 'REALTIME_UNAVAILABLE'.
|
|
14
|
+
*
|
|
15
|
+
* Tokens are fetched per connection attempt via the `realtime.getToken`
|
|
16
|
+
* RPC — the iframe never holds a long-lived credential.
|
|
17
|
+
*/
|
|
18
|
+
export declare class RealtimeModule {
|
|
19
|
+
private readonly transport;
|
|
20
|
+
private readonly realtimeUrl;
|
|
21
|
+
private client;
|
|
22
|
+
constructor(transport: Transport, realtimeUrl: string | undefined);
|
|
23
|
+
/** `true` when a realtime endpoint was provided by the host. */
|
|
24
|
+
get available(): boolean;
|
|
25
|
+
joinOrCreate<State = unknown>(roomType: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
26
|
+
create<State = unknown>(roomType: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
27
|
+
join<State = unknown>(roomType: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
28
|
+
joinById<State = unknown>(roomId: string, options?: JoinOptions): Promise<UnboxyRoom<State>>;
|
|
29
|
+
private connect;
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { RpcError } from '../core/Transport.js';
|
|
2
|
+
import { UnboxyRoom } from './UnboxyRoom.js';
|
|
3
|
+
/**
|
|
4
|
+
* Multiplayer rooms backed by unboxy-realtime-service (Colyseus under the hood).
|
|
5
|
+
*
|
|
6
|
+
* Availability: only when the host provided a `realtimeUrl` during the
|
|
7
|
+
* handshake (i.e. connected to unboxy-home-ui with multiplayer enabled).
|
|
8
|
+
* Unavailable in standalone/anonymous mode — methods throw RpcError
|
|
9
|
+
* 'REALTIME_UNAVAILABLE'.
|
|
10
|
+
*
|
|
11
|
+
* Tokens are fetched per connection attempt via the `realtime.getToken`
|
|
12
|
+
* RPC — the iframe never holds a long-lived credential.
|
|
13
|
+
*/
|
|
14
|
+
export class RealtimeModule {
|
|
15
|
+
constructor(transport, realtimeUrl) {
|
|
16
|
+
this.transport = transport;
|
|
17
|
+
this.realtimeUrl = realtimeUrl;
|
|
18
|
+
this.client = null;
|
|
19
|
+
}
|
|
20
|
+
/** `true` when a realtime endpoint was provided by the host. */
|
|
21
|
+
get available() {
|
|
22
|
+
return typeof this.realtimeUrl === 'string' && this.realtimeUrl.length > 0;
|
|
23
|
+
}
|
|
24
|
+
async joinOrCreate(roomType, options = {}) {
|
|
25
|
+
return this.connect(roomType, options, 'joinOrCreate');
|
|
26
|
+
}
|
|
27
|
+
async create(roomType, options = {}) {
|
|
28
|
+
return this.connect(roomType, options, 'create');
|
|
29
|
+
}
|
|
30
|
+
async join(roomType, options = {}) {
|
|
31
|
+
return this.connect(roomType, options, 'join');
|
|
32
|
+
}
|
|
33
|
+
async joinById(roomId, options = {}) {
|
|
34
|
+
return this.connect(roomId, options, 'joinById');
|
|
35
|
+
}
|
|
36
|
+
async connect(nameOrId, options, method) {
|
|
37
|
+
if (!this.available) {
|
|
38
|
+
throw new RpcError('REALTIME_UNAVAILABLE', 'Multiplayer is not available for this host. Unboxy.rooms requires running inside unboxy-home-ui with realtime enabled.');
|
|
39
|
+
}
|
|
40
|
+
if (!this.client) {
|
|
41
|
+
// Dynamic import so single-player games do not pull Colyseus into their
|
|
42
|
+
// bundle. Vite tree-shakes this path when `rooms` is never referenced.
|
|
43
|
+
const { Client } = await import('colyseus.js');
|
|
44
|
+
this.client = new Client(this.realtimeUrl);
|
|
45
|
+
}
|
|
46
|
+
const { token } = await this.transport.call('realtime.getToken', {});
|
|
47
|
+
// Inject gameId from the transport so the server-side gameId check sees
|
|
48
|
+
// a value consistent with the JWT — games never pass this directly.
|
|
49
|
+
const merged = { ...options, gameId: this.transport.gameId };
|
|
50
|
+
this.client.auth.token = token;
|
|
51
|
+
const room = await this.client[method](nameOrId, merged);
|
|
52
|
+
return new UnboxyRoom(room);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Room } from 'colyseus.js';
|
|
2
|
+
type Unsubscribe = () => void;
|
|
3
|
+
/**
|
|
4
|
+
* Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
|
|
5
|
+
* documented in SDK-GUIDE.md so games are portable to a different realtime
|
|
6
|
+
* backend should we migrate away from Colyseus.
|
|
7
|
+
*/
|
|
8
|
+
export declare class UnboxyRoom<State = unknown> {
|
|
9
|
+
private readonly room;
|
|
10
|
+
constructor(room: Room<State>);
|
|
11
|
+
get id(): string;
|
|
12
|
+
get name(): string;
|
|
13
|
+
get sessionId(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Current server-authoritative state. Proxied from Colyseus Schema — read
|
|
16
|
+
* values directly. Mutations do not propagate; only server-side handlers
|
|
17
|
+
* may change state.
|
|
18
|
+
*/
|
|
19
|
+
get state(): State;
|
|
20
|
+
/** Send a typed message to the server. */
|
|
21
|
+
send(type: string, payload?: unknown): void;
|
|
22
|
+
/** Register a handler for a server-sent message type. Returns unsubscribe. */
|
|
23
|
+
on(type: string, handler: (payload: unknown) => void): Unsubscribe;
|
|
24
|
+
/** Fires whenever the server-authoritative state changes. */
|
|
25
|
+
onStateChange(handler: (state: State) => void): Unsubscribe;
|
|
26
|
+
/**
|
|
27
|
+
* Fires when the connection closes (kick, server shutdown, network drop).
|
|
28
|
+
* `code` follows WebSocket close codes plus Colyseus-specific ones.
|
|
29
|
+
*/
|
|
30
|
+
onLeave(handler: (code: number) => void): Unsubscribe;
|
|
31
|
+
/** Fires when the server reports an error for this room. */
|
|
32
|
+
onError(handler: (code: number, message?: string) => void): Unsubscribe;
|
|
33
|
+
/** Disconnect from the room. Resolves with the close code. */
|
|
34
|
+
leave(consented?: boolean): Promise<number>;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unboxy-flavored wrapper around a Colyseus Room. Exposes only the surface
|
|
3
|
+
* documented in SDK-GUIDE.md so games are portable to a different realtime
|
|
4
|
+
* backend should we migrate away from Colyseus.
|
|
5
|
+
*/
|
|
6
|
+
export class UnboxyRoom {
|
|
7
|
+
constructor(room) {
|
|
8
|
+
this.room = room;
|
|
9
|
+
}
|
|
10
|
+
get id() { return this.room.roomId; }
|
|
11
|
+
get name() { return this.room.name; }
|
|
12
|
+
get sessionId() { return this.room.sessionId; }
|
|
13
|
+
/**
|
|
14
|
+
* Current server-authoritative state. Proxied from Colyseus Schema — read
|
|
15
|
+
* values directly. Mutations do not propagate; only server-side handlers
|
|
16
|
+
* may change state.
|
|
17
|
+
*/
|
|
18
|
+
get state() { return this.room.state; }
|
|
19
|
+
/** Send a typed message to the server. */
|
|
20
|
+
send(type, payload) {
|
|
21
|
+
this.room.send(type, payload);
|
|
22
|
+
}
|
|
23
|
+
/** Register a handler for a server-sent message type. Returns unsubscribe. */
|
|
24
|
+
on(type, handler) {
|
|
25
|
+
return this.room.onMessage(type, handler);
|
|
26
|
+
}
|
|
27
|
+
/** Fires whenever the server-authoritative state changes. */
|
|
28
|
+
onStateChange(handler) {
|
|
29
|
+
const cb = () => handler(this.room.state);
|
|
30
|
+
this.room.onStateChange(cb);
|
|
31
|
+
return () => this.room.onStateChange.remove(cb);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fires when the connection closes (kick, server shutdown, network drop).
|
|
35
|
+
* `code` follows WebSocket close codes plus Colyseus-specific ones.
|
|
36
|
+
*/
|
|
37
|
+
onLeave(handler) {
|
|
38
|
+
const cb = (code) => handler(code);
|
|
39
|
+
this.room.onLeave(cb);
|
|
40
|
+
return () => this.room.onLeave.remove(cb);
|
|
41
|
+
}
|
|
42
|
+
/** Fires when the server reports an error for this room. */
|
|
43
|
+
onError(handler) {
|
|
44
|
+
const cb = (code, message) => handler(code, message);
|
|
45
|
+
this.room.onError(cb);
|
|
46
|
+
return () => this.room.onError.remove(cb);
|
|
47
|
+
}
|
|
48
|
+
/** Disconnect from the room. Resolves with the close code. */
|
|
49
|
+
async leave(consented = true) {
|
|
50
|
+
return this.room.leave(consented);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unboxy/phaser-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
7
|
-
"files": [
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"SDK-GUIDE.md"
|
|
10
|
+
],
|
|
8
11
|
"scripts": {
|
|
9
12
|
"build": "tsc",
|
|
10
13
|
"prepublishOnly": "npm run build"
|
|
11
14
|
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"colyseus.js": "^0.16.0"
|
|
17
|
+
},
|
|
12
18
|
"peerDependencies": {
|
|
13
19
|
"phaser": "^3.60.0"
|
|
14
20
|
},
|
|
15
21
|
"devDependencies": {
|
|
22
|
+
"jose": "^6.2.2",
|
|
16
23
|
"phaser": "^3.80.0",
|
|
17
24
|
"typescript": "^5.5.0"
|
|
18
25
|
},
|