@kikorin/netcode 1.0.0
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/dist/index.cjs +1072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +375 -0
- package/dist/index.d.ts +375 -0
- package/dist/index.js +1030 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
type PeerId = string;
|
|
2
|
+
type GroupId = string;
|
|
3
|
+
type EntityId = number;
|
|
4
|
+
type ComponentId = number;
|
|
5
|
+
type FieldId = number;
|
|
6
|
+
type SequenceNumber = number;
|
|
7
|
+
type NetTypedArray = Float32Array | Float64Array | Int32Array | Uint32Array | Uint16Array | Uint8Array | Int8Array | Int16Array;
|
|
8
|
+
declare const HEADER_SIZE = 8;
|
|
9
|
+
declare const enum MessageType {
|
|
10
|
+
Handshake = 1,
|
|
11
|
+
Subscribe = 2,
|
|
12
|
+
Unsubscribe = 3,
|
|
13
|
+
DeltaUpdate = 4,
|
|
14
|
+
FullSync = 5,
|
|
15
|
+
Ack = 6,
|
|
16
|
+
Ping = 7,
|
|
17
|
+
Pong = 8,
|
|
18
|
+
LeadClaim = 9,
|
|
19
|
+
LeadYield = 10,
|
|
20
|
+
PeerList = 11,
|
|
21
|
+
GameEvent = 12
|
|
22
|
+
}
|
|
23
|
+
declare const enum MessageFlag {
|
|
24
|
+
None = 0,
|
|
25
|
+
Reliable = 1,
|
|
26
|
+
Compressed = 2,
|
|
27
|
+
Fragmented = 4
|
|
28
|
+
}
|
|
29
|
+
interface NetMessage {
|
|
30
|
+
type: MessageType;
|
|
31
|
+
flags: MessageFlag;
|
|
32
|
+
seq: SequenceNumber;
|
|
33
|
+
ack: SequenceNumber;
|
|
34
|
+
payload: ArrayBuffer;
|
|
35
|
+
}
|
|
36
|
+
interface FieldSchema {
|
|
37
|
+
id: FieldId;
|
|
38
|
+
name: string;
|
|
39
|
+
/** Direct reference to the bitecs component TypedArray (e.g. Position.x) */
|
|
40
|
+
array: NetTypedArray;
|
|
41
|
+
}
|
|
42
|
+
interface ComponentSchema {
|
|
43
|
+
id: ComponentId;
|
|
44
|
+
name: string;
|
|
45
|
+
fields: FieldSchema[];
|
|
46
|
+
}
|
|
47
|
+
interface InterestGroupConfig {
|
|
48
|
+
id: GroupId;
|
|
49
|
+
/** Max entities tracked by this group. Default 4096. */
|
|
50
|
+
maxEntities?: number;
|
|
51
|
+
/** Flush interval in ms. Default 50 (20hz). */
|
|
52
|
+
tickRateMs?: number;
|
|
53
|
+
/** Lead election strategy. Default 'min-id'. */
|
|
54
|
+
electionStrategy?: ElectionStrategy;
|
|
55
|
+
}
|
|
56
|
+
interface PeerNetConfig {
|
|
57
|
+
peerId: PeerId;
|
|
58
|
+
/** Exponential moving average alpha for RTT. Default 0.125. */
|
|
59
|
+
rttAlpha?: number;
|
|
60
|
+
}
|
|
61
|
+
interface DeltaEntry {
|
|
62
|
+
entityId: EntityId;
|
|
63
|
+
componentId: ComponentId;
|
|
64
|
+
fieldId: FieldId;
|
|
65
|
+
value: number;
|
|
66
|
+
}
|
|
67
|
+
type DeltaSet = DeltaEntry[];
|
|
68
|
+
type DeltaHandler = (deltas: DeltaSet, groupId: GroupId, fromPeer: PeerId) => void;
|
|
69
|
+
type ElectionStrategy = 'min-id' | 'hash-ring' | 'load-balanced';
|
|
70
|
+
interface LoadInfo {
|
|
71
|
+
peerId: PeerId;
|
|
72
|
+
connectionCount: number;
|
|
73
|
+
leadGroupCount: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PeerJSDataConnection {
|
|
77
|
+
readonly peer: string;
|
|
78
|
+
readonly open: boolean;
|
|
79
|
+
send(data: ArrayBuffer | Uint8Array): void;
|
|
80
|
+
close(): void;
|
|
81
|
+
on(event: 'data', cb: (data: unknown) => void): void;
|
|
82
|
+
on(event: 'open', cb: () => void): void;
|
|
83
|
+
on(event: 'close', cb: () => void): void;
|
|
84
|
+
on(event: 'error', cb: (err: Error) => void): void;
|
|
85
|
+
}
|
|
86
|
+
interface PeerJSPeer {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
connect(peerId: string, options?: {
|
|
89
|
+
reliable?: boolean;
|
|
90
|
+
serialization?: string;
|
|
91
|
+
}): PeerJSDataConnection;
|
|
92
|
+
on(event: 'connection', cb: (conn: PeerJSDataConnection) => void): void;
|
|
93
|
+
on(event: 'open', cb: (id: string) => void): void;
|
|
94
|
+
on(event: 'error', cb: (err: Error) => void): void;
|
|
95
|
+
on(event: 'close', cb: () => void): void;
|
|
96
|
+
destroy(): void;
|
|
97
|
+
}
|
|
98
|
+
type DataHandler = (data: ArrayBuffer, from: PeerId) => void;
|
|
99
|
+
type DisconnectHandler = (peerId: PeerId) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Manages the lifecycle of PeerJS data connections.
|
|
102
|
+
* Provides:
|
|
103
|
+
* - Lazy connection (auto-connects on first send)
|
|
104
|
+
* - Per-connection message queue drained on open
|
|
105
|
+
* - Exponential-backoff reconnect on close/error
|
|
106
|
+
* - Deduplication of inbound vs outbound connections to the same peer
|
|
107
|
+
*/
|
|
108
|
+
declare class ConnectionPool {
|
|
109
|
+
private _peer;
|
|
110
|
+
private _conns;
|
|
111
|
+
private _handlers;
|
|
112
|
+
private _disconnectHandlers;
|
|
113
|
+
private _reconnectDelay;
|
|
114
|
+
private _reconnectHandles;
|
|
115
|
+
private static readonly BASE_DELAY;
|
|
116
|
+
private static readonly MAX_DELAY;
|
|
117
|
+
setPeer(peer: PeerJSPeer): void;
|
|
118
|
+
connect(peerId: PeerId): Promise<void>;
|
|
119
|
+
send(peerId: PeerId, data: ArrayBuffer): void;
|
|
120
|
+
isOpen(peerId: PeerId): boolean;
|
|
121
|
+
disconnect(peerId: PeerId): void;
|
|
122
|
+
onData(handler: DataHandler): () => void;
|
|
123
|
+
/** Fires when a connection closes unexpectedly (not from a local disconnect() call). */
|
|
124
|
+
onDisconnect(handler: DisconnectHandler): () => void;
|
|
125
|
+
dispose(): void;
|
|
126
|
+
private _registerConn;
|
|
127
|
+
private _scheduleReconnect;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PeerState {
|
|
131
|
+
readonly peerId: PeerId;
|
|
132
|
+
isLead: boolean;
|
|
133
|
+
joinedAt: number;
|
|
134
|
+
lastSeenAt: number;
|
|
135
|
+
rttMs: number;
|
|
136
|
+
}
|
|
137
|
+
type SendFn = (to: PeerId, msg: NetMessage) => void;
|
|
138
|
+
/**
|
|
139
|
+
* Manages one interest group: membership, lead election, and message routing.
|
|
140
|
+
*
|
|
141
|
+
* Routing rules:
|
|
142
|
+
* - Non-lead peers send outbound deltas → lead only
|
|
143
|
+
* - Lead receives deltas → re-broadcasts to all other group peers + notifies local handlers
|
|
144
|
+
* - Local delta handlers always fire regardless of lead status
|
|
145
|
+
*
|
|
146
|
+
* Lead election is deterministic (all peers run the same algorithm) and
|
|
147
|
+
* re-runs on every membership change. No election messages are needed for
|
|
148
|
+
* convergence; LeadClaim messages just inform remote peers of the local result.
|
|
149
|
+
*/
|
|
150
|
+
declare class InterestGroup {
|
|
151
|
+
readonly id: GroupId;
|
|
152
|
+
private _config;
|
|
153
|
+
private _localPeerId;
|
|
154
|
+
private _peers;
|
|
155
|
+
private _leadId;
|
|
156
|
+
private _elector;
|
|
157
|
+
private _sendFn;
|
|
158
|
+
private _deltaHandlers;
|
|
159
|
+
private _pending;
|
|
160
|
+
private _tickHandle;
|
|
161
|
+
private _seq;
|
|
162
|
+
private _ack;
|
|
163
|
+
constructor(config: InterestGroupConfig, localPeerId: PeerId);
|
|
164
|
+
get leadId(): PeerId | null;
|
|
165
|
+
get isLead(): boolean;
|
|
166
|
+
get peerCount(): number;
|
|
167
|
+
getPeer(peerId: PeerId): Readonly<PeerState> | undefined;
|
|
168
|
+
get peerIds(): ReadonlySet<PeerId>;
|
|
169
|
+
setSendFn(fn: SendFn): void;
|
|
170
|
+
addPeer(peerId: PeerId): void;
|
|
171
|
+
removePeer(peerId: PeerId): void;
|
|
172
|
+
/** Queue deltas to be sent on the next tick flush */
|
|
173
|
+
publishDeltas(deltas: DeltaSet): void;
|
|
174
|
+
/** Send all pending deltas immediately (called by the tick interval) */
|
|
175
|
+
flush(): void;
|
|
176
|
+
handleMessage(msg: NetMessage, fromPeer: PeerId): void;
|
|
177
|
+
onDelta(handler: DeltaHandler): () => void;
|
|
178
|
+
startTick(): void;
|
|
179
|
+
stopTick(): void;
|
|
180
|
+
dispose(): void;
|
|
181
|
+
private _reelect;
|
|
182
|
+
private _broadcastLeadClaim;
|
|
183
|
+
private _onDeltaUpdate;
|
|
184
|
+
private _onFullSync;
|
|
185
|
+
private _onLeadClaim;
|
|
186
|
+
private _nextSeq;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* PeerNet — top-level peer-to-peer netcode facade.
|
|
191
|
+
*
|
|
192
|
+
* Responsibilities:
|
|
193
|
+
* - Owns the ConnectionPool (PeerJS connections)
|
|
194
|
+
* - Owns all InterestGroups (pub/sub routing)
|
|
195
|
+
* - Owns the ChangeTracker (minimal delta generation)
|
|
196
|
+
* - Routes raw inbound buffers to the correct group
|
|
197
|
+
* - RTT estimation via Ping/Pong
|
|
198
|
+
*
|
|
199
|
+
* Typical usage per game tick:
|
|
200
|
+
* 1. ECS systems run; call net.markEntityDirty(eid) for any changed entity
|
|
201
|
+
* 2. Call net.flushGroupDeltas(groupId, entityList) to compute+queue deltas
|
|
202
|
+
* 3. InterestGroup tick fires automatically (setInterval) and sends queued data
|
|
203
|
+
*
|
|
204
|
+
* Incoming deltas from remote peers surface via net.onGroupDelta(groupId, handler).
|
|
205
|
+
* Apply them to your ECS TypedArrays inside that handler.
|
|
206
|
+
*/
|
|
207
|
+
declare class PeerNet {
|
|
208
|
+
readonly localPeerId: PeerId;
|
|
209
|
+
private _config;
|
|
210
|
+
private _pool;
|
|
211
|
+
private _groups;
|
|
212
|
+
private _tracker;
|
|
213
|
+
private _seq;
|
|
214
|
+
private _ack;
|
|
215
|
+
private _rttMs;
|
|
216
|
+
private _gameEventHandlers;
|
|
217
|
+
constructor(config: PeerNetConfig);
|
|
218
|
+
/** Attach the live PeerJS Peer instance after it opens. */
|
|
219
|
+
attachPeer(peer: PeerJSPeer): void;
|
|
220
|
+
registerComponent(schema: ComponentSchema): void;
|
|
221
|
+
unregisterComponent(componentId: number): void;
|
|
222
|
+
createGroup(config: InterestGroupConfig): InterestGroup;
|
|
223
|
+
destroyGroup(groupId: GroupId): void;
|
|
224
|
+
getGroup(groupId: GroupId): InterestGroup | undefined;
|
|
225
|
+
connectPeer(peerId: PeerId): Promise<void>;
|
|
226
|
+
disconnectPeer(peerId: PeerId): void;
|
|
227
|
+
onPeerDisconnect(handler: (peerId: PeerId) => void): () => void;
|
|
228
|
+
sendGameEvent(peerId: PeerId, payload: ArrayBuffer): void;
|
|
229
|
+
onGameEvent(handler: (payload: ArrayBuffer, from: PeerId) => void): () => void;
|
|
230
|
+
/**
|
|
231
|
+
* Subscribe the local peer to a group and introduce remote peers.
|
|
232
|
+
* Sends a Subscribe control message to each remote peer.
|
|
233
|
+
*/
|
|
234
|
+
joinGroup(groupId: GroupId, remotePeers: PeerId[]): void;
|
|
235
|
+
/**
|
|
236
|
+
* Leave a group: notify remote peers and remove local state.
|
|
237
|
+
*/
|
|
238
|
+
leaveGroup(groupId: GroupId): void;
|
|
239
|
+
markEntityDirty(entityId: EntityId): void;
|
|
240
|
+
markEntitiesDirty(entities: EntityId[]): void;
|
|
241
|
+
invalidateEntity(entityId: EntityId): void;
|
|
242
|
+
/**
|
|
243
|
+
* Compute deltas for dirty entities and push them into the group's send queue.
|
|
244
|
+
* The group's tick interval handles the actual sending.
|
|
245
|
+
*
|
|
246
|
+
* Call once per game tick, after ECS systems run.
|
|
247
|
+
*/
|
|
248
|
+
flushGroupDeltas(groupId: GroupId, entities: EntityId[]): void;
|
|
249
|
+
/**
|
|
250
|
+
* Send a full-state sync to a newly joined peer for a given group.
|
|
251
|
+
* Generates a snapshot of all registered component fields for all provided entities.
|
|
252
|
+
*/
|
|
253
|
+
sendFullSync(groupId: GroupId, toPeer: PeerId, entities: EntityId[]): void;
|
|
254
|
+
onGroupDelta(groupId: GroupId, handler: DeltaHandler): () => void;
|
|
255
|
+
get rttMs(): number;
|
|
256
|
+
get dirtyEntityCount(): number;
|
|
257
|
+
ping(peerId: PeerId): void;
|
|
258
|
+
dispose(): void;
|
|
259
|
+
private _onRawData;
|
|
260
|
+
private _onSubscribe;
|
|
261
|
+
private _onUnsubscribe;
|
|
262
|
+
private _sendSubscribe;
|
|
263
|
+
private _sendUnsubscribe;
|
|
264
|
+
private _send;
|
|
265
|
+
private _nextSeq;
|
|
266
|
+
private _requireGroup;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Tracks the last-flushed values of registered ECS components and produces
|
|
271
|
+
* minimal delta sets by comparing current TypedArray state to the snapshot.
|
|
272
|
+
*
|
|
273
|
+
* Thread of ownership: single-threaded (JS). Call markDirty() whenever an
|
|
274
|
+
* ECS system modifies a component, then flush() once per network tick to
|
|
275
|
+
* collect only what changed.
|
|
276
|
+
*/
|
|
277
|
+
declare class ChangeTracker {
|
|
278
|
+
private _schemas;
|
|
279
|
+
private _snapshots;
|
|
280
|
+
private _dirtySet;
|
|
281
|
+
registerComponent(schema: ComponentSchema): void;
|
|
282
|
+
unregisterComponent(componentId: ComponentId): void;
|
|
283
|
+
markDirty(entityId: EntityId): void;
|
|
284
|
+
markDirtyBatch(entities: EntityId[]): void;
|
|
285
|
+
get dirtyCount(): number;
|
|
286
|
+
/**
|
|
287
|
+
* Compute deltas for a subset of entities and update the snapshot.
|
|
288
|
+
* Only entities that were marked dirty are compared; clean entities are skipped.
|
|
289
|
+
* Clears dirty flags for all entities in the provided list.
|
|
290
|
+
*/
|
|
291
|
+
flush(entities: EntityId[]): DeltaSet;
|
|
292
|
+
/**
|
|
293
|
+
* Force-flush all registered fields for the given entities, ignoring dirty state.
|
|
294
|
+
* Use when a new peer joins and needs a full state sync.
|
|
295
|
+
*/
|
|
296
|
+
fullSnapshot(entities: EntityId[]): DeltaSet;
|
|
297
|
+
/**
|
|
298
|
+
* Invalidate the snapshot for an entity (e.g. after it is destroyed and respawned).
|
|
299
|
+
* Next flush will treat all fields as changed.
|
|
300
|
+
*/
|
|
301
|
+
invalidateEntity(entityId: EntityId): void;
|
|
302
|
+
clearDirtyAll(): void;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Deterministic lead election for an interest group.
|
|
307
|
+
*
|
|
308
|
+
* All peers in a group run the same algorithm over the same candidate list,
|
|
309
|
+
* so they converge on the same lead without coordination messages. When the
|
|
310
|
+
* candidate set changes (peer joins/leaves), every peer re-elects locally.
|
|
311
|
+
*
|
|
312
|
+
* Strategies:
|
|
313
|
+
* min-id — lexicographically smallest peer ID wins. Zero coordination
|
|
314
|
+
* overhead, deterministic, but always assigns load to the
|
|
315
|
+
* same peer. Best for low-churn groups.
|
|
316
|
+
*
|
|
317
|
+
* hash-ring — consistent hashing with 20 virtual nodes per peer.
|
|
318
|
+
* Spreads lead responsibility across peers as groups multiply.
|
|
319
|
+
* A single peer join/leave only re-assigns ~1/N groups.
|
|
320
|
+
*
|
|
321
|
+
* load-balanced — weighted score from live load metrics sent by peers.
|
|
322
|
+
* Falls back to min-id for peers without reported metrics.
|
|
323
|
+
* Best when peers have heterogeneous capacity.
|
|
324
|
+
*/
|
|
325
|
+
declare class LeadElector {
|
|
326
|
+
private _strategy;
|
|
327
|
+
private _loadInfo;
|
|
328
|
+
constructor(strategy?: ElectionStrategy);
|
|
329
|
+
updateLoadInfo(info: LoadInfo): void;
|
|
330
|
+
removeLoadInfo(peerId: PeerId): void;
|
|
331
|
+
electLead(groupId: GroupId, candidates: PeerId[]): PeerId;
|
|
332
|
+
wouldReelect(groupId: GroupId, currentLead: PeerId, candidates: PeerId[]): boolean;
|
|
333
|
+
private _minId;
|
|
334
|
+
private _hashRing;
|
|
335
|
+
private _loadBalanced;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
declare function encodeHeader(view: DataView, type: MessageType, flags: MessageFlag, seq: number, ack: number, payloadLen: number): void;
|
|
339
|
+
declare function decodeHeader(view: DataView): {
|
|
340
|
+
type: MessageType;
|
|
341
|
+
flags: MessageFlag;
|
|
342
|
+
seq: number;
|
|
343
|
+
ack: number;
|
|
344
|
+
payloadLen: number;
|
|
345
|
+
};
|
|
346
|
+
declare function encodeMessage(msg: NetMessage): ArrayBuffer;
|
|
347
|
+
declare function decodeMessage(buf: ArrayBuffer): NetMessage;
|
|
348
|
+
declare function encodeDeltaPayload(groupId: string, deltas: DeltaSet): ArrayBuffer;
|
|
349
|
+
declare function decodeDeltaPayload(buf: ArrayBuffer): {
|
|
350
|
+
groupId: string;
|
|
351
|
+
deltas: DeltaSet;
|
|
352
|
+
};
|
|
353
|
+
declare function encodeJson(obj: Record<string, unknown>): ArrayBuffer;
|
|
354
|
+
declare function decodeJson(buf: ArrayBuffer): Record<string, unknown>;
|
|
355
|
+
|
|
356
|
+
interface PQEntry<T> {
|
|
357
|
+
readonly priority: number;
|
|
358
|
+
readonly seq: number;
|
|
359
|
+
readonly value: T;
|
|
360
|
+
}
|
|
361
|
+
declare class PriorityQueue<T> {
|
|
362
|
+
private _heap;
|
|
363
|
+
private _seq;
|
|
364
|
+
get size(): number;
|
|
365
|
+
get isEmpty(): boolean;
|
|
366
|
+
push(value: T, priority: number): void;
|
|
367
|
+
pop(): T | undefined;
|
|
368
|
+
peek(): T | undefined;
|
|
369
|
+
clear(): void;
|
|
370
|
+
private _lt;
|
|
371
|
+
private _bubbleUp;
|
|
372
|
+
private _siftDown;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export { ChangeTracker, type ComponentId, type ComponentSchema, ConnectionPool, type DeltaEntry, type DeltaHandler, type DeltaSet, type ElectionStrategy, type EntityId, type FieldId, type FieldSchema, type GroupId, HEADER_SIZE, InterestGroup, type InterestGroupConfig, LeadElector, type LoadInfo, MessageFlag, MessageType, type NetMessage, type NetTypedArray, type PQEntry, type PeerId, type PeerJSDataConnection, type PeerJSPeer, PeerNet, type PeerNetConfig, type PeerState, PriorityQueue, type SendFn, type SequenceNumber, decodeDeltaPayload, decodeHeader, decodeJson, decodeMessage, encodeDeltaPayload, encodeHeader, encodeJson, encodeMessage };
|