@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/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @carverjs/multiplayer
2
+
3
+ [![npm](https://img.shields.io/npm/v/@carverjs/multiplayer)](https://www.npmjs.com/package/@carverjs/multiplayer)
4
+ [![license](https://img.shields.io/npm/l/@carverjs/multiplayer)](https://github.com/MoneyTales/carverjs/blob/main/LICENSE)
5
+ [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/5ymwfD4hYE)
6
+
7
+ Serverless peer-to-peer multiplayer for [CarverJS](https://www.npmjs.com/package/@carverjs/core) games. A WebRTC data-channel mesh with pluggable signaling (MQTT or Firebase), lobbies, host authority, and three sync engines — **no game server required**.
8
+
9
+ > **Beta:** CarverJS is under active development. APIs may change between minor versions until 1.0.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @carverjs/multiplayer
15
+ # optional — Firebase RTDB signaling (MQTT is the zero-config default)
16
+ npm install firebase
17
+ ```
18
+
19
+ Peer dependencies: `@carverjs/core`, `@react-three/fiber`, `react`, `react-dom`. `firebase` is an optional peer, needed only when you choose the Firebase strategy.
20
+
21
+ ## How it works
22
+
23
+ Signaling (MQTT or Firebase) is used only to introduce peers and relay SDP/ICE. Once the WebRTC connection is established, **all game data flows directly peer-to-peer** over data channels — the signaling backend never sees gameplay traffic. One peer acts as the authoritative host; host migrates automatically if it leaves.
24
+
25
+ ## Quick start
26
+
27
+ Wrap your game in a provider, join a room, and exchange typed events:
28
+
29
+ ```tsx
30
+ import {
31
+ MultiplayerProvider, MultiplayerBridge,
32
+ useRoom, usePlayers, useNetworkEvents,
33
+ } from "@carverjs/multiplayer";
34
+ import { Game, World } from "@carverjs/core/components";
35
+
36
+ function App() {
37
+ return (
38
+ // Zero-config: free public MQTT brokers handle signaling
39
+ <MultiplayerProvider appId="my-game">
40
+ <Game mode="2d">
41
+ <MultiplayerBridge>
42
+ <World>
43
+ <Lobby />
44
+ </World>
45
+ </MultiplayerBridge>
46
+ </Game>
47
+ </MultiplayerProvider>
48
+ );
49
+ }
50
+
51
+ function Lobby() {
52
+ const room = useRoom("room-code-1234", { displayName: "Ada" });
53
+ const { players, self } = usePlayers();
54
+ const { broadcast, onEvent } = useNetworkEvents();
55
+
56
+ // room.connectionState, room.isHost, room.selfId, room.leave(), ...
57
+ return <span>{players.length} players · {room.isHost ? "host" : "client"}</span>;
58
+ }
59
+ ```
60
+
61
+ `MultiplayerBridge` connects the engine's render loop to the network layer; place it inside `<Game>` and around the scene that uses sync hooks.
62
+
63
+ ## Signaling strategies
64
+
65
+ ```tsx
66
+ // Free, zero-config (default): public MQTT brokers
67
+ <MultiplayerProvider appId="my-game">
68
+
69
+ // Firebase Realtime Database (bring your own project)
70
+ <MultiplayerProvider
71
+ appId="my-game"
72
+ strategy={{ type: "firebase", databaseURL: "https://your-project.firebaseio.com" }}
73
+ >
74
+ ```
75
+
76
+ ## STUN / TURN
77
+
78
+ Defaults to public STUN. Add a TURN relay so peers behind restrictive NATs or firewalls can still connect:
79
+
80
+ ```tsx
81
+ <MultiplayerProvider
82
+ appId="my-game"
83
+ iceServers={[
84
+ { urls: "stun:stun.cloudflare.com:3478" },
85
+ { urls: "turn:turn.cloudflare.com:3478", username: "...", credential: "..." },
86
+ ]}
87
+ >
88
+ ```
89
+
90
+ TURN is only used when a direct connection fails. For same-network testing, STUN alone is enough.
91
+
92
+ ## Sync modes
93
+
94
+ `useMultiplayer({ mode })` selects how world state is replicated:
95
+
96
+ | Mode | Use it for | How |
97
+ | --- | --- | --- |
98
+ | `events` | turn-based, sandbox, chat, infrequent state changes | typed messages over a reliable, ordered channel |
99
+ | `snapshot` | real-time movement | host broadcasts delta-compressed snapshots; clients interpolate |
100
+ | `prediction` | fast-paced action | client-side prediction with server reconciliation and rollback |
101
+
102
+ ```tsx
103
+ import { useMultiplayer } from "@carverjs/multiplayer";
104
+
105
+ function Scene() {
106
+ useMultiplayer({ mode: "snapshot", tickRate: 60 });
107
+ // ... actors marked networked are replicated automatically
108
+ }
109
+ ```
110
+
111
+ ## Hooks
112
+
113
+ | Hook | Purpose |
114
+ | --- | --- |
115
+ | `useRoom(roomId, opts)` | Join / leave a room; exposes connection state, host, and self id. |
116
+ | `useLobby()` | Browse advertised rooms. |
117
+ | `usePlayers()` | Live player list plus `self`. |
118
+ | `useHost()` | Host-only room controls — room state, lock, kick, host transfer. |
119
+ | `useMultiplayer({ mode })` | Drive the sync engine for a scene. |
120
+ | `useNetworkEvents()` | Typed `broadcast` / `sendEvent` / `onEvent` messaging. |
121
+ | `useNetworkState()` | Networked spawn / despawn helpers. |
122
+
123
+ ## Host authority & migration
124
+
125
+ Exactly one peer is the authoritative host. If it disconnects, the engine migrates host to another peer automatically, and host election is deterministic and consistent across all peers.
126
+
127
+ To pin a specific peer as host — for example the room creator that owns the world — advertise a host priority in player metadata. The lowest priority wins; peers that advertise none rank last (preserving the default lowest-peer-id election among them):
128
+
129
+ ```tsx
130
+ useRoom(roomId, {
131
+ displayName: name,
132
+ playerMetadata: { hostPriority: isCreator ? 0 : 1 },
133
+ });
134
+ ```
135
+
136
+ ## Advanced exports
137
+
138
+ For lower-level control, the package also exports:
139
+
140
+ - `MqttStrategy`, `FirebaseStrategy` — construct or inject a signaling strategy directly.
141
+ - `NetworkSimulator` — inject artificial latency and packet loss during development.
142
+ - `InterestManager` — area-of-interest filtering for large worlds.
143
+ - `InputBuffer`, `computeJustPressed` — input history and edge detection for prediction.
144
+ - `DebugOverlay` — on-screen network stats.
145
+
146
+ ## Links
147
+
148
+ - Documentation: [docs.carverjs.dev](https://docs.carverjs.dev)
149
+ - Community: [Discord](https://discord.gg/5ymwfD4hYE)
150
+ - Issues: [github.com/MoneyTales/carverjs/issues](https://github.com/MoneyTales/carverjs/issues)
151
+
152
+ ## License
153
+
154
+ [MIT](https://github.com/MoneyTales/carverjs/blob/main/LICENSE) — MoneyTales EduTech Private Limited
@@ -0,0 +1,61 @@
1
+ import { P as PlayerInput } from './NetworkManager-D-DxFgdM.mjs';
2
+
3
+ /**
4
+ * InputBuffer — unified ring-buffer for local and peer inputs.
5
+ *
6
+ * Ported from LumberNet's LumberInputBuffer. Stores:
7
+ * - local player tick-keyed inputs (storeTick / getTick / hasTick)
8
+ * - last-received input per remote peer (setRemote / getRemote / allRemotes)
9
+ * - per-peer tick-keyed inputs for accurate rollback resimulation (getRemoteAtTick)
10
+ *
11
+ * Generic over per-tick payload I. Caller supplies the neutral payload.
12
+ */
13
+
14
+ declare class InputBuffer<I extends PlayerInput = PlayerInput> {
15
+ private readonly _historySize;
16
+ private readonly _neutral;
17
+ /** Local player tick-keyed inputs (ring buffer). */
18
+ private readonly _local;
19
+ /** Last-received input per remote peer. */
20
+ private readonly _remotes;
21
+ /** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
22
+ private readonly _peerTicks;
23
+ constructor(neutralInput: I, historySize?: number);
24
+ /** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
25
+ storeTick(tick: number, input: I): void;
26
+ /** Return the local input at `tick`, or a neutral copy if out of range. */
27
+ getTick(tick: number): I;
28
+ /** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
29
+ hasTick(tick: number): boolean;
30
+ /** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
31
+ getJustPressedZero(): I;
32
+ /**
33
+ * Record a remote peer's input. If `tick` is given (the sender's local tick),
34
+ * also store it in the per-peer ring buffer for rollback.
35
+ */
36
+ setRemote(peerId: string, input: I, tick?: number): void;
37
+ /** Last-known input for a peer, or a neutral copy if never received. */
38
+ getRemote(peerId: string): I;
39
+ /** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
40
+ allRemotes(): Map<string, I>;
41
+ /**
42
+ * Return a peer's exact input at the given tick (for rollback accuracy),
43
+ * falling back to their last-known input when history does not reach that far.
44
+ */
45
+ getRemoteAtTick(peerId: string, tick: number): I;
46
+ /** Override the last-known input for a peer (does NOT touch tick history). */
47
+ overrideRemote(peerId: string, input: I): void;
48
+ /** Number of currently tracked remote peers. */
49
+ get peerCount(): number;
50
+ /** Iterate tracked peer IDs. */
51
+ peerIds(): IterableIterator<string>;
52
+ /**
53
+ * Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
54
+ * Call when the room's peer list changes so stale peers stop receiving input.
55
+ */
56
+ setPeerIds(peerIds: ReadonlySet<string> | string[]): void;
57
+ /** Clear local history, remote last-known inputs, and per-peer tick history. */
58
+ clear(): void;
59
+ }
60
+
61
+ export { InputBuffer as I };
@@ -0,0 +1,61 @@
1
+ import { P as PlayerInput } from './NetworkManager-DH9uGVMg.js';
2
+
3
+ /**
4
+ * InputBuffer — unified ring-buffer for local and peer inputs.
5
+ *
6
+ * Ported from LumberNet's LumberInputBuffer. Stores:
7
+ * - local player tick-keyed inputs (storeTick / getTick / hasTick)
8
+ * - last-received input per remote peer (setRemote / getRemote / allRemotes)
9
+ * - per-peer tick-keyed inputs for accurate rollback resimulation (getRemoteAtTick)
10
+ *
11
+ * Generic over per-tick payload I. Caller supplies the neutral payload.
12
+ */
13
+
14
+ declare class InputBuffer<I extends PlayerInput = PlayerInput> {
15
+ private readonly _historySize;
16
+ private readonly _neutral;
17
+ /** Local player tick-keyed inputs (ring buffer). */
18
+ private readonly _local;
19
+ /** Last-received input per remote peer. */
20
+ private readonly _remotes;
21
+ /** Per-peer tick-keyed inputs for accurate rollback re-simulation. */
22
+ private readonly _peerTicks;
23
+ constructor(neutralInput: I, historySize?: number);
24
+ /** Record a snapshot of the local input at the given tick. Evicts the entry exactly historySize back. */
25
+ storeTick(tick: number, input: I): void;
26
+ /** Return the local input at `tick`, or a neutral copy if out of range. */
27
+ getTick(tick: number): I;
28
+ /** True if we have a stored local input for this tick (used to avoid spurious justPressed after snap/rejoin). */
29
+ hasTick(tick: number): boolean;
30
+ /** Neutral payload with every boolean field forced false (use for justPressed when prev tick is unknown). */
31
+ getJustPressedZero(): I;
32
+ /**
33
+ * Record a remote peer's input. If `tick` is given (the sender's local tick),
34
+ * also store it in the per-peer ring buffer for rollback.
35
+ */
36
+ setRemote(peerId: string, input: I, tick?: number): void;
37
+ /** Last-known input for a peer, or a neutral copy if never received. */
38
+ getRemote(peerId: string): I;
39
+ /** Snapshot of all remote peers' last-known inputs (shallow copy of the map). */
40
+ allRemotes(): Map<string, I>;
41
+ /**
42
+ * Return a peer's exact input at the given tick (for rollback accuracy),
43
+ * falling back to their last-known input when history does not reach that far.
44
+ */
45
+ getRemoteAtTick(peerId: string, tick: number): I;
46
+ /** Override the last-known input for a peer (does NOT touch tick history). */
47
+ overrideRemote(peerId: string, input: I): void;
48
+ /** Number of currently tracked remote peers. */
49
+ get peerCount(): number;
50
+ /** Iterate tracked peer IDs. */
51
+ peerIds(): IterableIterator<string>;
52
+ /**
53
+ * Keep only these peer IDs; remove any other remotes (e.g. after leave/rejoin).
54
+ * Call when the room's peer list changes so stale peers stop receiving input.
55
+ */
56
+ setPeerIds(peerIds: ReadonlySet<string> | string[]): void;
57
+ /** Clear local history, remote last-known inputs, and per-peer tick history. */
58
+ clear(): void;
59
+ }
60
+
61
+ export { InputBuffer as I };
@@ -1,4 +1,4 @@
1
- import { S as SignalingStrategy } from './types-5LHBOW08.js';
1
+ import { S as SignalingStrategy } from './types-hNfCIBzj.mjs';
2
2
 
3
3
  type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'migrating' | 'reconnecting';
4
4
  type RoomState = 'lobby' | 'playing' | 'ended';
@@ -108,15 +108,68 @@ interface EntityState3D {
108
108
  c?: Record<string, unknown>;
109
109
  }
110
110
  type EntityState = EntityState2D | EntityState3D;
111
+ /** Flat per-tick input payload. Booleans get edge detection; numbers pass through. */
112
+ type PlayerInput = Record<string, boolean | number | undefined>;
113
+ /**
114
+ * Game simulation callback for prediction mode.
115
+ * Keys of both maps are peer ids (the local player appears under transport.peerId).
116
+ * Invoked once per fixed tick (isRollback=false) and once per resimulated tick (isRollback=true).
117
+ */
118
+ type PhysicsStepCallback = (inputs: Map<string, PlayerInput>, justPressed: Map<string, PlayerInput>, tick: number, isRollback: boolean, dt: number) => void;
119
+ /** Per-entity visual error offset produced by rollback. 2D uses x/y/a (z=0, q=identity); 3D uses x/y/z + quaternion (a=0). */
120
+ interface ErrorOffset {
121
+ x: number;
122
+ y: number;
123
+ z: number;
124
+ a: number;
125
+ qx: number;
126
+ qy: number;
127
+ qz: number;
128
+ qw: number;
129
+ }
130
+ /** World access used by PredictionSync for forward stepping and rollback. */
131
+ interface PredictionWorldDriver {
132
+ /** Read current state of every networked entity (raw physics, no error offsets). */
133
+ captureState(): Map<string, EntityState>;
134
+ /** Hard-apply states (position, rotation, velocities) to actors and rigid bodies, waking them. Skips tombstones. */
135
+ applyState(entities: Iterable<EntityState>): void;
136
+ /** Optional: step the physics world one fixed tick. If omitted, the game steps inside onPhysicsStep. */
137
+ stepWorld?(): void;
138
+ }
139
+ interface PredictionSyncOptions {
140
+ /** Max |localTick - (serverTick + driftTargetTicks)| before hard tick snap (no resim). Default 15. */
141
+ maxRewindTicks?: number;
142
+ /** Per-axis positional jump (units) above which a rollback error vector is suppressed (intentional teleport). Default 150. */
143
+ snapThreshold?: number;
144
+ /** Multiplicative error decay per render frame. Default 0.85. */
145
+ errorDecay?: number;
146
+ /** Max positional correction magnitude applied per render frame. 0 = disabled (full decaying error applied). Default 0. */
147
+ maxErrorPerFrame?: number;
148
+ /** Neutral input payload used as fallback for unknown ticks/peers. Default {}. */
149
+ neutralInput?: PlayerInput;
150
+ /** Tick-history ring size for local and per-peer inputs. Default 120. */
151
+ inputHistorySize?: number;
152
+ /** Rollback snap target offset: snap target = serverTick + driftTargetTicks. Default 4. */
153
+ driftTargetTicks?: number;
154
+ }
155
+ /** Fired by ClientReceiver after each snapshot is merged into the full world state. */
156
+ type SnapshotListener = (tick: number, entities: Map<string, EntityState>, hostInput: PlayerInput | undefined) => void;
157
+ /** Minimal structural source of merged snapshots (implemented by SnapshotSync). */
158
+ interface SnapshotSource {
159
+ onSnapshot(cb: SnapshotListener): void;
160
+ }
111
161
  interface SnapshotPacket {
112
162
  t: number;
113
163
  b: number;
114
164
  s: Uint8Array;
115
- hi?: unknown;
165
+ hi?: PlayerInput;
116
166
  }
117
167
  interface InputPacket {
168
+ /** Sender's local tick. */
118
169
  t: number;
119
- i: unknown;
170
+ /** Per-tick input payload. */
171
+ i: PlayerInput;
172
+ /** Sender peerId (informational; receivers MUST key by transport-provided peerId). */
120
173
  p: string;
121
174
  }
122
175
  interface EventPacket {
@@ -173,13 +226,7 @@ interface UseMultiplayerOptions {
173
226
  velocity?: number;
174
227
  custom?: 'strict' | number;
175
228
  };
176
- prediction?: {
177
- maxRewindTicks?: number;
178
- errorSmoothingDecay?: number;
179
- maxErrorPerFrame?: number;
180
- snapThreshold?: number;
181
- lagCompensation?: boolean;
182
- };
229
+ prediction?: PredictionSyncOptions;
183
230
  interpolation?: {
184
231
  bufferSize?: number;
185
232
  method?: 'hermite' | 'linear';
@@ -197,7 +244,9 @@ interface UseMultiplayerOptions {
197
244
  simulatedPacketLoss?: number;
198
245
  logLevel?: 'none' | 'error' | 'warn' | 'verbose';
199
246
  };
200
- onPhysicsStep?: (inputs: Map<string, unknown>, tick: number, isRollback: boolean) => void;
247
+ /** Optional: step the physics world one fixed tick. Used for both forward sim and rollback resim. */
248
+ stepWorld?: () => void;
249
+ onPhysicsStep?: PhysicsStepCallback;
201
250
  }
202
251
  interface MultiplayerContextValue {
203
252
  appId: string;
@@ -241,6 +290,8 @@ declare class TickKeeper {
241
290
  get drift(): number;
242
291
  /** Update server tick from received snapshot */
243
292
  setServerTick(serverTick: number): void;
293
+ /** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
294
+ snapTick(tick: number): void;
244
295
  /**
245
296
  * Accumulate time and return the number of fixed ticks to process.
246
297
  * Call this once per render frame with the raw frame delta.
@@ -302,13 +353,14 @@ declare class Codec {
302
353
  * Returns null if nothing changed.
303
354
  */
304
355
  computeDelta(current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined): EntityState[] | null;
305
- /** Serialize a delta snapshot packet */
306
- serializeDelta(tick: number, baseTick: number, current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined): Uint8Array | null;
356
+ /** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
357
+ serializeDelta(tick: number, baseTick: number, current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined, hostInput?: PlayerInput): Uint8Array | null;
307
358
  /** Deserialize a snapshot packet */
308
359
  deserializePacket(data: Uint8Array): {
309
360
  tick: number;
310
361
  baseTick: number;
311
362
  entities: EntityState[];
363
+ hostInput: PlayerInput | undefined;
312
364
  };
313
365
  private _hasChanged;
314
366
  private _quantizeEntity;
@@ -366,4 +418,4 @@ declare class NetworkManager {
366
418
  private _notifyRoomListeners;
367
419
  }
368
420
 
369
- export { type CarverTransport as C, type EntityState as E, type InputPacket as I, type JoinOptions as J, type MultiplayerContextValue as M, type NetworkQuality as N, type Player as P, type Room as R, SnapshotBuffer as S, type TransportConfig as T, type UseRoomOptions as U, type ChannelOptions as a, type CarverChannel as b, type RoomState as c, Codec as d, TickKeeper as e, type EntityState2D as f, type EntityState3D as g, type EventPacket as h, type SnapshotPacket as i, type SyncMode as j, NetworkManager as k, type ConnectionState as l, type CarverMultiplayerError as m, type UseLobbyOptions as n, type RoomConfig as o, type UseMultiplayerOptions as p, type CarverErrorCode as q };
421
+ export { type CarverTransport as C, type EntityState as E, type InputPacket as I, type JoinOptions as J, type MultiplayerContextValue as M, type NetworkQuality as N, type PlayerInput as P, type Room as R, SnapshotBuffer as S, type TransportConfig as T, type UseRoomOptions as U, type Player as a, type ChannelOptions as b, type CarverChannel as c, type RoomState as d, Codec as e, type SnapshotListener as f, type SnapshotSource as g, TickKeeper as h, type PredictionSyncOptions as i, type PhysicsStepCallback as j, type PredictionWorldDriver as k, type ErrorOffset as l, type EntityState2D as m, type EntityState3D as n, type EventPacket as o, type SnapshotPacket as p, type SyncMode as q, NetworkManager as r, type ConnectionState as s, type CarverMultiplayerError as t, type UseLobbyOptions as u, type RoomConfig as v, type UseMultiplayerOptions as w, type CarverErrorCode as x };
@@ -1,4 +1,4 @@
1
- import { S as SignalingStrategy } from './types-5LHBOW08.mjs';
1
+ import { S as SignalingStrategy } from './types-hNfCIBzj.js';
2
2
 
3
3
  type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'migrating' | 'reconnecting';
4
4
  type RoomState = 'lobby' | 'playing' | 'ended';
@@ -108,15 +108,68 @@ interface EntityState3D {
108
108
  c?: Record<string, unknown>;
109
109
  }
110
110
  type EntityState = EntityState2D | EntityState3D;
111
+ /** Flat per-tick input payload. Booleans get edge detection; numbers pass through. */
112
+ type PlayerInput = Record<string, boolean | number | undefined>;
113
+ /**
114
+ * Game simulation callback for prediction mode.
115
+ * Keys of both maps are peer ids (the local player appears under transport.peerId).
116
+ * Invoked once per fixed tick (isRollback=false) and once per resimulated tick (isRollback=true).
117
+ */
118
+ type PhysicsStepCallback = (inputs: Map<string, PlayerInput>, justPressed: Map<string, PlayerInput>, tick: number, isRollback: boolean, dt: number) => void;
119
+ /** Per-entity visual error offset produced by rollback. 2D uses x/y/a (z=0, q=identity); 3D uses x/y/z + quaternion (a=0). */
120
+ interface ErrorOffset {
121
+ x: number;
122
+ y: number;
123
+ z: number;
124
+ a: number;
125
+ qx: number;
126
+ qy: number;
127
+ qz: number;
128
+ qw: number;
129
+ }
130
+ /** World access used by PredictionSync for forward stepping and rollback. */
131
+ interface PredictionWorldDriver {
132
+ /** Read current state of every networked entity (raw physics, no error offsets). */
133
+ captureState(): Map<string, EntityState>;
134
+ /** Hard-apply states (position, rotation, velocities) to actors and rigid bodies, waking them. Skips tombstones. */
135
+ applyState(entities: Iterable<EntityState>): void;
136
+ /** Optional: step the physics world one fixed tick. If omitted, the game steps inside onPhysicsStep. */
137
+ stepWorld?(): void;
138
+ }
139
+ interface PredictionSyncOptions {
140
+ /** Max |localTick - (serverTick + driftTargetTicks)| before hard tick snap (no resim). Default 15. */
141
+ maxRewindTicks?: number;
142
+ /** Per-axis positional jump (units) above which a rollback error vector is suppressed (intentional teleport). Default 150. */
143
+ snapThreshold?: number;
144
+ /** Multiplicative error decay per render frame. Default 0.85. */
145
+ errorDecay?: number;
146
+ /** Max positional correction magnitude applied per render frame. 0 = disabled (full decaying error applied). Default 0. */
147
+ maxErrorPerFrame?: number;
148
+ /** Neutral input payload used as fallback for unknown ticks/peers. Default {}. */
149
+ neutralInput?: PlayerInput;
150
+ /** Tick-history ring size for local and per-peer inputs. Default 120. */
151
+ inputHistorySize?: number;
152
+ /** Rollback snap target offset: snap target = serverTick + driftTargetTicks. Default 4. */
153
+ driftTargetTicks?: number;
154
+ }
155
+ /** Fired by ClientReceiver after each snapshot is merged into the full world state. */
156
+ type SnapshotListener = (tick: number, entities: Map<string, EntityState>, hostInput: PlayerInput | undefined) => void;
157
+ /** Minimal structural source of merged snapshots (implemented by SnapshotSync). */
158
+ interface SnapshotSource {
159
+ onSnapshot(cb: SnapshotListener): void;
160
+ }
111
161
  interface SnapshotPacket {
112
162
  t: number;
113
163
  b: number;
114
164
  s: Uint8Array;
115
- hi?: unknown;
165
+ hi?: PlayerInput;
116
166
  }
117
167
  interface InputPacket {
168
+ /** Sender's local tick. */
118
169
  t: number;
119
- i: unknown;
170
+ /** Per-tick input payload. */
171
+ i: PlayerInput;
172
+ /** Sender peerId (informational; receivers MUST key by transport-provided peerId). */
120
173
  p: string;
121
174
  }
122
175
  interface EventPacket {
@@ -173,13 +226,7 @@ interface UseMultiplayerOptions {
173
226
  velocity?: number;
174
227
  custom?: 'strict' | number;
175
228
  };
176
- prediction?: {
177
- maxRewindTicks?: number;
178
- errorSmoothingDecay?: number;
179
- maxErrorPerFrame?: number;
180
- snapThreshold?: number;
181
- lagCompensation?: boolean;
182
- };
229
+ prediction?: PredictionSyncOptions;
183
230
  interpolation?: {
184
231
  bufferSize?: number;
185
232
  method?: 'hermite' | 'linear';
@@ -197,7 +244,9 @@ interface UseMultiplayerOptions {
197
244
  simulatedPacketLoss?: number;
198
245
  logLevel?: 'none' | 'error' | 'warn' | 'verbose';
199
246
  };
200
- onPhysicsStep?: (inputs: Map<string, unknown>, tick: number, isRollback: boolean) => void;
247
+ /** Optional: step the physics world one fixed tick. Used for both forward sim and rollback resim. */
248
+ stepWorld?: () => void;
249
+ onPhysicsStep?: PhysicsStepCallback;
201
250
  }
202
251
  interface MultiplayerContextValue {
203
252
  appId: string;
@@ -241,6 +290,8 @@ declare class TickKeeper {
241
290
  get drift(): number;
242
291
  /** Update server tick from received snapshot */
243
292
  setServerTick(serverTick: number): void;
293
+ /** Hard-set the local tick (prediction rollback snap). Does not touch the accumulator. */
294
+ snapTick(tick: number): void;
244
295
  /**
245
296
  * Accumulate time and return the number of fixed ticks to process.
246
297
  * Call this once per render frame with the raw frame delta.
@@ -302,13 +353,14 @@ declare class Codec {
302
353
  * Returns null if nothing changed.
303
354
  */
304
355
  computeDelta(current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined): EntityState[] | null;
305
- /** Serialize a delta snapshot packet */
306
- serializeDelta(tick: number, baseTick: number, current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined): Uint8Array | null;
356
+ /** Serialize a delta snapshot packet (optionally embedding the host's own input as `hi`) */
357
+ serializeDelta(tick: number, baseTick: number, current: Map<string, EntityState>, baseline: Map<string, EntityState> | undefined, hostInput?: PlayerInput): Uint8Array | null;
307
358
  /** Deserialize a snapshot packet */
308
359
  deserializePacket(data: Uint8Array): {
309
360
  tick: number;
310
361
  baseTick: number;
311
362
  entities: EntityState[];
363
+ hostInput: PlayerInput | undefined;
312
364
  };
313
365
  private _hasChanged;
314
366
  private _quantizeEntity;
@@ -366,4 +418,4 @@ declare class NetworkManager {
366
418
  private _notifyRoomListeners;
367
419
  }
368
420
 
369
- export { type CarverTransport as C, type EntityState as E, type InputPacket as I, type JoinOptions as J, type MultiplayerContextValue as M, type NetworkQuality as N, type Player as P, type Room as R, SnapshotBuffer as S, type TransportConfig as T, type UseRoomOptions as U, type ChannelOptions as a, type CarverChannel as b, type RoomState as c, Codec as d, TickKeeper as e, type EntityState2D as f, type EntityState3D as g, type EventPacket as h, type SnapshotPacket as i, type SyncMode as j, NetworkManager as k, type ConnectionState as l, type CarverMultiplayerError as m, type UseLobbyOptions as n, type RoomConfig as o, type UseMultiplayerOptions as p, type CarverErrorCode as q };
421
+ export { type CarverTransport as C, type EntityState as E, type InputPacket as I, type JoinOptions as J, type MultiplayerContextValue as M, type NetworkQuality as N, type PlayerInput as P, type Room as R, SnapshotBuffer as S, type TransportConfig as T, type UseRoomOptions as U, type Player as a, type ChannelOptions as b, type CarverChannel as c, type RoomState as d, Codec as e, type SnapshotListener as f, type SnapshotSource as g, TickKeeper as h, type PredictionSyncOptions as i, type PhysicsStepCallback as j, type PredictionWorldDriver as k, type ErrorOffset as l, type EntityState2D as m, type EntityState3D as n, type EventPacket as o, type SnapshotPacket as p, type SyncMode as q, NetworkManager as r, type ConnectionState as s, type CarverMultiplayerError as t, type UseLobbyOptions as u, type RoomConfig as v, type UseMultiplayerOptions as w, type CarverErrorCode as x };
@@ -76,6 +76,7 @@ var MqttStrategy = class {
76
76
  this._peerExpiryTimer = null;
77
77
  this._lobbySubscribed = false;
78
78
  this._destroyed = false;
79
+ this._lastAnnouncement = null;
79
80
  this.selfId = generatePeerId();
80
81
  this._appId = appId;
81
82
  this._config = config;
@@ -141,9 +142,19 @@ var MqttStrategy = class {
141
142
  removeFromArray(this._onLobby, cb);
142
143
  };
143
144
  }
145
+ updateRoomOccupancy(roomId, playerCount, state) {
146
+ const ann = this._lastAnnouncement;
147
+ if (!ann || ann.roomId !== roomId || !this._client) return;
148
+ ann.playerCount = playerCount;
149
+ if (state) ann.state = state;
150
+ ann.lastSeen = Date.now();
151
+ const topic = mqttTopics(this._appId, roomId, "").roomLobbyEntry;
152
+ this._client.publish(topic, JSON.stringify(ann), { retain: true, qos: 1 });
153
+ }
144
154
  announceRoom(announcement) {
145
155
  if (!this._client) return;
146
156
  const topic = mqttTopics(this._appId, announcement.roomId, "").roomLobbyEntry;
157
+ this._lastAnnouncement = announcement;
147
158
  announcement.lastSeen = Date.now();
148
159
  this._client.publish(topic, JSON.stringify(announcement), { retain: true, qos: 1 });
149
160
  if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
@@ -341,6 +352,8 @@ var FirebaseStrategy = class {
341
352
  // State
342
353
  this._knownPeers = /* @__PURE__ */ new Set();
343
354
  this._lobbyAnnounceTimer = null;
355
+ this._lastAnnouncement = null;
356
+ this._lobbyWired = false;
344
357
  this._destroyed = false;
345
358
  this.selfId = generatePeerId();
346
359
  this._appId = appId;
@@ -356,7 +369,7 @@ var FirebaseStrategy = class {
356
369
  this._joinGeneration++;
357
370
  this._roomId = roomId;
358
371
  this._peerMeta = peerMeta;
359
- const { ref, set, onChildAdded, onChildRemoved, onDisconnect, remove } = this._fb;
372
+ const { ref, set, onChildAdded, onChildRemoved, onDisconnect, onValue, remove } = this._fb;
360
373
  const paths = firebasePaths(this._appId, roomId, this.selfId);
361
374
  await remove(ref(this._db, paths.peerSignalInbox)).catch(() => {
362
375
  });
@@ -367,6 +380,18 @@ var FirebaseStrategy = class {
367
380
  ts: Date.now()
368
381
  });
369
382
  onDisconnect(presenceRef).remove();
383
+ const generation = this._joinGeneration;
384
+ const connectedRef = ref(this._db, ".info/connected");
385
+ const connectedUnsub = onValue(connectedRef, (snap) => {
386
+ if (snap.val() !== true) return;
387
+ if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
388
+ onDisconnect(presenceRef).remove().then(() => {
389
+ if (this._destroyed || this._joinGeneration !== generation || this._roomId !== roomId) return;
390
+ return set(presenceRef, { peerId: this.selfId, meta: peerMeta, ts: Date.now() });
391
+ }).catch(() => {
392
+ });
393
+ });
394
+ this._listeners.push(() => connectedUnsub());
370
395
  const peersRef = ref(this._db, paths.peers);
371
396
  const addedUnsub = onChildAdded(peersRef, (snapshot) => {
372
397
  const data = snapshot.val();
@@ -429,6 +454,12 @@ var FirebaseStrategy = class {
429
454
  }
430
455
  subscribeToLobby(cb) {
431
456
  this._onLobby.push(cb);
457
+ if (this._lobbyWired) {
458
+ return () => {
459
+ removeFromArray(this._onLobby, cb);
460
+ };
461
+ }
462
+ this._lobbyWired = true;
432
463
  this._ensureInit().then(() => {
433
464
  if (!this._db || !this._fb || this._destroyed) return;
434
465
  const { ref, onValue } = this._fb;
@@ -456,14 +487,25 @@ var FirebaseStrategy = class {
456
487
  if (!this._db || !this._fb) return;
457
488
  const { ref, set } = this._fb;
458
489
  const paths = firebasePaths(this._appId, announcement.roomId, "");
490
+ this._lastAnnouncement = announcement;
459
491
  announcement.lastSeen = Date.now();
460
- set(ref(this._db, paths.roomLobbyEntry), announcement);
492
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
461
493
  if (this._lobbyAnnounceTimer) clearInterval(this._lobbyAnnounceTimer);
462
494
  this._lobbyAnnounceTimer = setInterval(() => {
463
495
  announcement.lastSeen = Date.now();
464
- set(ref(this._db, paths.roomLobbyEntry), announcement);
496
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(announcement));
465
497
  }, ROOM_ANNOUNCE_INTERVAL_MS);
466
498
  }
499
+ updateRoomOccupancy(roomId, playerCount, state) {
500
+ const ann = this._lastAnnouncement;
501
+ if (!ann || ann.roomId !== roomId || !this._db || !this._fb) return;
502
+ ann.playerCount = playerCount;
503
+ if (state) ann.state = state;
504
+ ann.lastSeen = Date.now();
505
+ const { ref, set } = this._fb;
506
+ const paths = firebasePaths(this._appId, roomId, "");
507
+ set(ref(this._db, paths.roomLobbyEntry), sanitizeForFirebase(ann));
508
+ }
467
509
  removeRoomAnnouncement(roomId) {
468
510
  if (!this._db || !this._fb) return;
469
511
  const { ref, remove } = this._fb;
@@ -566,6 +608,7 @@ function sanitizeForFirebase(obj) {
566
608
  if (typeof obj === "object" && obj !== null) {
567
609
  const result = {};
568
610
  for (const [key, value] of Object.entries(obj)) {
611
+ if (value === void 0) continue;
569
612
  result[key] = value === null ? "__null__" : sanitizeForFirebase(value);
570
613
  }
571
614
  return result;
@@ -578,4 +621,4 @@ export {
578
621
  MqttStrategy,
579
622
  FirebaseStrategy
580
623
  };
581
- //# sourceMappingURL=chunk-UD6FDZMX.mjs.map
624
+ //# sourceMappingURL=chunk-GOTAQDBJ.mjs.map