@genex-ai/multiplayer 0.2.2 → 0.2.6

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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/dist/index.js +122 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,11 +87,13 @@ room.leave();
87
87
 
88
88
  ## Smoothing remote players
89
89
 
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.
90
+ 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
91
 
92
92
  - `player.state` is the **smoothed** value (what you draw); `player.stateRaw` is the **latest raw** snapshot — use it for hit-tests / discrete logic.
93
93
  - **Your own player is never delayed** — you always render yourself live.
94
- - Only **numeric** top-level fields are lerped; everything else snaps.
94
+ - **Numbers** are interpolated; a 4-element `[x, y, z, w]` **quaternion** is **slerped** (shortest-arc); anything else snaps.
95
+ - **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.
96
+ - **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
97
 
96
98
  > 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
99
 
@@ -114,7 +116,7 @@ setInterval(() => room.me.set(me), 66);
114
116
  function frame() {
115
117
  for (const [id, p] of room.players) {
116
118
  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
119
+ cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // auto-smoothed
118
120
  }
119
121
  requestAnimationFrame(frame);
120
122
  }
@@ -134,7 +136,7 @@ onKey('right', () => { me.x += 1; });
134
136
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
135
137
  - **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
136
138
  - **`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.
139
+ - **Remote players are smoothed automatically** — read `stateRaw` when you need the raw latest (hit-tests). No server-side prediction — you render yourself live.
138
140
  - `me.set` replaces your whole state object — send the full object each time, not a partial.
139
141
 
140
142
  ---
package/dist/index.js CHANGED
@@ -1,17 +1,30 @@
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
+ function parseData(s) {
14
+ if (!s) return { state: {} };
10
15
  try {
11
16
  const v = JSON.parse(s);
12
- return v && typeof v === "object" ? v : {};
17
+ if (!v || typeof v !== "object") return { state: {} };
18
+ const o = v;
19
+ const t = o[SEND_TIME_KEY];
20
+ if (typeof t === "number" && Number.isFinite(t)) {
21
+ const state = { ...o };
22
+ delete state[SEND_TIME_KEY];
23
+ return { state, sendTime: t };
24
+ }
25
+ return { state: o };
13
26
  } catch {
14
- return {};
27
+ return { state: {} };
15
28
  }
16
29
  }
17
30
  function parseValue(s) {
@@ -23,26 +36,82 @@ function parseValue(s) {
23
36
  }
24
37
  }
25
38
  var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
26
- function lerpState(a, b, alpha) {
39
+ function slerp(a, b, t) {
40
+ let [bx, by, bz, bw] = b;
41
+ const [ax, ay, az, aw] = a;
42
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
43
+ if (dot < 0) {
44
+ bx = -bx;
45
+ by = -by;
46
+ bz = -bz;
47
+ bw = -bw;
48
+ dot = -dot;
49
+ }
50
+ if (dot > 0.9995) {
51
+ const x = ax + (bx - ax) * t, y = ay + (by - ay) * t, z = az + (bz - az) * t, w = aw + (bw - aw) * t;
52
+ const l = Math.hypot(x, y, z, w) || 1;
53
+ return [x / l, y / l, z / l, w / l];
54
+ }
55
+ const theta0 = Math.acos(dot);
56
+ const sin0 = Math.sin(theta0);
57
+ const s0 = Math.sin(theta0 - theta0 * t) / sin0;
58
+ const s1 = Math.sin(theta0 * t) / sin0;
59
+ return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
60
+ }
61
+ function isQuat(v) {
62
+ return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === "number" && Number.isFinite(n));
63
+ }
64
+ function blend(a, b, tNum, tQuat) {
27
65
  const out = {};
28
66
  for (const k in b) {
29
67
  const av = a[k];
30
68
  const bv = b[k];
31
- out[k] = typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv) ? av + (bv - av) * alpha : bv;
69
+ if (typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv)) {
70
+ out[k] = av + (bv - av) * tNum;
71
+ } else if (isQuat(av) && isQuat(bv)) {
72
+ out[k] = slerp(av, bv, tQuat);
73
+ } else {
74
+ out[k] = bv;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ function easeState(prev, target, aNum, aQuat) {
80
+ const out = {};
81
+ for (const k in target) {
82
+ const tv = target[k];
83
+ const pv = prev[k];
84
+ if (typeof pv === "number" && typeof tv === "number" && Number.isFinite(pv) && Number.isFinite(tv)) {
85
+ out[k] = pv + (tv - pv) * aNum;
86
+ } else if (isQuat(pv) && isQuat(tv)) {
87
+ out[k] = slerp(pv, tv, aQuat);
88
+ } else {
89
+ out[k] = tv;
90
+ }
32
91
  }
33
92
  return out;
34
93
  }
35
94
  function sampleBuffer(buf, renderTime) {
36
95
  const n = buf.length;
37
96
  if (n === 0) return void 0;
97
+ if (n === 1) return buf[0].state;
38
98
  let j = 0;
39
99
  while (j < n && buf[j].t <= renderTime) j++;
40
100
  if (j === 0) return buf[0].state;
41
- if (j >= n) return buf[n - 1].state;
101
+ if (j >= n) {
102
+ const a2 = buf[n - 2];
103
+ const b2 = buf[n - 1];
104
+ const span2 = b2.t - a2.t;
105
+ if (span2 <= 0) return b2.state;
106
+ const capped = Math.min(renderTime, b2.t + MAX_EXTRAP_MS);
107
+ const t2 = (capped - a2.t) / span2;
108
+ return blend(a2.state, b2.state, t2, 1);
109
+ }
42
110
  const a = buf[j - 1];
43
111
  const b = buf[j];
44
112
  const span = b.t - a.t;
45
- return lerpState(a.state, b.state, span > 0 ? (renderTime - a.t) / span : 1);
113
+ const t = span > 0 ? (renderTime - a.t) / span : 1;
114
+ return blend(a.state, b.state, t, t);
46
115
  }
47
116
  function median(xs) {
48
117
  if (xs.length === 0) return 66;
@@ -57,13 +126,25 @@ async function connect(opts) {
57
126
  ...opts.options
58
127
  });
59
128
  const buffers = /* @__PURE__ */ new Map();
60
- const lastArrival = /* @__PURE__ */ new Map();
129
+ const lastStamp = /* @__PURE__ */ new Map();
130
+ const clockOffsets = /* @__PURE__ */ new Map();
131
+ const offsetWin = /* @__PURE__ */ new Map();
132
+ const smoothed = /* @__PURE__ */ new Map();
133
+ const smoothedAt = /* @__PURE__ */ new Map();
61
134
  const intervals = [];
62
135
  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 pushSnap = (id, state, sendTime) => {
137
+ const arrival = nowMs();
138
+ const t = sendTime ?? arrival;
139
+ if (sendTime != null) {
140
+ let win = offsetWin.get(id);
141
+ if (!win) offsetWin.set(id, win = []);
142
+ win.push(arrival - sendTime);
143
+ if (win.length > CLOCK_WIN) win.shift();
144
+ clockOffsets.set(id, Math.min(...win));
145
+ }
146
+ const prev = lastStamp.get(id);
147
+ lastStamp.set(id, t);
67
148
  if (prev != null) {
68
149
  const gap = t - prev;
69
150
  if (gap > 5 && gap < 1e3) {
@@ -85,19 +166,23 @@ async function connect(opts) {
85
166
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
86
167
  const $ = getStateCallbacks(room);
87
168
  $(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);
169
+ const { state, sendTime } = parseData(player.data);
170
+ emit("join", id, state);
171
+ if (id !== room.sessionId) pushSnap(id, state, sendTime);
91
172
  $(player).onChange(() => {
92
- const st = parseObject(player.data);
173
+ const { state: st, sendTime: ts } = parseData(player.data);
93
174
  emit("change", id, st);
94
- if (id !== room.sessionId) pushSnap(id, st);
175
+ if (id !== room.sessionId) pushSnap(id, st, ts);
95
176
  });
96
177
  });
97
178
  $(room.state).players.onRemove((_player, id) => {
98
179
  emit("leave", id);
99
180
  buffers.delete(id);
100
- lastArrival.delete(id);
181
+ lastStamp.delete(id);
182
+ clockOffsets.delete(id);
183
+ offsetWin.delete(id);
184
+ smoothed.delete(id);
185
+ smoothedAt.delete(id);
101
186
  });
102
187
  $(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
103
188
  const on = ((event, cb) => {
@@ -116,14 +201,25 @@ async function connect(opts) {
116
201
  id: room.sessionId,
117
202
  get players() {
118
203
  const out = /* @__PURE__ */ new Map();
119
- const renderTime = nowMs() - autoDelay;
204
+ const localNow = nowMs();
120
205
  room.state?.players?.forEach((p, id) => {
121
- const latest = parseObject(p.data);
206
+ const { state: latest } = parseData(p.data);
122
207
  let state = latest;
123
208
  if (id !== room.sessionId) {
124
209
  const buf = buffers.get(id);
210
+ const renderTime = localNow - (clockOffsets.get(id) ?? 0) - autoDelay;
125
211
  const sampled = buf && sampleBuffer(buf, renderTime);
126
- if (sampled) state = sampled;
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);
222
+ }
127
223
  }
128
224
  out.set(id, { id, name: p.name, state, stateRaw: latest });
129
225
  });
@@ -131,7 +227,7 @@ async function connect(opts) {
131
227
  },
132
228
  me: {
133
229
  set(state) {
134
- room.send("state", JSON.stringify(state));
230
+ room.send("state", JSON.stringify({ ...state, [SEND_TIME_KEY]: nowMs() }));
135
231
  }
136
232
  },
137
233
  shared: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.2.2",
3
+ "version": "0.2.6",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",