@carverjs/multiplayer 0.0.1 → 0.0.3

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 (43) hide show
  1. package/README.md +154 -0
  2. package/dist/InputBuffer-J6XT_Tt0.d.mts +61 -0
  3. package/dist/InputBuffer-V7XfHbc6.d.ts +61 -0
  4. package/dist/{NetworkManager-nvVAOr1O.d.ts → NetworkManager-D-DxFgdM.d.mts} +66 -14
  5. package/dist/{NetworkManager-DrKM2tEx.d.mts → NetworkManager-DH9uGVMg.d.ts} +66 -14
  6. package/dist/{chunk-UD6FDZMX.mjs → chunk-GOTAQDBJ.mjs} +47 -4
  7. package/dist/chunk-GOTAQDBJ.mjs.map +1 -0
  8. package/dist/{chunk-3KT73N2S.mjs → chunk-LPNEP2VH.mjs} +0 -0
  9. package/dist/chunk-LPNEP2VH.mjs.map +1 -0
  10. package/dist/{chunk-EO3YNPRQ.mjs → chunk-Q25TJEY4.mjs} +494 -204
  11. package/dist/chunk-Q25TJEY4.mjs.map +1 -0
  12. package/dist/{firebase-CPu87KA0.d.ts → firebase-B5MgLlHk.d.ts} +6 -1
  13. package/dist/{firebase-PE6MxGdJ.d.mts → firebase-GrbVrNgs.d.mts} +6 -1
  14. package/dist/index.d.mts +27 -6
  15. package/dist/index.d.ts +27 -6
  16. package/dist/index.js +821 -258
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +172 -37
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/strategy.d.mts +2 -2
  21. package/dist/strategy.d.ts +2 -2
  22. package/dist/strategy.js +46 -3
  23. package/dist/strategy.js.map +1 -1
  24. package/dist/strategy.mjs +1 -1
  25. package/dist/sync.d.mts +134 -50
  26. package/dist/sync.d.ts +134 -50
  27. package/dist/sync.js +499 -205
  28. package/dist/sync.js.map +1 -1
  29. package/dist/sync.mjs +15 -3
  30. package/dist/transport.d.mts +0 -0
  31. package/dist/transport.d.ts +0 -0
  32. package/dist/transport.js +0 -0
  33. package/dist/transport.js.map +1 -1
  34. package/dist/transport.mjs +2 -2
  35. package/dist/{types-5LHBOW08.d.mts → types-hNfCIBzj.d.mts} +7 -0
  36. package/dist/{types-5LHBOW08.d.ts → types-hNfCIBzj.d.ts} +7 -0
  37. package/dist/types.d.mts +2 -2
  38. package/dist/types.d.ts +2 -2
  39. package/dist/types.js.map +1 -1
  40. package/package.json +26 -5
  41. package/dist/chunk-3KT73N2S.mjs.map +0 -1
  42. package/dist/chunk-EO3YNPRQ.mjs.map +0 -1
  43. 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;
@@ -408,7 +423,7 @@ var FirebaseStrategy = class {
408
423
  this._joinGeneration++;
409
424
  this._roomId = roomId;
410
425
  this._peerMeta = peerMeta;
411
- const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
426
+ const { ref, set, onChildAdded, onChildRemoved, onDisconnect, onValue, remove } = this._fb;
412
427
  const paths = firebasePaths(this._appId, roomId, this.selfId);
413
428
  await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
414
429
  });
@@ -419,6 +434,18 @@ var FirebaseStrategy = class {
419
434
  ts: Date.now()
420
435
  });
421
436
  onDisconnect(presenceRef).remove();
437
+ const generation = this._joinGeneration;
438
+ const connectedRef = ref(this._db, ".info/connected");
439
+ const connectedUnsub = onValue(connectedRef, (snap) => {
440
+ if (snap.val() !== true) return;
441
+ if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
442
+ onDisconnect(presenceRef).remove().then(() => {
443
+ if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
444
+ return set(presenceRef, { peerId: this.selfId, meta: peerMeta, ts: Date.now() });
445
+ }).catch(() => {
446
+ });
447
+ });
448
+ this._listeners.push(() => connectedUnsub());
422
449
  const peersRef = ref(this._db, paths.peers);
423
450
  const addedUnsub = onChildAdded(peersRef, (snapshot) => {
424
451
  const data = snapshot.val();
@@ -481,6 +508,12 @@ var FirebaseStrategy = class {
481
508
  }
482
509
  subscribeToLobby(cb) {
483
510
  this._onLobby.push(cb);
511
+ if (this._lobbyWired) {
512
+ return () => {
513
+ removeFromArray(this._onLobby, cb);
514
+ };
515
+ }
516
+ this._lobbyWired = true;
484
517
  this._ensureInit().then(() => {
485
518
  if (!this._db || !this._fb || this._destroyed) return;
486
519
  const { ref, onValue } = this._fb;
@@ -508,14 +541,25 @@ var FirebaseStrategy = class {
508
541
  if (!this._db || !this._fb) return;
509
542
  const { ref, set } = this._fb;
510
543
  const paths = firebasePaths(this._appId, announcement.roomId, "");
544
+ this._lastAnnouncement = announcement;
511
545
  announcement.lastSeen = Date.now();
512
- set(ref(this._db, paths.roomLobbyEntry), announcement);
546
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
513
547
  if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
514
548
  this._lobbyAnnounceTimer = setInterval(() => {
515
549
  announcement.lastSeen = Date.now();
516
- set(ref(this._db, paths.roomLobbyEntry), announcement);
550
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
517
551
  }, ROOM_ANNOUNCE_INTERVAL_MS);
518
552
  }
553
+ updateRoomOccupancy(roomId, playerCount, state) {
554
+ const ann = this._lastAnnouncement;
555
+ if (!ann || ann.roomId !== roomId || !this._db || !this._fb) return;
556
+ ann.playerCount = playerCount;
557
+ if (state) ann.state = state;
558
+ ann.lastSeen = Date.now();
559
+ const { ref, set } = this._fb;
560
+ const paths = firebasePaths(this._appId, roomId, "");
561
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(ann));
562
+ }
519
563
  removeRoomAnnouncement(roomId) {
520
564
  if (!this._db || !this._fb) return;
521
565
  const { ref, remove } = this._fb;
@@ -618,6 +662,7 @@ function sanitizeForFirebase(obj) {
618
662
  if (typeof obj === "object" && obj !== null) {
619
663
  const result = {};
620
664
  for (const [key, value] of Object.entries(obj)) {
665
+ if (value === void 0) continue;
621
666
  result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
622
667
  }
623
668
  return result;
@@ -665,6 +710,11 @@ var _TickKeeper = class _TickKeeper {
665
710
  this._serverTick = serverTick;
666
711
  this._updateDriftCorrection();
667
712
  }
713
+ /** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
714
+ snapTick(tick) {
715
+ this._tick = tick;
716
+ this._updateDriftCorrection();
717
+ }
668
718
  /**
669
719
  * Accumulate time and return the number of fixed ticks to process.
670
720
  * Call this once per render frame with the raw frame delta.
@@ -789,15 +839,16 @@ var Codec = class {
789
839
  }
790
840
  return changed.length > 0 ? changed : null;
791
841
  }
792
- /** Serialize a delta snapshot packet */
793
- serializeDelta(tick, baseTick, current, baseline) {
842
+ /** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
843
+ serializeDelta(tick, baseTick, current, baseline, hostInput) {
794
844
  const delta = this.computeDelta(current, baseline);
795
845
  if (!delta) return null;
796
846
  const packet = {
797
847
  t: tick,
798
848
  b: baseline ? baseTick : -1,
799
849
  // -1 = keyframe
800
- s: this.serialize(delta)
850
+ s: this.serialize(delta),
851
+ ...hostInput !== void 0 ? { hi: hostInput } : {}
801
852
  };
802
853
  return (0, import_msgpackr.pack)(packet);
803
854
  }
@@ -807,7 +858,8 @@ var Codec = class {
807
858
  return {
808
859
  tick: packet.t,
809
860
  baseTick: packet.b,
810
- entities: this.deserialize(packet.s)
861
+ entities: this.deserialize(packet.s),
862
+ hostInput: packet.hi
811
863
  };
812
864
  }
813
865
  _hasChanged(current, prev) {
@@ -1119,6 +1171,7 @@ function MultiplayerProvider({
1119
1171
  }) {
1120
1172
  const managerRef = (0, import_react2.useRef)(null);
1121
1173
  const strategyRef = (0, import_react2.useRef)(null);
1174
+ const destroyTimerRef = (0, import_react2.useRef)(null);
1122
1175
  if (!managerRef.current) {
1123
1176
  managerRef.current = new NetworkManager();
1124
1177
  }
@@ -1126,11 +1179,18 @@ function MultiplayerProvider({
1126
1179
  strategyRef.current = createStrategy(appId, strategyConfig);
1127
1180
  }
1128
1181
  (0, import_react2.useEffect)(() => {
1182
+ if (destroyTimerRef.current) {
1183
+ clearTimeout(destroyTimerRef.current);
1184
+ destroyTimerRef.current = null;
1185
+ }
1129
1186
  return () => {
1130
- strategyRef.current?.destroy();
1131
- strategyRef.current = null;
1132
- managerRef.current?.destroy();
1133
- managerRef.current = null;
1187
+ destroyTimerRef.current = setTimeout(() => {
1188
+ destroyTimerRef.current = null;
1189
+ strategyRef.current?.destroy();
1190
+ strategyRef.current = null;
1191
+ managerRef.current?.destroy();
1192
+ managerRef.current = null;
1193
+ }, 0);
1134
1194
  };
1135
1195
  }, []);
1136
1196
  const value = {
@@ -1220,8 +1280,8 @@ var PeerConnection = class {
1220
1280
  this._events.onStateChange(newState);
1221
1281
  }
1222
1282
  }
1223
- async createOffer() {
1224
- const offer = await this._connection.createOffer();
1283
+ async createOffer(options) {
1284
+ const offer = await this._connection.createOffer(options);
1225
1285
  await this._connection.setLocalDescription(offer);
1226
1286
  return offer;
1227
1287
  }
@@ -1297,8 +1357,17 @@ var PeerConnection = class {
1297
1357
 
1298
1358
  // src/transport/webrtc/WebRTCTransport.ts
1299
1359
  var ROOM_CONTROL_CHANNEL = "carver:room-control";
1300
- function electHost(peerIds) {
1301
- return [...peerIds].sort()[0];
1360
+ function electHost(peerIds, rankOf) {
1361
+ let best = peerIds[0];
1362
+ let bestRank = rankOf(best);
1363
+ for (const id of peerIds) {
1364
+ const rank = rankOf(id);
1365
+ if (rank < bestRank || rank === bestRank && id < best) {
1366
+ best = id;
1367
+ bestRank = rank;
1368
+ }
1369
+ }
1370
+ return best;
1302
1371
  }
1303
1372
  var WebRTCTransport = class {
1304
1373
  /**
@@ -1326,6 +1395,11 @@ var WebRTCTransport = class {
1326
1395
  this._playerMap = /* @__PURE__ */ new Map();
1327
1396
  this._initialPeers = [];
1328
1397
  this._strategyUnsubs = [];
1398
+ // ── Private: WebRTC peer management ──
1399
+ /** Grace timers for transient ICE 'disconnected' states */
1400
+ this._disconnectTimers = /* @__PURE__ */ new Map();
1401
+ /** Sends queued while a data channel is not yet open: key = peerIdchannel */
1402
+ this._pendingSends = /* @__PURE__ */ new Map();
1329
1403
  this._strategy = strategy;
1330
1404
  this._peerId = strategy.selfId;
1331
1405
  this._iceConfig = buildICEConfig({ iceServers, iceTransportPolicy });
@@ -1421,10 +1495,7 @@ var WebRTCTransport = class {
1421
1495
  );
1422
1496
  this._strategyUnsubs.push(
1423
1497
  this._strategy.onPeerLeft((peerId) => {
1424
- this._removePeer(peerId);
1425
- this._playerMap.delete(peerId);
1426
- this._electAndSetHost();
1427
- for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1498
+ this._onStrategyPeerLeft(peerId);
1428
1499
  })
1429
1500
  );
1430
1501
  this._strategyUnsubs.push(
@@ -1537,16 +1608,17 @@ var WebRTCTransport = class {
1537
1608
  _onStrategyPeerDiscovered(peerId, meta) {
1538
1609
  this._connectToPeer(peerId);
1539
1610
  this._peerSet.add(peerId);
1611
+ const existing = this._playerMap.get(peerId);
1540
1612
  const player = {
1541
1613
  peerId,
1542
- displayName: meta.displayName ?? `Player-${peerId.slice(0, 4)}`,
1614
+ displayName: meta.displayName ?? existing?.displayName ?? `Player-${peerId.slice(0, 4)}`,
1543
1615
  isHost: false,
1544
1616
  isSelf: false,
1545
- isReady: false,
1617
+ isReady: existing?.isReady ?? false,
1546
1618
  isConnected: true,
1547
1619
  metadata: meta,
1548
- latencyMs: 0,
1549
- joinedAt: Date.now()
1620
+ latencyMs: existing?.latencyMs ?? 0,
1621
+ joinedAt: existing?.joinedAt ?? Date.now()
1550
1622
  };
1551
1623
  this._playerMap.set(peerId, player);
1552
1624
  this._electAndSetHost();
@@ -1626,6 +1698,7 @@ var WebRTCTransport = class {
1626
1698
  case "request-room-state": {
1627
1699
  if (!this._isHost || !this._room) break;
1628
1700
  this._room.state = msg.state;
1701
+ this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
1629
1702
  this._broadcastControlMessage({ type: "room-updated", room: this._room });
1630
1703
  break;
1631
1704
  }
@@ -1670,9 +1743,20 @@ var WebRTCTransport = class {
1670
1743
  this._handleControlMessage(msg, this._peerId);
1671
1744
  }
1672
1745
  // ── Private: Host election ──
1746
+ /**
1747
+ * Host-election rank for a peer: lower wins. Reads `metadata.hostPriority`
1748
+ * (advertised via player metadata / signaling presence, so every peer sees
1749
+ * the same value). Peers that don't advertise a priority rank last, which
1750
+ * preserves the legacy lowest-peerId election among them.
1751
+ */
1752
+ _hostRank(peerId) {
1753
+ const meta = this._playerMap.get(peerId)?.metadata;
1754
+ const priority = meta?.hostPriority;
1755
+ return typeof priority === "number" && Number.isFinite(priority) ? priority : Number.POSITIVE_INFINITY;
1756
+ }
1673
1757
  _electAndSetHost() {
1674
1758
  const allIds = [this._peerId, ...this._peerSet];
1675
- const newHostId = electHost(allIds);
1759
+ const newHostId = electHost(allIds, (id) => this._hostRank(id));
1676
1760
  const changed = newHostId !== this._hostId;
1677
1761
  this._hostId = newHostId;
1678
1762
  this._isHost = newHostId === this._peerId;
@@ -1682,16 +1766,23 @@ var WebRTCTransport = class {
1682
1766
  if (this._room) {
1683
1767
  this._room.hostId = newHostId;
1684
1768
  this._room.playerCount = this._playerMap.size;
1769
+ this._strategy.updateRoomOccupancy?.(this._room.id, this._room.playerCount, this._room.state);
1685
1770
  }
1686
1771
  if (changed) {
1687
1772
  for (const cb of this._callbacks.onHostChanged) cb(newHostId);
1688
1773
  }
1689
1774
  }
1690
- // ── Private: WebRTC peer management ──
1691
1775
  _connectToPeer(peerId) {
1692
1776
  if (this._peers.has(peerId)) return;
1693
1777
  const peer = new PeerConnection(peerId, this._iceConfig, {
1694
1778
  onStateChange: (state) => {
1779
+ if (state === "connected") {
1780
+ const timer = this._disconnectTimers.get(peerId);
1781
+ if (timer) {
1782
+ clearTimeout(timer);
1783
+ this._disconnectTimers.delete(peerId);
1784
+ }
1785
+ }
1695
1786
  if (state === "connected" && this._isHost && this._room) {
1696
1787
  const syncMsg = {
1697
1788
  type: "sync-state",
@@ -1703,10 +1794,7 @@ var WebRTCTransport = class {
1703
1794
  }, 100);
1704
1795
  }
1705
1796
  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);
1797
+ this._handleConnectionDrop(peerId, state);
1710
1798
  }
1711
1799
  },
1712
1800
  onDataChannel: (channel) => {
@@ -1766,21 +1854,90 @@ var WebRTCTransport = class {
1766
1854
  } catch {
1767
1855
  }
1768
1856
  };
1857
+ dataChannel.onopen = () => this._flushPendingSends(peerId, channelName);
1858
+ if (dataChannel.readyState === "open") this._flushPendingSends(peerId, channelName);
1769
1859
  }
1770
1860
  _sendOnChannel(channelName, data, target) {
1771
1861
  const serialized = typeof data === "object" && data !== null && !(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) ? JSON.stringify(data) : data;
1772
1862
  const targets = target ? Array.isArray(target) ? target : [target] : Array.from(this._peers.keys());
1773
1863
  for (const pid of targets) {
1774
1864
  const peer = this._peers.get(pid);
1775
- const ch = peer?.getDataChannel(channelName);
1865
+ if (!peer) continue;
1866
+ const ch = peer.getDataChannel(channelName);
1776
1867
  if (ch?.readyState === "open") {
1777
1868
  try {
1778
1869
  ch.send(serialized);
1779
1870
  } catch {
1780
1871
  }
1872
+ } else {
1873
+ const key = pid + "\0" + channelName;
1874
+ const q = this._pendingSends.get(key) ?? [];
1875
+ if (q.length < 200) q.push(serialized);
1876
+ this._pendingSends.set(key, q);
1877
+ }
1878
+ }
1879
+ }
1880
+ _flushPendingSends(peerId, channelName) {
1881
+ const key = peerId + "\0" + channelName;
1882
+ const q = this._pendingSends.get(key);
1883
+ if (!q || q.length === 0) return;
1884
+ const ch = this._peers.get(peerId)?.getDataChannel(channelName);
1885
+ if (ch?.readyState !== "open") return;
1886
+ this._pendingSends.delete(key);
1887
+ for (const msg of q) {
1888
+ try {
1889
+ ch.send(msg);
1890
+ } catch {
1891
+ break;
1781
1892
  }
1782
1893
  }
1783
1894
  }
1895
+ /**
1896
+ * Signaling presence reports a peer left. This is NOT authoritative for an
1897
+ * established session: a transient signaling (Firebase/MQTT) disconnect can
1898
+ * remove a peer's presence while the direct WebRTC link is perfectly healthy.
1899
+ * Keep the peer if its connection is still 'connected'; genuine departures
1900
+ * are also surfaced by the WebRTC connection-state machine
1901
+ * (failed/disconnected -> _handleConnectionDrop), which tears the peer down.
1902
+ */
1903
+ _onStrategyPeerLeft(peerId) {
1904
+ const peer = this._peers.get(peerId);
1905
+ if (peer && peer.state === "connected") return;
1906
+ this._teardownPeer(peerId);
1907
+ }
1908
+ /**
1909
+ * Self-heal a dropped P2P link instead of tearing it down immediately. ICE
1910
+ * 'disconnected' is frequently transient and 'failed' can often recover via
1911
+ * an ICE restart. The deterministic initiator (lower peerId) renegotiates by
1912
+ * sending a fresh offer with iceRestart; the answerer replies through the
1913
+ * existing _handleSignal('offer') path. A grace timer is the fallback: tear
1914
+ * the peer down only if it doesn't return to 'connected'. Recovery to
1915
+ * 'connected' cancels the timer (see onStateChange in _connectToPeer).
1916
+ */
1917
+ _handleConnectionDrop(peerId, state) {
1918
+ if (this._disconnectTimers.has(peerId)) return;
1919
+ if (this._peerId < peerId) {
1920
+ const peer = this._peers.get(peerId);
1921
+ peer?.createOffer({ iceRestart: true }).then((offer) => {
1922
+ if (this._peers.get(peerId) === peer) {
1923
+ this._strategy.signal(peerId, { type: "offer", sdp: offer });
1924
+ }
1925
+ }).catch(() => {
1926
+ });
1927
+ }
1928
+ const graceMs = state === "failed" ? 8e3 : 5e3;
1929
+ this._disconnectTimers.set(peerId, setTimeout(() => {
1930
+ this._disconnectTimers.delete(peerId);
1931
+ const p = this._peers.get(peerId);
1932
+ if (p && p.state !== "connected") this._teardownPeer(peerId);
1933
+ }, graceMs));
1934
+ }
1935
+ _teardownPeer(peerId) {
1936
+ this._removePeer(peerId);
1937
+ this._playerMap.delete(peerId);
1938
+ this._electAndSetHost();
1939
+ for (const cb of this._callbacks.onPeerLeave) cb(peerId);
1940
+ }
1784
1941
  _removePeer(peerId) {
1785
1942
  const peer = this._peers.get(peerId);
1786
1943
  if (peer) {
@@ -1789,6 +1946,14 @@ var WebRTCTransport = class {
1789
1946
  }
1790
1947
  this._peerSet.delete(peerId);
1791
1948
  this._rateLimitCounters.delete(peerId);
1949
+ const timer = this._disconnectTimers.get(peerId);
1950
+ if (timer) {
1951
+ clearTimeout(timer);
1952
+ this._disconnectTimers.delete(peerId);
1953
+ }
1954
+ for (const key of [...this._pendingSends.keys()]) {
1955
+ if (key.startsWith(peerId + "\0")) this._pendingSends.delete(key);
1956
+ }
1792
1957
  }
1793
1958
  _checkRateLimit(peerId) {
1794
1959
  const now = Date.now();
@@ -1970,7 +2135,7 @@ function announcementToRoom(ann) {
1970
2135
  isPrivate: ann.isPrivate,
1971
2136
  metadata: ann.metadata,
1972
2137
  createdAt: ann.createdAt,
1973
- state: "lobby"
2138
+ state: ann.state ?? "lobby"
1974
2139
  };
1975
2140
  }
1976
2141
  function useLobby(options) {
@@ -2016,7 +2181,7 @@ function useLobby(options) {
2016
2181
  hostId: strategy.selfId,
2017
2182
  playerCount: 0,
2018
2183
  maxPlayers: config.maxPlayers ?? 8,
2019
- gameMode: config.metadata?.gameMode,
2184
+ gameMode: config.gameMode ?? config.metadata?.gameMode,
2020
2185
  isPrivate: config.isPrivate ?? false,
2021
2186
  metadata: config.metadata ?? {},
2022
2187
  createdAt: Date.now(),
@@ -2246,8 +2411,9 @@ var HostAuthority = class {
2246
2411
  /**
2247
2412
  * Called every fixed tick by the sync engine.
2248
2413
  * Collects entity states and decides whether to broadcast.
2414
+ * `hostInput` (prediction mode) is embedded in the snapshot packet as `hi`.
2249
2415
  */
2250
- tick(currentTick, entities, delta) {
2416
+ tick(currentTick, entities, delta, hostInput) {
2251
2417
  this._tick = currentTick;
2252
2418
  this._snapshotBuffer.store(currentTick, new Map(entities));
2253
2419
  this._broadcastAccumulator += delta;
@@ -2255,16 +2421,16 @@ var HostAuthority = class {
2255
2421
  if (this._broadcastAccumulator < broadcastInterval) return;
2256
2422
  this._broadcastAccumulator -= broadcastInterval;
2257
2423
  for (const peerId of this._transport.peers) {
2258
- this._broadcastToClient(peerId, currentTick, entities);
2424
+ this._broadcastToClient(peerId, currentTick, entities, hostInput);
2259
2425
  }
2260
2426
  }
2261
2427
  /** Force a keyframe broadcast to all clients (e.g., after host migration) */
2262
- forceKeyframe(currentTick, entities) {
2428
+ forceKeyframe(currentTick, entities, hostInput) {
2263
2429
  this._clientBaselines.clear();
2264
2430
  this._clientLastKeyframeTick.clear();
2265
2431
  this._snapshotBuffer.store(currentTick, new Map(entities));
2266
2432
  for (const peerId of this._transport.peers) {
2267
- this._broadcastToClient(peerId, currentTick, entities);
2433
+ this._broadcastToClient(peerId, currentTick, entities, hostInput);
2268
2434
  }
2269
2435
  }
2270
2436
  destroy() {
@@ -2273,7 +2439,7 @@ var HostAuthority = class {
2273
2439
  this._clientBaselines.clear();
2274
2440
  this._clientLastKeyframeTick.clear();
2275
2441
  }
2276
- _broadcastToClient(peerId, currentTick, entities) {
2442
+ _broadcastToClient(peerId, currentTick, entities, hostInput) {
2277
2443
  let clientEntities = entities;
2278
2444
  if (this._interestFilter) {
2279
2445
  clientEntities = /* @__PURE__ */ new Map();
@@ -2297,7 +2463,8 @@ var HostAuthority = class {
2297
2463
  currentTick,
2298
2464
  needsKeyframe ? -1 : clientBaseTick ?? -1,
2299
2465
  clientEntities,
2300
- baseline
2466
+ baseline,
2467
+ hostInput
2301
2468
  );
2302
2469
  if (packet) {
2303
2470
  this._snapshotChannel.send(packet, peerId);
@@ -2319,6 +2486,8 @@ var ClientReceiver = class {
2319
2486
  this._packetCount = 0;
2320
2487
  // Entity state: full accumulated state from keyframes + deltas
2321
2488
  this._fullState = /* @__PURE__ */ new Map();
2489
+ // Listeners fired after each snapshot is merged into the full world state
2490
+ this._snapshotListeners = [];
2322
2491
  this._transport = transport;
2323
2492
  this._codec = codec;
2324
2493
  this._bufferSize = options?.bufferSize ?? 3;
@@ -2405,16 +2574,21 @@ var ClientReceiver = class {
2405
2574
  requestKeyframe() {
2406
2575
  this._ackChannel.send("-1");
2407
2576
  }
2577
+ /** Register a listener fired after each snapshot is merged into the full world state */
2578
+ onSnapshot(cb) {
2579
+ this._snapshotListeners.push(cb);
2580
+ }
2408
2581
  destroy() {
2409
2582
  this._snapshotChannel.close();
2410
2583
  this._ackChannel.close();
2411
2584
  this._buffer = [];
2412
2585
  this._interpolatedState.clear();
2413
2586
  this._fullState.clear();
2587
+ this._snapshotListeners = [];
2414
2588
  }
2415
2589
  _handleSnapshot(data) {
2416
2590
  try {
2417
- const { tick, baseTick, entities } = this._codec.deserializePacket(data);
2591
+ const { tick, baseTick, entities, hostInput } = this._codec.deserializePacket(data);
2418
2592
  const now = performance.now();
2419
2593
  if (baseTick === -1) {
2420
2594
  this._fullState.clear();
@@ -2430,15 +2604,19 @@ var ClientReceiver = class {
2430
2604
  }
2431
2605
  }
2432
2606
  }
2607
+ const fullStateClone = new Map(this._fullState);
2433
2608
  this._buffer.push({
2434
2609
  tick,
2435
- entities: new Map(this._fullState),
2610
+ entities: fullStateClone,
2436
2611
  receivedAt: now
2437
2612
  });
2438
2613
  while (this._buffer.length > this._bufferSize * 2) {
2439
2614
  this._buffer.shift();
2440
2615
  }
2441
2616
  this._ackChannel.send(String(tick));
2617
+ for (const cb of this._snapshotListeners) {
2618
+ cb(tick, fullStateClone, hostInput);
2619
+ }
2442
2620
  this._lastSnapshotTime = now;
2443
2621
  this._packetCount++;
2444
2622
  } catch {
@@ -2596,6 +2774,8 @@ var SnapshotSync = class {
2596
2774
  constructor(transport, codec, snapshotBuffer, options) {
2597
2775
  this._hostAuthority = null;
2598
2776
  this._clientReceiver = null;
2777
+ // Snapshot listeners survive host migration (forwarder re-attached on demote)
2778
+ this._snapshotListeners = [];
2599
2779
  this._transport = transport;
2600
2780
  this._codec = codec;
2601
2781
  this._snapshotBuffer = snapshotBuffer;
@@ -2616,6 +2796,7 @@ var SnapshotSync = class {
2616
2796
  extrapolateMs: options?.extrapolateMs,
2617
2797
  is2D: options?.is2D
2618
2798
  });
2799
+ this._attachSnapshotForwarder(this._clientReceiver);
2619
2800
  }
2620
2801
  }
2621
2802
  get isHost() {
@@ -2628,8 +2809,12 @@ var SnapshotSync = class {
2628
2809
  return this._clientReceiver;
2629
2810
  }
2630
2811
  /** Host: called every fixed tick to potentially broadcast state */
2631
- hostTick(tick, entities, delta) {
2632
- this._hostAuthority?.tick(tick, entities, delta);
2812
+ hostTick(tick, entities, delta, hostInput) {
2813
+ this._hostAuthority?.tick(tick, entities, delta, hostInput);
2814
+ }
2815
+ /** Register a listener fired after each merged snapshot (client side). */
2816
+ onSnapshot(cb) {
2817
+ this._snapshotListeners.push(cb);
2633
2818
  }
2634
2819
  /** Client: called every render frame to interpolate */
2635
2820
  clientInterpolate(renderTime) {
@@ -2667,254 +2852,516 @@ var SnapshotSync = class {
2667
2852
  is2D: options?.is2D
2668
2853
  }
2669
2854
  );
2855
+ this._attachSnapshotForwarder(this._clientReceiver);
2670
2856
  }
2671
2857
  destroy() {
2672
2858
  this._hostAuthority?.destroy();
2673
2859
  this._clientReceiver?.destroy();
2674
2860
  this._hostAuthority = null;
2675
2861
  this._clientReceiver = null;
2862
+ this._snapshotListeners = [];
2863
+ }
2864
+ // ── Private ──
2865
+ /** Forward receiver snapshots to registered listeners (survives host migration). */
2866
+ _attachSnapshotForwarder(receiver) {
2867
+ receiver.onSnapshot((t, e, hi) => {
2868
+ for (const l of this._snapshotListeners) l(t, e, hi);
2869
+ });
2870
+ }
2871
+ };
2872
+
2873
+ // src/core/InputBuffer.ts
2874
+ var InputBuffer = class {
2875
+ constructor(neutralInput, historySize = 120) {
2876
+ /** Local player tick-keyed inputs (ring buffer). */
2877
+ this._local = /* @__PURE__ */ new Map();
2878
+ /** Last-received input per remote peer. */
2879
+ this._remotes = /* @__PURE__ */ new Map();
2880
+ /** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
2881
+ this._peerTicks = /* @__PURE__ */ new Map();
2882
+ this._neutral = { ...neutralInput };
2883
+ this._historySize = historySize;
2884
+ }
2885
+ // ── Local input ──
2886
+ /** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
2887
+ storeTick(tick, input) {
2888
+ this._local.set(tick, { ...input });
2889
+ this._local.delete(tick - this._historySize);
2890
+ }
2891
+ /** Return the local input at `tick`, or a neutral copy if out of range. */
2892
+ getTick(tick) {
2893
+ return this._local.get(tick) ?? { ...this._neutral };
2894
+ }
2895
+ /** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
2896
+ hasTick(tick) {
2897
+ return this._local.has(tick);
2898
+ }
2899
+ /** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
2900
+ getJustPressedZero() {
2901
+ const out = {};
2902
+ for (const key in this._neutral) {
2903
+ const v = this._neutral[key];
2904
+ out[key] = typeof v === "boolean" ? false : v;
2905
+ }
2906
+ return out;
2907
+ }
2908
+ // ── Remote (peer) inputs ──
2909
+ /**
2910
+ * Record a remote peer's input. If `tick` is given (the sender's local tick),
2911
+ * also store it in the per-peer ring buffer for rollback.
2912
+ */
2913
+ setRemote(peerId, input, tick) {
2914
+ this._remotes.set(peerId, input);
2915
+ if (tick !== void 0) {
2916
+ let peerMap = this._peerTicks.get(peerId);
2917
+ if (!peerMap) {
2918
+ peerMap = /* @__PURE__ */ new Map();
2919
+ this._peerTicks.set(peerId, peerMap);
2920
+ }
2921
+ peerMap.set(tick, input);
2922
+ peerMap.delete(tick - this._historySize);
2923
+ }
2924
+ }
2925
+ /** Last-known input for a peer, or a neutral copy if never received. */
2926
+ getRemote(peerId) {
2927
+ return this._remotes.get(peerId) ?? { ...this._neutral };
2928
+ }
2929
+ /** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
2930
+ allRemotes() {
2931
+ return new Map(this._remotes);
2932
+ }
2933
+ /**
2934
+ * Return a peer's exact input at the given tick (for rollback accuracy),
2935
+ * falling back to their last-known input when history does not reach that far.
2936
+ */
2937
+ getRemoteAtTick(peerId, tick) {
2938
+ return this._peerTicks.get(peerId)?.get(tick) ?? this.getRemote(peerId);
2939
+ }
2940
+ /** Override the last-known input for a peer (does NOT touch tick history). */
2941
+ overrideRemote(peerId, input) {
2942
+ this._remotes.set(peerId, input);
2943
+ }
2944
+ /** Number of currently tracked remote peers. */
2945
+ get peerCount() {
2946
+ return this._remotes.size;
2947
+ }
2948
+ /** Iterate tracked peer IDs. */
2949
+ peerIds() {
2950
+ return this._remotes.keys();
2951
+ }
2952
+ /**
2953
+ * Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
2954
+ * Call when the room's peer list changes so stale peers stop receiving input.
2955
+ */
2956
+ setPeerIds(peerIds) {
2957
+ const set = peerIds instanceof Set ? peerIds : new Set(peerIds);
2958
+ for (const id of this._remotes.keys()) {
2959
+ if (!set.has(id)) {
2960
+ this._remotes.delete(id);
2961
+ this._peerTicks.delete(id);
2962
+ }
2963
+ }
2964
+ }
2965
+ // ── Lifecycle ──
2966
+ /** Clear local history, remote last-known inputs, and per-peer tick history. */
2967
+ clear() {
2968
+ this._local.clear();
2969
+ this._remotes.clear();
2970
+ this._peerTicks.clear();
2676
2971
  }
2677
2972
  };
2678
2973
 
2974
+ // src/core/InputUtils.ts
2975
+ function computeJustPressed(curr, prev) {
2976
+ const out = {};
2977
+ for (const key in curr) {
2978
+ const c = curr[key];
2979
+ out[key] = typeof c === "boolean" ? c === true && prev[key] !== true : c;
2980
+ }
2981
+ return out;
2982
+ }
2983
+
2984
+ // src/sync/Rollback.ts
2985
+ var IDENTITY_QUAT = { x: 0, y: 0, z: 0, w: 1 };
2986
+ function quatMultiply(a, b) {
2987
+ return {
2988
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
2989
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
2990
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
2991
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
2992
+ };
2993
+ }
2994
+ function quatInvert(q) {
2995
+ return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
2996
+ }
2997
+ function quatNormalize(q) {
2998
+ const mag = Math.hypot(q.x, q.y, q.z, q.w);
2999
+ if (mag < 1e-12) return { ...IDENTITY_QUAT };
3000
+ return { x: q.x / mag, y: q.y / mag, z: q.z / mag, w: q.w / mag };
3001
+ }
3002
+ function quatAngle(q) {
3003
+ return 2 * Math.acos(Math.min(1, Math.abs(q.w)));
3004
+ }
3005
+ function quatScaleAngle(q, factor) {
3006
+ let n = quatNormalize(q);
3007
+ if (n.w < 0) n = { x: -n.x, y: -n.y, z: -n.z, w: -n.w };
3008
+ const angle = 2 * Math.acos(Math.min(1, n.w));
3009
+ if (angle < 1e-6) return { ...IDENTITY_QUAT };
3010
+ const s = Math.sin(angle / 2);
3011
+ const ax = n.x / s;
3012
+ const ay = n.y / s;
3013
+ const az = n.z / s;
3014
+ const na = angle * factor;
3015
+ const ns = Math.sin(na / 2);
3016
+ return { x: ax * ns, y: ay * ns, z: az * ns, w: Math.cos(na / 2) };
3017
+ }
3018
+ function applyRollback(params) {
3019
+ const {
3020
+ serverTick,
3021
+ serverState,
3022
+ localTick,
3023
+ localPeerId,
3024
+ inputs,
3025
+ currentErrors,
3026
+ driver,
3027
+ callback,
3028
+ dt,
3029
+ driftTargetTicks,
3030
+ maxRewindTicks,
3031
+ snapThreshold
3032
+ } = params;
3033
+ const preState = driver.captureState();
3034
+ const preMap = /* @__PURE__ */ new Map();
3035
+ for (const [id, s] of preState) {
3036
+ const e = currentErrors.get(id);
3037
+ const ex = e?.x ?? 0;
3038
+ const ey = e?.y ?? 0;
3039
+ if ("z" in s) {
3040
+ const errQ = e ? { x: e.qx, y: e.qy, z: e.qz, w: e.qw } : { ...IDENTITY_QUAT };
3041
+ preMap.set(id, {
3042
+ x: s.x + ex,
3043
+ y: s.y + ey,
3044
+ z: s.z + (e?.z ?? 0),
3045
+ a: 0,
3046
+ q: quatMultiply(errQ, { x: s.qx, y: s.qy, z: s.qz, w: s.qw }),
3047
+ is3D: true
3048
+ });
3049
+ } else {
3050
+ preMap.set(id, {
3051
+ x: s.x + ex,
3052
+ y: s.y + ey,
3053
+ z: 0,
3054
+ a: s.a + (e?.a ?? 0),
3055
+ q: { ...IDENTITY_QUAT },
3056
+ is3D: false
3057
+ });
3058
+ }
3059
+ }
3060
+ driver.applyState(serverState.values());
3061
+ const targetTick = serverTick + driftTargetTicks;
3062
+ const tickDiff = localTick - targetTick;
3063
+ let newLocalTick = localTick;
3064
+ let snapped = false;
3065
+ if (Math.abs(tickDiff) > maxRewindTicks) {
3066
+ newLocalTick = targetTick;
3067
+ snapped = true;
3068
+ } else {
3069
+ for (let i = serverTick + 1; i <= localTick; i++) {
3070
+ if (callback) {
3071
+ const tickInputs = /* @__PURE__ */ new Map();
3072
+ const justPressed = /* @__PURE__ */ new Map();
3073
+ for (const peerId of inputs.peerIds()) {
3074
+ if (peerId === localPeerId) continue;
3075
+ const curr = inputs.getRemoteAtTick(peerId, i);
3076
+ tickInputs.set(peerId, curr);
3077
+ justPressed.set(
3078
+ peerId,
3079
+ computeJustPressed(curr, inputs.getRemoteAtTick(peerId, i - 1))
3080
+ );
3081
+ }
3082
+ const localCurr = inputs.getTick(i);
3083
+ tickInputs.set(localPeerId, localCurr);
3084
+ justPressed.set(
3085
+ localPeerId,
3086
+ computeJustPressed(localCurr, inputs.getTick(i - 1))
3087
+ );
3088
+ callback(tickInputs, justPressed, i, true, dt);
3089
+ }
3090
+ driver.stepWorld?.();
3091
+ }
3092
+ }
3093
+ const postState = driver.captureState();
3094
+ const errors = /* @__PURE__ */ new Map();
3095
+ for (const [id, post] of postState) {
3096
+ const pre = preMap.get(id);
3097
+ if (!pre) continue;
3098
+ const errX = pre.x - post.x;
3099
+ const errY = pre.y - post.y;
3100
+ let errZ = 0;
3101
+ let errA = 0;
3102
+ let q = { ...IDENTITY_QUAT };
3103
+ if ("z" in post) {
3104
+ errZ = pre.z - post.z;
3105
+ let dq = quatNormalize(
3106
+ quatMultiply(
3107
+ pre.q,
3108
+ quatInvert({ x: post.qx, y: post.qy, z: post.qz, w: post.qw })
3109
+ )
3110
+ );
3111
+ if (dq.w < 0) dq = { x: -dq.x, y: -dq.y, z: -dq.z, w: -dq.w };
3112
+ q = dq;
3113
+ } else {
3114
+ const ad = pre.a - post.a;
3115
+ errA = ad - Math.PI * 2 * Math.floor((ad + Math.PI) / (Math.PI * 2));
3116
+ }
3117
+ if (Math.abs(errX) > snapThreshold || Math.abs(errY) > snapThreshold || Math.abs(errZ) > snapThreshold) {
3118
+ errors.set(id, { x: 0, y: 0, z: 0, a: 0, qx: 0, qy: 0, qz: 0, qw: 1 });
3119
+ } else {
3120
+ errors.set(id, {
3121
+ x: errX,
3122
+ y: errY,
3123
+ z: errZ,
3124
+ a: errA,
3125
+ qx: q.x,
3126
+ qy: q.y,
3127
+ qz: q.z,
3128
+ qw: q.w
3129
+ });
3130
+ }
3131
+ }
3132
+ return { newLocalTick, snapped, errors };
3133
+ }
3134
+
2679
3135
  // src/sync/PredictionSync.ts
2680
- var import_msgpackr2 = require("msgpackr");
2681
3136
  var DEFAULT_OPTIONS = {
2682
3137
  maxRewindTicks: 15,
2683
- errorSmoothingDecay: 0.85,
2684
- maxErrorPerFrame: 5,
2685
- snapThreshold: 15,
2686
- lagCompensation: false
3138
+ snapThreshold: 150,
3139
+ errorDecay: 0.85,
3140
+ maxErrorPerFrame: 0,
3141
+ neutralInput: {},
3142
+ inputHistorySize: 120,
3143
+ driftTargetTicks: 4
2687
3144
  };
2688
3145
  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();
3146
+ constructor(transport, tickKeeper, snapshots, options) {
3147
+ // Local input; persists across ticks until replaced (hold-input semantics)
3148
+ this._currentInput = null;
3149
+ // Newest pending server snapshot awaiting rollback (only the newest survives)
3150
+ this._pending = null;
3151
+ // Per-entity accumulated visual error offsets
3152
+ this._errors = /* @__PURE__ */ new Map();
2700
3153
  this._serverTick = 0;
2701
- // Physics step callback (provided by developer)
3154
+ this._lastAppliedServerTick = 0;
3155
+ this._worldDriver = null;
2702
3156
  this._onPhysicsStep = null;
2703
- // Own input for current tick
2704
- this._currentInput = null;
2705
3157
  this._transport = transport;
2706
- this._codec = codec;
2707
3158
  this._tickKeeper = tickKeeper;
2708
3159
  this._options = { ...DEFAULT_OPTIONS, ...options };
2709
- this._isHost = transport.isHost;
3160
+ this._inputs = new InputBuffer(
3161
+ this._options.neutralInput,
3162
+ this._options.inputHistorySize
3163
+ );
2710
3164
  this._inputChannel = transport.createChannel("carver:inputs", {
2711
3165
  reliable: true,
2712
3166
  ordered: true
2713
3167
  });
2714
- this._stateChannel = transport.createChannel("carver:pred-state", {
2715
- reliable: false,
2716
- ordered: false,
2717
- maxRetransmits: 0
3168
+ this._inputChannel.onReceive(
3169
+ (data, peerId) => {
3170
+ try {
3171
+ const packet = typeof data === "string" ? JSON.parse(data) : data;
3172
+ if (typeof packet.t === "number" && packet.i !== null && typeof packet.i === "object") {
3173
+ this._inputs.setRemote(peerId, packet.i, packet.t);
3174
+ }
3175
+ } catch (err) {
3176
+ if (typeof console !== "undefined")
3177
+ console.debug("[CarverJS] Malformed input packet:", err);
3178
+ }
3179
+ }
3180
+ );
3181
+ snapshots.onSnapshot((tick, entities, hostInput) => {
3182
+ if (this._transport.isHost) return;
3183
+ if (tick <= this._lastAppliedServerTick) return;
3184
+ this._serverTick = tick;
3185
+ this._tickKeeper.setServerTick(tick);
3186
+ if (!this._pending || tick > this._pending.t) {
3187
+ this._pending = { t: tick, entities, hostInput };
3188
+ }
2718
3189
  });
2719
- this._ackChannel = transport.createChannel("carver:pred-acks", {
2720
- reliable: true,
2721
- ordered: true
3190
+ transport.onPeerLeave(() => {
3191
+ this._inputs.setPeerIds(this._transport.peers);
2722
3192
  });
2723
- if (this._isHost) {
2724
- this._setupHostListeners();
2725
- } else {
2726
- this._setupClientListeners();
2727
- }
2728
3193
  }
2729
- /** Set the physics step callback (required for rollback re-simulation) */
3194
+ // ── Wiring ──
3195
+ /** Set the game simulation callback (forward sim + rollback resim). */
2730
3196
  setPhysicsStep(cb) {
2731
3197
  this._onPhysicsStep = cb;
2732
3198
  }
2733
- /** Set the current input for this tick (client-side) */
3199
+ /** Set the world driver used for forward stepping and rollback. */
3200
+ setWorldDriver(driver) {
3201
+ this._worldDriver = driver;
3202
+ }
3203
+ // ── Input ──
3204
+ /** Set the local player's input. PERSISTS across ticks until replaced. */
2734
3205
  setInput(input) {
2735
3206
  this._currentInput = input;
2736
3207
  }
3208
+ /** Local input stored at the given tick (neutral fallback). Used by the host to embed `hi`. */
3209
+ getLocalInput(tick) {
3210
+ return this._inputs.getTick(tick);
3211
+ }
3212
+ // ── Frame lifecycle ──
2737
3213
  /**
2738
- * Called every fixed tick on the client.
2739
- * Applies input locally (prediction), buffers it, and sends to host.
3214
+ * Apply the newest pending server snapshot (full-world rollback).
3215
+ * Call once per render frame BEFORE tickKeeper.update().
3216
+ * No-op on host or when nothing is pending.
2740
3217
  */
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;
3218
+ beginFrame() {
3219
+ if (this._transport.isHost || !this._pending) return;
3220
+ const pending = this._pending;
3221
+ this._pending = null;
3222
+ this._lastAppliedServerTick = pending.t;
3223
+ if (pending.hostInput !== void 0) {
3224
+ this._inputs.setRemote(
3225
+ this._transport.hostId,
3226
+ pending.hostInput,
3227
+ pending.t
3228
+ );
2757
3229
  }
2758
- const minTick = tick - this._options.maxRewindTicks * 2;
2759
- while (this._inputBuffer.length > 0 && this._inputBuffer[0].tick < minTick) {
2760
- this._inputBuffer.shift();
3230
+ if (!this._worldDriver) return;
3231
+ const result = applyRollback({
3232
+ serverTick: pending.t,
3233
+ serverState: pending.entities,
3234
+ localTick: this._tickKeeper.tick,
3235
+ localPeerId: this._transport.peerId,
3236
+ inputs: this._inputs,
3237
+ currentErrors: this._errors,
3238
+ driver: this._worldDriver,
3239
+ callback: this._onPhysicsStep,
3240
+ dt: this._tickKeeper.tickDelta,
3241
+ driftTargetTicks: this._options.driftTargetTicks,
3242
+ maxRewindTicks: this._options.maxRewindTicks,
3243
+ snapThreshold: this._options.snapThreshold
3244
+ });
3245
+ this._errors = result.errors;
3246
+ if (result.newLocalTick !== this._tickKeeper.tick) {
3247
+ this._tickKeeper.snapTick(result.newLocalTick);
2761
3248
  }
2762
3249
  }
2763
3250
  /**
2764
- * Called every fixed tick on the host.
2765
- * Processes received inputs and broadcasts authoritative state.
3251
+ * Run one forward fixed tick (host AND client): store + broadcast input,
3252
+ * build per-tick input maps, invoke the callback, then step the world.
2766
3253
  */
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);
3254
+ tick(tick) {
3255
+ const localInput = this._currentInput ?? { ...this._options.neutralInput };
3256
+ this._inputs.storeTick(tick, localInput);
3257
+ this._inputChannel.send({
3258
+ t: tick,
3259
+ i: localInput,
3260
+ p: this._transport.peerId
3261
+ });
3262
+ const prevTick = tick - 1;
3263
+ const tickInputs = this._inputs.allRemotes();
3264
+ tickInputs.delete(this._transport.peerId);
3265
+ const justPressed = /* @__PURE__ */ new Map();
3266
+ for (const [peerId, inp] of tickInputs) {
3267
+ justPressed.set(
3268
+ peerId,
3269
+ computeJustPressed(inp, this._inputs.getRemoteAtTick(peerId, prevTick))
3270
+ );
2779
3271
  }
3272
+ tickInputs.set(this._transport.peerId, localInput);
3273
+ justPressed.set(
3274
+ this._transport.peerId,
3275
+ this._inputs.hasTick(prevTick) ? computeJustPressed(localInput, this._inputs.getTick(prevTick)) : this._inputs.getJustPressedZero()
3276
+ // suppress spurious edges after snap/rejoin
3277
+ );
3278
+ if (this._onPhysicsStep) {
3279
+ this._onPhysicsStep(
3280
+ tickInputs,
3281
+ justPressed,
3282
+ tick,
3283
+ false,
3284
+ this._tickKeeper.tickDelta
3285
+ );
3286
+ }
3287
+ this._worldDriver?.stepWorld?.();
2780
3288
  }
2781
3289
  /**
2782
- * Called every render frame on the client to apply visual error smoothing.
2783
- * Returns the corrected entity states.
3290
+ * Decay stored error offsets and return the portion to ADD to rendered
3291
+ * transforms this frame. Call exactly once per render frame.
2784
3292
  */
2785
- applyErrorSmoothing(entities) {
3293
+ getRenderErrorOffsets() {
2786
3294
  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;
3295
+ const decay = this._options.errorDecay;
3296
+ const maxErr = this._options.maxErrorPerFrame;
3297
+ for (const [id, e] of this._errors) {
3298
+ e.x *= decay;
3299
+ e.y *= decay;
3300
+ e.z *= decay;
3301
+ e.a *= decay;
3302
+ let q = quatScaleAngle({ x: e.qx, y: e.qy, z: e.qz, w: e.qw }, decay);
3303
+ if (Math.abs(e.x) < 0.1) e.x = 0;
3304
+ if (Math.abs(e.y) < 0.1) e.y = 0;
3305
+ if (Math.abs(e.z) < 0.1) e.z = 0;
3306
+ if (Math.abs(e.a) < 1e-3) e.a = 0;
3307
+ if (quatAngle(q) < 1e-3) q = { x: 0, y: 0, z: 0, w: 1 };
3308
+ e.qx = q.x;
3309
+ e.qy = q.y;
3310
+ e.qz = q.z;
3311
+ e.qw = q.w;
3312
+ let ax = e.x;
3313
+ let ay = e.y;
3314
+ let az = e.z;
3315
+ if (maxErr > 0) {
3316
+ const mag = Math.hypot(e.x, e.y, e.z);
3317
+ if (mag > maxErr) {
3318
+ const s = maxErr / mag;
3319
+ ax = e.x * s;
3320
+ ay = e.y * s;
3321
+ az = e.z * s;
3322
+ e.x -= ax;
3323
+ e.y -= ay;
3324
+ e.z -= az;
3325
+ } else {
3326
+ e.x = 0;
3327
+ e.y = 0;
3328
+ e.z = 0;
3329
+ }
2793
3330
  }
2794
- const corrected = { ...entity };
2795
- corrected.x += correction.x;
2796
- corrected.y += correction.y;
2797
- if ("z" in corrected) {
2798
- corrected.z += correction.z;
3331
+ const quatIsIdentity = e.qx === 0 && e.qy === 0 && e.qz === 0 && e.qw === 1;
3332
+ if (ax !== 0 || ay !== 0 || az !== 0 || e.a !== 0 || !quatIsIdentity) {
3333
+ result.set(id, {
3334
+ x: ax,
3335
+ y: ay,
3336
+ z: az,
3337
+ a: e.a,
3338
+ qx: e.qx,
3339
+ qy: e.qy,
3340
+ qz: e.qz,
3341
+ qw: e.qw
3342
+ });
2799
3343
  }
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);
3344
+ if (e.x === 0 && e.y === 0 && e.z === 0 && e.a === 0 && quatIsIdentity) {
3345
+ this._errors.delete(id);
2806
3346
  }
2807
- result.set(id, corrected);
2808
3347
  }
2809
3348
  return result;
2810
3349
  }
2811
- get predictedState() {
2812
- return this._predictedState;
2813
- }
3350
+ // ── State ──
3351
+ /** Tick of the newest RECEIVED snapshot. */
2814
3352
  get serverTick() {
2815
3353
  return this._serverTick;
2816
3354
  }
3355
+ /** Tick of the newest APPLIED (rolled-back) snapshot, 0 initially. */
3356
+ get lastAppliedServerTick() {
3357
+ return this._lastAppliedServerTick;
3358
+ }
2817
3359
  destroy() {
2818
3360
  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);
3361
+ this._inputs.clear();
3362
+ this._errors.clear();
3363
+ this._pending = null;
3364
+ this._currentInput = null;
2918
3365
  }
2919
3366
  };
2920
3367
 
@@ -3110,6 +3557,34 @@ function applyState3D(ref, state) {
3110
3557
  }
3111
3558
  }
3112
3559
  }
3560
+ function applyStateHard2D(ref, state) {
3561
+ applyState2D(ref, state);
3562
+ if (ref.rigidBody) {
3563
+ try {
3564
+ if (typeof ref.rigidBody.setLinvel === "function") {
3565
+ ref.rigidBody.setLinvel({ x: state.vx, y: state.vy }, true);
3566
+ }
3567
+ if (typeof ref.rigidBody.setAngvel === "function") {
3568
+ ref.rigidBody.setAngvel(state.va, true);
3569
+ }
3570
+ } catch {
3571
+ }
3572
+ }
3573
+ }
3574
+ function applyStateHard3D(ref, state) {
3575
+ applyState3D(ref, state);
3576
+ if (ref.rigidBody) {
3577
+ try {
3578
+ if (typeof ref.rigidBody.setLinvel === "function") {
3579
+ ref.rigidBody.setLinvel({ x: state.vx, y: state.vy, z: state.vz }, true);
3580
+ }
3581
+ if (typeof ref.rigidBody.setAngvel === "function") {
3582
+ ref.rigidBody.setAngvel({ x: state.wx, y: state.wy, z: state.wz }, true);
3583
+ }
3584
+ } catch {
3585
+ }
3586
+ }
3587
+ }
3113
3588
  function applyEntityState(ref, state, is2D) {
3114
3589
  if (is2D) {
3115
3590
  applyState2D(ref, state);
@@ -3125,6 +3600,10 @@ function applyStatesToActors(states, registry, is2D) {
3125
3600
  applyEntityState(ref, state, is2D);
3126
3601
  }
3127
3602
  }
3603
+ var IDENTITY_QUAT2 = { x: 0, y: 0, z: 0, w: 1 };
3604
+ function isIdentityQuat(q) {
3605
+ return q.x === 0 && q.y === 0 && q.z === 0 && q.w === 1;
3606
+ }
3128
3607
  function useMultiplayer(options = {}) {
3129
3608
  const { networkManager } = useMultiplayerContext();
3130
3609
  const mode = options.mode ?? networkManager.syncMode;
@@ -3140,6 +3619,7 @@ function useMultiplayer(options = {}) {
3140
3619
  const [drift, setDrift] = (0, import_react8.useState)(0);
3141
3620
  const actorRegistry = (0, import_react8.useRef)((0, import_systems.getActorRegistry)());
3142
3621
  const wasHostRef = (0, import_react8.useRef)(null);
3622
+ const appliedErrorsRef = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
3143
3623
  const buildSnapshotOpts = (0, import_react8.useCallback)(() => {
3144
3624
  return {
3145
3625
  broadcastRate: options.broadcastRate,
@@ -3156,6 +3636,36 @@ function useMultiplayer(options = {}) {
3156
3636
  options.interpolation?.method,
3157
3637
  options.interpolation?.extrapolateMs
3158
3638
  ]);
3639
+ const undoAppliedErrorOffsets = (0, import_react8.useCallback)(() => {
3640
+ const registry = actorRegistry.current;
3641
+ for (const [id, e] of appliedErrorsRef.current) {
3642
+ const ref = registry.get(id);
3643
+ if (!ref) {
3644
+ appliedErrorsRef.current.delete(id);
3645
+ continue;
3646
+ }
3647
+ const pos = ref.object3D.position;
3648
+ 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;
3649
+ if (untouched) {
3650
+ pos.x -= e.x;
3651
+ pos.y -= e.y;
3652
+ pos.z -= e.z;
3653
+ if (e.a !== 0) {
3654
+ ref.object3D.rotation.z -= e.a;
3655
+ }
3656
+ if (!isIdentityQuat(e.q)) {
3657
+ const inv = quatInvert(e.q);
3658
+ const cur = ref.object3D.quaternion;
3659
+ const r = quatMultiply(inv, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
3660
+ cur.set(r.x, r.y, r.z, r.w);
3661
+ }
3662
+ }
3663
+ }
3664
+ appliedErrorsRef.current.clear();
3665
+ }, []);
3666
+ const setInput = (0, import_react8.useCallback)((input) => {
3667
+ predictionSyncRef.current?.setInput(input);
3668
+ }, []);
3159
3669
  (0, import_react8.useEffect)(() => {
3160
3670
  const transport = networkManager.transport;
3161
3671
  if (!transport) return;
@@ -3184,15 +3694,30 @@ function useMultiplayer(options = {}) {
3184
3694
  );
3185
3695
  }
3186
3696
  if (mode === "prediction") {
3697
+ const driver = {
3698
+ captureState: () => buildEntityMap(actorRegistry.current.getNetworked(), is2DRef.current ?? true),
3699
+ applyState: (entities) => {
3700
+ const is2D = is2DRef.current ?? true;
3701
+ for (const state of entities) {
3702
+ if (state.c && state.c.__removed) continue;
3703
+ const ref = actorRegistry.current.get(state.id);
3704
+ if (!ref) continue;
3705
+ if (is2D) applyStateHard2D(ref, state);
3706
+ else applyStateHard3D(ref, state);
3707
+ }
3708
+ },
3709
+ ...options.stepWorld ? { stepWorld: options.stepWorld } : {}
3710
+ };
3187
3711
  predictionSyncRef.current = new PredictionSync(
3188
3712
  transport,
3189
- networkManager.codec,
3190
3713
  networkManager.tickKeeper,
3714
+ snapshotSyncRef.current,
3191
3715
  options.prediction
3192
3716
  );
3193
3717
  if (options.onPhysicsStep) {
3194
3718
  predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
3195
3719
  }
3720
+ predictionSyncRef.current.setWorldDriver(driver);
3196
3721
  }
3197
3722
  wasHostRef.current = networkManager.isHost;
3198
3723
  setIsActive(true);
@@ -3221,9 +3746,10 @@ function useMultiplayer(options = {}) {
3221
3746
  snapshotSyncRef.current = null;
3222
3747
  predictionSyncRef.current = null;
3223
3748
  networkSimulatorRef.current = null;
3749
+ appliedErrorsRef.current.clear();
3224
3750
  setIsActive(false);
3225
3751
  };
3226
- }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
3752
+ }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.stepWorld, options.debug]);
3227
3753
  (0, import_react8.useEffect)(() => {
3228
3754
  const unsub = networkManager.onConnectionStateChange(() => {
3229
3755
  setNetworkQuality(networkManager.networkQuality);
@@ -3243,33 +3769,67 @@ function useMultiplayer(options = {}) {
3243
3769
  }
3244
3770
  const is2D = is2DRef.current ?? true;
3245
3771
  if (mode === "events") return;
3772
+ if (mode === "prediction" && predictionSyncRef.current) {
3773
+ undoAppliedErrorOffsets();
3774
+ predictionSyncRef.current.beginFrame();
3775
+ }
3246
3776
  const ticksThisFrame = tickKeeper.update(delta);
3247
3777
  for (let i = 0; i < ticksThisFrame; i++) {
3248
3778
  const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
3249
- if (isHost) {
3779
+ if (mode === "prediction" && predictionSyncRef.current) {
3780
+ predictionSyncRef.current.tick(currentTick);
3781
+ if (isHost) {
3782
+ const entities = buildEntityMap(actorRegistry.current.getNetworked(), is2D);
3783
+ snapshotSyncRef.current?.hostTick(
3784
+ currentTick,
3785
+ entities,
3786
+ tickKeeper.tickDelta,
3787
+ predictionSyncRef.current.getLocalInput(currentTick)
3788
+ );
3789
+ }
3790
+ } else if (mode === "snapshot" && isHost) {
3250
3791
  const networked = actorRegistry.current.getNetworked();
3251
3792
  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
- }
3793
+ snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
3262
3794
  }
3263
3795
  }
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);
3796
+ if (!isHost && mode === "snapshot" && snapshotSyncRef.current) {
3797
+ const renderTime = performance.now();
3798
+ const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
3799
+ applyStatesToActors(interpolated, actorRegistry.current, is2D);
3800
+ }
3801
+ if (mode === "prediction" && predictionSyncRef.current) {
3802
+ const offsets = predictionSyncRef.current.getRenderErrorOffsets();
3803
+ for (const [id, off] of offsets) {
3804
+ const ref = actorRegistry.current.get(id);
3805
+ if (!ref) continue;
3806
+ const pos = ref.object3D.position;
3807
+ pos.x += off.x;
3808
+ pos.y += off.y;
3809
+ if (!is2D) pos.z += off.z;
3810
+ let appliedA = 0;
3811
+ let appliedQ = IDENTITY_QUAT2;
3812
+ if (is2D) {
3813
+ ref.object3D.rotation.z += off.a;
3814
+ appliedA = off.a;
3815
+ } else {
3816
+ appliedQ = { x: off.qx, y: off.qy, z: off.qz, w: off.qw };
3817
+ if (!isIdentityQuat(appliedQ)) {
3818
+ const cur = ref.object3D.quaternion;
3819
+ const r = quatMultiply(appliedQ, { x: cur.x, y: cur.y, z: cur.z, w: cur.w });
3820
+ cur.set(r.x, r.y, r.z, r.w);
3821
+ }
3822
+ }
3823
+ appliedErrorsRef.current.set(id, {
3824
+ x: off.x,
3825
+ y: off.y,
3826
+ z: is2D ? 0 : off.z,
3827
+ a: appliedA,
3828
+ q: appliedQ,
3829
+ px: pos.x,
3830
+ py: pos.y,
3831
+ pz: pos.z
3832
+ });
3273
3833
  }
3274
3834
  }
3275
3835
  if (ticksThisFrame > 0) {
@@ -3289,7 +3849,8 @@ function useMultiplayer(options = {}) {
3289
3849
  tick,
3290
3850
  serverTick,
3291
3851
  drift,
3292
- syncEngine: mode
3852
+ syncEngine: mode,
3853
+ setInput
3293
3854
  };
3294
3855
  }
3295
3856
 
@@ -3801,11 +4362,13 @@ var InterestManager = class {
3801
4362
  0 && (module.exports = {
3802
4363
  DebugOverlay,
3803
4364
  FirebaseStrategy,
4365
+ InputBuffer,
3804
4366
  InterestManager,
3805
4367
  MqttStrategy,
3806
4368
  MultiplayerBridge,
3807
4369
  MultiplayerProvider,
3808
4370
  NetworkSimulator,
4371
+ computeJustPressed,
3809
4372
  useHost,
3810
4373
  useLobby,
3811
4374
  useMultiplayer,