@carverjs/multiplayer 0.0.1

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/dist/NetworkManager-DrKM2tEx.d.mts +369 -0
  2. package/dist/NetworkManager-nvVAOr1O.d.ts +369 -0
  3. package/dist/chunk-3KT73N2S.mjs +655 -0
  4. package/dist/chunk-3KT73N2S.mjs.map +1 -0
  5. package/dist/chunk-EO3YNPRQ.mjs +817 -0
  6. package/dist/chunk-EO3YNPRQ.mjs.map +1 -0
  7. package/dist/chunk-UD6FDZMX.mjs +581 -0
  8. package/dist/chunk-UD6FDZMX.mjs.map +1 -0
  9. package/dist/firebase-CPu87KA0.d.ts +100 -0
  10. package/dist/firebase-PE6MxGdJ.d.mts +100 -0
  11. package/dist/index.d.mts +316 -0
  12. package/dist/index.d.ts +316 -0
  13. package/dist/index.js +3817 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/index.mjs +1743 -0
  16. package/dist/index.mjs.map +1 -0
  17. package/dist/strategy.d.mts +7 -0
  18. package/dist/strategy.d.ts +7 -0
  19. package/dist/strategy.js +619 -0
  20. package/dist/strategy.js.map +1 -0
  21. package/dist/strategy.mjs +11 -0
  22. package/dist/strategy.mjs.map +1 -0
  23. package/dist/sync.d.mts +212 -0
  24. package/dist/sync.d.ts +212 -0
  25. package/dist/sync.js +845 -0
  26. package/dist/sync.js.map +1 -0
  27. package/dist/sync.mjs +11 -0
  28. package/dist/sync.mjs.map +1 -0
  29. package/dist/transport.d.mts +159 -0
  30. package/dist/transport.d.ts +159 -0
  31. package/dist/transport.js +1274 -0
  32. package/dist/transport.js.map +1 -0
  33. package/dist/transport.mjs +19 -0
  34. package/dist/transport.mjs.map +1 -0
  35. package/dist/types-5LHBOW08.d.mts +74 -0
  36. package/dist/types-5LHBOW08.d.ts +74 -0
  37. package/dist/types.d.mts +2 -0
  38. package/dist/types.d.ts +2 -0
  39. package/dist/types.js +19 -0
  40. package/dist/types.js.map +1 -0
  41. package/dist/types.mjs +1 -0
  42. package/dist/types.mjs.map +1 -0
  43. package/package.json +73 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,1743 @@
1
+ import {
2
+ WebRTCTransport
3
+ } from "./chunk-3KT73N2S.mjs";
4
+ import {
5
+ FirebaseStrategy,
6
+ MqttStrategy
7
+ } from "./chunk-UD6FDZMX.mjs";
8
+ import {
9
+ EventSync,
10
+ PredictionSync,
11
+ SnapshotSync
12
+ } from "./chunk-EO3YNPRQ.mjs";
13
+
14
+ // src/components/MultiplayerProvider.ts
15
+ import { createElement, useRef, useEffect } from "react";
16
+
17
+ // src/core/TickKeeper.ts
18
+ var _TickKeeper = class _TickKeeper {
19
+ constructor(tickRate = 60) {
20
+ this._accumulator = 0;
21
+ this._tick = 0;
22
+ this._serverTick = 0;
23
+ this._alpha = 0;
24
+ this._timeScale = 1;
25
+ this._tickRate = tickRate;
26
+ this._tickDelta = 1 / tickRate;
27
+ }
28
+ get tick() {
29
+ return this._tick;
30
+ }
31
+ get serverTick() {
32
+ return this._serverTick;
33
+ }
34
+ get tickDelta() {
35
+ return this._tickDelta;
36
+ }
37
+ get tickRate() {
38
+ return this._tickRate;
39
+ }
40
+ /** Interpolation alpha for rendering between ticks (0-1) */
41
+ get alpha() {
42
+ return this._alpha;
43
+ }
44
+ /** Current time scale (affected by drift correction) */
45
+ get timeScale() {
46
+ return this._timeScale;
47
+ }
48
+ /** Ticks ahead of server (positive = ahead, negative = behind) */
49
+ get drift() {
50
+ return this._tick - this._serverTick;
51
+ }
52
+ /** Update server tick from received snapshot */
53
+ setServerTick(serverTick) {
54
+ this._serverTick = serverTick;
55
+ this._updateDriftCorrection();
56
+ }
57
+ /**
58
+ * Accumulate time and return the number of fixed ticks to process.
59
+ * Call this once per render frame with the raw frame delta.
60
+ */
61
+ update(rawDelta) {
62
+ const maxDelta = this._tickDelta * 8;
63
+ const delta = Math.min(rawDelta, maxDelta) * this._timeScale;
64
+ this._accumulator += delta;
65
+ let ticksThisFrame = 0;
66
+ while (this._accumulator >= this._tickDelta) {
67
+ this._accumulator -= this._tickDelta;
68
+ this._tick++;
69
+ ticksThisFrame++;
70
+ }
71
+ this._alpha = this._accumulator / this._tickDelta;
72
+ return ticksThisFrame;
73
+ }
74
+ /** Reset to initial state */
75
+ reset() {
76
+ this._accumulator = 0;
77
+ this._tick = 0;
78
+ this._serverTick = 0;
79
+ this._alpha = 0;
80
+ this._timeScale = 1;
81
+ }
82
+ /** Set tick rate (updates tickDelta accordingly) */
83
+ setTickRate(rate) {
84
+ this._tickRate = rate;
85
+ this._tickDelta = 1 / rate;
86
+ }
87
+ _updateDriftCorrection() {
88
+ const drift = this.drift;
89
+ if (drift < _TickKeeper.DRIFT_BEHIND_THRESHOLD) {
90
+ this._timeScale = _TickKeeper.SPEED_UP_SCALE;
91
+ } else if (drift > _TickKeeper.DRIFT_AHEAD_THRESHOLD) {
92
+ this._timeScale = _TickKeeper.SLOW_DOWN_SCALE;
93
+ } else {
94
+ this._timeScale = _TickKeeper.NORMAL_SCALE;
95
+ }
96
+ }
97
+ };
98
+ // Drift correction zones
99
+ _TickKeeper.DRIFT_BEHIND_THRESHOLD = -5;
100
+ _TickKeeper.DRIFT_AHEAD_THRESHOLD = 5;
101
+ _TickKeeper.SPEED_UP_SCALE = 1.5;
102
+ _TickKeeper.SLOW_DOWN_SCALE = 0.1;
103
+ _TickKeeper.NORMAL_SCALE = 1;
104
+ var TickKeeper = _TickKeeper;
105
+
106
+ // src/core/codec.ts
107
+ import { pack, unpack } from "msgpackr";
108
+ var DEFAULT_THRESHOLDS = {
109
+ position: 0.01,
110
+ rotation: 1e-3,
111
+ velocity: 0.05,
112
+ custom: "strict"
113
+ };
114
+ var SnapshotBuffer = class {
115
+ constructor(capacity = 120) {
116
+ this._capacity = capacity;
117
+ this._buffer = /* @__PURE__ */ new Map();
118
+ }
119
+ /** Store a snapshot at the given tick */
120
+ store(tick, entities) {
121
+ this._buffer.set(tick, entities);
122
+ if (this._buffer.size > this._capacity) {
123
+ const sortedTicks = Array.from(this._buffer.keys()).sort((a, b) => a - b);
124
+ const toRemove = sortedTicks.length - this._capacity;
125
+ for (let i = 0; i < toRemove; i++) {
126
+ this._buffer.delete(sortedTicks[i]);
127
+ }
128
+ }
129
+ }
130
+ /** Get a snapshot at the given tick */
131
+ get(tick) {
132
+ return this._buffer.get(tick);
133
+ }
134
+ /** Clear all stored snapshots */
135
+ clear() {
136
+ this._buffer.clear();
137
+ }
138
+ };
139
+ var Codec = class {
140
+ constructor(options) {
141
+ this._thresholds = { ...DEFAULT_THRESHOLDS, ...options?.thresholds };
142
+ this._quantize = options?.quantize;
143
+ this._is2D = options?.is2D ?? false;
144
+ }
145
+ /** Serialize entity states to binary (msgpackr) */
146
+ serialize(entities) {
147
+ const quantized = this._quantize ? entities.map((e) => this._quantizeEntity(e)) : entities;
148
+ return pack(quantized);
149
+ }
150
+ /** Deserialize binary to entity states */
151
+ deserialize(data) {
152
+ return unpack(data);
153
+ }
154
+ /**
155
+ * Compute delta: only include entities that changed beyond thresholds
156
+ * since the baseline snapshot.
157
+ * Returns null if nothing changed.
158
+ */
159
+ computeDelta(current, baseline) {
160
+ if (!baseline) {
161
+ return Array.from(current.values());
162
+ }
163
+ const changed = [];
164
+ for (const [id, entity] of current) {
165
+ const prev = baseline.get(id);
166
+ if (!prev || this._hasChanged(entity, prev)) {
167
+ changed.push(entity);
168
+ }
169
+ }
170
+ for (const id of baseline.keys()) {
171
+ if (!current.has(id)) {
172
+ if (this._is2D) {
173
+ changed.push({ id, x: 0, y: 0, a: 0, vx: 0, vy: 0, va: 0, c: { __removed: true } });
174
+ } else {
175
+ changed.push({ id, x: 0, y: 0, z: 0, qx: 0, qy: 0, qz: 0, qw: 1, vx: 0, vy: 0, vz: 0, wx: 0, wy: 0, wz: 0, c: { __removed: true } });
176
+ }
177
+ }
178
+ }
179
+ return changed.length > 0 ? changed : null;
180
+ }
181
+ /** Serialize a delta snapshot packet */
182
+ serializeDelta(tick, baseTick, current, baseline) {
183
+ const delta = this.computeDelta(current, baseline);
184
+ if (!delta) return null;
185
+ const packet = {
186
+ t: tick,
187
+ b: baseline ? baseTick : -1,
188
+ // -1 = keyframe
189
+ s: this.serialize(delta)
190
+ };
191
+ return pack(packet);
192
+ }
193
+ /** Deserialize a snapshot packet */
194
+ deserializePacket(data) {
195
+ const packet = unpack(data);
196
+ return {
197
+ tick: packet.t,
198
+ baseTick: packet.b,
199
+ entities: this.deserialize(packet.s)
200
+ };
201
+ }
202
+ _hasChanged(current, prev) {
203
+ const t = this._thresholds;
204
+ if (Math.abs(current.x - prev.x) > t.position) return true;
205
+ if (Math.abs(current.y - prev.y) > t.position) return true;
206
+ if ("z" in current && "z" in prev) {
207
+ const c = current;
208
+ const p = prev;
209
+ if (Math.abs(c.z - p.z) > t.position) return true;
210
+ if (Math.abs(c.qx - p.qx) > t.rotation) return true;
211
+ if (Math.abs(c.qy - p.qy) > t.rotation) return true;
212
+ if (Math.abs(c.qz - p.qz) > t.rotation) return true;
213
+ if (Math.abs(c.qw - p.qw) > t.rotation) return true;
214
+ if (Math.abs(c.vx - p.vx) > t.velocity) return true;
215
+ if (Math.abs(c.vy - p.vy) > t.velocity) return true;
216
+ if (Math.abs(c.vz - p.vz) > t.velocity) return true;
217
+ if (Math.abs(c.wx - p.wx) > t.velocity) return true;
218
+ if (Math.abs(c.wy - p.wy) > t.velocity) return true;
219
+ if (Math.abs(c.wz - p.wz) > t.velocity) return true;
220
+ } else {
221
+ const c = current;
222
+ const p = prev;
223
+ if (Math.abs(c.a - p.a) > t.rotation) return true;
224
+ if (Math.abs(c.vx - p.vx) > t.velocity) return true;
225
+ if (Math.abs(c.vy - p.vy) > t.velocity) return true;
226
+ if (Math.abs(c.va - p.va) > t.velocity) return true;
227
+ }
228
+ if (current.c || prev.c) {
229
+ const cc = current.c ?? {};
230
+ const pc = prev.c ?? {};
231
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(cc), ...Object.keys(pc)]);
232
+ for (const key of allKeys) {
233
+ if (t.custom === "strict") {
234
+ if (cc[key] !== pc[key]) return true;
235
+ } else {
236
+ const diff = typeof cc[key] === "number" && typeof pc[key] === "number" ? Math.abs(cc[key] - pc[key]) : cc[key] === pc[key] ? 0 : 1;
237
+ if (diff > t.custom) return true;
238
+ }
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+ _quantizeEntity(entity) {
244
+ const q = this._quantize;
245
+ const result = { ...entity };
246
+ if (q.position !== void 0) {
247
+ const m = Math.pow(10, q.position);
248
+ result.x = Math.round(result.x * m) / m;
249
+ result.y = Math.round(result.y * m) / m;
250
+ if ("z" in result) {
251
+ result.z = Math.round(result.z * m) / m;
252
+ }
253
+ }
254
+ if (q.rotation !== void 0) {
255
+ const m = Math.pow(10, q.rotation);
256
+ if ("a" in result) {
257
+ result.a = Math.round(result.a * m) / m;
258
+ } else if ("qx" in result) {
259
+ const r = result;
260
+ r.qx = Math.round(r.qx * m) / m;
261
+ r.qy = Math.round(r.qy * m) / m;
262
+ r.qz = Math.round(r.qz * m) / m;
263
+ r.qw = Math.round(r.qw * m) / m;
264
+ }
265
+ }
266
+ if (q.velocity !== void 0) {
267
+ const m = Math.pow(10, q.velocity);
268
+ result.vx = Math.round(result.vx * m) / m;
269
+ result.vy = Math.round(result.vy * m) / m;
270
+ if ("vz" in result) {
271
+ const r = result;
272
+ r.vz = Math.round(r.vz * m) / m;
273
+ r.wx = Math.round(r.wx * m) / m;
274
+ r.wy = Math.round(r.wy * m) / m;
275
+ r.wz = Math.round(r.wz * m) / m;
276
+ }
277
+ if ("va" in result) {
278
+ result.va = Math.round(result.va * m) / m;
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ };
284
+
285
+ // src/core/NetworkManager.ts
286
+ var NetworkManager = class {
287
+ constructor(options = {}) {
288
+ // Transport
289
+ this._transport = null;
290
+ this._connectionState = "disconnected";
291
+ // Room state
292
+ this._room = null;
293
+ this._players = /* @__PURE__ */ new Map();
294
+ // Sync state
295
+ this._syncMode = "snapshot";
296
+ this._networkQuality = "good";
297
+ // Change listeners
298
+ this._connectionListeners = [];
299
+ this._playerListeners = [];
300
+ this._roomListeners = [];
301
+ this._errorListeners = [];
302
+ this._options = options;
303
+ this._syncMode = options.mode ?? "snapshot";
304
+ this._tickKeeper = new TickKeeper(options.tickRate ?? 60);
305
+ this._codec = new Codec({
306
+ thresholds: options.deltaThresholds,
307
+ quantize: options.quantize
308
+ });
309
+ this._snapshotBuffer = new SnapshotBuffer();
310
+ }
311
+ // -- Getters --
312
+ get transport() {
313
+ return this._transport;
314
+ }
315
+ get connectionState() {
316
+ return this._connectionState;
317
+ }
318
+ get room() {
319
+ return this._room;
320
+ }
321
+ get players() {
322
+ return this._players;
323
+ }
324
+ get selfId() {
325
+ return this._transport?.peerId || null;
326
+ }
327
+ get isHost() {
328
+ return this._transport?.isHost ?? false;
329
+ }
330
+ get hostId() {
331
+ return this._transport?.hostId ?? null;
332
+ }
333
+ get syncMode() {
334
+ return this._syncMode;
335
+ }
336
+ get tickKeeper() {
337
+ return this._tickKeeper;
338
+ }
339
+ get codec() {
340
+ return this._codec;
341
+ }
342
+ get snapshotBuffer() {
343
+ return this._snapshotBuffer;
344
+ }
345
+ get networkQuality() {
346
+ return this._networkQuality;
347
+ }
348
+ get options() {
349
+ return this._options;
350
+ }
351
+ // -- Transport management --
352
+ setTransport(transport) {
353
+ this._transport = transport;
354
+ transport.onPeerJoin((peerId) => {
355
+ if (!this._players.has(peerId)) {
356
+ this._players.set(peerId, {
357
+ peerId,
358
+ displayName: `Player-${peerId.slice(0, 4)}`,
359
+ isHost: peerId === transport.hostId,
360
+ isSelf: false,
361
+ isReady: false,
362
+ isConnected: true,
363
+ metadata: {},
364
+ latencyMs: 0,
365
+ joinedAt: Date.now()
366
+ });
367
+ }
368
+ this._notifyPlayerListeners();
369
+ });
370
+ transport.onPeerUpdated((player) => {
371
+ this._players.set(player.peerId, {
372
+ ...player,
373
+ isSelf: player.peerId === this.selfId
374
+ });
375
+ this._notifyPlayerListeners();
376
+ });
377
+ transport.onPeerLeave((peerId) => {
378
+ this._players.delete(peerId);
379
+ this._notifyPlayerListeners();
380
+ });
381
+ transport.onHostChanged((_newHostId) => {
382
+ this._notifyRoomListeners();
383
+ });
384
+ }
385
+ // -- Connection state --
386
+ setConnectionState(state) {
387
+ this._connectionState = state;
388
+ for (const listener of this._connectionListeners) listener(state);
389
+ }
390
+ onConnectionStateChange(cb) {
391
+ this._connectionListeners.push(cb);
392
+ return () => {
393
+ const idx = this._connectionListeners.indexOf(cb);
394
+ if (idx >= 0) this._connectionListeners.splice(idx, 1);
395
+ };
396
+ }
397
+ // -- Room state --
398
+ setRoom(room) {
399
+ this._room = room;
400
+ this._notifyRoomListeners();
401
+ }
402
+ onRoomChange(cb) {
403
+ this._roomListeners.push(cb);
404
+ return () => {
405
+ const idx = this._roomListeners.indexOf(cb);
406
+ if (idx >= 0) this._roomListeners.splice(idx, 1);
407
+ };
408
+ }
409
+ // -- Players --
410
+ setPlayers(players) {
411
+ this._players.clear();
412
+ for (const p of players) {
413
+ this._players.set(p.peerId, p);
414
+ }
415
+ this._notifyPlayerListeners();
416
+ }
417
+ updatePlayer(player) {
418
+ this._players.set(player.peerId, player);
419
+ this._notifyPlayerListeners();
420
+ }
421
+ removePlayer(peerId) {
422
+ this._players.delete(peerId);
423
+ this._notifyPlayerListeners();
424
+ }
425
+ onPlayersChange(cb) {
426
+ this._playerListeners.push(cb);
427
+ return () => {
428
+ const idx = this._playerListeners.indexOf(cb);
429
+ if (idx >= 0) this._playerListeners.splice(idx, 1);
430
+ };
431
+ }
432
+ // -- Errors --
433
+ emitError(error) {
434
+ for (const listener of this._errorListeners) listener(error);
435
+ }
436
+ onError(cb) {
437
+ this._errorListeners.push(cb);
438
+ return () => {
439
+ const idx = this._errorListeners.indexOf(cb);
440
+ if (idx >= 0) this._errorListeners.splice(idx, 1);
441
+ };
442
+ }
443
+ // -- Network quality --
444
+ setNetworkQuality(quality) {
445
+ this._networkQuality = quality;
446
+ }
447
+ // -- Sync options --
448
+ updateOptions(options) {
449
+ this._options = options;
450
+ if (options.mode) this._syncMode = options.mode;
451
+ if (options.tickRate) this._tickKeeper.setTickRate(options.tickRate);
452
+ if (options.deltaThresholds || options.quantize) {
453
+ this._codec = new Codec({
454
+ thresholds: options.deltaThresholds,
455
+ quantize: options.quantize
456
+ });
457
+ }
458
+ }
459
+ // -- Cleanup --
460
+ destroy() {
461
+ this._transport?.disconnect();
462
+ this._transport = null;
463
+ this._connectionState = "disconnected";
464
+ this._room = null;
465
+ this._players.clear();
466
+ this._tickKeeper.reset();
467
+ this._snapshotBuffer.clear();
468
+ this._connectionListeners = [];
469
+ this._playerListeners = [];
470
+ this._roomListeners = [];
471
+ this._errorListeners = [];
472
+ }
473
+ // -- Private --
474
+ _notifyPlayerListeners() {
475
+ for (const listener of this._playerListeners) listener();
476
+ }
477
+ _notifyRoomListeners() {
478
+ for (const listener of this._roomListeners) listener();
479
+ }
480
+ };
481
+
482
+ // src/core/MultiplayerContext.ts
483
+ import { createContext, useContext } from "react";
484
+ var MultiplayerContext = createContext(null);
485
+ function useMultiplayerContext() {
486
+ const ctx = useContext(MultiplayerContext);
487
+ if (!ctx) {
488
+ throw new Error("useMultiplayerContext must be used inside a <MultiplayerProvider>.");
489
+ }
490
+ return ctx;
491
+ }
492
+
493
+ // src/components/MultiplayerProvider.ts
494
+ function createStrategy(appId, config) {
495
+ if (!config || config.type === "mqtt") {
496
+ return new MqttStrategy(appId, config ?? { type: "mqtt" });
497
+ }
498
+ if (config.type === "firebase") {
499
+ return new FirebaseStrategy(appId, config);
500
+ }
501
+ throw new Error(`Unknown strategy type: ${config.type}`);
502
+ }
503
+ function MultiplayerProvider({
504
+ appId,
505
+ strategy: strategyConfig,
506
+ iceServers,
507
+ children
508
+ }) {
509
+ const managerRef = useRef(null);
510
+ const strategyRef = useRef(null);
511
+ if (!managerRef.current) {
512
+ managerRef.current = new NetworkManager();
513
+ }
514
+ if (!strategyRef.current) {
515
+ strategyRef.current = createStrategy(appId, strategyConfig);
516
+ }
517
+ useEffect(() => {
518
+ return () => {
519
+ strategyRef.current?.destroy();
520
+ strategyRef.current = null;
521
+ managerRef.current?.destroy();
522
+ managerRef.current = null;
523
+ };
524
+ }, []);
525
+ const value = {
526
+ appId,
527
+ strategy: strategyRef.current,
528
+ iceServers,
529
+ networkManager: managerRef.current
530
+ };
531
+ return createElement(MultiplayerContext.Provider, { value }, children);
532
+ }
533
+
534
+ // src/components/MultiplayerBridge.ts
535
+ import { createElement as createElement2, useContext as useContext2 } from "react";
536
+ function MultiplayerBridge({ children }) {
537
+ const ctx = useContext2(MultiplayerContext);
538
+ if (!ctx) return createElement2("group", null, children);
539
+ return createElement2(MultiplayerContext.Provider, { value: ctx }, children);
540
+ }
541
+
542
+ // src/hooks/useRoom.ts
543
+ import { useState, useEffect as useEffect2, useCallback, useRef as useRef2 } from "react";
544
+ function useRoom(roomId, options) {
545
+ const { strategy, iceServers, networkManager } = useMultiplayerContext();
546
+ const [connectionState, setConnectionState] = useState("disconnected");
547
+ const [isHost, setIsHost] = useState(false);
548
+ const [hostId, setHostId] = useState(null);
549
+ const [selfId, setSelfId] = useState(null);
550
+ const [currentRoomId, setCurrentRoomId] = useState(null);
551
+ const [error, setError] = useState(null);
552
+ const [transport, setTransport] = useState(null);
553
+ const [room, setRoom] = useState(null);
554
+ const reconnectAttemptsRef = useRef2(0);
555
+ const maxReconnectAttempts = options?.reconnectAttempts ?? 3;
556
+ const optionsRef = useRef2(options);
557
+ optionsRef.current = options;
558
+ const createTransport = useCallback(() => {
559
+ const opt = optionsRef.current;
560
+ if (opt?.transport && typeof opt.transport === "object" && "connect" in opt.transport) {
561
+ return opt.transport;
562
+ }
563
+ const servers = opt?.iceServers ?? iceServers;
564
+ const policy = opt?.privacy === "relay" ? "relay" : "all";
565
+ return new WebRTCTransport(strategy, servers, policy);
566
+ }, [strategy, iceServers]);
567
+ const transportRef = useRef2(null);
568
+ const doJoin = useCallback(async (targetRoomId, joinOptions) => {
569
+ if (transportRef.current) {
570
+ transportRef.current.disconnect();
571
+ transportRef.current = null;
572
+ }
573
+ const t = createTransport();
574
+ transportRef.current = t;
575
+ try {
576
+ setError(null);
577
+ setConnectionState("connecting");
578
+ networkManager.setConnectionState("connecting");
579
+ setTransport(t);
580
+ networkManager.setTransport(t);
581
+ t.onPeerJoin(() => {
582
+ });
583
+ t.onPeerLeave(() => {
584
+ });
585
+ t.onHostChanged((newHostId) => {
586
+ setHostId(newHostId);
587
+ setIsHost(t.peerId === newHostId);
588
+ optionsRef.current?.onHostMigration?.(newHostId);
589
+ });
590
+ if ("onRoomUpdated" in t && typeof t.onRoomUpdated === "function") {
591
+ t.onRoomUpdated((updatedRoom) => {
592
+ networkManager.setRoom(updatedRoom);
593
+ });
594
+ }
595
+ await t.connect(targetRoomId, {
596
+ displayName: optionsRef.current?.displayName,
597
+ playerMetadata: optionsRef.current?.playerMetadata,
598
+ password: joinOptions?.password ?? optionsRef.current?.password,
599
+ iceServers: optionsRef.current?.iceServers,
600
+ iceTransportPolicy: optionsRef.current?.privacy === "relay" ? "relay" : "all"
601
+ });
602
+ if (transportRef.current !== t) return;
603
+ setCurrentRoomId(targetRoomId);
604
+ setSelfId(t.peerId);
605
+ setHostId(t.hostId);
606
+ setIsHost(t.isHost);
607
+ setConnectionState("connected");
608
+ networkManager.setConnectionState("connected");
609
+ if (t.room) {
610
+ networkManager.setRoom(t.room);
611
+ }
612
+ if (t.initialPlayers) {
613
+ const players = t.initialPlayers.map((p) => ({
614
+ ...p,
615
+ isSelf: p.peerId === t.peerId
616
+ }));
617
+ networkManager.setPlayers(players);
618
+ }
619
+ reconnectAttemptsRef.current = 0;
620
+ optionsRef.current?.onConnected?.();
621
+ } catch (err) {
622
+ if (transportRef.current !== t) return;
623
+ const carverError = {
624
+ code: "CONNECTION_FAILED",
625
+ message: err instanceof Error ? err.message : "Connection failed",
626
+ recoverable: reconnectAttemptsRef.current < maxReconnectAttempts
627
+ };
628
+ setError(carverError);
629
+ networkManager.emitError(carverError);
630
+ setConnectionState("disconnected");
631
+ networkManager.setConnectionState("disconnected");
632
+ optionsRef.current?.onError?.(carverError);
633
+ }
634
+ }, [createTransport, networkManager, maxReconnectAttempts]);
635
+ const leave = useCallback(() => {
636
+ if (transportRef.current) {
637
+ transportRef.current.disconnect();
638
+ transportRef.current = null;
639
+ }
640
+ setTransport(null);
641
+ setConnectionState("disconnected");
642
+ setCurrentRoomId(null);
643
+ setSelfId(null);
644
+ setHostId(null);
645
+ setIsHost(false);
646
+ setError(null);
647
+ networkManager.setConnectionState("disconnected");
648
+ optionsRef.current?.onDisconnected?.("user_left");
649
+ }, [networkManager]);
650
+ const setReady = useCallback((ready) => {
651
+ transport?.setReady?.(ready);
652
+ const selfPlayer = networkManager.players.get(transport?.peerId ?? "");
653
+ if (selfPlayer) {
654
+ networkManager.updatePlayer({ ...selfPlayer, isReady: ready });
655
+ }
656
+ }, [transport, networkManager]);
657
+ const setMetadata = useCallback((meta) => {
658
+ transport?.setMetadata?.(meta);
659
+ }, [transport]);
660
+ const setRoomMetadata = useCallback((meta) => {
661
+ transport?.setRoomMetadata?.(meta);
662
+ }, [transport]);
663
+ useEffect2(() => {
664
+ if (roomId) {
665
+ doJoin(roomId);
666
+ }
667
+ return () => {
668
+ if (transportRef.current) {
669
+ transportRef.current.disconnect();
670
+ transportRef.current = null;
671
+ }
672
+ };
673
+ }, [roomId, doJoin]);
674
+ useEffect2(() => {
675
+ const unsub = networkManager.onRoomChange(() => {
676
+ setRoom(networkManager.room);
677
+ });
678
+ setRoom(networkManager.room);
679
+ return unsub;
680
+ }, [networkManager]);
681
+ return {
682
+ roomId: currentRoomId,
683
+ connectionState,
684
+ isHost,
685
+ hostId,
686
+ selfId,
687
+ room,
688
+ error,
689
+ join: doJoin,
690
+ leave,
691
+ setReady,
692
+ setMetadata,
693
+ setRoomMetadata,
694
+ transport
695
+ };
696
+ }
697
+
698
+ // src/hooks/useLobby.ts
699
+ import { useState as useState2, useEffect as useEffect3, useCallback as useCallback2, useRef as useRef3 } from "react";
700
+ function announcementToRoom(ann) {
701
+ return {
702
+ id: ann.roomId,
703
+ name: ann.name,
704
+ hostId: ann.hostId,
705
+ playerCount: ann.playerCount,
706
+ maxPlayers: ann.maxPlayers,
707
+ gameMode: ann.gameMode,
708
+ isPrivate: ann.isPrivate,
709
+ metadata: ann.metadata,
710
+ createdAt: ann.createdAt,
711
+ state: "lobby"
712
+ };
713
+ }
714
+ function useLobby(options) {
715
+ const { strategy } = useMultiplayerContext();
716
+ const [rooms, setRooms] = useState2([]);
717
+ const [isLoading, setIsLoading] = useState2(true);
718
+ const [error, setError] = useState2(null);
719
+ const optionsRef = useRef3(options);
720
+ optionsRef.current = options;
721
+ const filterRooms = useCallback2((roomList) => {
722
+ const filter = optionsRef.current?.filter;
723
+ if (!filter) return roomList;
724
+ return roomList.filter((room) => {
725
+ if (filter.maxPlayers !== void 0 && room.maxPlayers > filter.maxPlayers) return false;
726
+ if (filter.gameMode !== void 0 && room.gameMode !== filter.gameMode) return false;
727
+ if (filter.hasPassword !== void 0 && room.isPrivate !== filter.hasPassword) return false;
728
+ return true;
729
+ });
730
+ }, []);
731
+ useEffect3(() => {
732
+ setIsLoading(true);
733
+ setError(null);
734
+ const unsub = strategy.subscribeToLobby((announcements) => {
735
+ const converted = announcements.map(announcementToRoom);
736
+ setRooms(filterRooms(converted));
737
+ setIsLoading(false);
738
+ });
739
+ const timeout = setTimeout(() => setIsLoading(false), 3e3);
740
+ return () => {
741
+ unsub();
742
+ clearTimeout(timeout);
743
+ };
744
+ }, [strategy, filterRooms]);
745
+ const refresh = useCallback2(() => {
746
+ setIsLoading(true);
747
+ setTimeout(() => setIsLoading(false), 1e3);
748
+ }, []);
749
+ const createRoom = useCallback2(async (config) => {
750
+ const roomId = `${config.name.toLowerCase().replace(/\s+/g, "-")}-${Date.now().toString(36)}`;
751
+ const announcement = {
752
+ roomId,
753
+ name: config.name,
754
+ hostId: strategy.selfId,
755
+ playerCount: 0,
756
+ maxPlayers: config.maxPlayers ?? 8,
757
+ gameMode: config.metadata?.gameMode,
758
+ isPrivate: config.isPrivate ?? false,
759
+ metadata: config.metadata ?? {},
760
+ createdAt: Date.now(),
761
+ lastSeen: Date.now()
762
+ };
763
+ strategy.announceRoom(announcement);
764
+ return roomId;
765
+ }, [strategy]);
766
+ return {
767
+ rooms,
768
+ isLoading,
769
+ error,
770
+ refresh,
771
+ createRoom
772
+ };
773
+ }
774
+
775
+ // src/hooks/usePlayers.ts
776
+ import { useState as useState3, useEffect as useEffect4, useCallback as useCallback3 } from "react";
777
+ function usePlayers() {
778
+ const { networkManager } = useMultiplayerContext();
779
+ const [players, setPlayers] = useState3([]);
780
+ const [, setVersion] = useState3(0);
781
+ useEffect4(() => {
782
+ const unsubscribe = networkManager.onPlayersChange(() => {
783
+ setPlayers(Array.from(networkManager.players.values()));
784
+ setVersion((v) => v + 1);
785
+ });
786
+ setPlayers(Array.from(networkManager.players.values()));
787
+ return unsubscribe;
788
+ }, [networkManager]);
789
+ const self = players.find((p) => p.isSelf) ?? null;
790
+ const host = players.find((p) => p.isHost) ?? null;
791
+ const allReady = players.length > 0 && players.every((p) => p.isReady);
792
+ const getPlayer = useCallback3(
793
+ (peerId) => players.find((p) => p.peerId === peerId),
794
+ [players]
795
+ );
796
+ return {
797
+ players,
798
+ self,
799
+ host,
800
+ count: players.length,
801
+ allReady,
802
+ getPlayer
803
+ };
804
+ }
805
+
806
+ // src/hooks/useHost.ts
807
+ import { useCallback as useCallback4 } from "react";
808
+ function useHost() {
809
+ const { networkManager } = useMultiplayerContext();
810
+ const getTransport = useCallback4(() => {
811
+ const transport = networkManager.transport;
812
+ if (!transport || !networkManager.isHost) return null;
813
+ return transport;
814
+ }, [networkManager]);
815
+ const kick = useCallback4((peerId, reason) => {
816
+ getTransport()?.kick?.(peerId, reason);
817
+ }, [getTransport]);
818
+ const transferHost = useCallback4((peerId) => {
819
+ getTransport()?.transferHost?.(peerId);
820
+ }, [getTransport]);
821
+ const setRoomState = useCallback4((state) => {
822
+ getTransport()?.setRoomState?.(state);
823
+ }, [getTransport]);
824
+ const setMaxPlayers = useCallback4((n) => {
825
+ getTransport()?.setMaxPlayers?.(n);
826
+ }, [getTransport]);
827
+ const lockRoom = useCallback4(() => {
828
+ getTransport()?.lockRoom?.();
829
+ }, [getTransport]);
830
+ const unlockRoom = useCallback4(() => {
831
+ getTransport()?.unlockRoom?.();
832
+ }, [getTransport]);
833
+ return {
834
+ kick,
835
+ transferHost,
836
+ setRoomState,
837
+ setMaxPlayers,
838
+ lockRoom,
839
+ unlockRoom
840
+ };
841
+ }
842
+
843
+ // src/hooks/useMultiplayer.ts
844
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState4, useCallback as useCallback5 } from "react";
845
+ import { useFrame } from "@react-three/fiber";
846
+ import { getActorRegistry } from "@carverjs/core/systems";
847
+
848
+ // src/core/NetworkSimulator.ts
849
+ var NetworkSimulator = class {
850
+ constructor(options) {
851
+ this.sentCount = 0;
852
+ this.droppedCount = 0;
853
+ this.latencySum = 0;
854
+ // running sum of applied latencies
855
+ /** Active timeout handles so we can cancel them on destroy() */
856
+ this.pending = /* @__PURE__ */ new Set();
857
+ const opts = options ?? {};
858
+ this.latencyMs = opts.latencyMs ?? 0;
859
+ this.packetLoss = opts.packetLoss ?? 0;
860
+ this.jitterMs = opts.jitterMs ?? 0;
861
+ }
862
+ /* ---- public API ---- */
863
+ /** Update simulation parameters at runtime. */
864
+ setOptions(options) {
865
+ if (options.latencyMs !== void 0) this.latencyMs = options.latencyMs;
866
+ if (options.packetLoss !== void 0) this.packetLoss = options.packetLoss;
867
+ if (options.jitterMs !== void 0) this.jitterMs = options.jitterMs;
868
+ }
869
+ /**
870
+ * Wrap an existing send function so that every call goes through the
871
+ * simulated network conditions (latency, jitter, packet loss).
872
+ */
873
+ wrapSend(originalSend) {
874
+ return (data, target) => {
875
+ if (Math.random() < this.packetLoss) {
876
+ this.droppedCount++;
877
+ return;
878
+ }
879
+ this.sentCount++;
880
+ const jitter = this.jitterMs > 0 ? Math.random() * 2 * this.jitterMs - this.jitterMs : 0;
881
+ const delay = Math.max(0, this.latencyMs + jitter);
882
+ this.latencySum += delay;
883
+ if (delay === 0) {
884
+ originalSend(data, target);
885
+ } else {
886
+ const handle = setTimeout(() => {
887
+ this.pending.delete(handle);
888
+ originalSend(data, target);
889
+ }, delay);
890
+ this.pending.add(handle);
891
+ }
892
+ };
893
+ }
894
+ /** Current statistics snapshot. */
895
+ get stats() {
896
+ return {
897
+ sentCount: this.sentCount,
898
+ droppedCount: this.droppedCount,
899
+ avgLatencyMs: this.sentCount > 0 ? this.latencySum / this.sentCount : 0
900
+ };
901
+ }
902
+ /** Cancel all pending delayed sends and clean up. */
903
+ destroy() {
904
+ for (const handle of this.pending) {
905
+ clearTimeout(handle);
906
+ }
907
+ this.pending.clear();
908
+ }
909
+ };
910
+
911
+ // src/hooks/useMultiplayer.ts
912
+ var Z_THRESHOLD = 0.01;
913
+ function detect2D(actors) {
914
+ for (const [, ref] of actors) {
915
+ if (Math.abs(ref.object3D.position.z) > Z_THRESHOLD) return false;
916
+ }
917
+ return true;
918
+ }
919
+ function readEntityState2D(ref) {
920
+ const pos = ref.object3D.position;
921
+ const rot = ref.object3D.rotation;
922
+ const rb = ref.rigidBody;
923
+ let vx = 0;
924
+ let vy = 0;
925
+ let va = 0;
926
+ if (rb) {
927
+ try {
928
+ const lv = rb.linvel();
929
+ vx = lv.x;
930
+ vy = lv.y;
931
+ } catch {
932
+ }
933
+ try {
934
+ const av = rb.angvel();
935
+ va = typeof av === "number" ? av : av?.z ?? 0;
936
+ } catch {
937
+ }
938
+ }
939
+ const nc = ref.userData.networked;
940
+ const custom = nc?.custom;
941
+ return {
942
+ id: ref.id,
943
+ x: pos.x,
944
+ y: pos.y,
945
+ a: rot.z,
946
+ vx,
947
+ vy,
948
+ va,
949
+ ...custom ? { c: custom } : {}
950
+ };
951
+ }
952
+ function readEntityState3D(ref) {
953
+ const pos = ref.object3D.position;
954
+ const quat = ref.object3D.quaternion;
955
+ const rb = ref.rigidBody;
956
+ let vx = 0;
957
+ let vy = 0;
958
+ let vz = 0;
959
+ let wx = 0;
960
+ let wy = 0;
961
+ let wz = 0;
962
+ if (rb) {
963
+ try {
964
+ const lv = rb.linvel();
965
+ vx = lv.x;
966
+ vy = lv.y;
967
+ vz = lv.z ?? 0;
968
+ } catch {
969
+ }
970
+ try {
971
+ const av = rb.angvel();
972
+ wx = av.x ?? 0;
973
+ wy = av.y ?? 0;
974
+ wz = av.z ?? 0;
975
+ } catch {
976
+ }
977
+ }
978
+ const nc = ref.userData.networked;
979
+ const custom = nc?.custom;
980
+ return {
981
+ id: ref.id,
982
+ x: pos.x,
983
+ y: pos.y,
984
+ z: pos.z,
985
+ qx: quat.x,
986
+ qy: quat.y,
987
+ qz: quat.z,
988
+ qw: quat.w,
989
+ vx,
990
+ vy,
991
+ vz,
992
+ wx,
993
+ wy,
994
+ wz,
995
+ ...custom ? { c: custom } : {}
996
+ };
997
+ }
998
+ function buildEntityMap(actors, is2D) {
999
+ const entities = /* @__PURE__ */ new Map();
1000
+ for (const [id, ref] of actors) {
1001
+ const nc = ref.userData.networked;
1002
+ if (nc && nc.sync === false) continue;
1003
+ entities.set(id, is2D ? readEntityState2D(ref) : readEntityState3D(ref));
1004
+ }
1005
+ return entities;
1006
+ }
1007
+ function applyState2D(ref, state) {
1008
+ ref.object3D.position.set(state.x, state.y, 0);
1009
+ ref.object3D.rotation.z = state.a;
1010
+ if (ref.rigidBody) {
1011
+ try {
1012
+ if (typeof ref.rigidBody.setTranslation === "function") {
1013
+ ref.rigidBody.setTranslation({ x: state.x, y: state.y }, true);
1014
+ }
1015
+ if (typeof ref.rigidBody.setRotation === "function") {
1016
+ ref.rigidBody.setRotation(state.a, true);
1017
+ }
1018
+ } catch {
1019
+ }
1020
+ }
1021
+ }
1022
+ function applyState3D(ref, state) {
1023
+ ref.object3D.position.set(state.x, state.y, state.z);
1024
+ ref.object3D.quaternion.set(state.qx, state.qy, state.qz, state.qw);
1025
+ if (ref.rigidBody) {
1026
+ try {
1027
+ if (typeof ref.rigidBody.setTranslation === "function") {
1028
+ ref.rigidBody.setTranslation({ x: state.x, y: state.y, z: state.z }, true);
1029
+ }
1030
+ if (typeof ref.rigidBody.setRotation === "function") {
1031
+ ref.rigidBody.setRotation(
1032
+ { x: state.qx, y: state.qy, z: state.qz, w: state.qw },
1033
+ true
1034
+ );
1035
+ }
1036
+ } catch {
1037
+ }
1038
+ }
1039
+ }
1040
+ function applyEntityState(ref, state, is2D) {
1041
+ if (is2D) {
1042
+ applyState2D(ref, state);
1043
+ } else {
1044
+ applyState3D(ref, state);
1045
+ }
1046
+ }
1047
+ function applyStatesToActors(states, registry, is2D) {
1048
+ for (const [id, state] of states) {
1049
+ if (state.c && state.c.__removed) continue;
1050
+ const ref = registry.get(id);
1051
+ if (!ref) continue;
1052
+ applyEntityState(ref, state, is2D);
1053
+ }
1054
+ }
1055
+ function useMultiplayer(options = {}) {
1056
+ const { networkManager } = useMultiplayerContext();
1057
+ const mode = options.mode ?? networkManager.syncMode;
1058
+ const eventSyncRef = useRef4(null);
1059
+ const snapshotSyncRef = useRef4(null);
1060
+ const predictionSyncRef = useRef4(null);
1061
+ const networkSimulatorRef = useRef4(null);
1062
+ const is2DRef = useRef4(null);
1063
+ const [isActive, setIsActive] = useState4(false);
1064
+ const [networkQuality, setNetworkQuality] = useState4("good");
1065
+ const [tick, setTick] = useState4(0);
1066
+ const [serverTick, setServerTick] = useState4(0);
1067
+ const [drift, setDrift] = useState4(0);
1068
+ const actorRegistry = useRef4(getActorRegistry());
1069
+ const wasHostRef = useRef4(null);
1070
+ const buildSnapshotOpts = useCallback5(() => {
1071
+ return {
1072
+ broadcastRate: options.broadcastRate,
1073
+ keyframeInterval: options.keyframeInterval,
1074
+ bufferSize: options.interpolation?.bufferSize,
1075
+ interpolationMethod: options.interpolation?.method,
1076
+ extrapolateMs: options.interpolation?.extrapolateMs,
1077
+ is2D: is2DRef.current ?? true
1078
+ };
1079
+ }, [
1080
+ options.broadcastRate,
1081
+ options.keyframeInterval,
1082
+ options.interpolation?.bufferSize,
1083
+ options.interpolation?.method,
1084
+ options.interpolation?.extrapolateMs
1085
+ ]);
1086
+ useEffect5(() => {
1087
+ const transport = networkManager.transport;
1088
+ if (!transport) return;
1089
+ const debugOpts = options.debug;
1090
+ let simulator = null;
1091
+ if (debugOpts?.simulatedLatencyMs || debugOpts?.simulatedPacketLoss) {
1092
+ simulator = new NetworkSimulator({
1093
+ latencyMs: debugOpts.simulatedLatencyMs,
1094
+ packetLoss: debugOpts.simulatedPacketLoss
1095
+ });
1096
+ networkSimulatorRef.current = simulator;
1097
+ const origCreateChannel = transport.createChannel.bind(transport);
1098
+ transport.createChannel = (name, channelOpts) => {
1099
+ const ch = origCreateChannel(name, channelOpts);
1100
+ const wrappedSend = simulator.wrapSend(ch.send.bind(ch));
1101
+ return { ...ch, send: wrappedSend };
1102
+ };
1103
+ }
1104
+ eventSyncRef.current = new EventSync(transport);
1105
+ if (mode === "snapshot" || mode === "prediction") {
1106
+ snapshotSyncRef.current = new SnapshotSync(
1107
+ transport,
1108
+ networkManager.codec,
1109
+ networkManager.snapshotBuffer,
1110
+ buildSnapshotOpts()
1111
+ );
1112
+ }
1113
+ if (mode === "prediction") {
1114
+ predictionSyncRef.current = new PredictionSync(
1115
+ transport,
1116
+ networkManager.codec,
1117
+ networkManager.tickKeeper,
1118
+ options.prediction
1119
+ );
1120
+ if (options.onPhysicsStep) {
1121
+ predictionSyncRef.current.setPhysicsStep(options.onPhysicsStep);
1122
+ }
1123
+ }
1124
+ wasHostRef.current = networkManager.isHost;
1125
+ setIsActive(true);
1126
+ const unsubHostChanged = (() => {
1127
+ const onHostChanged = (newHostId) => {
1128
+ const amNewHost = newHostId === transport.peerId;
1129
+ const wasPreviouslyHost = wasHostRef.current;
1130
+ wasHostRef.current = amNewHost;
1131
+ if (amNewHost && !wasPreviouslyHost) {
1132
+ snapshotSyncRef.current?.promoteToHost(buildSnapshotOpts());
1133
+ } else if (!amNewHost && wasPreviouslyHost) {
1134
+ snapshotSyncRef.current?.demoteToClient(buildSnapshotOpts());
1135
+ }
1136
+ };
1137
+ transport.onHostChanged(onHostChanged);
1138
+ return () => {
1139
+ };
1140
+ })();
1141
+ return () => {
1142
+ unsubHostChanged();
1143
+ eventSyncRef.current?.destroy();
1144
+ snapshotSyncRef.current?.destroy();
1145
+ predictionSyncRef.current?.destroy();
1146
+ networkSimulatorRef.current?.destroy();
1147
+ eventSyncRef.current = null;
1148
+ snapshotSyncRef.current = null;
1149
+ predictionSyncRef.current = null;
1150
+ networkSimulatorRef.current = null;
1151
+ setIsActive(false);
1152
+ };
1153
+ }, [networkManager, mode, buildSnapshotOpts, options.prediction, options.onPhysicsStep, options.debug]);
1154
+ useEffect5(() => {
1155
+ const unsub = networkManager.onConnectionStateChange(() => {
1156
+ setNetworkQuality(networkManager.networkQuality);
1157
+ });
1158
+ return unsub;
1159
+ }, [networkManager]);
1160
+ useFrame((_state, delta) => {
1161
+ const transport = networkManager.transport;
1162
+ if (!transport) return;
1163
+ const tickKeeper = networkManager.tickKeeper;
1164
+ const isHost = networkManager.isHost;
1165
+ if (is2DRef.current === null) {
1166
+ const networked = actorRegistry.current.getNetworked();
1167
+ if (networked.size > 0) {
1168
+ is2DRef.current = detect2D(networked);
1169
+ }
1170
+ }
1171
+ const is2D = is2DRef.current ?? true;
1172
+ if (mode === "events") return;
1173
+ const ticksThisFrame = tickKeeper.update(delta);
1174
+ for (let i = 0; i < ticksThisFrame; i++) {
1175
+ const currentTick = tickKeeper.tick - (ticksThisFrame - 1 - i);
1176
+ if (isHost) {
1177
+ const networked = actorRegistry.current.getNetworked();
1178
+ const entities = buildEntityMap(networked, is2D);
1179
+ if (mode === "snapshot" || mode === "prediction") {
1180
+ snapshotSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
1181
+ }
1182
+ if (mode === "prediction") {
1183
+ predictionSyncRef.current?.hostTick(currentTick, entities, tickKeeper.tickDelta);
1184
+ }
1185
+ } else {
1186
+ if (mode === "prediction" && predictionSyncRef.current) {
1187
+ predictionSyncRef.current.clientTick(currentTick);
1188
+ }
1189
+ }
1190
+ }
1191
+ if (!isHost) {
1192
+ if (mode === "snapshot" && snapshotSyncRef.current) {
1193
+ const renderTime = performance.now();
1194
+ const interpolated = snapshotSyncRef.current.clientInterpolate(renderTime);
1195
+ applyStatesToActors(interpolated, actorRegistry.current, is2D);
1196
+ } else if (mode === "prediction" && predictionSyncRef.current) {
1197
+ const predicted = predictionSyncRef.current.predictedState;
1198
+ const smoothed = predictionSyncRef.current.applyErrorSmoothing(predicted);
1199
+ applyStatesToActors(smoothed, actorRegistry.current, is2D);
1200
+ }
1201
+ }
1202
+ if (ticksThisFrame > 0) {
1203
+ const newTick = tickKeeper.tick;
1204
+ const newServerTick = tickKeeper.serverTick;
1205
+ const newDrift = tickKeeper.drift;
1206
+ const newQuality = networkManager.networkQuality;
1207
+ setTick((prev) => prev !== newTick ? newTick : prev);
1208
+ setServerTick((prev) => prev !== newServerTick ? newServerTick : prev);
1209
+ setDrift((prev) => prev !== newDrift ? newDrift : prev);
1210
+ setNetworkQuality((prev) => prev !== newQuality ? newQuality : prev);
1211
+ }
1212
+ }, -55);
1213
+ return {
1214
+ isActive,
1215
+ networkQuality,
1216
+ tick,
1217
+ serverTick,
1218
+ drift,
1219
+ syncEngine: mode
1220
+ };
1221
+ }
1222
+
1223
+ // src/hooks/useNetworkEvents.ts
1224
+ import { useRef as useRef5, useEffect as useEffect6, useCallback as useCallback6 } from "react";
1225
+ function useNetworkEvents(options) {
1226
+ const { networkManager } = useMultiplayerContext();
1227
+ const eventSyncRef = useRef5(null);
1228
+ const pendingRef = useRef5([]);
1229
+ const drainedUnsubsRef = useRef5([]);
1230
+ useEffect6(() => {
1231
+ const transport = networkManager.transport;
1232
+ if (!transport) return;
1233
+ const eventSync = new EventSync(transport, {
1234
+ hostValidation: options?.hostValidation
1235
+ });
1236
+ eventSyncRef.current = eventSync;
1237
+ const unsubs = [];
1238
+ for (const entry of pendingRef.current) {
1239
+ const unsub = eventSync.onEvent(entry.type, entry.callback);
1240
+ entry.unsub = unsub;
1241
+ unsubs.push(unsub);
1242
+ }
1243
+ pendingRef.current = [];
1244
+ drainedUnsubsRef.current = unsubs;
1245
+ return () => {
1246
+ for (const unsub of drainedUnsubsRef.current) {
1247
+ unsub();
1248
+ }
1249
+ drainedUnsubsRef.current = [];
1250
+ eventSync.destroy();
1251
+ eventSyncRef.current = null;
1252
+ };
1253
+ }, [networkManager.transport, options?.hostValidation]);
1254
+ const sendEvent = useCallback6((type, payload, target) => {
1255
+ eventSyncRef.current?.sendEvent(type, payload, target);
1256
+ }, []);
1257
+ const broadcast = useCallback6((type, payload) => {
1258
+ eventSyncRef.current?.broadcast(type, payload);
1259
+ }, []);
1260
+ const onEvent = useCallback6((type, callback) => {
1261
+ const castCallback = callback;
1262
+ if (eventSyncRef.current) {
1263
+ return eventSyncRef.current.onEvent(type, castCallback);
1264
+ }
1265
+ const entry = { type, callback: castCallback, unsub: null };
1266
+ pendingRef.current.push(entry);
1267
+ return () => {
1268
+ if (entry.unsub) {
1269
+ entry.unsub();
1270
+ drainedUnsubsRef.current = drainedUnsubsRef.current.filter((u) => u !== entry.unsub);
1271
+ } else {
1272
+ pendingRef.current = pendingRef.current.filter((e) => e !== entry);
1273
+ }
1274
+ };
1275
+ }, []);
1276
+ return { sendEvent, broadcast, onEvent };
1277
+ }
1278
+
1279
+ // src/hooks/useNetworkState.ts
1280
+ import { useState as useState5, useEffect as useEffect7, useCallback as useCallback7, useRef as useRef6 } from "react";
1281
+ var CHANNEL_NAME = "carver:network-state";
1282
+ function useNetworkState() {
1283
+ const { networkManager } = useMultiplayerContext();
1284
+ const [entities, setEntities] = useState5(
1285
+ () => /* @__PURE__ */ new Map()
1286
+ );
1287
+ const entitiesRef = useRef6(entities);
1288
+ entitiesRef.current = entities;
1289
+ const channelRef = useRef6(null);
1290
+ const replaceEntities = useCallback7(
1291
+ (updater) => {
1292
+ setEntities((prev) => {
1293
+ const next = updater(prev);
1294
+ entitiesRef.current = next;
1295
+ return next;
1296
+ });
1297
+ },
1298
+ []
1299
+ );
1300
+ const applySpawn = useCallback7(
1301
+ (id, state) => {
1302
+ replaceEntities((prev) => {
1303
+ const next = new Map(prev);
1304
+ next.set(id, state);
1305
+ return next;
1306
+ });
1307
+ },
1308
+ [replaceEntities]
1309
+ );
1310
+ const applyDespawn = useCallback7(
1311
+ (id) => {
1312
+ replaceEntities((prev) => {
1313
+ if (!prev.has(id)) return prev;
1314
+ const next = new Map(prev);
1315
+ next.delete(id);
1316
+ return next;
1317
+ });
1318
+ },
1319
+ [replaceEntities]
1320
+ );
1321
+ useEffect7(() => {
1322
+ const transport = networkManager.transport;
1323
+ if (!transport) return;
1324
+ const channel = transport.createChannel(CHANNEL_NAME, {
1325
+ reliable: true,
1326
+ ordered: true
1327
+ });
1328
+ channelRef.current = channel;
1329
+ channel.onReceive((msg, _peerId) => {
1330
+ switch (msg.action) {
1331
+ // --- Spawn broadcast (sent by host to everyone) ---
1332
+ case "spawn": {
1333
+ if (msg.state) {
1334
+ applySpawn(msg.id, msg.state);
1335
+ }
1336
+ break;
1337
+ }
1338
+ // --- Despawn broadcast (sent by host to everyone) ---
1339
+ case "despawn": {
1340
+ applyDespawn(msg.id);
1341
+ break;
1342
+ }
1343
+ // --- Request-spawn (client -> host only) ---
1344
+ case "request-spawn": {
1345
+ if (!transport.isHost) break;
1346
+ if (entitiesRef.current.has(msg.id)) break;
1347
+ const config = msg.state ?? {};
1348
+ const newState = {
1349
+ id: msg.id,
1350
+ x: 0,
1351
+ y: 0,
1352
+ a: 0,
1353
+ vx: 0,
1354
+ vy: 0,
1355
+ va: 0,
1356
+ ...config,
1357
+ // Ensure id is authoritative.
1358
+ ...config.id !== void 0 ? {} : {}
1359
+ };
1360
+ newState.id = msg.id;
1361
+ applySpawn(msg.id, newState);
1362
+ channel.send({ action: "spawn", id: msg.id, state: newState });
1363
+ break;
1364
+ }
1365
+ }
1366
+ });
1367
+ return () => {
1368
+ channel.close();
1369
+ channelRef.current = null;
1370
+ };
1371
+ }, [networkManager.transport, applySpawn, applyDespawn]);
1372
+ const spawn = useCallback7(
1373
+ (id, initialState) => {
1374
+ const transport = networkManager.transport;
1375
+ if (!transport) {
1376
+ console.warn("[useNetworkState] spawn called before transport is ready.");
1377
+ return;
1378
+ }
1379
+ if (!transport.isHost) {
1380
+ console.warn(
1381
+ "[useNetworkState] spawn is host-only. Use requestSpawn() from a client."
1382
+ );
1383
+ return;
1384
+ }
1385
+ if (entitiesRef.current.has(id)) {
1386
+ console.warn(
1387
+ `[useNetworkState] entity "${id}" already exists. Ignoring spawn.`
1388
+ );
1389
+ return;
1390
+ }
1391
+ const state = { ...initialState, id };
1392
+ applySpawn(id, state);
1393
+ channelRef.current?.send({ action: "spawn", id, state });
1394
+ },
1395
+ [networkManager, applySpawn]
1396
+ );
1397
+ const despawn = useCallback7(
1398
+ (id) => {
1399
+ const transport = networkManager.transport;
1400
+ if (!transport) {
1401
+ console.warn("[useNetworkState] despawn called before transport is ready.");
1402
+ return;
1403
+ }
1404
+ if (!transport.isHost) {
1405
+ console.warn("[useNetworkState] despawn is host-only.");
1406
+ return;
1407
+ }
1408
+ if (!entitiesRef.current.has(id)) {
1409
+ console.warn(
1410
+ `[useNetworkState] entity "${id}" does not exist. Ignoring despawn.`
1411
+ );
1412
+ return;
1413
+ }
1414
+ applyDespawn(id);
1415
+ channelRef.current?.send({ action: "despawn", id });
1416
+ },
1417
+ [networkManager, applyDespawn]
1418
+ );
1419
+ const requestSpawn = useCallback7(
1420
+ (id, config) => {
1421
+ const transport = networkManager.transport;
1422
+ if (!transport) {
1423
+ console.warn(
1424
+ "[useNetworkState] requestSpawn called before transport is ready."
1425
+ );
1426
+ return;
1427
+ }
1428
+ if (transport.isHost) {
1429
+ if (entitiesRef.current.has(id)) {
1430
+ console.warn(
1431
+ `[useNetworkState] entity "${id}" already exists. Ignoring requestSpawn.`
1432
+ );
1433
+ return;
1434
+ }
1435
+ const newState = {
1436
+ id,
1437
+ x: 0,
1438
+ y: 0,
1439
+ a: 0,
1440
+ vx: 0,
1441
+ vy: 0,
1442
+ va: 0,
1443
+ ...config
1444
+ };
1445
+ newState.id = id;
1446
+ applySpawn(id, newState);
1447
+ channelRef.current?.send({ action: "spawn", id, state: newState });
1448
+ return;
1449
+ }
1450
+ channelRef.current?.send(
1451
+ { action: "request-spawn", id, state: config },
1452
+ transport.hostId
1453
+ );
1454
+ },
1455
+ [networkManager, applySpawn]
1456
+ );
1457
+ const getState = useCallback7(
1458
+ (id) => {
1459
+ return entitiesRef.current.get(id);
1460
+ },
1461
+ []
1462
+ );
1463
+ const setState = useCallback7(
1464
+ (id, partialState) => {
1465
+ const transport = networkManager.transport;
1466
+ if (!transport) {
1467
+ console.warn("[useNetworkState] setState called before transport is ready.");
1468
+ return;
1469
+ }
1470
+ if (!transport.isHost) {
1471
+ console.warn("[useNetworkState] setState is host-only.");
1472
+ return;
1473
+ }
1474
+ const existing = entitiesRef.current.get(id);
1475
+ if (!existing) {
1476
+ console.warn(
1477
+ `[useNetworkState] entity "${id}" does not exist. Cannot setState.`
1478
+ );
1479
+ return;
1480
+ }
1481
+ const updated = {
1482
+ ...existing,
1483
+ ...partialState,
1484
+ id
1485
+ // id is immutable
1486
+ };
1487
+ replaceEntities((prev) => {
1488
+ const next = new Map(prev);
1489
+ next.set(id, updated);
1490
+ return next;
1491
+ });
1492
+ channelRef.current?.send({ action: "spawn", id, state: updated });
1493
+ },
1494
+ [networkManager, replaceEntities]
1495
+ );
1496
+ return {
1497
+ spawn,
1498
+ despawn,
1499
+ requestSpawn,
1500
+ getState,
1501
+ setState,
1502
+ entities
1503
+ };
1504
+ }
1505
+
1506
+ // src/core/DebugOverlay.ts
1507
+ var QUALITY_COLORS = {
1508
+ good: "#00ff00",
1509
+ degraded: "#ffff00",
1510
+ poor: "#ff4444"
1511
+ };
1512
+ var DebugOverlay = class {
1513
+ constructor(options) {
1514
+ this.visible = true;
1515
+ this.lastHTML = "";
1516
+ const opts = options ?? {};
1517
+ this.keyboardToggle = opts.keyboardToggle ?? "F3";
1518
+ this.el = document.createElement("div");
1519
+ const pos = opts.position ?? "top-right";
1520
+ const posStyles = this.positionStyles(pos);
1521
+ Object.assign(this.el.style, {
1522
+ position: "fixed",
1523
+ ...posStyles,
1524
+ background: "rgba(0,0,0,0.75)",
1525
+ color: "#00ff00",
1526
+ fontFamily: "'Courier New', monospace",
1527
+ fontSize: "11px",
1528
+ padding: "8px",
1529
+ borderRadius: "4px",
1530
+ zIndex: "99999",
1531
+ pointerEvents: "none",
1532
+ whiteSpace: "pre",
1533
+ lineHeight: "1.4",
1534
+ minWidth: "200px",
1535
+ boxSizing: "border-box"
1536
+ });
1537
+ document.body.appendChild(this.el);
1538
+ this.handleKey = (e) => {
1539
+ if (e.key === this.keyboardToggle) {
1540
+ e.preventDefault();
1541
+ this.toggle();
1542
+ }
1543
+ };
1544
+ window.addEventListener("keydown", this.handleKey);
1545
+ }
1546
+ /* ---- public API ---- */
1547
+ update(stats) {
1548
+ if (!this.visible) return;
1549
+ const latencyDisplay = this.formatLatency(stats.latencyMs);
1550
+ const qualityColor = QUALITY_COLORS[stats.networkQuality] ?? "#00ff00";
1551
+ const bwIn = (stats.bandwidthIn / 1024).toFixed(1);
1552
+ const bwOut = (stats.bandwidthOut / 1024).toFixed(1);
1553
+ const loss = (stats.packetLossRate * 100).toFixed(1);
1554
+ const html = `<b style="color:#00ccff">== NET DEBUG ==</b>
1555
+ tick ${stats.tick}
1556
+ srv tick ${stats.serverTick}
1557
+ drift ${stats.drift}
1558
+ latency ${latencyDisplay} ms
1559
+ loss ${loss}%
1560
+ bw in ${bwIn} KB/s
1561
+ bw out ${bwOut} KB/s
1562
+ quality <span style="color:${qualityColor}">${stats.networkQuality}</span>
1563
+ peers ${stats.peerCount}
1564
+ host ${stats.isHost ? "YES" : "NO"}
1565
+ sync ${stats.syncMode}`;
1566
+ if (html !== this.lastHTML) {
1567
+ this.el.innerHTML = html;
1568
+ this.lastHTML = html;
1569
+ }
1570
+ }
1571
+ show() {
1572
+ this.visible = true;
1573
+ this.el.style.display = "block";
1574
+ }
1575
+ hide() {
1576
+ this.visible = false;
1577
+ this.el.style.display = "none";
1578
+ }
1579
+ toggle() {
1580
+ if (this.visible) {
1581
+ this.hide();
1582
+ } else {
1583
+ this.show();
1584
+ }
1585
+ }
1586
+ destroy() {
1587
+ window.removeEventListener("keydown", this.handleKey);
1588
+ this.el.remove();
1589
+ }
1590
+ /* ---- helpers ---- */
1591
+ positionStyles(pos) {
1592
+ switch (pos) {
1593
+ case "top-left":
1594
+ return { top: "8px", left: "8px" };
1595
+ case "top-right":
1596
+ return { top: "8px", right: "8px" };
1597
+ case "bottom-left":
1598
+ return { bottom: "8px", left: "8px" };
1599
+ case "bottom-right":
1600
+ return { bottom: "8px", right: "8px" };
1601
+ }
1602
+ }
1603
+ formatLatency(latency) {
1604
+ if (typeof latency === "number") {
1605
+ return latency.toFixed(1);
1606
+ }
1607
+ if (latency.size === 0) return "\u2014";
1608
+ let sum = 0;
1609
+ latency.forEach((v) => sum += v);
1610
+ return (sum / latency.size).toFixed(1);
1611
+ }
1612
+ };
1613
+
1614
+ // src/core/InterestManager.ts
1615
+ var InterestManager = class {
1616
+ constructor(options) {
1617
+ this._cellSize = options?.cellSize ?? 50;
1618
+ this._defaultRadius = options?.defaultRadius ?? 200;
1619
+ this._alwaysRelevant = new Set(options?.alwaysRelevant ?? []);
1620
+ this._cells = /* @__PURE__ */ new Map();
1621
+ this._entityPositions = /* @__PURE__ */ new Map();
1622
+ }
1623
+ // ── Public API ──
1624
+ /**
1625
+ * Rebuild the spatial hash from the authoritative entity map.
1626
+ * Called once per host tick before any relevance queries.
1627
+ */
1628
+ updateEntities(entities) {
1629
+ this._cells.clear();
1630
+ this._entityPositions.clear();
1631
+ for (const [id, entity] of entities) {
1632
+ const z = "z" in entity ? entity.z : 0;
1633
+ const pos = { x: entity.x, y: entity.y, z };
1634
+ this._entityPositions.set(id, pos);
1635
+ const key = this._cellKey(pos.x, pos.y, pos.z);
1636
+ let bucket = this._cells.get(key);
1637
+ if (!bucket) {
1638
+ bucket = /* @__PURE__ */ new Set();
1639
+ this._cells.set(key, bucket);
1640
+ }
1641
+ bucket.add(id);
1642
+ }
1643
+ }
1644
+ /**
1645
+ * Return the set of entity ids relevant to a single client.
1646
+ *
1647
+ * Relevance is the *union* of:
1648
+ * 1. Entities whose cell overlaps the client's bounding sphere
1649
+ * 2. Entities in the `alwaysRelevant` set
1650
+ * 3. Entities owned by this client
1651
+ */
1652
+ getRelevantEntities(clientPosition, clientId, owners, overrideRadius) {
1653
+ const radius = overrideRadius ?? this._defaultRadius;
1654
+ const cx = clientPosition.x;
1655
+ const cy = clientPosition.y;
1656
+ const cz = clientPosition.z ?? 0;
1657
+ const result = /* @__PURE__ */ new Set();
1658
+ const minCellX = Math.floor((cx - radius) / this._cellSize);
1659
+ const maxCellX = Math.floor((cx + radius) / this._cellSize);
1660
+ const minCellY = Math.floor((cy - radius) / this._cellSize);
1661
+ const maxCellY = Math.floor((cy + radius) / this._cellSize);
1662
+ const minCellZ = Math.floor((cz - radius) / this._cellSize);
1663
+ const maxCellZ = Math.floor((cz + radius) / this._cellSize);
1664
+ for (let ix = minCellX; ix <= maxCellX; ix++) {
1665
+ for (let iy = minCellY; iy <= maxCellY; iy++) {
1666
+ for (let iz = minCellZ; iz <= maxCellZ; iz++) {
1667
+ const key = `${ix},${iy},${iz}`;
1668
+ const bucket = this._cells.get(key);
1669
+ if (bucket) {
1670
+ for (const entityId of bucket) {
1671
+ result.add(entityId);
1672
+ }
1673
+ }
1674
+ }
1675
+ }
1676
+ }
1677
+ for (const id of this._alwaysRelevant) {
1678
+ if (this._entityPositions.has(id)) {
1679
+ result.add(id);
1680
+ }
1681
+ }
1682
+ for (const [entityId, ownerId] of owners) {
1683
+ if (ownerId === clientId && this._entityPositions.has(entityId)) {
1684
+ result.add(entityId);
1685
+ }
1686
+ }
1687
+ return result;
1688
+ }
1689
+ /**
1690
+ * Build a filter callback compatible with
1691
+ * `HostAuthority.setInterestFilter`.
1692
+ *
1693
+ * The returned function closes over a single relevance pass for every
1694
+ * known client so that per-entity filtering during broadcast is a cheap
1695
+ * `Set.has` lookup.
1696
+ *
1697
+ * @param clientPositions peerId -> position of that client's camera/player
1698
+ * @param owners entityId -> ownerPeerId
1699
+ */
1700
+ createFilter(clientPositions, owners) {
1701
+ const relevanceSets = /* @__PURE__ */ new Map();
1702
+ for (const [peerId, pos] of clientPositions) {
1703
+ relevanceSets.set(
1704
+ peerId,
1705
+ this.getRelevantEntities(pos, peerId, owners)
1706
+ );
1707
+ }
1708
+ return (entityId, peerId) => {
1709
+ const set = relevanceSets.get(peerId);
1710
+ if (!set) return true;
1711
+ return set.has(entityId);
1712
+ };
1713
+ }
1714
+ /** Remove all data from the grid. */
1715
+ clear() {
1716
+ this._cells.clear();
1717
+ this._entityPositions.clear();
1718
+ }
1719
+ // ── Private helpers ──
1720
+ _cellKey(x, y, z) {
1721
+ const cx = Math.floor(x / this._cellSize);
1722
+ const cy = Math.floor(y / this._cellSize);
1723
+ const cz = Math.floor(z / this._cellSize);
1724
+ return `${cx},${cy},${cz}`;
1725
+ }
1726
+ };
1727
+ export {
1728
+ DebugOverlay,
1729
+ FirebaseStrategy,
1730
+ InterestManager,
1731
+ MqttStrategy,
1732
+ MultiplayerBridge,
1733
+ MultiplayerProvider,
1734
+ NetworkSimulator,
1735
+ useHost,
1736
+ useLobby,
1737
+ useMultiplayer,
1738
+ useNetworkEvents,
1739
+ useNetworkState,
1740
+ usePlayers,
1741
+ useRoom
1742
+ };
1743
+ //# sourceMappingURL=index.mjs.map