@genex-ai/multiplayer 0.2.6 → 0.4.1

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/README.md CHANGED
@@ -57,6 +57,42 @@ room.shared.get('round'); // 3
57
57
  room.shared.keys(); // ['round', ...]
58
58
  ```
59
59
 
60
+ ## `objects` — shared objects nobody owns until claimed
61
+
62
+ For a **ball, a puck, an NPC** — a moving thing that isn't any one player. Exactly one client
63
+ **owns** an object at a time (the server enforces it); the owner simulates it and `set`s it, and
64
+ everyone else reads it **auto-smoothed**, on the same interpolation as remote players. Ownership
65
+ survives the owner leaving — the object is reassigned to the room host.
66
+
67
+ ```ts
68
+ if (iKicked) room.objects.claim('ball'); // take ownership (last claim wins)
69
+
70
+ if (room.objects.get('ball')?.isMine) {
71
+ room.objects.set('ball', simulateBall()); // only the owner's writes land
72
+ }
73
+ const ball = room.objects.get('ball'); // { owner, isMine, state, stateRaw }
74
+ drawBall(ball.state); // smoothed for everyone (live for the owner)
75
+
76
+ room.objects.release('ball'); // give it up (stays until reclaimed)
77
+ room.objects.remove('bullet'); // destroy transient objects when done
78
+ ```
79
+
80
+ Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
81
+ player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.ids()` lists them.
82
+
83
+ ## `isHost` / `host` — the elected room authority
84
+
85
+ One client is the **host** (the first joiner still present, re-elected automatically when they
86
+ leave). Use it to pick a single writer for scores/rounds or a single simulator for NPCs.
87
+
88
+ ```ts
89
+ if (room.isHost) room.shared.set('round', nextRound); // only the host advances the round
90
+ room.on('host', (id) => {}); // host changed (migration)
91
+ ```
92
+
93
+ `isHost`/`host` settle within the first patch after `connect()` — read them in your loop (or react
94
+ to `on('host')`), not once at startup.
95
+
60
96
  ## `on` — events
61
97
 
62
98
  ```ts
@@ -64,6 +100,8 @@ room.on('join', (id, state) => {}); // a player appeared (also fires for YOU
64
100
  room.on('change', (id, state) => {}); // a player updated their me-state
65
101
  room.on('leave', (id) => {}); // a player left
66
102
  room.on('shared', (key, value) => {}); // a shared key changed
103
+ room.on('object', (id) => {}); // an object's owner changed (handoff)
104
+ room.on('host', (id) => {}); // the room host changed
67
105
  room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
68
106
  ```
69
107
 
@@ -143,13 +181,18 @@ onKey('right', () => { me.x += 1; });
143
181
 
144
182
  ## Persistent worlds (optional)
145
183
 
146
- The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, save/load a JSON blob via the API (keyed by the project slug):
184
+ The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, use `@genex-ai/embed-sdk`'s state helpers (they own auth tokens, guest handling, and write-conflict decoding):
147
185
 
148
186
  ```ts
187
+ import { loadWorldState, saveWorldState } from '@genex-ai/embed-sdk';
188
+
149
189
  // load on boot
150
- const { data } = await fetch(`${API}/api/projects/${slug}/state`).then(r => r.json());
190
+ const { data, version } = await loadWorldState();
151
191
  initWorld(data ?? defaultWorld());
152
192
 
153
- // save whenever (from ONE authority — e.g. a host client — to avoid races)
154
- fetch(`${API}/api/projects/${slug}/state`, { method: 'PUT', body: JSON.stringify(world) });
193
+ // save (from ONE authority — the elected host — debounced, with ifVersion so
194
+ // races surface as { conflict: true } instead of silently clobbering)
195
+ if (room.isHost) await saveWorldState(world, { ifVersion: version });
155
196
  ```
197
+
198
+ The relay prefers signed-in players when electing the host (guests cannot write saves), so host-driven persistence works whenever any account is in the room. Per-player progression belongs in `savePlayerState()` (each player's own slot), never in the shared world blob.
package/dist/index.d.ts CHANGED
@@ -7,8 +7,20 @@ type ConnectOptions = {
7
7
  /** Game id — everyone who passes the same id shares a room; different ids are
8
8
  * fully isolated. Use your project slug (or any stable per-game string). */
9
9
  room: string;
10
- /** Display name for this player (anonymous is fine). */
10
+ /** Display name for this player. The server prefers the verified identity's
11
+ * name (from `auth`) when present; this is the display fallback. */
11
12
  name?: string;
13
+ /**
14
+ * Verified-identity credential — REQUIRED to actually join: the relay
15
+ * rejects tokenless joins (there is no anonymous play). Pass
16
+ * `genexEmbed.getColyseusAuth()` AFTER `await genexEmbed.waitForAuth()`
17
+ * (from `@genex-ai/embed-sdk`), and read it fresh at every connect() call —
18
+ * never cache the object across reconnects (tokens rotate every ~10 min).
19
+ * NEVER log this value.
20
+ */
21
+ auth?: {
22
+ embedToken: string;
23
+ };
12
24
  /** Extra join options passed to the room. */
13
25
  options?: Record<string, unknown>;
14
26
  };
@@ -21,6 +33,19 @@ type PlayerView<TState> = {
21
33
  /** The latest raw snapshot (never smoothed/delayed) — use for hit-tests / logic. */
22
34
  stateRaw: TState;
23
35
  };
36
+ /** A shared object (a ball, a puck, an NPC) owned by exactly one client at a time. */
37
+ type ObjectView<TState> = {
38
+ id: string;
39
+ /** Session id of the current owner, or `undefined` if unowned. */
40
+ owner: string | undefined;
41
+ /** True if you own it (so you should simulate + `objects.set` it). */
42
+ isMine: boolean;
43
+ /** State to render — **auto-smoothed** like a remote player when someone else owns it;
44
+ * live (your last `set`) when it's yours. */
45
+ state: TState;
46
+ /** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
47
+ stateRaw: TState;
48
+ };
24
49
  type Session<TState extends Record<string, unknown>> = {
25
50
  /** Your own session id. */
26
51
  readonly id: string;
@@ -45,12 +70,41 @@ type Session<TState extends Record<string, unknown>> = {
45
70
  /** All shared keys currently set. */
46
71
  keys(): string[];
47
72
  };
73
+ /**
74
+ * Shared objects nobody owns until claimed — a ball, a puck, an NPC. Exactly one client
75
+ * owns an object at a time (server-enforced); the owner simulates it and `set`s it, and
76
+ * everyone else reads it **auto-smoothed**, on the same interpolation as remote players.
77
+ * Ownership survives an owner leaving: the object is reassigned to the room host.
78
+ */
79
+ readonly objects: {
80
+ /** Take ownership (last claim wins — call it when you kick/touch the object). */
81
+ claim(id: string): void;
82
+ /** Give up ownership (leaves the object unowned until someone claims it). */
83
+ release(id: string): void;
84
+ /** Destroy the object everywhere (owner-only) — use for transient objects (bullets, pickups). */
85
+ remove(id: string): void;
86
+ /** Publish the object's state — only takes effect while you own it. Full object each call. */
87
+ set(id: string, state: Record<string, unknown>): void;
88
+ /** Read an object, or `undefined` if it doesn't exist yet. */
89
+ get<T extends Record<string, unknown> = Record<string, unknown>>(id: string): ObjectView<T> | undefined;
90
+ /** Ids of every object seen so far. */
91
+ ids(): string[];
92
+ };
93
+ /** True if you are the elected room host (first joiner still present). Settles within the
94
+ * first state patch after connect — read it in your loop or react to `on('host')`, not once. */
95
+ readonly isHost: boolean;
96
+ /** Session id of the current room host, or `undefined` until the first host patch lands. */
97
+ readonly host: string | undefined;
48
98
  /** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
49
99
  on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
50
100
  /** A player left. */
51
101
  on(event: 'leave', cb: (id: string) => void): () => void;
52
- /** A shared key changed (fires on first set; for live values just read `shared.get`). */
102
+ /** A shared key changed (reserved `__*` keys are hidden). For live values just read `shared.get`. */
53
103
  on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
104
+ /** An object's owner or state changed (fires with its id). */
105
+ on(event: 'object', cb: (id: string) => void): () => void;
106
+ /** The room host changed (fires with the new host's id, or `undefined`). */
107
+ on(event: 'host', cb: (host: string | undefined) => void): () => void;
54
108
  /** A custom event another client sent via `send(type, ...)`. */
55
109
  on(event: string & {}, cb: (payload: unknown) => void): () => void;
56
110
  /** Fire-and-forget event to all OTHER clients (not stored in state). */
@@ -75,4 +129,4 @@ type Session<TState extends Record<string, unknown>> = {
75
129
  */
76
130
  declare function connect<TState extends Record<string, unknown> = never>(opts: ConnectOptions): Promise<Session<TState>>;
77
131
 
78
- export { type ConnectOptions, type PlayerView, type Session, connect };
132
+ export { type ConnectOptions, type ObjectView, type PlayerView, type Session, connect };
package/dist/index.js CHANGED
@@ -10,6 +10,10 @@ var CLOCK_WIN = 80;
10
10
  var SMOOTH_TAU_POS = 30;
11
11
  var SMOOTH_TAU_ROT = 70;
12
12
  var SEND_TIME_KEY = "__t";
13
+ var RESERVED_PREFIX = "__";
14
+ var OBJ_PREFIX = "__obj:";
15
+ var HOST_KEY = "__host";
16
+ var MP_PROTOCOL = 1;
13
17
  function parseData(s) {
14
18
  if (!s) return { state: {} };
15
19
  try {
@@ -123,7 +127,10 @@ async function connect(opts) {
123
127
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
124
128
  gameId: opts.room,
125
129
  name: opts.name,
126
- ...opts.options
130
+ embedToken: opts.auth?.embedToken,
131
+ ...opts.options,
132
+ __v: MP_PROTOCOL
133
+ // tells the relay this client hides reserved `__` keys (see MP_PROTOCOL)
127
134
  });
128
135
  const buffers = /* @__PURE__ */ new Map();
129
136
  const lastStamp = /* @__PURE__ */ new Map();
@@ -131,8 +138,13 @@ async function connect(opts) {
131
138
  const offsetWin = /* @__PURE__ */ new Map();
132
139
  const smoothed = /* @__PURE__ */ new Map();
133
140
  const smoothedAt = /* @__PURE__ */ new Map();
134
- const intervals = [];
135
- let autoDelay = 100;
141
+ const intervalsById = /* @__PURE__ */ new Map();
142
+ const autoDelayById = /* @__PURE__ */ new Map();
143
+ let localState;
144
+ const objOwners = /* @__PURE__ */ new Map();
145
+ const objRaw = /* @__PURE__ */ new Map();
146
+ const localObjState = /* @__PURE__ */ new Map();
147
+ let hostId;
136
148
  const pushSnap = (id, state, sendTime) => {
137
149
  const arrival = nowMs();
138
150
  const t = sendTime ?? arrival;
@@ -148,10 +160,13 @@ async function connect(opts) {
148
160
  if (prev != null) {
149
161
  const gap = t - prev;
150
162
  if (gap > 5 && gap < 1e3) {
151
- intervals.push(gap);
152
- if (intervals.length > 30) intervals.shift();
153
- const target = Math.min(INTERP_MAX_MS, Math.max(INTERP_MIN_MS, INTERP_RATIO * median(intervals)));
154
- autoDelay += (target - autoDelay) * INTERP_EASE;
163
+ let iv = intervalsById.get(id);
164
+ if (!iv) intervalsById.set(id, iv = []);
165
+ iv.push(gap);
166
+ if (iv.length > 30) iv.shift();
167
+ const target = Math.min(INTERP_MAX_MS, Math.max(INTERP_MIN_MS, INTERP_RATIO * median(iv)));
168
+ const cur = autoDelayById.get(id) ?? 100;
169
+ autoDelayById.set(id, cur + (target - cur) * INTERP_EASE);
155
170
  }
156
171
  }
157
172
  let buf = buffers.get(id);
@@ -160,9 +175,37 @@ async function connect(opts) {
160
175
  const cutoff = t - 1500;
161
176
  while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
162
177
  };
178
+ const sampleSmoothed = (streamKey, localNow) => {
179
+ const buf = buffers.get(streamKey);
180
+ const autoDelay = autoDelayById.get(streamKey) ?? 100;
181
+ const renderTime = localNow - (clockOffsets.get(streamKey) ?? 0) - autoDelay;
182
+ const sampled = buf && sampleBuffer(buf, renderTime);
183
+ if (!sampled) return void 0;
184
+ const prev = smoothed.get(streamKey);
185
+ let out;
186
+ if (prev) {
187
+ const dt = Math.min(250, Math.max(0, localNow - (smoothedAt.get(streamKey) ?? localNow)));
188
+ out = easeState(prev, sampled, 1 - Math.exp(-dt / SMOOTH_TAU_POS), 1 - Math.exp(-dt / SMOOTH_TAU_ROT));
189
+ } else {
190
+ out = sampled;
191
+ }
192
+ smoothed.set(streamKey, out);
193
+ smoothedAt.set(streamKey, localNow);
194
+ return out;
195
+ };
196
+ const clearStream = (streamKey) => {
197
+ buffers.delete(streamKey);
198
+ lastStamp.delete(streamKey);
199
+ clockOffsets.delete(streamKey);
200
+ offsetWin.delete(streamKey);
201
+ smoothed.delete(streamKey);
202
+ smoothedAt.delete(streamKey);
203
+ intervalsById.delete(streamKey);
204
+ autoDelayById.delete(streamKey);
205
+ };
163
206
  const emitters = /* @__PURE__ */ new Map();
164
207
  const bridged = /* @__PURE__ */ new Set();
165
- const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared"]);
208
+ const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared", "object", "host"]);
166
209
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
167
210
  const $ = getStateCallbacks(room);
168
211
  $(room.state).players.onAdd((player, id) => {
@@ -177,14 +220,60 @@ async function connect(opts) {
177
220
  });
178
221
  $(room.state).players.onRemove((_player, id) => {
179
222
  emit("leave", id);
180
- buffers.delete(id);
181
- lastStamp.delete(id);
182
- clockOffsets.delete(id);
183
- offsetWin.delete(id);
184
- smoothed.delete(id);
185
- smoothedAt.delete(id);
223
+ clearStream(id);
224
+ });
225
+ const lastShared = /* @__PURE__ */ new Map();
226
+ const handleShared = (value, key) => {
227
+ if (lastShared.get(key) === value) return;
228
+ lastShared.set(key, value);
229
+ if (key === HOST_KEY) {
230
+ const next = parseValue(value) || void 0;
231
+ if (next !== hostId) {
232
+ hostId = next;
233
+ emit("host", hostId);
234
+ }
235
+ return;
236
+ }
237
+ if (key.startsWith(OBJ_PREFIX)) {
238
+ const id = key.slice(OBJ_PREFIX.length);
239
+ const blob = parseValue(value);
240
+ if (!blob || typeof blob !== "object") return;
241
+ const owner = typeof blob.o === "string" && blob.o ? blob.o : void 0;
242
+ const ownerChanged = owner !== objOwners.get(id);
243
+ if (ownerChanged) {
244
+ if (owner) objOwners.set(id, owner);
245
+ else objOwners.delete(id);
246
+ if (owner !== room.sessionId) localObjState.delete(id);
247
+ }
248
+ if (blob.d && typeof blob.d === "object" && Object.keys(blob.d).length > 0) {
249
+ objRaw.set(id, blob.d);
250
+ if (owner !== room.sessionId) pushSnap(key, blob.d, blob.t);
251
+ }
252
+ if (ownerChanged) emit("object", id);
253
+ return;
254
+ }
255
+ emit("shared", key, parseValue(value));
256
+ };
257
+ $(room.state).shared.onAdd(handleShared);
258
+ $(room.state).shared.onChange(handleShared);
259
+ $(room.state).shared.onRemove((_value, key) => {
260
+ lastShared.delete(key);
261
+ if (key === HOST_KEY) {
262
+ if (hostId !== void 0) {
263
+ hostId = void 0;
264
+ emit("host", void 0);
265
+ }
266
+ return;
267
+ }
268
+ if (key.startsWith(OBJ_PREFIX)) {
269
+ const id = key.slice(OBJ_PREFIX.length);
270
+ clearStream(key);
271
+ objRaw.delete(id);
272
+ objOwners.delete(id);
273
+ localObjState.delete(id);
274
+ emit("object", id);
275
+ }
186
276
  });
187
- $(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
188
277
  const on = ((event, cb) => {
189
278
  let set = emitters.get(event);
190
279
  if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
@@ -205,42 +294,92 @@ async function connect(opts) {
205
294
  room.state?.players?.forEach((p, id) => {
206
295
  const { state: latest } = parseData(p.data);
207
296
  let state = latest;
208
- if (id !== room.sessionId) {
209
- const buf = buffers.get(id);
210
- const renderTime = localNow - (clockOffsets.get(id) ?? 0) - autoDelay;
211
- const sampled = buf && sampleBuffer(buf, renderTime);
212
- if (sampled) {
213
- const prev = smoothed.get(id);
214
- if (prev) {
215
- const dt = Math.min(250, Math.max(0, localNow - (smoothedAt.get(id) ?? localNow)));
216
- state = easeState(prev, sampled, 1 - Math.exp(-dt / SMOOTH_TAU_POS), 1 - Math.exp(-dt / SMOOTH_TAU_ROT));
217
- } else {
218
- state = sampled;
219
- }
220
- smoothed.set(id, state);
221
- smoothedAt.set(id, localNow);
297
+ let raw = latest;
298
+ if (id === room.sessionId) {
299
+ if (localState) {
300
+ state = localState;
301
+ raw = localState;
222
302
  }
303
+ } else {
304
+ state = sampleSmoothed(id, localNow) ?? latest;
223
305
  }
224
- out.set(id, { id, name: p.name, state, stateRaw: latest });
306
+ out.set(id, { id, name: p.name, state, stateRaw: raw });
225
307
  });
226
308
  return out;
227
309
  },
228
310
  me: {
229
311
  set(state) {
312
+ localState = { ...state };
230
313
  room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
231
314
  }
232
315
  },
233
316
  shared: {
234
317
  get(key) {
318
+ if (key.startsWith(RESERVED_PREFIX)) return void 0;
235
319
  return parseValue(room.state?.shared?.get(key));
236
320
  },
237
321
  set(key, value) {
322
+ if (key.startsWith(RESERVED_PREFIX)) return;
238
323
  room.send("shared", { key, value: JSON.stringify(value) });
239
324
  },
240
325
  keys() {
241
- return room.state?.shared ? Array.from(room.state.shared.keys()) : [];
326
+ return room.state?.shared ? Array.from(room.state.shared.keys()).filter((k) => !k.startsWith(RESERVED_PREFIX)) : [];
327
+ }
328
+ },
329
+ objects: {
330
+ claim(id) {
331
+ const key = String(id);
332
+ objOwners.set(key, room.sessionId);
333
+ room.send("claim", { id: key });
334
+ },
335
+ release(id) {
336
+ const key = String(id);
337
+ localObjState.delete(key);
338
+ if (objOwners.get(key) === room.sessionId) objOwners.delete(key);
339
+ room.send("release", { id: key });
340
+ },
341
+ remove(id) {
342
+ const key = String(id);
343
+ if (objOwners.get(key) === room.sessionId) {
344
+ localObjState.delete(key);
345
+ objRaw.delete(key);
346
+ objOwners.delete(key);
347
+ clearStream(OBJ_PREFIX + key);
348
+ }
349
+ room.send("destroy", { id: key });
350
+ },
351
+ set(id, state) {
352
+ const key = String(id);
353
+ if (objOwners.get(key) === room.sessionId) localObjState.set(key, { ...state });
354
+ room.send("obj", { id: key, d: { ...state } });
355
+ },
356
+ get(id) {
357
+ const key = String(id);
358
+ const owner = objOwners.get(key);
359
+ const mine = localObjState.get(key);
360
+ if (owner === void 0 && !objRaw.has(key) && !mine) return void 0;
361
+ const isMine = owner !== void 0 && owner === room.sessionId;
362
+ let state;
363
+ let raw;
364
+ if (isMine && mine) {
365
+ state = mine;
366
+ raw = mine;
367
+ } else {
368
+ raw = objRaw.get(key) ?? {};
369
+ state = sampleSmoothed(OBJ_PREFIX + key, nowMs()) ?? raw;
370
+ }
371
+ return { id: key, owner, isMine, state, stateRaw: raw };
372
+ },
373
+ ids() {
374
+ return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
242
375
  }
243
376
  },
377
+ get isHost() {
378
+ return hostId !== void 0 && hostId === room.sessionId;
379
+ },
380
+ get host() {
381
+ return hostId;
382
+ },
244
383
  on,
245
384
  send(type, payload) {
246
385
  room.send(type, payload);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.2.6",
3
+ "version": "0.4.1",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",