@genex-ai/multiplayer 0.1.0 → 0.2.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Tiny realtime multiplayer for genex games.
4
4
 
5
- **Relay model:** whatever you write to `me` or `shared` is synced to everyone; everything else stays local. The server does **no physics, no prediction, no interpolation** — you run your own game logic, this just shares state.
5
+ **Relay model:** whatever you write to `me` or `shared` is synced to everyone; everything else stays local. The server does **no physics, no prediction** — you run your own game logic, this just shares state. Remote players are **smoothed automatically** (client-side entity interpolation — see [Smoothing remote players](#smoothing-remote-players)).
6
6
 
7
7
  ```ts
8
8
  import { connect } from '@genex-ai/multiplayer';
@@ -36,7 +36,7 @@ room.me.set({ x, y, z });
36
36
 
37
37
  ## `players` — everyone in the room
38
38
 
39
- A fresh `Map` each read; **includes you** (skip with `id === room.id`). It's the latest snapshot — snap to it (or smooth client-side if you want; there's no built-in interpolation).
39
+ A fresh `Map` each read; **includes you** (skip with `id === room.id`). For remote players `p.state` is **auto-smoothed** (interpolated); for yourself it's live. `p.stateRaw` is always the latest raw snapshot (use it for hit-tests / logic).
40
40
 
41
41
  ```ts
42
42
  for (const [id, p] of room.players) {
@@ -45,6 +45,8 @@ for (const [id, p] of room.players) {
45
45
  }
46
46
  ```
47
47
 
48
+ Remote players glide instead of teleporting between network updates — see **[Smoothing remote players](#smoothing-remote-players)**.
49
+
48
50
  ## `shared` — shared room state
49
51
 
50
52
  A key/value store (any JSON) synced to everyone. Use for world state, scores, the current round, etc.
@@ -83,6 +85,18 @@ room.leave();
83
85
 
84
86
  ---
85
87
 
88
+ ## Smoothing remote players
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.
91
+
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
+ - **Your own player is never delayed** — you always render yourself live.
94
+ - Only **numeric** top-level fields are lerped; everything else snaps.
95
+
96
+ > 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
+
98
+ ---
99
+
86
100
  ## Complete minimal example (move a cube, see others as cubes)
87
101
 
88
102
  ```ts
@@ -100,7 +114,7 @@ setInterval(() => room.me.set(me), 66);
100
114
  function frame() {
101
115
  for (const [id, p] of room.players) {
102
116
  const cube = cubeFor(id); // create-or-reuse a mesh per id
103
- cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // snap — no interpolation
117
+ cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // smoothed if interpolation:true, else snaps
104
118
  }
105
119
  requestAnimationFrame(frame);
106
120
  }
@@ -120,7 +134,7 @@ onKey('right', () => { me.x += 1; });
120
134
  - **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
121
135
  - **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
122
136
  - **`room` (game id) must be stable per game** — use the project slug. Same id = same room; different id = isolated.
123
- - **No interpolation / prediction.** You get the latest state; snap to it, or smooth it yourself.
137
+ - **Smooth remote players** with `interpolation` (or read `stateRaw` and smooth yourself). No server-side prediction — you render yourself live.
124
138
  - `me.set` replaces your whole state object — send the full object each time, not a partial.
125
139
 
126
140
  ---
package/dist/index.d.ts CHANGED
@@ -15,15 +15,19 @@ type ConnectOptions = {
15
15
  type PlayerView<TState> = {
16
16
  id: string;
17
17
  name: string;
18
- /** This player's latest synced state (what they passed to `me.set`). */
18
+ /** State to render — for remote players this is **auto-smoothed** (interpolated);
19
+ * for yourself it's live. Numeric fields are lerped; everything else snaps. */
19
20
  state: TState;
21
+ /** The latest raw snapshot (never smoothed/delayed) — use for hit-tests / logic. */
22
+ stateRaw: TState;
20
23
  };
21
24
  type Session<TState extends Record<string, unknown>> = {
22
25
  /** Your own session id. */
23
26
  readonly id: string;
24
27
  /**
25
28
  * Everyone in the room, **including you** — skip yourself with
26
- * `if (id === room.id)`. Returns a fresh Map on each read.
29
+ * `if (id === room.id)`. Returns a fresh Map on each read. Remote players'
30
+ * `state` is auto-smoothed; read `stateRaw` for the raw latest value.
27
31
  */
28
32
  readonly players: Map<string, PlayerView<TState>>;
29
33
  readonly me: {
@@ -62,11 +66,12 @@ type Session<TState extends Record<string, unknown>> = {
62
66
  * url: 'ws://localhost:2567', room: gameId, name: 'ada',
63
67
  * });
64
68
  * room.me.set({ x, y, z }); // typed + validated by the compiler
65
- * room.players.get(id)?.state.x; // number — no cast
69
+ * room.players.get(id)?.state.x; // number — auto-smoothed for remote players
66
70
  * ```
67
71
  *
68
- * Relay model: no physics, no prediction, no interpolation. Whatever you write
69
- * to `me` or `shared` syncs to everyone; everything else stays local.
72
+ * Relay model: no server physics/prediction. Whatever you write to `me`/`shared`
73
+ * syncs to everyone; everything else stays local. Remote players are smoothed
74
+ * automatically (entity interpolation) — `state` is smooth, `stateRaw` is raw.
70
75
  */
71
76
  declare function connect<TState extends Record<string, unknown> = never>(opts: ConnectOptions): Promise<Session<TState>>;
72
77
 
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
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;
7
+ var INTERP_EASE = 0.1;
4
8
  function parseObject(s) {
5
9
  if (!s) return {};
6
10
  try {
@@ -18,6 +22,33 @@ function parseValue(s) {
18
22
  return void 0;
19
23
  }
20
24
  }
25
+ var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
26
+ function lerpState(a, b, alpha) {
27
+ const out = {};
28
+ for (const k in b) {
29
+ const av = a[k];
30
+ const bv = b[k];
31
+ out[k] = typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv) ? av + (bv - av) * alpha : bv;
32
+ }
33
+ return out;
34
+ }
35
+ function sampleBuffer(buf, renderTime) {
36
+ const n = buf.length;
37
+ if (n === 0) return void 0;
38
+ let j = 0;
39
+ while (j < n && buf[j].t <= renderTime) j++;
40
+ if (j === 0) return buf[0].state;
41
+ if (j >= n) return buf[n - 1].state;
42
+ const a = buf[j - 1];
43
+ const b = buf[j];
44
+ const span = b.t - a.t;
45
+ return lerpState(a.state, b.state, span > 0 ? (renderTime - a.t) / span : 1);
46
+ }
47
+ function median(xs) {
48
+ if (xs.length === 0) return 66;
49
+ const s = [...xs].sort((a, b) => a - b);
50
+ return s[s.length >> 1];
51
+ }
21
52
  async function connect(opts) {
22
53
  const client = new Client(opts.url);
23
54
  const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
@@ -25,16 +56,49 @@ async function connect(opts) {
25
56
  name: opts.name,
26
57
  ...opts.options
27
58
  });
59
+ 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);
67
+ if (prev != null) {
68
+ const gap = t - prev;
69
+ 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;
74
+ }
75
+ }
76
+ let buf = buffers.get(id);
77
+ if (!buf) buffers.set(id, buf = []);
78
+ buf.push({ t, state });
79
+ const cutoff = t - 1500;
80
+ while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
81
+ };
28
82
  const emitters = /* @__PURE__ */ new Map();
29
83
  const bridged = /* @__PURE__ */ new Set();
30
84
  const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared"]);
31
85
  const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
32
86
  const $ = getStateCallbacks(room);
33
87
  $(room.state).players.onAdd((player, id) => {
34
- emit("join", id, parseObject(player.data));
35
- $(player).onChange(() => emit("change", id, parseObject(player.data)));
88
+ const initial = parseObject(player.data);
89
+ emit("join", id, initial);
90
+ if (id !== room.sessionId) pushSnap(id, initial);
91
+ $(player).onChange(() => {
92
+ const st = parseObject(player.data);
93
+ emit("change", id, st);
94
+ if (id !== room.sessionId) pushSnap(id, st);
95
+ });
96
+ });
97
+ $(room.state).players.onRemove((_player, id) => {
98
+ emit("leave", id);
99
+ buffers.delete(id);
100
+ lastArrival.delete(id);
36
101
  });
37
- $(room.state).players.onRemove((_player, id) => emit("leave", id));
38
102
  $(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
39
103
  const on = ((event, cb) => {
40
104
  let set = emitters.get(event);
@@ -52,9 +116,17 @@ async function connect(opts) {
52
116
  id: room.sessionId,
53
117
  get players() {
54
118
  const out = /* @__PURE__ */ new Map();
55
- room.state?.players?.forEach(
56
- (p, id) => out.set(id, { id, name: p.name, state: parseObject(p.data) })
57
- );
119
+ const renderTime = nowMs() - autoDelay;
120
+ room.state?.players?.forEach((p, id) => {
121
+ const latest = parseObject(p.data);
122
+ 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;
127
+ }
128
+ out.set(id, { id, name: p.name, state, stateRaw: latest });
129
+ });
58
130
  return out;
59
131
  },
60
132
  me: {
package/package.json CHANGED
@@ -1,23 +1,34 @@
1
1
  {
2
2
  "name": "@genex-ai/multiplayer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The multiplayer client wrapper + shared room schema for genex.",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": {
8
- "types": "./dist/index.d.ts",
9
- "import": "./dist/index.js"
10
- },
11
- "./schema": {
12
- "types": "./dist/schema.d.ts",
13
- "import": "./dist/schema.js"
14
- }
7
+ ".": "./src/index.ts",
8
+ "./schema": "./src/schema.ts"
15
9
  },
16
10
  "files": [
17
11
  "dist"
18
12
  ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "typecheck": "tsc --noEmit",
16
+ "prepack": "pnpm build"
17
+ },
19
18
  "publishConfig": {
20
- "access": "public"
19
+ "access": "public",
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./schema": {
28
+ "types": "./dist/schema.d.ts",
29
+ "import": "./dist/schema.js"
30
+ }
31
+ }
21
32
  },
22
33
  "dependencies": {
23
34
  "@colyseus/schema": "^3.0.76",
@@ -39,11 +50,5 @@
39
50
  "url": "git+https://github.com/me-ai-org/genex-demo.git",
40
51
  "directory": "packages/multiplayer"
41
52
  },
42
- "license": "MIT",
43
- "scripts": {
44
- "build": "tsup",
45
- "typecheck": "tsc --noEmit"
46
- },
47
- "main": "./dist/index.js",
48
- "types": "./dist/index.d.ts"
49
- }
53
+ "license": "MIT"
54
+ }