@genex-ai/multiplayer 0.2.2 → 0.4.0

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
 
@@ -87,11 +125,13 @@ room.leave();
87
125
 
88
126
  ## Smoothing remote players
89
127
 
90
- Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and lerped between snapshots, so motion glides instead of teleporting between network updates (you send ~10–20Hz but render at 60). The delay **self-tunes to the live snapshot rate** (held ~2 snapshots back, the way Valve Source / Unity do it), so it works whether you send at 5Hz or 30Hz.
128
+ Remote players are **smoothed automatically** — nothing to configure. `room.players` gives each remote player's `state` rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the **sender's clock** (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay **self-tunes to the live snapshot rate**, so it works whether you send at 5Hz or 30Hz.
91
129
 
92
130
  - `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
93
131
  - **Your own player is never delayed** — you always render yourself live.
94
- - Only **numeric** top-level fields are lerped; everything else snaps.
132
+ - **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
133
+ - **Send rotation as a quaternion:** `state.q = mesh.quaternion.toArray()`, then on the remote `mesh.quaternion.fromArray(p.state.q)`. The package slerps it and **output-smooths** it so it glides through turns.
134
+ - **Late/dropped packets keep moving:** **position** dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; **rotation** holds its last orientation rather than extrapolating (extrapolating a turn overshoots and snaps — Mirror/Unity interpolate rotation only). Both are output-smoothed, so the catch-up glides. This is what holds up on high-latency links.
95
135
 
96
136
  > No client-side prediction/reconciliation: in this relay you're authoritative over **yourself** (you render instantly, the server never corrects you), so there's nothing to predict — smoothing *remote* players is the piece that matters.
97
137
 
@@ -114,7 +154,7 @@ setInterval(() => room.me.set(me), 66);
114
154
  function frame() {
115
155
  for (const [id, p] of room.players) {
116
156
  const cube = cubeFor(id); // create-or-reuse a mesh per id
117
- cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // smoothed if interpolation:true, else snaps
157
+ cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // auto-smoothed
118
158
  }
119
159
  requestAnimationFrame(frame);
120
160
  }
@@ -134,7 +174,7 @@ onKey('right', () => { me.x += 1; });
134
174
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
135
175
  - **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
136
176
  - **`room` (game id) must be stable per game** — use the project slug. Same id = same room; different id = isolated.
137
- - **Smooth remote players** with `interpolation` (or read `stateRaw` and smooth yourself). No server-side prediction — you render yourself live.
177
+ - **Remote players are smoothed automatically** — read `stateRaw` when you need the raw latest (hit-tests). No server-side prediction — you render yourself live.
138
178
  - `me.set` replaces your whole state object — send the full object each time, not a partial.
139
179
 
140
180
  ---
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
@@ -1,17 +1,34 @@
1
1
  // src/index.ts
2
2
  import { Client, getStateCallbacks } from "colyseus.js";
3
3
  var RELAY_ROOM_TYPE = "play";
4
- var INTERP_RATIO = 2;
5
- var INTERP_MIN_MS = 60;
6
- var INTERP_MAX_MS = 400;
4
+ var INTERP_RATIO = 1.5;
5
+ var INTERP_MIN_MS = 50;
6
+ var INTERP_MAX_MS = 300;
7
7
  var INTERP_EASE = 0.1;
8
- function parseObject(s) {
9
- if (!s) return {};
8
+ var MAX_EXTRAP_MS = 250;
9
+ var CLOCK_WIN = 80;
10
+ var SMOOTH_TAU_POS = 30;
11
+ var SMOOTH_TAU_ROT = 70;
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;
17
+ function parseData(s) {
18
+ if (!s) return { state: {} };
10
19
  try {
11
20
  const v = JSON.parse(s);
12
- return v && typeof v === "object" ? v : {};
21
+ if (!v || typeof v !== "object") return { state: {} };
22
+ const o = v;
23
+ const t = o[SEND_TIME_KEY];
24
+ if (typeof t === "number" && Number.isFinite(t)) {
25
+ const state = { ...o };
26
+ delete state[SEND_TIME_KEY];
27
+ return { state, sendTime: t };
28
+ }
29
+ return { state: o };
13
30
  } catch {
14
- return {};
31
+ return { state: {} };
15
32
  }
16
33
  }
17
34
  function parseValue(s) {
@@ -23,26 +40,82 @@ function parseValue(s) {
23
40
  }
24
41
  }
25
42
  var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
26
- function lerpState(a, b, alpha) {
43
+ function slerp(a, b, t) {
44
+ let [bx, by, bz, bw] = b;
45
+ const [ax, ay, az, aw] = a;
46
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
47
+ if (dot < 0) {
48
+ bx = -bx;
49
+ by = -by;
50
+ bz = -bz;
51
+ bw = -bw;
52
+ dot = -dot;
53
+ }
54
+ if (dot > 0.9995) {
55
+ const x = ax + (bx - ax) * t, y = ay + (by - ay) * t, z = az + (bz - az) * t, w = aw + (bw - aw) * t;
56
+ const l = Math.hypot(x, y, z, w) || 1;
57
+ return [x / l, y / l, z / l, w / l];
58
+ }
59
+ const theta0 = Math.acos(dot);
60
+ const sin0 = Math.sin(theta0);
61
+ const s0 = Math.sin(theta0 - theta0 * t) / sin0;
62
+ const s1 = Math.sin(theta0 * t) / sin0;
63
+ return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
64
+ }
65
+ function isQuat(v) {
66
+ return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === "number" && Number.isFinite(n));
67
+ }
68
+ function blend(a, b, tNum, tQuat) {
27
69
  const out = {};
28
70
  for (const k in b) {
29
71
  const av = a[k];
30
72
  const bv = b[k];
31
- out[k] = typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv) ? av + (bv - av) * alpha : bv;
73
+ if (typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv)) {
74
+ out[k] = av + (bv - av) * tNum;
75
+ } else if (isQuat(av) && isQuat(bv)) {
76
+ out[k] = slerp(av, bv, tQuat);
77
+ } else {
78
+ out[k] = bv;
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ function easeState(prev, target, aNum, aQuat) {
84
+ const out = {};
85
+ for (const k in target) {
86
+ const tv = target[k];
87
+ const pv = prev[k];
88
+ if (typeof pv === "number" && typeof tv === "number" && Number.isFinite(pv) && Number.isFinite(tv)) {
89
+ out[k] = pv + (tv - pv) * aNum;
90
+ } else if (isQuat(pv) && isQuat(tv)) {
91
+ out[k] = slerp(pv, tv, aQuat);
92
+ } else {
93
+ out[k] = tv;
94
+ }
32
95
  }
33
96
  return out;
34
97
  }
35
98
  function sampleBuffer(buf, renderTime) {
36
99
  const n = buf.length;
37
100
  if (n === 0) return void 0;
101
+ if (n === 1) return buf[0].state;
38
102
  let j = 0;
39
103
  while (j < n && buf[j].t <= renderTime) j++;
40
104
  if (j === 0) return buf[0].state;
41
- if (j >= n) return buf[n - 1].state;
105
+ if (j >= n) {
106
+ const a2 = buf[n - 2];
107
+ const b2 = buf[n - 1];
108
+ const span2 = b2.t - a2.t;
109
+ if (span2 <= 0) return b2.state;
110
+ const capped = Math.min(renderTime, b2.t + MAX_EXTRAP_MS);
111
+ const t2 = (capped - a2.t) / span2;
112
+ return blend(a2.state, b2.state, t2, 1);
113
+ }
42
114
  const a = buf[j - 1];
43
115
  const b = buf[j];
44
116
  const span = b.t - a.t;
45
- return lerpState(a.state, b.state, span > 0 ? (renderTime - a.t) / span : 1);
117
+ const t = span > 0 ? (renderTime - a.t) / span : 1;
118
+ return blend(a.state, b.state, t, t);
46
119
  }
47
120
  function median(xs) {
48
121
  if (xs.length === 0) return 66;
@@ -54,23 +127,46 @@ async function connect(opts) {
54
127
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
55
128
  gameId: opts.room,
56
129
  name: opts.name,
57
- ...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)
58
134
  });
59
135
  const buffers = /* @__PURE__ */ new Map();
60
- const lastArrival = /* @__PURE__ */ new Map();
61
- const intervals = [];
62
- let autoDelay = 100;
63
- const pushSnap = (id, state) => {
64
- const t = nowMs();
65
- const prev = lastArrival.get(id);
66
- lastArrival.set(id, t);
136
+ const lastStamp = /* @__PURE__ */ new Map();
137
+ const clockOffsets = /* @__PURE__ */ new Map();
138
+ const offsetWin = /* @__PURE__ */ new Map();
139
+ const smoothed = /* @__PURE__ */ new Map();
140
+ const smoothedAt = /* @__PURE__ */ new Map();
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;
148
+ const pushSnap = (id, state, sendTime) => {
149
+ const arrival = nowMs();
150
+ const t = sendTime ?? arrival;
151
+ if (sendTime != null) {
152
+ let win = offsetWin.get(id);
153
+ if (!win) offsetWin.set(id, win = []);
154
+ win.push(arrival - sendTime);
155
+ if (win.length > CLOCK_WIN) win.shift();
156
+ clockOffsets.set(id, Math.min(...win));
157
+ }
158
+ const prev = lastStamp.get(id);
159
+ lastStamp.set(id, t);
67
160
  if (prev != null) {
68
161
  const gap = t - prev;
69
162
  if (gap > 5 && gap < 1e3) {
70
- intervals.push(gap);
71
- if (intervals.length > 30) intervals.shift();
72
- const target = Math.min(INTERP_MAX_MS, Math.max(INTERP_MIN_MS, INTERP_RATIO * median(intervals)));
73
- 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);
74
170
  }
75
171
  }
76
172
  let buf = buffers.get(id);
@@ -79,27 +175,105 @@ async function connect(opts) {
79
175
  const cutoff = t - 1500;
80
176
  while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
81
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
+ };
82
206
  const emitters = /* @__PURE__ */ new Map();
83
207
  const bridged = /* @__PURE__ */ new Set();
84
- const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared"]);
208
+ const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared", "object", "host"]);
85
209
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
86
210
  const $ = getStateCallbacks(room);
87
211
  $(room.state).players.onAdd((player, id) => {
88
- const initial = parseObject(player.data);
89
- emit("join", id, initial);
90
- if (id !== room.sessionId) pushSnap(id, initial);
212
+ const { state, sendTime } = parseData(player.data);
213
+ emit("join", id, state);
214
+ if (id !== room.sessionId) pushSnap(id, state, sendTime);
91
215
  $(player).onChange(() => {
92
- const st = parseObject(player.data);
216
+ const { state: st, sendTime: ts } = parseData(player.data);
93
217
  emit("change", id, st);
94
- if (id !== room.sessionId) pushSnap(id, st);
218
+ if (id !== room.sessionId) pushSnap(id, st, ts);
95
219
  });
96
220
  });
97
221
  $(room.state).players.onRemove((_player, id) => {
98
222
  emit("leave", id);
99
- buffers.delete(id);
100
- lastArrival.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
+ }
101
276
  });
102
- $(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
103
277
  const on = ((event, cb) => {
104
278
  let set = emitters.get(event);
105
279
  if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
@@ -116,35 +290,96 @@ async function connect(opts) {
116
290
  id: room.sessionId,
117
291
  get players() {
118
292
  const out = /* @__PURE__ */ new Map();
119
- const renderTime = nowMs() - autoDelay;
293
+ const localNow = nowMs();
120
294
  room.state?.players?.forEach((p, id) => {
121
- const latest = parseObject(p.data);
295
+ const { state: latest } = parseData(p.data);
122
296
  let state = latest;
123
- if (id !== room.sessionId) {
124
- const buf = buffers.get(id);
125
- const sampled = buf && sampleBuffer(buf, renderTime);
126
- if (sampled) state = sampled;
297
+ let raw = latest;
298
+ if (id === room.sessionId) {
299
+ if (localState) {
300
+ state = localState;
301
+ raw = localState;
302
+ }
303
+ } else {
304
+ state = sampleSmoothed(id, localNow) ?? latest;
127
305
  }
128
- out.set(id, { id, name: p.name, state, stateRaw: latest });
306
+ out.set(id, { id, name: p.name, state, stateRaw: raw });
129
307
  });
130
308
  return out;
131
309
  },
132
310
  me: {
133
311
  set(state) {
134
- room.send("state", JSON.stringify(state));
312
+ localState = { ...state };
313
+ room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
135
314
  }
136
315
  },
137
316
  shared: {
138
317
  get(key) {
318
+ if (key.startsWith(RESERVED_PREFIX)) return void 0;
139
319
  return parseValue(room.state?.shared?.get(key));
140
320
  },
141
321
  set(key, value) {
322
+ if (key.startsWith(RESERVED_PREFIX)) return;
142
323
  room.send("shared", { key, value: JSON.stringify(value) });
143
324
  },
144
325
  keys() {
145
- 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()])];
146
375
  }
147
376
  },
377
+ get isHost() {
378
+ return hostId !== void 0 && hostId === room.sessionId;
379
+ },
380
+ get host() {
381
+ return hostId;
382
+ },
148
383
  on,
149
384
  send(type, payload) {
150
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.2",
3
+ "version": "0.4.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,12 +20,6 @@
20
20
  "files": [
21
21
  "dist"
22
22
  ],
23
- "scripts": {
24
- "build": "tsup",
25
- "typecheck": "tsc --noEmit",
26
- "check": "pnpm build && publint && attw --pack . --profile esm-only",
27
- "prepack": "pnpm build && publint"
28
- },
29
23
  "publishConfig": {
30
24
  "access": "public"
31
25
  },
@@ -51,5 +45,10 @@
51
45
  "url": "git+https://github.com/me-ai-org/genex-demo.git",
52
46
  "directory": "packages/multiplayer"
53
47
  },
54
- "license": "MIT"
55
- }
48
+ "license": "MIT",
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "typecheck": "tsc --noEmit",
52
+ "check": "pnpm build && publint && attw --pack . --profile esm-only"
53
+ }
54
+ }