@carverjs/multiplayer 0.0.1 → 0.0.2

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 (42) hide show
  1. package/dist/InputBuffer-J6XT_Tt0.d.mts +61 -0
  2. package/dist/InputBuffer-V7XfHbc6.d.ts +61 -0
  3. package/dist/{NetworkManager-nvVAOr1O.d.ts → NetworkManager-D-DxFgdM.d.mts} +66 -14
  4. package/dist/{NetworkManager-DrKM2tEx.d.mts → NetworkManager-DH9uGVMg.d.ts} +66 -14
  5. package/dist/{chunk-UD6FDZMX.mjs → chunk-CBTAOVXP.mjs} +34 -3
  6. package/dist/chunk-CBTAOVXP.mjs.map +1 -0
  7. package/dist/{chunk-EO3YNPRQ.mjs → chunk-Q25TJEY4.mjs} +494 -204
  8. package/dist/chunk-Q25TJEY4.mjs.map +1 -0
  9. package/dist/{chunk-3KT73N2S.mjs → chunk-UKEFWQ76.mjs} +0 -0
  10. package/dist/chunk-UKEFWQ76.mjs.map +1 -0
  11. package/dist/{firebase-CPu87KA0.d.ts → firebase-B5MgLlHk.d.ts} +6 -1
  12. package/dist/{firebase-PE6MxGdJ.d.mts → firebase-GrbVrNgs.d.mts} +6 -1
  13. package/dist/index.d.mts +27 -6
  14. package/dist/index.d.ts +27 -6
  15. package/dist/index.js +744 -245
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +172 -37
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/strategy.d.mts +2 -2
  20. package/dist/strategy.d.ts +2 -2
  21. package/dist/strategy.js +33 -2
  22. package/dist/strategy.js.map +1 -1
  23. package/dist/strategy.mjs +1 -1
  24. package/dist/sync.d.mts +134 -50
  25. package/dist/sync.d.ts +134 -50
  26. package/dist/sync.js +499 -205
  27. package/dist/sync.js.map +1 -1
  28. package/dist/sync.mjs +15 -3
  29. package/dist/transport.d.mts +0 -0
  30. package/dist/transport.d.ts +0 -0
  31. package/dist/transport.js +0 -0
  32. package/dist/transport.js.map +1 -1
  33. package/dist/transport.mjs +2 -2
  34. package/dist/{types-5LHBOW08.d.mts → types-hNfCIBzj.d.mts} +7 -0
  35. package/dist/{types-5LHBOW08.d.ts → types-hNfCIBzj.d.ts} +7 -0
  36. package/dist/types.d.mts +2 -2
  37. package/dist/types.d.ts +2 -2
  38. package/dist/types.js.map +1 -1
  39. package/package.json +26 -5
  40. package/dist/chunk-3KT73N2S.mjs.map +0 -1
  41. package/dist/chunk-EO3YNPRQ.mjs.map +0 -1
  42. package/dist/chunk-UD6FDZMX.mjs.map +0 -1
package/dist/index.js CHANGED
@@ -32,11 +32,13 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  DebugOverlay: () => DebugOverlay,
34
34
  FirebaseStrategy: () => FirebaseStrategy,
35
+ InputBuffer: () => InputBuffer,
35
36
  InterestManager: () => InterestManager,
36
37
  MqttStrategy: () => MqttStrategy,
37
38
  MultiplayerBridge: () => MultiplayerBridge,
38
39
  MultiplayerProvider: () => MultiplayerProvider,
39
40
  NetworkSimulator: () => NetworkSimulator,
41
+ computeJustPressed: () => computeJustPressed,
40
42
  useHost: () => useHost,
41
43
  useLobby: () => useLobby,
42
44
  useMultiplayer: () => useMultiplayer,
@@ -128,6 +130,7 @@ var MqttStrategy = class {
128
130
  this._peerExpiryTimer = null;
129
131
  this._lobbySubscribed = false;
130
132
  this._destroyed = false;
133
+ this._lastAnnouncement = null;
131
134
  this.selfId = generatePeerId();
132
135
  this._appId = appId;
133
136
  this._config = config;
@@ -193,9 +196,19 @@ var MqttStrategy = class {
193
196
  removeFromArray(this._onLobby, cb);
194
197
  };
195
198
  }
199
+ updateRoomOccupancy(roomId, playerCount, state) {
200
+ const ann = this._lastAnnouncement;
201
+ if (!ann || ann.roomId !== roomId || !this._client) return;
202
+ ann.playerCount = playerCount;
203
+ if (state) ann.state = state;
204
+ ann.lastSeen = Date.now();
205
+ const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
206
+ this._client.publish(topic, JSON.stringify(ann), { retain: true, qos: 1 });
207
+ }
196
208
  announceRoom(announcement) {
197
209
  if (!this._client) return;
198
210
  const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
211
+ this._lastAnnouncement = announcement;
199
212
  announcement.lastSeen = Date.now();
200
213
  this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
201
214
  if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
@@ -393,6 +406,8 @@ var FirebaseStrategy = class {
393
406
  // State
394
407
  this._knownPeers = /* @__PURE__ */ new Set();
395
408
  this._lobbyAnnounceTimer = null;
409
+ this._lastAnnouncement = null;
410
+ this._lobbyWired = false;
396
411
  this._destroyed = false;
397
412
  this.selfId = generatePeerId();
398
413
  this._appId = appId;
@@ -481,6 +496,12 @@ var FirebaseStrategy = class {
481
496
  }
482
497
  subscribeToLobby(cb) {
483
498
  this._onLobby.push(cb);
499
+ if (this._lobbyWired) {
500
+ return () => {
501
+ removeFromArray(this._onLobby, cb);
502
+ };
503
+ }
504
+ this._lobbyWired = true;
484
505
  this._ensureInit().then(() => {
485
506
  if (!this._db || !this._fb || this._destroyed) return;
486
507
  const { ref, onValue } = this._fb;
@@ -508,14 +529,25 @@ var FirebaseStrategy = class {
508
529
  if (!this._db || !this._fb) return;
509
530
  const { ref, set } = this._fb;
510
531
  const paths = firebasePaths(this._appId, announcement.roomId, "");
532
+ this._lastAnnouncement = announcement;
511
533
  announcement.lastSeen = Date.now();
512
- set(ref(this._db, paths.roomLobbyEntry), announcement);
534
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
513
535
  if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
514
536
  this._lobbyAnnounceTimer = setInterval(() => {
515
537
  announcement.lastSeen = Date.now();
516
- set(ref(this._db, paths.roomLobbyEntry), announcement);
538
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
517
539
  }, ROOM_ANNOUNCE_INTERVAL_MS);
518
540
  }
541
+ updateRoomOccupancy(roomId, playerCount, state) {
542
+ const ann = this._lastAnnouncement;
543
+ if (!ann || ann.roomId !== roomId || !this._db || !this._fb) return;
544
+ ann.playerCount = playerCount;
545
+ if (state) ann.state = state;
546
+ ann.lastSeen = Date.now();
547
+ const { ref, set } = this._fb;
548
+ const paths = firebasePaths(this._appId, roomId, "");
549
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(ann));
550
+ }
519
551
  removeRoomAnnouncement(roomId) {
520
552
  if (!this._db || !this._fb) return;
521
553
  const { ref, remove } = this._fb;
@@ -618,6 +650,7 @@ function sanitizeForFirebase(obj) {
618
650
  if (typeof obj === "object" && obj !== null) {
619
651
  const result = {};
620
652
  for (const [key, value] of Object.entries(obj)) {
653
+ if (value === void 0) continue;
621
654
  result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
622
655
  }
623
656
  return result;
@@ -665,6 +698,11 @@ var _TickKeeper = class _TickKeeper {
665
698
  this._serverTick = serverTick;
666
699
  this._updateDriftCorrection();
667
700
  }
701
+ /** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
702
+ snapTick(tick) {
703
+ this._tick = tick;
704
+ this._updateDriftCorrection();
705
+ }
668
706
  /**
669
707
  * Accumulate time and return the number of fixed ticks to process.
670
708
  * Call this once per render frame with the raw frame delta.
@@ -789,15 +827,16 @@ var Codec = class {
789
827
  }
790
828
  return changed.length > 0 ? changed : null;
791
829
  }
792
- /** Serialize a delta snapshot packet */
793
- serializeDelta(tick, baseTick, current, baseline) {
830
+ /** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
831
+ serializeDelta(tick, baseTick, current, baseline, hostInput) {
794
832
  const delta = this.computeDelta(current, baseline);
795
833
  if (!delta) return null;
796
834
  const packet = {
797
835
  t: tick,
798
836
  b: baseline ? baseTick : -1,
799
837
  // -1 = keyframe
800
- s: this.serialize(delta)
838
+ s: this.serialize(delta),
839
+ ...hostInput !== void 0 ? { hi: hostInput } : {}
801
840
  };
802
841
  return (0, import_msgpackr.pack)(packet);
803
842
  }
@@ -807,7 +846,8 @@ var Codec = class {
807
846
  return {
808
847
  tick: packet.t,
809
848
  baseTick: packet.b,
810
- entities: this.deserialize(packet.s)
849
+ entities: this.deserialize(packet.s),
850
+ hostInput: packet.hi
811
851
  };
812
852
  }
813
853
  _hasChanged(current, prev) {
@@ -1119,6 +1159,7 @@ function MultiplayerProvider({
1119
1159
  }) {
1120
1160
  const managerRef = (0, import_react2.useRef)(null);
1121
1161
  const strategyRef = (0, import_react2.useRef)(null);
1162
+ const destroyTimerRef = (0, import_react2.useRef)(null);
1122
1163
  if (!managerRef.current) {
1123
1164
  managerRef.current = new NetworkManager();
1124
1165
  }
@@ -1126,11 +1167,18 @@ function MultiplayerProvider({
1126
1167
  strategyRef.current = createStrategy(appId, strategyConfig);
1127
1168
  }
1128
1169
  (0, import_react2.useEffect)(() => {
1170
+ if (destroyTimerRef.current) {
1171
+ clearTimeout(destroyTimerRef.current);
1172
+ destroyTimerRef.current = null;
1173
+ }
1129
1174
  return () => {
1130
- strategyRef.current?.destroy();
1131
- strategyRef.current = null;
1132
- managerRef.current?.destroy();
1133
- managerRef.current = null;
1175
+ destroyTimerRef.current = setTimeout(() => {
1176
+ destroyTimerRef.current = null;
1177
+ strategyRef.current?.destroy();
1178
+ strategyRef.current = null;
1179
+ managerRef.current?.destroy();
1180
+ managerRef.current = null;
1181
+ }, 0);
1134
1182
  };
1135
1183
  }, []);
1136
1184
  const value = {
@@ -1326,6 +1374,11 @@ var WebRTCTransport = class {
1326
1374
  this._playerMap = /* @__PURE__ */ new Map();
1327
1375
  this._initialPeers = [];
1328
1376
  this._strategyUnsubs = [];
1377
+ // ── Private: WebRTC peer management ──
1378
+ /** Grace timers for transient ICE 'disconnected' states */
1379
+ this._disconnectTimers = /* @__PURE__ */ new Map();
1380
+ /** Sends queued while a data channel is not yet open: key = peerIdchannel */
1381
+ this._pendingSends = /* @__PURE__ */ new Map();
1329
1382
  this._strategy = strategy;
1330
1383
  this._peerId = strategy.selfId;
1331
1384
  this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
@@ -1626,6 +1679,7 @@ var WebRTCTransport = class {
1626
1679
  case "request-room-state": {
1627
1680
  if (!this._isHost || !this._room) break;
1628
1681
  this._room.state = msg.state;
1682
+ this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
1629
1683
  this._broadcastControlMessage({ type: "room-updated", room: this._room });
1630
1684
  break;
1631
1685
  }
@@ -1682,16 +1736,23 @@ var WebRTCTransport = class {
1682
1736
  if (this._room) {
1683
1737
  this._room.hostId = newHostId;
1684
1738
  this._room.playerCount = this._playerMap.size;
1739
+ this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
1685
1740
  }
1686
1741
  if (changed) {
1687
1742
  for (const cb of this._callbacks.onHostChanged) cb(newHostId);
1688
1743
  }
1689
1744
  }
1690
- // ── Private: WebRTC peer management ──
1691
1745
  _connectToPeer(peerId) {
1692
1746
  if (this._peers.has(peerId)) return;
1693
1747
  const peer = new PeerConnection(peerId, this._iceConfig, {
1694
1748
  onStateChange: (state) => {
1749
+ if (state === "connected") {
1750
+ const timer = this._disconnectTimers.get(peerId);
1751
+ if (timer) {
1752
+ clearTimeout(timer);
1753
+ this._disconnectTimers.delete(peerId);
1754
+ }
1755
+ }
1695
1756
  if (state === "connected" && this._isHost && this._room) {
1696
1757
  const syncMsg = {
1697
1758
  type: "sync-state",
@@ -1702,11 +1763,14 @@ var WebRTCTransport = class {
1702
1763
  this._sendOnChannel(ROOM_CONTROL_CHANNEL, syncMsg, peerId);
1703
1764
  }, 100);
1704
1765
  }
1705
- if (state === "failed" || state === "disconnected") {
1706
- this._removePeer(peerId);
1707
- this._playerMap.delete(peerId);
1708
- this._electAndSetHost();
1709
- for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1766
+ if (state === "failed") {
1767
+ this._teardownPeer(peerId);
1768
+ } else if (state === "disconnected" && !this._disconnectTimers.has(peerId)) {
1769
+ this._disconnectTimers.set(peerId, setTimeout(() => {
1770
+ this._disconnectTimers.delete(peerId);
1771
+ const p = this._peers.get(peerId);
1772
+ if (p && p.state !== "connected") this._teardownPeer(peerId);
1773
+ }, 5e3));
1710
1774
  }
1711
1775
  },
1712
1776
  onDataChannel: (channel) => {
@@ -1766,21 +1830,50 @@ var WebRTCTransport = class {
1766
1830
  } catch {
1767
1831
  }
1768
1832
  };
1833
+ dataChannel.onopen = () => this._flushPendingSends(peerId, channelName);
1834
+ if (dataChannel.readyState === "open") this._flushPendingSends(peerId, channelName);
1769
1835
  }
1770
1836
  _sendOnChannel(channelName, data, target) {
1771
1837
  const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
1772
1838
  const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
1773
1839
  for (const pid of targets) {
1774
1840
  const peer = this._peers.get(pid);
1775
- const ch = peer?.getDataChannel(channelName);
1841
+ if (!peer) continue;
1842
+ const ch = peer.getDataChannel(channelName);
1776
1843
  if (ch?.readyState === "open") {
1777
1844
  try {
1778
1845
  ch.send(serialized);
1779
1846
  } catch {
1780
1847
  }
1848
+ } else {
1849
+ const key = pid + "\0" + channelName;
1850
+ const q = this._pendingSends.get(key) ?? [];
1851
+ if (q.length < 200) q.push(serialized);
1852
+ this._pendingSends.set(key, q);
1853
+ }
1854
+ }
1855
+ }
1856
+ _flushPendingSends(peerId, channelName) {
1857
+ const key = peerId + "\0" + channelName;
1858
+ const q = this._pendingSends.get(key);
1859
+ if (!q || q.length === 0) return;
1860
+ const ch = this._peers.get(peerId)?.getDataChannel(channelName);
1861
+ if (ch?.readyState !== "open") return;
1862
+ this._pendingSends.delete(key);
1863
+ for (const msg of q) {
1864
+ try {
1865
+ ch.send(msg);
1866
+ } catch {
1867
+ break;
1781
1868
  }
1782
1869
  }
1783
1870
  }
1871
+ _teardownPeer(peerId) {
1872
+ this._removePeer(peerId);
1873
+ this._playerMap.delete(peerId);
1874
+ this._electAndSetHost();
1875
+ for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1876
+ }
1784
1877
  _removePeer(peerId) {
1785
1878
  const peer = this._peers.get(peerId);
1786
1879
  if (peer) {
@@ -1789,6 +1882,14 @@ var WebRTCTransport = class {
1789
1882
  }
1790
1883
  this._peerSet.delete(peerId);
1791
1884
  this._rateLimitCounters.delete(peerId);
1885
+ const timer = this._disconnectTimers.get(peerId);
1886
+ if (timer) {
1887
+ clearTimeout(timer);
1888
+ this._disconnectTimers.delete(peerId);
1889
+ }
1890
+ for (const key of [...this._pendingSends.keys()]) {
1891
+ if (key.startsWith(peerId + "\0")) this._pendingSends.delete(key);
1892
+ }
1792
1893
  }
1793
1894
  _checkRateLimit(peerId) {
1794
1895
  const now = Date.now();
@@ -1970,7 +2071,7 @@ function announcementToRoom(ann) {
1970
2071
  isPrivate: ann.isPrivate,
1971
2072
  metadata: ann.metadata,
1972
2073
  createdAt: ann.createdAt,
1973
- state: "lobby"
2074
+ state: ann.state ?? "lobby"
1974
2075
  };
1975
2076
  }
1976
2077
  function useLobby(options) {
@@ -2016,7 +2117,7 @@ function useLobby(options) {
2016
2117
  hostId: strategy.selfId,
2017
2118
  playerCount: 0,
2018
2119
  maxPlayers: config.maxPlayers ?? 8,
2019
- gameMode: config.metadata?.gameMode,
2120
+ gameMode: config.gameMode ?? config.metadata?.gameMode,
2020
2121
  isPrivate: config.isPrivate ?? false,
2021
2122
  metadata: config.metadata ?? {},
2022
2123
  createdAt: Date.now(),
@@ -2246,8 +2347,9 @@ var HostAuthority = class {
2246
2347
  /**
2247
2348
  * Called every fixed tick by the sync engine.
2248
2349
  * Collects entity states and decides whether to broadcast.
2350
+ * `hostInput` (prediction mode) is embedded in the snapshot packet as `hi`.
2249
2351
  */
2250
- tick(currentTick, entities, delta) {
2352
+ tick(currentTick, entities, delta, hostInput) {
2251
2353
  this._tick = currentTick;
2252
2354
  this._snapshotBuffer.store(currentTick, new Map(entities));
2253
2355
  this._broadcastAccumulator += delta;
@@ -2255,16 +2357,16 @@ var HostAuthority = class {
2255
2357
  if (this._broadcastAccumulator < broadcastInterval) return;
2256
2358
  this._broadcastAccumulator -= broadcastInterval;
2257
2359
  for (const peerId of this._transport.peers) {
2258
- this._broadcastToClient(peerId, currentTick, entities);
2360
+ this._broadcastToClient(peerId, currentTick, entities, hostInput);
2259
2361
  }
2260
2362
  }
2261
2363
  /** Force a keyframe broadcast to all clients (e.g., after host migration) */
2262
- forceKeyframe(currentTick, entities) {
2364
+ forceKeyframe(currentTick, entities, hostInput) {
2263
2365
  this._clientBaselines.clear();
2264
2366
  this._clientLastKeyframeTick.clear();
2265
2367
  this._snapshotBuffer.store(currentTick, new Map(entities));
2266
2368
  for (const peerId of this._transport.peers) {
2267
- this._broadcastToClient(peerId, currentTick, entities);
2369
+ this._broadcastToClient(peerId, currentTick, entities, hostInput);
2268
2370
  }
2269
2371
  }
2270
2372
  destroy() {
@@ -2273,7 +2375,7 @@ var HostAuthority = class {
2273
2375
  this._clientBaselines.clear();
2274
2376
  this._clientLastKeyframeTick.clear();
2275
2377
  }
2276
- _broadcastToClient(peerId, currentTick, entities) {
2378
+ _broadcastToClient(peerId, currentTick, entities, hostInput) {
2277
2379
  let clientEntities = entities;
2278
2380
  if (this._interestFilter) {
2279
2381
  clientEntities = /* @__PURE__ */ new Map();
@@ -2297,7 +2399,8 @@ var HostAuthority = class {
2297
2399
  currentTick,
2298
2400
  needsKeyframe ? -1 : clientBaseTick ?? -1,
2299
2401
  clientEntities,
2300
- baseline
2402
+ baseline,
2403
+ hostInput
2301
2404
  );
2302
2405
  if (packet) {
2303
2406
  this._snapshotChannel.send(packet, peerId);
@@ -2319,6 +2422,8 @@ var ClientReceiver = class {
2319
2422
  this._packetCount = 0;
2320
2423
  // Entity state: full accumulated state from keyframes + deltas
2321
2424
  this._fullState = /* @__PURE__ */ new Map();
2425
+ // Listeners fired after each snapshot is merged into the full world state
2426
+ this._snapshotListeners = [];
2322
2427
  this._transport = transport;
2323
2428
  this._codec = codec;
2324
2429
  this._bufferSize = options?.bufferSize ?? 3;
@@ -2405,16 +2510,21 @@ var ClientReceiver = class {
2405
2510
  requestKeyframe() {
2406
2511
  this._ackChannel.send("-1");
2407
2512
  }
2513
+ /** Register a listener fired after each snapshot is merged into the full world state */
2514
+ onSnapshot(cb) {
2515
+ this._snapshotListeners.push(cb);
2516
+ }
2408
2517
  destroy() {
2409
2518
  this._snapshotChannel.close();
2410
2519
  this._ackChannel.close();
2411
2520
  this._buffer = [];
2412
2521
  this._interpolatedState.clear();
2413
2522
  this._fullState.clear();
2523
+ this._snapshotListeners = [];
2414
2524
  }
2415
2525
  _handleSnapshot(data) {
2416
2526
  try {
2417
- const { tick, baseTick, entities } = this._codec.deserializePacket(data);
2527
+ const { tick, baseTick, entities, hostInput } = this._codec.deserializePacket(data);
2418
2528
  const now = performance.now();
2419
2529
  if (baseTick === -1) {
2420
2530
  this._fullState.clear();
@@ -2430,15 +2540,19 @@ var ClientReceiver = class {
2430
2540
  }
2431
2541
  }
2432
2542
  }
2543
+ const fullStateClone = new Map(this._fullState);
2433
2544
  this._buffer.push({
2434
2545
  tick,
2435
- entities: new Map(this._fullState),
2546
+ entities: fullStateClone,
2436
2547
  receivedAt: now
2437
2548
  });
2438
2549
  while (this._buffer.length > this._bufferSize * 2) {
2439
2550
  this._buffer.shift();
2440
2551
  }
2441
2552
  this._ackChannel.send(String(tick));
2553
+ for (const cb of this._snapshotListeners) {
2554
+ cb(tick, fullStateClone, hostInput);
2555
+ }
2442
2556
  this._lastSnapshotTime = now;
2443
2557
  this._packetCount++;
2444
2558
  } catch {
@@ -2596,6 +2710,8 @@ var SnapshotSync = class {
2596
2710
  constructor(transport, codec, snapshotBuffer, options) {
2597
2711
  this._hostAuthority = null;
2598
2712
  this._clientReceiver = null;
2713
+ // Snapshot listeners survive host migration (forwarder re-attached on demote)
2714
+ this._snapshotListeners = [];
2599
2715
  this._transport = transport;
2600
2716
  this._codec = codec;
2601
2717
  this._snapshotBuffer = snapshotBuffer;
@@ -2616,6 +2732,7 @@ var SnapshotSync = class {
2616
2732
  extrapolateMs: options?.extrapolateMs,
2617
2733
  is2D: options?.is2D
2618
2734
  });
2735
+ this._attachSnapshotForwarder(this._clientReceiver);
2619
2736
  }
2620
2737
  }
2621
2738
  get isHost() {
@@ -2628,8 +2745,12 @@ var SnapshotSync = class {
2628
2745
  return this._clientReceiver;
2629
2746
  }
2630
2747
  /** Host: called every fixed tick to potentially broadcast state */
2631
- hostTick(tick, entities, delta) {
2632
- this._hostAuthority?.tick(tick, entities, delta);
2748
+ hostTick(tick, entities, delta, hostInput) {
2749
+ this._hostAuthority?.tick(tick, entities, delta, hostInput);
2750
+ }
2751
+ /** Register a listener fired after each merged snapshot (client side). */
2752
+ onSnapshot(cb) {
2753
+ this._snapshotListeners.push(cb);
2633
2754
  }
2634
2755
  /** Client: called every render frame to interpolate */
2635
2756
  clientInterpolate(renderTime) {
@@ -2667,254 +2788,516 @@ var SnapshotSync = class {
2667
2788
  is2D: options?.is2D
2668
2789
  }
2669
2790
  );
2791
+ this._attachSnapshotForwarder(this._clientReceiver);
2670
2792
  }
2671
2793
  destroy() {
2672
2794
  this._hostAuthority?.destroy();
2673
2795
  this._clientReceiver?.destroy();
2674
2796
  this._hostAuthority = null;
2675
2797
  this._clientReceiver = null;
2798
+ this._snapshotListeners = [];
2799
+ }
2800
+ // ── Private ──
2801
+ /** Forward receiver snapshots to registered listeners (survives host migration). */
2802
+ _attachSnapshotForwarder(receiver) {
2803
+ receiver.onSnapshot((t, e, hi) => {
2804
+ for (const l of this._snapshotListeners) l(t, e, hi);
2805
+ });
2806
+ }
2807
+ };
2808
+
2809
+ // src/core/InputBuffer.ts
2810
+ var InputBuffer = class {
2811
+ constructor(neutralInput, historySize = 120) {
2812
+ /** Local player tick-keyed inputs (ring buffer). */
2813
+ this._local = /* @__PURE__ */ new Map();
2814
+ /** Last-received input per remote peer. */
2815
+ this._remotes = /* @__PURE__ */ new Map();
2816
+ /** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
2817
+ this._peerTicks = /* @__PURE__ */ new Map();
2818
+ this._neutral = { ...neutralInput };
2819
+ this._historySize = historySize;
2820
+ }
2821
+ // ── Local input ──
2822
+ /** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
2823
+ storeTick(tick, input) {
2824
+ this._local.set(tick, { ...input });
2825
+ this._local.delete(tick - this._historySize);
2826
+ }
2827
+ /** Return the local input at `tick`, or a neutral copy if out of range. */
2828
+ getTick(tick) {
2829
+ return this._local.get(tick) ?? { ...this._neutral };
2830
+ }
2831
+ /** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
2832
+ hasTick(tick) {
2833
+ return this._local.has(tick);
2834
+ }
2835
+ /** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
2836
+ getJustPressedZero() {
2837
+ const out = {};
2838
+ for (const key in this._neutral) {
2839
+ const v = this._neutral[key];
2840
+ out[key] = typeof v === "boolean" ? false : v;
2841
+ }
2842
+ return out;
2843
+ }
2844
+ // ── Remote (peer) inputs ──
2845
+ /**
2846
+ * Record a remote peer's input. If `tick` is given (the sender's local tick),
2847
+ * also store it in the per-peer ring buffer for rollback.
2848
+ */
2849
+ setRemote(peerId, input, tick) {
2850
+ this._remotes.set(peerId, input);
2851
+ if (tick !== void 0) {
2852
+ let peerMap = this._peerTicks.get(peerId);
2853
+ if (!peerMap) {
2854
+ peerMap = /* @__PURE__ */ new Map();
2855
+ this._peerTicks.set(peerId, peerMap);
2856
+ }
2857
+ peerMap.set(tick, input);
2858
+ peerMap.delete(tick - this._historySize);
2859
+ }
2860
+ }
2861
+ /** Last-known input for a peer, or a neutral copy if never received. */
2862
+ getRemote(peerId) {
2863
+ return this._remotes.get(peerId) ?? { ...this._neutral };
2864
+ }
2865
+ /** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
2866
+ allRemotes() {
2867
+ return new Map(this._remotes);
2868
+ }
2869
+ /**
2870
+ * Return a peer's exact input at the given tick (for rollback accuracy),
2871
+ * falling back to their last-known input when history does not reach that far.
2872
+ */
2873
+ getRemoteAtTick(peerId, tick) {
2874
+ return this._peerTicks.get(peerId)?.get(tick) ?? this.getRemote(peerId);
2875
+ }
2876
+ /** Override the last-known input for a peer (does NOT touch tick history). */
2877
+ overrideRemote(peerId, input) {
2878
+ this._remotes.set(peerId, input);
2879
+ }
2880
+ /** Number of currently tracked remote peers. */
2881
+ get peerCount() {
2882
+ return this._remotes.size;
2883
+ }
2884
+ /** Iterate tracked peer IDs. */
2885
+ peerIds() {
2886
+ return this._remotes.keys();
2887
+ }
2888
+ /**
2889
+ * Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
2890
+ * Call when the room's peer list changes so stale peers stop receiving input.
2891
+ */
2892
+ setPeerIds(peerIds) {
2893
+ const set = peerIds instanceof Set ? peerIds : new Set(peerIds);
2894
+ for (const id of this._remotes.keys()) {
2895
+ if (!set.has(id)) {
2896
+ this._remotes.delete(id);
2897
+ this._peerTicks.delete(id);
2898
+ }
2899
+ }
2900
+ }
2901
+ // ── Lifecycle ──
2902
+ /** Clear local history, remote last-known inputs, and per-peer tick history. */
2903
+ clear() {
2904
+ this._local.clear();
2905
+ this._remotes.clear();
2906
+ this._peerTicks.clear();
2676
2907
  }
2677
2908
  };
2678
2909
 
2910
+ // src/core/InputUtils.ts
2911
+ function computeJustPressed(curr, prev) {
2912
+ const out = {};
2913
+ for (const key in curr) {
2914
+ const c = curr[key];
2915
+ out[key] = typeof c === "boolean" ? c === true && prev[key] !== true : c;
2916
+ }
2917
+ return out;
2918
+ }
2919
+
2920
+ // src/sync/Rollback.ts
2921
+ var IDENTITY_QUAT = { x: 0, y: 0, z: 0, w: 1 };
2922
+ function quatMultiply(a, b) {
2923
+ return {
2924
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
2925
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
2926
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
2927
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
2928
+ };
2929
+ }
2930
+ function quatInvert(q) {
2931
+ return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
2932
+ }
2933
+ function quatNormalize(q) {
2934
+ const mag = Math.hypot(q.x, q.y, q.z, q.w);
2935
+ if (mag < 1e-12) return { ...IDENTITY_QUAT };
2936
+ return { x: q.x / mag, y: q.y / mag, z: q.z / mag, w: q.w / mag };
2937
+ }
2938
+ function quatAngle(q) {
2939
+ return 2 * Math.acos(Math.min(1, Math.abs(q.w)));
2940
+ }
2941
+ function quatScaleAngle(q, factor) {
2942
+ let n = quatNormalize(q);
2943
+ if (n.w < 0) n = { x: -n.x, y: -n.y, z: -n.z, w: -n.w };
2944
+ const angle = 2 * Math.acos(Math.min(1, n.w));
2945
+ if (angle < 1e-6) return { ...IDENTITY_QUAT };
2946
+ const s = Math.sin(angle / 2);
2947
+ const ax = n.x / s;
2948
+ const ay = n.y / s;
2949
+ const az = n.z / s;
2950
+ const na = angle * factor;
2951
+ const ns = Math.sin(na / 2);
2952
+ return { x: ax * ns, y: ay * ns, z: az * ns, w: Math.cos(na / 2) };
2953
+ }
2954
+ function applyRollback(params) {
2955
+ const {
2956
+ serverTick,
2957
+ serverState,
2958
+ localTick,
2959
+ localPeerId,
2960
+ inputs,
2961
+ currentErrors,
2962
+ driver,
2963
+ callback,
2964
+ dt,
2965
+ driftTargetTicks,
2966
+ maxRewindTicks,
2967
+ snapThreshold
2968
+ } = params;
2969
+ const preState = driver.captureState();
2970
+ const preMap = /* @__PURE__ */ new Map();
2971
+ for (const [id, s] of preState) {
2972
+ const e = currentErrors.get(id);
2973
+ const ex = e?.x ?? 0;
2974
+ const ey = e?.y ?? 0;
2975
+ if ("z" in s) {
2976
+ const errQ = e ? { x: e.qx, y: e.qy, z: e.qz, w: e.qw } : { ...IDENTITY_QUAT };
2977
+ preMap.set(id, {
2978
+ x: s.x + ex,
2979
+ y: s.y + ey,
2980
+ z: s.z + (e?.z ?? 0),
2981
+ a: 0,
2982
+ q: quatMultiply(errQ, { x: s.qx, y: s.qy, z: s.qz, w: s.qw }),
2983
+ is3D: true
2984
+ });
2985
+ } else {
2986
+ preMap.set(id, {
2987
+ x: s.x + ex,
2988
+ y: s.y + ey,
2989
+ z: 0,
2990
+ a: s.a + (e?.a ?? 0),
2991
+ q: { ...IDENTITY_QUAT },
2992
+ is3D: false
2993
+ });
2994
+ }
2995
+ }
2996
+ driver.applyState(serverState.values());
2997
+ const targetTick = serverTick + driftTargetTicks;
2998
+ const tickDiff = localTick - targetTick;
2999
+ let newLocalTick = localTick;
3000
+ let snapped = false;
3001
+ if (Math.abs(tickDiff) > maxRewindTicks) {
3002
+ newLocalTick = targetTick;
3003
+ snapped = true;
3004
+ } else {
3005
+ for (let i = serverTick + 1; i <= localTick; i++) {
3006
+ if (callback) {
3007
+ const tickInputs = /* @__PURE__ */ new Map();
3008
+ const justPressed = /* @__PURE__ */ new Map();
3009
+ for (const peerId of inputs.peerIds()) {
3010
+ if (peerId === localPeerId) continue;
3011
+ const curr = inputs.getRemoteAtTick(peerId, i);
3012
+ tickInputs.set(peerId, curr);
3013
+ justPressed.set(
3014
+ peerId,
3015
+ computeJustPressed(curr, inputs.getRemoteAtTick(peerId, i - 1))
3016
+ );
3017
+ }
3018
+ const localCurr = inputs.getTick(i);
3019
+ tickInputs.set(localPeerId, localCurr);
3020
+ justPressed.set(
3021
+ localPeerId,
3022
+ computeJustPressed(localCurr, inputs.getTick(i - 1))
3023
+ );
3024
+ callback(tickInputs, justPressed, i, true, dt);
3025
+ }
3026
+ driver.stepWorld?.();
3027
+ }
3028
+ }
3029
+ const postState = driver.captureState();
3030
+ const errors = /* @__PURE__ */ new Map();
3031
+ for (const [id, post] of postState) {
3032
+ const pre = preMap.get(id);
3033
+ if (!pre) continue;
3034
+ const errX = pre.x - post.x;
3035
+ const errY = pre.y - post.y;
3036
+ let errZ = 0;
3037
+ let errA = 0;
3038
+ let q = { ...IDENTITY_QUAT };
3039
+ if ("z" in post) {
3040
+ errZ = pre.z - post.z;
3041
+ let dq = quatNormalize(
3042
+ quatMultiply(
3043
+ pre.q,
3044
+ quatInvert({ x: post.qx, y: post.qy, z: post.qz, w: post.qw })
3045
+ )
3046
+ );
3047
+ if (dq.w < 0) dq = { x: -dq.x, y: -dq.y, z: -dq.z, w: -dq.w };
3048
+ q = dq;
3049
+ } else {
3050
+ const ad = pre.a - post.a;
3051
+ errA = ad - Math.PI * 2 * Math.floor((ad + Math.PI) / (Math.PI * 2));
3052
+ }
3053
+ if (Math.abs(errX) > snapThreshold || Math.abs(errY) > snapThreshold || Math.abs(errZ) > snapThreshold) {
3054
+ errors.set(id, { x: 0, y: 0, z: 0, a: 0, qx: 0, qy: 0, qz: 0, qw: 1 });
3055
+ } else {
3056
+ errors.set(id, {
3057
+ x: errX,
3058
+ y: errY,
3059
+ z: errZ,
3060
+ a: errA,
3061
+ qx: q.x,
3062
+ qy: q.y,
3063
+ qz: q.z,
3064
+ qw: q.w
3065
+ });
3066
+ }
3067
+ }
3068
+ return { newLocalTick, snapped, errors };
3069
+ }
3070
+
2679
3071
  // src/sync/PredictionSync.ts
2680
- var import_msgpackr2 = require("msgpackr");
2681
3072
  var DEFAULT_OPTIONS = {
2682
3073
  maxRewindTicks: 15,
2683
- errorSmoothingDecay: 0.85,
2684
- maxErrorPerFrame: 5,
2685
- snapThreshold: 15,
2686
- lagCompensation: false
3074
+ snapThreshold: 150,
3075
+ errorDecay: 0.85,
3076
+ maxErrorPerFrame: 0,
3077
+ neutralInput: {},
3078
+ inputHistorySize: 120,
3079
+ driftTargetTicks: 4
2687
3080
  };
2688
3081
  var PredictionSync = class {
2689
- constructor(transport, codec, tickKeeper, options) {
2690
- // Input buffer: ring buffer of recent inputs keyed by tick
2691
- this._inputBuffer = [];
2692
- // Per-client last processed input tick (host-side tracking)
2693
- this._clientLastProcessedTick = /* @__PURE__ */ new Map();
2694
- // Predicted state (client-side)
2695
- this._predictedState = /* @__PURE__ */ new Map();
2696
- // Error correction vectors per entity
2697
- this._errorCorrections = /* @__PURE__ */ new Map();
2698
- // Server state (last received authoritative snapshot)
2699
- this._serverState = /* @__PURE__ */ new Map();
3082
+ constructor(transport, tickKeeper, snapshots, options) {
3083
+ // Local input; persists across ticks until replaced (hold-input semantics)
3084
+ this._currentInput = null;
3085
+ // Newest pending server snapshot awaiting rollback (only the newest survives)
3086
+ this._pending = null;
3087
+ // Per-entity accumulated visual error offsets
3088
+ this._errors = /* @__PURE__ */ new Map();
2700
3089
  this._serverTick = 0;
2701
- // Physics step callback (provided by developer)
3090
+ this._lastAppliedServerTick = 0;
3091
+ this._worldDriver = null;
2702
3092
  this._onPhysicsStep = null;
2703
- // Own input for current tick
2704
- this._currentInput = null;
2705
3093
  this._transport = transport;
2706
- this._codec = codec;
2707
3094
  this._tickKeeper = tickKeeper;
2708
3095
  this._options = { ...DEFAULT_OPTIONS, ...options };
2709
- this._isHost = transport.isHost;
3096
+ this._inputs = new InputBuffer(
3097
+ this._options.neutralInput,
3098
+ this._options.inputHistorySize
3099
+ );
2710
3100
  this._inputChannel = transport.createChannel("carver:inputs", {
2711
3101
  reliable: true,
2712
3102
  ordered: true
2713
3103
  });
2714
- this._stateChannel = transport.createChannel("carver:pred-state", {
2715
- reliable: false,
2716
- ordered: false,
2717
- maxRetransmits: 0
3104
+ this._inputChannel.onReceive(
3105
+ (data, peerId) => {
3106
+ try {
3107
+ const packet = typeof data === "string" ? JSON.parse(data) : data;
3108
+ if (typeof packet.t === "number" && packet.i !== null && typeof packet.i === "object") {
3109
+ this._inputs.setRemote(peerId, packet.i, packet.t);
3110
+ }
3111
+ } catch (err) {
3112
+ if (typeof console !== "undefined")
3113
+ console.debug("[CarverJS] Malformed input packet:", err);
3114
+ }
3115
+ }
3116
+ );
3117
+ snapshots.onSnapshot((tick, entities, hostInput) => {
3118
+ if (this._transport.isHost) return;
3119
+ if (tick <= this._lastAppliedServerTick) return;
3120
+ this._serverTick = tick;
3121
+ this._tickKeeper.setServerTick(tick);
3122
+ if (!this._pending || tick > this._pending.t) {
3123
+ this._pending = { t: tick, entities, hostInput };
3124
+ }
2718
3125
  });
2719
- this._ackChannel = transport.createChannel("carver:pred-acks", {
2720
- reliable: true,
2721
- ordered: true
3126
+ transport.onPeerLeave(() => {
3127
+ this._inputs.setPeerIds(this._transport.peers);
2722
3128
  });
2723
- if (this._isHost) {
2724
- this._setupHostListeners();
2725
- } else {
2726
- this._setupClientListeners();
2727
- }
2728
3129
  }
2729
- /** Set the physics step callback (required for rollback re-simulation) */
3130
+ // ── Wiring ──
3131
+ /** Set the game simulation callback (forward sim + rollback resim). */
2730
3132
  setPhysicsStep(cb) {
2731
3133
  this._onPhysicsStep = cb;
2732
3134
  }
2733
- /** Set the current input for this tick (client-side) */
3135
+ /** Set the world driver used for forward stepping and rollback. */
3136
+ setWorldDriver(driver) {
3137
+ this._worldDriver = driver;
3138
+ }
3139
+ // ── Input ──
3140
+ /** Set the local player's input. PERSISTS across ticks until replaced. */
2734
3141
  setInput(input) {
2735
3142
  this._currentInput = input;
2736
3143
  }
3144
+ /** Local input stored at the given tick (neutral fallback). Used by the host to embed `hi`. */
3145
+ getLocalInput(tick) {
3146
+ return this._inputs.getTick(tick);
3147
+ }
3148
+ // ── Frame lifecycle ──
2737
3149
  /**
2738
- * Called every fixed tick on the client.
2739
- * Applies input locally (prediction), buffers it, and sends to host.
3150
+ * Apply the newest pending server snapshot (full-world rollback).
3151
+ * Call once per render frame BEFORE tickKeeper.update().
3152
+ * No-op on host or when nothing is pending.
2740
3153
  */
2741
- clientTick(tick) {
2742
- if (this._isHost) return;
2743
- if (this._currentInput !== null) {
2744
- this._inputBuffer.push({ tick, input: this._currentInput });
2745
- const packet = {
2746
- t: tick,
2747
- i: this._currentInput,
2748
- p: this._transport.peerId
2749
- };
2750
- this._inputChannel.send(JSON.stringify(packet));
2751
- if (this._onPhysicsStep) {
2752
- const inputs = /* @__PURE__ */ new Map();
2753
- inputs.set(this._transport.peerId, this._currentInput);
2754
- this._onPhysicsStep(inputs, tick, false);
2755
- }
2756
- this._currentInput = null;
3154
+ beginFrame() {
3155
+ if (this._transport.isHost || !this._pending) return;
3156
+ const pending = this._pending;
3157
+ this._pending = null;
3158
+ this._lastAppliedServerTick = pending.t;
3159
+ if (pending.hostInput !== void 0) {
3160
+ this._inputs.setRemote(
3161
+ this._transport.hostId,
3162
+ pending.hostInput,
3163
+ pending.t
3164
+ );
2757
3165
  }
2758
- const minTick = tick - this._options.maxRewindTicks * 2;
2759
- while (this._inputBuffer.length > 0 && this._inputBuffer[0].tick < minTick) {
2760
- this._inputBuffer.shift();
3166
+ if (!this._worldDriver) return;
3167
+ const result = applyRollback({
3168
+ serverTick: pending.t,
3169
+ serverState: pending.entities,
3170
+ localTick: this._tickKeeper.tick,
3171
+ localPeerId: this._transport.peerId,
3172
+ inputs: this._inputs,
3173
+ currentErrors: this._errors,
3174
+ driver: this._worldDriver,
3175
+ callback: this._onPhysicsStep,
3176
+ dt: this._tickKeeper.tickDelta,
3177
+ driftTargetTicks: this._options.driftTargetTicks,
3178
+ maxRewindTicks: this._options.maxRewindTicks,
3179
+ snapThreshold: this._options.snapThreshold
3180
+ });
3181
+ this._errors = result.errors;
3182
+ if (result.newLocalTick !== this._tickKeeper.tick) {
3183
+ this._tickKeeper.snapTick(result.newLocalTick);
2761
3184
  }
2762
3185
  }
2763
3186
  /**
2764
- * Called every fixed tick on the host.
2765
- * Processes received inputs and broadcasts authoritative state.
3187
+ * Run one forward fixed tick (host AND client): store + broadcast input,
3188
+ * build per-tick input maps, invoke the callback, then step the world.
2766
3189
  */
2767
- hostTick(tick, entities, _delta) {
2768
- if (!this._isHost) return;
2769
- const stateArray = Array.from(entities.values());
2770
- const data = this._codec.serialize(stateArray);
2771
- for (const peerId of this._transport.peers) {
2772
- const lastTick = this._clientLastProcessedTick.get(peerId) ?? -1;
2773
- const packet = {
2774
- t: tick,
2775
- s: data,
2776
- li: lastTick
2777
- };
2778
- this._stateChannel.send((0, import_msgpackr2.pack)(packet), peerId);
3190
+ tick(tick) {
3191
+ const localInput = this._currentInput ?? { ...this._options.neutralInput };
3192
+ this._inputs.storeTick(tick, localInput);
3193
+ this._inputChannel.send({
3194
+ t: tick,
3195
+ i: localInput,
3196
+ p: this._transport.peerId
3197
+ });
3198
+ const prevTick = tick - 1;
3199
+ const tickInputs = this._inputs.allRemotes();
3200
+ tickInputs.delete(this._transport.peerId);
3201
+ const justPressed = /* @__PURE__ */ new Map();
3202
+ for (const [peerId, inp] of tickInputs) {
3203
+ justPressed.set(
3204
+ peerId,
3205
+ computeJustPressed(inp, this._inputs.getRemoteAtTick(peerId, prevTick))
3206
+ );
3207
+ }
3208
+ tickInputs.set(this._transport.peerId, localInput);
3209
+ justPressed.set(
3210
+ this._transport.peerId,
3211
+ this._inputs.hasTick(prevTick) ? computeJustPressed(localInput, this._inputs.getTick(prevTick)) : this._inputs.getJustPressedZero()
3212
+ // suppress spurious edges after snap/rejoin
3213
+ );
3214
+ if (this._onPhysicsStep) {
3215
+ this._onPhysicsStep(
3216
+ tickInputs,
3217
+ justPressed,
3218
+ tick,
3219
+ false,
3220
+ this._tickKeeper.tickDelta
3221
+ );
2779
3222
  }
3223
+ this._worldDriver?.stepWorld?.();
2780
3224
  }
2781
3225
  /**
2782
- * Called every render frame on the client to apply visual error smoothing.
2783
- * Returns the corrected entity states.
3226
+ * Decay stored error offsets and return the portion to ADD to rendered
3227
+ * transforms this frame. Call exactly once per render frame.
2784
3228
  */
2785
- applyErrorSmoothing(entities) {
3229
+ getRenderErrorOffsets() {
2786
3230
  const result = /* @__PURE__ */ new Map();
2787
- const decay = this._options.errorSmoothingDecay;
2788
- for (const [id, entity] of entities) {
2789
- const correction = this._errorCorrections.get(id);
2790
- if (!correction) {
2791
- result.set(id, entity);
2792
- continue;
3231
+ const decay = this._options.errorDecay;
3232
+ const maxErr = this._options.maxErrorPerFrame;
3233
+ for (const [id, e] of this._errors) {
3234
+ e.x *= decay;
3235
+ e.y *= decay;
3236
+ e.z *= decay;
3237
+ e.a *= decay;
3238
+ let q = quatScaleAngle({ x: e.qx, y: e.qy, z: e.qz, w: e.qw }, decay);
3239
+ if (Math.abs(e.x) < 0.1) e.x = 0;
3240
+ if (Math.abs(e.y) < 0.1) e.y = 0;
3241
+ if (Math.abs(e.z) < 0.1) e.z = 0;
3242
+ if (Math.abs(e.a) < 1e-3) e.a = 0;
3243
+ if (quatAngle(q) < 1e-3) q = { x: 0, y: 0, z: 0, w: 1 };
3244
+ e.qx = q.x;
3245
+ e.qy = q.y;
3246
+ e.qz = q.z;
3247
+ e.qw = q.w;
3248
+ let ax = e.x;
3249
+ let ay = e.y;
3250
+ let az = e.z;
3251
+ if (maxErr > 0) {
3252
+ const mag = Math.hypot(e.x, e.y, e.z);
3253
+ if (mag > maxErr) {
3254
+ const s = maxErr / mag;
3255
+ ax = e.x * s;
3256
+ ay = e.y * s;
3257
+ az = e.z * s;
3258
+ e.x -= ax;
3259
+ e.y -= ay;
3260
+ e.z -= az;
3261
+ } else {
3262
+ e.x = 0;
3263
+ e.y = 0;
3264
+ e.z = 0;
3265
+ }
2793
3266
  }
2794
- const corrected = { ...entity };
2795
- corrected.x += correction.x;
2796
- corrected.y += correction.y;
2797
- if ("z" in corrected) {
2798
- corrected.z += correction.z;
3267
+ const quatIsIdentity = e.qx === 0 && e.qy === 0 && e.qz === 0 && e.qw === 1;
3268
+ if (ax !== 0 || ay !== 0 || az !== 0 || e.a !== 0 || !quatIsIdentity) {
3269
+ result.set(id, {
3270
+ x: ax,
3271
+ y: ay,
3272
+ z: az,
3273
+ a: e.a,
3274
+ qx: e.qx,
3275
+ qy: e.qy,
3276
+ qz: e.qz,
3277
+ qw: e.qw
3278
+ });
2799
3279
  }
2800
- correction.x *= decay;
2801
- correction.y *= decay;
2802
- correction.z *= decay;
2803
- const mag = Math.abs(correction.x) + Math.abs(correction.y) + Math.abs(correction.z);
2804
- if (mag < 1e-3) {
2805
- this._errorCorrections.delete(id);
3280
+ if (e.x === 0 && e.y === 0 && e.z === 0 && e.a === 0 && quatIsIdentity) {
3281
+ this._errors.delete(id);
2806
3282
  }
2807
- result.set(id, corrected);
2808
3283
  }
2809
3284
  return result;
2810
3285
  }
2811
- get predictedState() {
2812
- return this._predictedState;
2813
- }
3286
+ // ── State ──
3287
+ /** Tick of the newest RECEIVED snapshot. */
2814
3288
  get serverTick() {
2815
3289
  return this._serverTick;
2816
3290
  }
3291
+ /** Tick of the newest APPLIED (rolled-back) snapshot, 0 initially. */
3292
+ get lastAppliedServerTick() {
3293
+ return this._lastAppliedServerTick;
3294
+ }
2817
3295
  destroy() {
2818
3296
  this._inputChannel.close();
2819
- this._stateChannel.close();
2820
- this._ackChannel.close();
2821
- this._inputBuffer = [];
2822
- this._predictedState.clear();
2823
- this._errorCorrections.clear();
2824
- this._serverState.clear();
2825
- }
2826
- // ── Private: Host-side ──
2827
- _setupHostListeners() {
2828
- this._inputChannel.onReceive((rawData, peerId) => {
2829
- try {
2830
- const packet = JSON.parse(rawData);
2831
- if (this._onPhysicsStep) {
2832
- const inputs = /* @__PURE__ */ new Map();
2833
- inputs.set(peerId, packet.i);
2834
- this._onPhysicsStep(inputs, packet.t, false);
2835
- }
2836
- const prevTick = this._clientLastProcessedTick.get(peerId) ?? -1;
2837
- this._clientLastProcessedTick.set(peerId, Math.max(prevTick, packet.t));
2838
- } catch (err) {
2839
- if (typeof console !== "undefined") console.debug("[CarverJS] Malformed input packet:", err);
2840
- }
2841
- });
2842
- }
2843
- // ── Private: Client-side ──
2844
- _setupClientListeners() {
2845
- this._stateChannel.onReceive((data) => {
2846
- try {
2847
- const packet = (0, import_msgpackr2.unpack)(data);
2848
- const entities = this._codec.deserialize(packet.s);
2849
- const serverTick = packet.t;
2850
- const lastInputTick = packet.li;
2851
- this._serverTick = serverTick;
2852
- this._tickKeeper.setServerTick(serverTick);
2853
- this._serverState.clear();
2854
- for (const entity of entities) {
2855
- this._serverState.set(entity.id, entity);
2856
- }
2857
- this._reconcile(lastInputTick);
2858
- } catch (err) {
2859
- if (typeof console !== "undefined") console.debug("[CarverJS] Malformed state packet:", err);
2860
- }
2861
- });
2862
- }
2863
- _reconcile(lastInputTick) {
2864
- this._inputBuffer = this._inputBuffer.filter((entry) => entry.tick > lastInputTick);
2865
- let needsRollback = false;
2866
- let maxError = 0;
2867
- for (const [id, serverEntity] of this._serverState) {
2868
- const predicted = this._predictedState.get(id);
2869
- if (!predicted) continue;
2870
- const error = this._computeError(predicted, serverEntity);
2871
- maxError = Math.max(maxError, error);
2872
- if (error > this._options.maxErrorPerFrame) {
2873
- needsRollback = true;
2874
- }
2875
- }
2876
- if (maxError > this._options.snapThreshold) {
2877
- this._predictedState = new Map(this._serverState);
2878
- this._errorCorrections.clear();
2879
- return;
2880
- }
2881
- if (needsRollback) {
2882
- const oldPositions = /* @__PURE__ */ new Map();
2883
- for (const [id, entity] of this._predictedState) {
2884
- oldPositions.set(id, {
2885
- x: entity.x,
2886
- y: entity.y,
2887
- z: "z" in entity ? entity.z : 0
2888
- });
2889
- }
2890
- this._predictedState = new Map(this._serverState);
2891
- if (this._onPhysicsStep) {
2892
- for (const entry of this._inputBuffer) {
2893
- const inputs = /* @__PURE__ */ new Map();
2894
- inputs.set(this._transport.peerId, entry.input);
2895
- this._onPhysicsStep(inputs, entry.tick, true);
2896
- }
2897
- }
2898
- for (const [id, newEntity] of this._predictedState) {
2899
- const oldPos = oldPositions.get(id);
2900
- if (oldPos) {
2901
- this._errorCorrections.set(id, {
2902
- x: oldPos.x - newEntity.x,
2903
- y: oldPos.y - newEntity.y,
2904
- z: oldPos.z - ("z" in newEntity ? newEntity.z : 0)
2905
- });
2906
- }
2907
- }
2908
- }
2909
- }
2910
- _computeError(predicted, server) {
2911
- const dx = predicted.x - server.x;
2912
- const dy = predicted.y - server.y;
2913
- let dz = 0;
2914
- if ("z" in predicted && "z" in server) {
2915
- dz = predicted.z - server.z;
2916
- }
2917
- return Math.sqrt(dx * dx + dy * dy + dz * dz);
3297
+ this._inputs.clear();
3298
+ this._errors.clear();
3299
+ this._pending = null;
3300
+ this._currentInput = null;
2918
3301
  }
2919
3302
  };
2920
3303
 
@@ -3110,6 +3493,34 @@ function applyState3D(ref, state) {
3110
3493
  }
3111
3494
  }
3112
3495
  }
3496
+ function applyStateHard2D(ref, state) {
3497
+ applyState2D(ref, state);
3498
+ if (ref.rigidBody) {
3499
+ try {
3500
+ if (typeof ref.rigidBody.setLinvel === "function") {
3501
+ ref.rigidBody.setLinvel({ x: state.vx, y: state.vy }, true);
3502
+ }
3503
+ if (typeof ref.rigidBody.setAngvel === "function") {
3504
+ ref.rigidBody.setAngvel(state.va, true);
3505
+ }
3506
+ } catch {
3507
+ }
3508
+ }
3509
+ }
3510
+ function applyStateHard3D(ref, state) {
3511
+ applyState3D(ref, state);
3512
+ if (ref.rigidBody) {
3513
+ try {
3514
+ if (typeof ref.rigidBody.setLinvel === "function") {
3515
+ ref.rigidBody.setLinvel({ x: state.vx, y: state.vy, z: state.vz }, true);
3516
+ }
3517
+ if (typeof ref.rigidBody.setAngvel === "function") {
3518
+ ref.rigidBody.setAngvel({ x: state.wx, y: state.wy, z: state.wz }, true);
3519
+ }
3520
+ } catch {
3521
+ }
3522
+ }
3523
+ }
3113
3524
  function applyEntityState(ref, state, is2D) {
3114
3525
  if (is2D) {
3115
3526
  applyState2D(ref, state);
@@ -3125,6 +3536,10 @@ function applyStatesToActors(states, registry, is2D) {
3125
3536
  applyEntityState(ref, state, is2D);
3126
3537
  }
3127
3538
  }
3539
+ var IDENTITY_QUAT2 = { x: 0, y: 0, z: 0, w: 1 };
3540
+ function isIdentityQuat(q) {
3541
+ return q.x === 0 && q.y === 0 && q.z === 0 && q.w === 1;
3542
+ }
3128
3543
  function useMultiplayer(options = {}) {
3129
3544
  const { networkManager } = useMultiplayerContext();
3130
3545
  const mode = options.mode ?? networkManager.syncMode;
@@ -3140,6 +3555,7 @@ function useMultiplayer(options = {}) {
3140
3555
  const [drift, setDrift] = (0, import_react8.useState)(0);
3141
3556
  const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
3142
3557
  const wasHostRef = (0, import_react8.useRef)(null);
3558
+ const appliedErrorsRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
3143
3559
  const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
3144
3560
  return {
3145
3561
  broadcastRate: options.broadcastRate,
@@ -3156,6 +3572,36 @@ function useMultiplayer(options = {}) {
3156
3572
  options.interpolation?.method,
3157
3573
  options.interpolation?.extrapolateMs
3158
3574
  ]);
3575
+ const undoAppliedErrorOffsets = (0, import_react8.useCallback)(() => {
3576
+ const registry = actorRegistry.current;
3577
+ for (const [id, e] of appliedErrorsRef.current) {
3578
+ const ref = registry.get(id);
3579
+ if (!ref) {
3580
+ appliedErrorsRef.current.delete(id);
3581
+ continue;
3582
+ }
3583
+ const pos = ref.object3D.position;
3584
+ const untouched = Math.abs(pos.x - e.px) < 1e-9 && Math.abs(pos.y - e.py) < 1e-9 && Math.abs(pos.z - e.pz) < 1e-9;
3585
+ if (untouched) {
3586
+ pos.x -= e.x;
3587
+ pos.y -= e.y;
3588
+ pos.z -= e.z;
3589
+ if (e.a !== 0) {
3590
+ ref.object3D.rotation.z -= e.a;
3591
+ }
3592
+ if (!isIdentityQuat(e.q)) {
3593
+ const inv = quatInvert(e.q);
3594
+ const cur = ref.object3D.quaternion;
3595
+ const r = quatMultiply(inv, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
3596
+ cur.set(r.x, r.y, r.z, r.w);
3597
+ }
3598
+ }
3599
+ }
3600
+ appliedErrorsRef.current.clear();
3601
+ }, []);
3602
+ const setInput = (0, import_react8.useCallback)((input) => {
3603
+ predictionSyncRef.current?.setInput(input);
3604
+ }, []);
3159
3605
  (0, import_react8.useEffect)(() => {
3160
3606
  const transport = networkManager.transport;
3161
3607
  if (!transport) return;
@@ -3184,15 +3630,30 @@ function useMultiplayer(options = {}) {
3184
3630
  );
3185
3631
  }
3186
3632
  if (mode === "prediction") {
3633
+ const driver = {
3634
+ captureState: () => buildEntityMap(actorRegistry.current.getNetworked(), is2DRef.current ?? true),
3635
+ applyState: (entities) => {
3636
+ const is2D = is2DRef.current ?? true;
3637
+ for (const state of entities) {
3638
+ if (state.c && state.c.__removed) continue;
3639
+ const ref = actorRegistry.current.get(state.id);
3640
+ if (!ref) continue;
3641
+ if (is2D) applyStateHard2D(ref, state);
3642
+ else applyStateHard3D(ref, state);
3643
+ }
3644
+ },
3645
+ ...options.stepWorld ? { stepWorld: options.stepWorld } : {}
3646
+ };
3187
3647
  predictionSyncRef.current = new PredictionSync(
3188
3648
  transport,
3189
- networkManager.codec,
3190
3649
  networkManager.tickKeeper,
3650
+ snapshotSyncRef.current,
3191
3651
  options.prediction
3192
3652
  );
3193
3653
  if (options.onPhysicsStep) {
3194
3654
  predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
3195
3655
  }
3656
+ predictionSyncRef.current.setWorldDriver(driver);
3196
3657
  }
3197
3658
  wasHostRef.current = networkManager.isHost;
3198
3659
  setIsActive(true);
@@ -3221,9 +3682,10 @@ function useMultiplayer(options = {}) {
3221
3682
  snapshotSyncRef.current = null;
3222
3683
  predictionSyncRef.current = null;
3223
3684
  networkSimulatorRef.current = null;
3685
+ appliedErrorsRef.current.clear();
3224
3686
  setIsActive(false);
3225
3687
  };
3226
- }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
3688
+ }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.stepWorld, options.debug]);
3227
3689
  (0, import_react8.useEffect)(() => {
3228
3690
  const unsub = networkManager.onConnectionStateChange(() => {
3229
3691
  setNetworkQuality(networkManager.networkQuality);
@@ -3243,33 +3705,67 @@ function useMultiplayer(options = {}) {
3243
3705
  }
3244
3706
  const is2D = is2DRef.current ?? true;
3245
3707
  if (mode === "events") return;
3708
+ if (mode === "prediction" && predictionSyncRef.current) {
3709
+ undoAppliedErrorOffsets();
3710
+ predictionSyncRef.current.beginFrame();
3711
+ }
3246
3712
  const ticksThisFrame = tickKeeper.update(delta);
3247
3713
  for (let i = 0; i < ticksThisFrame; i++) {
3248
3714
  const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
3249
- if (isHost) {
3715
+ if (mode === "prediction" && predictionSyncRef.current) {
3716
+ predictionSyncRef.current.tick(currentTick);
3717
+ if (isHost) {
3718
+ const entities = buildEntityMap(actorRegistry.current.getNetworked(), is2D);
3719
+ snapshotSyncRef.current?.hostTick(
3720
+ currentTick,
3721
+ entities,
3722
+ tickKeeper.tickDelta,
3723
+ predictionSyncRef.current.getLocalInput(currentTick)
3724
+ );
3725
+ }
3726
+ } else if (mode === "snapshot" && isHost) {
3250
3727
  const networked = actorRegistry.current.getNetworked();
3251
3728
  const entities = buildEntityMap(networked, is2D);
3252
- if (mode === "snapshot" || mode === "prediction") {
3253
- snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3254
- }
3255
- if (mode === "prediction") {
3256
- predictionSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3257
- }
3258
- } else {
3259
- if (mode === "prediction" && predictionSyncRef.current) {
3260
- predictionSyncRef.current.clientTick(currentTick);
3261
- }
3729
+ snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3262
3730
  }
3263
3731
  }
3264
- if (!isHost) {
3265
- if (mode === "snapshot" && snapshotSyncRef.current) {
3266
- const renderTime = performance.now();
3267
- const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
3268
- applyStatesToActors(interpolated, actorRegistry.current, is2D);
3269
- } else if (mode === "prediction" && predictionSyncRef.current) {
3270
- const predicted = predictionSyncRef.current.predictedState;
3271
- const smoothed = predictionSyncRef.current.applyErrorSmoothing(predicted);
3272
- applyStatesToActors(smoothed, actorRegistry.current, is2D);
3732
+ if (!isHost && mode === "snapshot" && snapshotSyncRef.current) {
3733
+ const renderTime = performance.now();
3734
+ const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
3735
+ applyStatesToActors(interpolated, actorRegistry.current, is2D);
3736
+ }
3737
+ if (mode === "prediction" && predictionSyncRef.current) {
3738
+ const offsets = predictionSyncRef.current.getRenderErrorOffsets();
3739
+ for (const [id, off] of offsets) {
3740
+ const ref = actorRegistry.current.get(id);
3741
+ if (!ref) continue;
3742
+ const pos = ref.object3D.position;
3743
+ pos.x += off.x;
3744
+ pos.y += off.y;
3745
+ if (!is2D) pos.z += off.z;
3746
+ let appliedA = 0;
3747
+ let appliedQ = IDENTITY_QUAT2;
3748
+ if (is2D) {
3749
+ ref.object3D.rotation.z += off.a;
3750
+ appliedA = off.a;
3751
+ } else {
3752
+ appliedQ = { x: off.qx, y: off.qy, z: off.qz, w: off.qw };
3753
+ if (!isIdentityQuat(appliedQ)) {
3754
+ const cur = ref.object3D.quaternion;
3755
+ const r = quatMultiply(appliedQ, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
3756
+ cur.set(r.x, r.y, r.z, r.w);
3757
+ }
3758
+ }
3759
+ appliedErrorsRef.current.set(id, {
3760
+ x: off.x,
3761
+ y: off.y,
3762
+ z: is2D ? 0 : off.z,
3763
+ a: appliedA,
3764
+ q: appliedQ,
3765
+ px: pos.x,
3766
+ py: pos.y,
3767
+ pz: pos.z
3768
+ });
3273
3769
  }
3274
3770
  }
3275
3771
  if (ticksThisFrame > 0) {
@@ -3289,7 +3785,8 @@ function useMultiplayer(options = {}) {
3289
3785
  tick,
3290
3786
  serverTick,
3291
3787
  drift,
3292
- syncEngine: mode
3788
+ syncEngine: mode,
3789
+ setInput
3293
3790
  };
3294
3791
  }
3295
3792
 
@@ -3801,11 +4298,13 @@ var InterestManager = class {
3801
4298
  0 && (module.exports = {
3802
4299
  DebugOverlay,
3803
4300
  FirebaseStrategy,
4301
+ InputBuffer,
3804
4302
  InterestManager,
3805
4303
  MqttStrategy,
3806
4304
  MultiplayerBridge,
3807
4305
  MultiplayerProvider,
3808
4306
  NetworkSimulator,
4307
+ computeJustPressed,
3809
4308
  useHost,
3810
4309
  useLobby,
3811
4310
  useMultiplayer,