@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.
- package/README.md +154 -0
- package/dist/InputBuffer-J6XT_Tt0.d.mts +61 -0
- package/dist/InputBuffer-V7XfHbc6.d.ts +61 -0
- package/dist/{NetworkManager-nvVAOr1O.d.ts → NetworkManager-D-DxFgdM.d.mts} +66 -14
- package/dist/{NetworkManager-DrKM2tEx.d.mts → NetworkManager-DH9uGVMg.d.ts} +66 -14
- package/dist/{chunk-UD6FDZMX.mjs → chunk-GOTAQDBJ.mjs} +47 -4
- package/dist/chunk-GOTAQDBJ.mjs.map +1 -0
- package/dist/{chunk-3KT73N2S.mjs → chunk-LPNEP2VH.mjs} +0 -0
- package/dist/chunk-LPNEP2VH.mjs.map +1 -0
- package/dist/{chunk-EO3YNPRQ.mjs → chunk-Q25TJEY4.mjs} +494 -204
- package/dist/chunk-Q25TJEY4.mjs.map +1 -0
- package/dist/{firebase-CPu87KA0.d.ts → firebase-B5MgLlHk.d.ts} +6 -1
- package/dist/{firebase-PE6MxGdJ.d.mts → firebase-GrbVrNgs.d.mts} +6 -1
- package/dist/index.d.mts +27 -6
- package/dist/index.d.ts +27 -6
- package/dist/index.js +821 -258
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -37
- package/dist/index.mjs.map +1 -1
- package/dist/strategy.d.mts +2 -2
- package/dist/strategy.d.ts +2 -2
- package/dist/strategy.js +46 -3
- package/dist/strategy.js.map +1 -1
- package/dist/strategy.mjs +1 -1
- package/dist/sync.d.mts +134 -50
- package/dist/sync.d.ts +134 -50
- package/dist/sync.js +499 -205
- package/dist/sync.js.map +1 -1
- package/dist/sync.mjs +15 -3
- package/dist/transport.d.mts +0 -0
- package/dist/transport.d.ts +0 -0
- package/dist/transport.js +0 -0
- package/dist/transport.js.map +1 -1
- package/dist/transport.mjs +2 -2
- package/dist/{types-5LHBOW08.d.mts → types-hNfCIBzj.d.mts} +7 -0
- package/dist/{types-5LHBOW08.d.ts → types-hNfCIBzj.d.ts} +7 -0
- package/dist/types.d.mts +2 -2
- package/dist/types.d.ts +2 -2
- package/dist/types.js.map +1 -1
- package/package.json +26 -5
- package/dist/chunk-3KT73N2S.mjs.map +0 -1
- package/dist/chunk-EO3YNPRQ.mjs.map +0 -1
- package/dist/chunk-UD6FDZMX.mjs.map +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# @carverjs/multiplayer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@carverjs/multiplayer)
|
|
4
|
+
[](https://github.com/MoneyTales/carverjs/blob/main/LICENSE)
|
|
5
|
+
[](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-
|
|
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?:
|
|
165
|
+
hi?: PlayerInput;
|
|
116
166
|
}
|
|
117
167
|
interface InputPacket {
|
|
168
|
+
/** Sender's local tick. */
|
|
118
169
|
t: number;
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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?:
|
|
165
|
+
hi?: PlayerInput;
|
|
116
166
|
}
|
|
117
167
|
interface InputPacket {
|
|
168
|
+
/** Sender's local tick. */
|
|
118
169
|
t: number;
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
624
|
+
//# sourceMappingURL=chunk-GOTAQDBJ.mjs.map
|