@genex-ai/multiplayer 0.10.1 → 0.11.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
@@ -34,6 +34,13 @@ across reconnects. Never log it.
34
34
 
35
35
  Different `room` ids are **fully isolated** — players in game A never see game B.
36
36
 
37
+ `connect()` joins immediately. Use it for one ongoing drop-in world. If the game has a
38
+ Play/Online/Local/Bots menu, call it only after the online click and call `room.leave()` when the
39
+ player returns to the offline menu. Immediate connect + spawn is valid only when loading the game
40
+ already means joining the always-online world; do not put a fake Play screen in front of a player
41
+ who is already present. Use `matchmake()` below for fresh bounded sessions with queue/quorum/fair
42
+ start/backfill behavior.
43
+
37
44
  ---
38
45
 
39
46
  ## `me` — publish your own state
@@ -101,7 +108,11 @@ room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });
101
108
  ```
102
109
 
103
110
  Keep object state **flat** (top-level numbers + a 4-number quaternion) so it smooths, same as a
104
- player. `state` is smoothed; `stateRaw` is the raw latest (hit-tests). `objects.ids()` lists them.
111
+ player. `state` is render-only and smoothed; `stateRaw` is the raw latest for every gameplay read
112
+ (hit-tests, reach, pickups, physics adoption, discrete values). `objects.ids()` lists them.
113
+ `objects.set()` and `objects.snap()` are owner-only: SDK 0.10.2+ ignores an unowned call and warns
114
+ once per object/operation. Await `claimConfirmed()` and check `accepted` before an irreversible
115
+ write; legacy `claim(); set()` remains valid because `claim()` is optimistic locally.
105
116
 
106
117
  The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
107
118
  reassert is a true no-op: it does not extend that hold. Legacy `claim()` remains optimistic for
package/dist/index.d.ts CHANGED
@@ -9,6 +9,11 @@ type ConnectOptions = {
9
9
  /** Regional relay urls (pass `getColyseusUrls()`) — the SDK pings each and connects to the
10
10
  * lowest-latency one; a single entry just connects. */
11
11
  urls: string[];
12
+ /** Cross-region invite override: connect to THIS exact relay url, skipping latency selection. Two
13
+ * players are only in the same room if they share one region's relay, so an invitee must follow the
14
+ * inviter's region (carried in the invite) instead of picking its own nearest. Omit for normal
15
+ * nearest-region play. */
16
+ url?: string;
12
17
  /** Game id — everyone who passes the same id shares a room; different ids are
13
18
  * fully isolated. Use your project slug (or any stable per-game string). */
14
19
  room: string;
@@ -34,8 +39,9 @@ type PlayerView<TState> = {
34
39
  name: string;
35
40
  /** Transport is live now. False while this reserved seat is inside reconnect grace. */
36
41
  connected: boolean;
37
- /** State to render — for remote players this is **auto-smoothed** (interpolated);
38
- * for yourself it's live. Numeric fields are lerped; everything else snaps. */
42
+ /** RENDER ONLY — for remote players this is **auto-smoothed** (interpolated) and delayed;
43
+ * for yourself it's live. Numeric fields are lerped; everything else snaps. Use `stateRaw`
44
+ * for every gameplay read (hits, reach, pickups, flags, physics adoption). */
39
45
  state: TState;
40
46
  /** The latest raw snapshot (never smoothed/delayed) — use for hit-tests / logic. */
41
47
  stateRaw: TState;
@@ -49,8 +55,8 @@ type ObjectView<TState> = {
49
55
  owner: string | undefined;
50
56
  /** True if you own it (so you should simulate + `objects.set` it). */
51
57
  isMine: boolean;
52
- /** State to render — **auto-smoothed** like a remote player when someone else owns it;
53
- * live (your last `set`) when it's yours. */
58
+ /** RENDER ONLY — **auto-smoothed** and delayed like a remote player when someone else owns it;
59
+ * live (your last `set`) when it's yours. Use `stateRaw` for every gameplay read. */
54
60
  state: TState;
55
61
  /** The latest raw snapshot (never smoothed/delayed) — for hit-tests / logic. */
56
62
  stateRaw: TState;
@@ -276,6 +282,9 @@ type Matchmaking<TState extends Record<string, unknown>> = {
276
282
  type MatchmakeOptions = {
277
283
  /** Regional relay urls (pass `getColyseusUrls()`) — pings each, connects to the lowest-latency one. */
278
284
  urls: string[];
285
+ /** Cross-region override: matchmake on THIS exact relay url, skipping latency selection — an invitee
286
+ * follows the inviter's region so both land on the same relay. Omit for normal nearest-region play. */
287
+ url?: string;
279
288
  /** Game id (your project slug/id) — isolates this game's matchmaking. */
280
289
  room: string;
281
290
  name?: string;
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ var STATE_SNAP_MSG = "__state:snap";
28
28
  var OBJECT_SNAP_MSG = "__object:snap";
29
29
  var MP_PROTOCOL = 2;
30
30
  async function relayClient(opts) {
31
+ if (opts.url) return new Client(opts.url);
31
32
  if (opts.urls.length > 1) return Client.selectByLatency(opts.urls);
32
33
  const url = opts.urls[0];
33
34
  if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
@@ -208,6 +209,7 @@ function attachSession(client, initialRoom) {
208
209
  const objEpochs = /* @__PURE__ */ new Map();
209
210
  const objRaw = /* @__PURE__ */ new Map();
210
211
  const localObjState = /* @__PURE__ */ new Map();
212
+ const warnedUnownedWrites = /* @__PURE__ */ new Set();
211
213
  const pendingBridge = /* @__PURE__ */ new Map();
212
214
  let hostId;
213
215
  let objectControlVersion = 0;
@@ -215,6 +217,17 @@ function attachSession(client, initialRoom) {
215
217
  const pendingControls = /* @__PURE__ */ new Map();
216
218
  const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
217
219
  const now = testSkew ? () => nowMs() + testSkew : nowMs;
220
+ const rejectUnownedWrite = (operation, id) => {
221
+ if (objOwners.get(id) === room.sessionId) return false;
222
+ const warningKey = `${operation}:${id}`;
223
+ if (!warnedUnownedWrites.has(warningKey)) {
224
+ warnedUnownedWrites.add(warningKey);
225
+ console.warn(
226
+ `[multiplayer] objects.${operation}('${id}') ignored because this session does not own the object \u2014 await objects.claimConfirmed('${id}') and check accepted before publishing`
227
+ );
228
+ }
229
+ return true;
230
+ };
218
231
  const pushSnap = (id, state, sendTime) => {
219
232
  const arrival = now();
220
233
  const t = sendTime ?? arrival;
@@ -801,12 +814,14 @@ function attachSession(client, initialRoom) {
801
814
  },
802
815
  set(id, state) {
803
816
  const key = String(id);
817
+ if (rejectUnownedWrite("set", key)) return;
804
818
  const clean = sanitizeFinite(state);
805
819
  if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
806
820
  send0("obj", { id: key, d: clean, t: now() });
807
821
  },
808
822
  snap(id, state) {
809
823
  const key = String(id);
824
+ if (rejectUnownedWrite("snap", key)) return;
810
825
  const clean = sanitizeFinite(state);
811
826
  if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
812
827
  send0(OBJECT_SNAP_MSG, { id: key, d: clean, t: now() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",