@net-mesh/browser 0.42.0 → 0.44.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +12 -0
  3. package/dist/index.bundle.js +2 -2
  4. package/dist/leader/session.d.ts +4 -2
  5. package/dist/leader/session.js +30 -6
  6. package/dist/lobby.d.ts +29 -2
  7. package/dist/lobby.js +80 -10
  8. package/dist/net_leaf.d.ts +19 -21
  9. package/dist/net_leaf.js +42 -44
  10. package/dist/net_leaf_bg.wasm +0 -0
  11. package/dist/net_leaf_bg.wasm.d.ts +10 -10
  12. package/dist/netcode/assignment.d.ts +169 -0
  13. package/dist/netcode/assignment.js +321 -0
  14. package/dist/netcode/client.d.ts +125 -3
  15. package/dist/netcode/client.js +291 -27
  16. package/dist/netcode/codec.d.ts +180 -0
  17. package/dist/netcode/codec.js +547 -0
  18. package/dist/netcode/host.d.ts +58 -1
  19. package/dist/netcode/host.js +254 -9
  20. package/dist/netcode/index.d.ts +14 -4
  21. package/dist/netcode/index.js +8 -3
  22. package/dist/netcode/interpolate.d.ts +24 -1
  23. package/dist/netcode/interpolate.js +52 -1
  24. package/dist/netcode/paths.d.ts +115 -0
  25. package/dist/netcode/paths.js +261 -0
  26. package/dist/netcode/shards.d.ts +138 -0
  27. package/dist/netcode/shards.js +270 -0
  28. package/dist/netcode/wire.d.ts +37 -3
  29. package/dist/netcode/wire.js +28 -1
  30. package/dist/node.d.ts +20 -0
  31. package/dist/node.js +76 -1
  32. package/dist/store/host.js +10 -4
  33. package/dist/store/join.js +38 -2
  34. package/dist/store/owner.d.ts +11 -0
  35. package/dist/store/owner.js +27 -7
  36. package/dist/store/types.d.ts +6 -0
  37. package/dist/stream.d.ts +29 -1
  38. package/dist/stream.js +66 -4
  39. package/dist/udp-probe.d.ts +21 -3
  40. package/dist/udp-probe.js +78 -7
  41. package/dist/world/link.d.ts +9 -0
  42. package/dist/world/link.js +4 -3
  43. package/dist/world/regions.d.ts +9 -2
  44. package/dist/world/regions.js +34 -4
  45. package/package.json +3 -2
@@ -16,7 +16,28 @@
16
16
  */
17
17
  import { ClockEstimator } from './clock.js';
18
18
  import { lerpNumbers, SnapshotBuffer } from './interpolate.js';
19
- import { MAX_NETCODE_INTEREST_KEYS, MAX_NETCODE_INTEREST_KEY_LENGTH, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, peerHex, } from './wire.js';
19
+ import { MAX_NETCODE_INTEREST_KEYS, MAX_NETCODE_INTEREST_KEY_LENGTH, MAX_NAME_REQUESTS, NAME_FORGET_MS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, peerHex, } from './wire.js';
20
+ import { decodeBinarySnapshot, isBinaryFrame } from './codec.js';
21
+ /** A typed join failure from {@link NetcodeClient.ready}. */
22
+ export class NetcodeError extends Error {
23
+ kind;
24
+ constructor(kind, message) {
25
+ super(message);
26
+ this.kind = kind;
27
+ this.name = 'NetcodeError';
28
+ }
29
+ }
30
+ /**
31
+ * Unacknowledged inputs one frame may carry when there is a backlog — the
32
+ * inputs held across a change of owner while the new epoch propagated. One
33
+ * frame, so they arrive together: inputs split over frames can land in two
34
+ * ticks, and a host skips the gap the earlier frame would have filled.
35
+ */
36
+ const MAX_INPUT_BACKLOG = 64;
37
+ /** A fresh {@link InputSequencer}: `seq` 0, nothing pending. */
38
+ export function createInputSequencer() {
39
+ return { seq: 0, pending: [] };
40
+ }
20
41
  /** Join a host's netcode. */
21
42
  export function joinNetcode(options) {
22
43
  const now = options.now ?? defaultNow;
@@ -25,14 +46,22 @@ export function joinNetcode(options) {
25
46
  throw new TypeError(`host is not a node id: ${JSON.stringify(options.host)}`);
26
47
  const label = options.label;
27
48
  const delay = options.interpolationDelayMs ?? 100;
28
- const blend = options.interpolate ?? lerpNumbers;
49
+ const blend = options.interpolate ?? options.codec?.interpolate ?? lerpNumbers;
29
50
  const redundancy = Math.max(1, options.inputRedundancy ?? 8);
30
51
  const clock = new ClockEstimator();
31
- const snapshots = new SnapshotBuffer();
52
+ // Replaced, not cleared, when a binary snapshot shows a re-created host.
53
+ let snapshots = new SnapshotBuffer();
32
54
  let stream = null;
33
55
  let closed = false;
34
- let seq = 0;
35
- let pending = [];
56
+ // Shared with the other shards' clients when `sequencer` is given, so the
57
+ // sequence and the unacknowledged inputs survive a change of owner.
58
+ const inputs = options.sequencer ?? createInputSequencer();
59
+ // Whether this client speaks for the inputs: sends them, and trims them
60
+ // on its host's acks. With a shared sequencer (`joinShards`) only the
61
+ // client predicting the local entity does; the other shards' clients hold
62
+ // the same list and must neither send it to their hosts nor trim it on
63
+ // acks for inputs those hosts never received.
64
+ const speaks = options.sequencer === undefined || options.local !== undefined;
36
65
  let predicted = null;
37
66
  // A correction being blended in: the stale prediction (still advanced
38
67
  // by every input, so it keeps pace with the controls) and when it began.
@@ -50,6 +79,10 @@ export function joinNetcode(options) {
50
79
  return blend(correcting.from, predicted, Math.max(0, alpha));
51
80
  };
52
81
  let snapshotsSeen = 0;
82
+ // With a codec the host's state arrives quantized, and a prediction is
83
+ // never exactly a quantized value: compare within the codec's quanta, or
84
+ // every snapshot would read as a correction.
85
+ const sameState = (a, b) => options.codec !== undefined ? options.codec.near(a, b) : JSON.stringify(a) === JSON.stringify(b);
53
86
  // The interest set: what was asked, its version, and the newest version a
54
87
  // snapshot has echoed (the `w` frame repeats until they meet).
55
88
  let interest = null;
@@ -82,23 +115,73 @@ export function joinNetcode(options) {
82
115
  let partialSnapshots = 0;
83
116
  let corrections = 0;
84
117
  let joined = false;
118
+ // The epoch of the newest-tick snapshot that carried one.
119
+ let snapshotEpoch = null;
120
+ let epochTick = -1;
121
+ let lastSnapshotAt = null;
122
+ const inputEpoch = () => (options.epoch === undefined ? {} : { ep: options.epoch() });
123
+ // The newest `redundancy` inputs — or, past that, the whole backlog up to
124
+ // MAX_INPUT_BACKLOG, in one frame.
125
+ const sendInputs = () => {
126
+ const count = inputs.pending.length > redundancy ? MAX_INPUT_BACKLOG : redundancy;
127
+ send({ n: label, k: 'i', i: inputs.pending.slice(-count), ...inputEpoch() });
128
+ };
85
129
  const offset = () => clock.estimate()?.offsetMs ?? null;
86
130
  const hostNow = () => {
87
131
  const o = offset();
88
132
  return o === null ? null : now() + o;
89
133
  };
134
+ // What the last attempt to reach the host said, for `stats()` and for
135
+ // `ready()`'s typed failure: a join that never connects must say why.
136
+ let connectOutcome = null;
137
+ let lastError = null;
138
+ const message = (error) => String(error?.message ?? error);
139
+ // Whether a reopen should offer again. Found by the S0b topology run: a
140
+ // pair whose direct attempt ended relayed was re-offered on EVERY stream
141
+ // reopen. `connectPeer` is a no-op only on a healthy direct pair, so each
142
+ // reopen began a fresh ICE attempt, a late one bumped the session, the
143
+ // stream went stale, the next send failed and reopened — 44 attempts in
144
+ // five minutes and no snapshot. A relayed pair is a supported outcome:
145
+ // reopen the stream on it, and try direct again only after
146
+ // `directRetryMs`. `noAnnouncement` is retried at once (it is cheap and
147
+ // discovery needs it); a direct pair's call is the idempotent check.
148
+ //
149
+ // A `connectPeer` that rejects (the peer answered `Reject`, say) is
150
+ // throttled the same way: it leaves no outcome, and without this every
151
+ // reopen would offer again.
152
+ const directRetryMs = Math.max(0, options.directRetryMs ?? 30_000);
153
+ let lastOfferAt = -Infinity;
154
+ let offerRejected = false;
155
+ const shouldOffer = () => now() - lastOfferAt >= directRetryMs ||
156
+ (!offerRejected &&
157
+ (connectOutcome === null || connectOutcome === 'noAnnouncement' || connectOutcome === 'direct'));
90
158
  let opening = null;
91
159
  const open = () => {
92
160
  opening ??= (async () => {
93
- await options.transport.connectPeer?.(host).catch(() => { });
161
+ if (options.transport.connectPeer !== undefined && shouldOffer()) {
162
+ lastOfferAt = now();
163
+ try {
164
+ const outcome = (await options.transport.connectPeer(host));
165
+ offerRejected = false;
166
+ if (outcome !== null && typeof outcome === 'object' && typeof outcome.type === 'string')
167
+ connectOutcome = outcome.type;
168
+ }
169
+ catch (error) {
170
+ offerRejected = true;
171
+ lastError = message(error);
172
+ }
173
+ }
94
174
  const opened = await options.transport.openStream({ reliability: 'fireAndForget', peer: host, label, lossy: true });
95
175
  if (closed) {
96
176
  opened.close();
97
177
  return;
98
178
  }
99
179
  stream = opened;
180
+ lastError = null;
100
181
  })()
101
- .catch(() => { })
182
+ .catch(error => {
183
+ lastError = message(error);
184
+ })
102
185
  .finally(() => {
103
186
  opening = null;
104
187
  });
@@ -114,7 +197,9 @@ export function joinNetcode(options) {
114
197
  void open();
115
198
  return;
116
199
  }
117
- const retire = () => {
200
+ const retire = (error) => {
201
+ if (error !== undefined)
202
+ lastError = message(error);
118
203
  if (stream === current) {
119
204
  stream = null;
120
205
  try {
@@ -128,8 +213,8 @@ export function joinNetcode(options) {
128
213
  try {
129
214
  void Promise.resolve(current.send(encodeFrame(frame))).catch(retire);
130
215
  }
131
- catch {
132
- retire();
216
+ catch (error) {
217
+ retire(error);
133
218
  }
134
219
  };
135
220
  const stopEvents = options.transport.onEvent(event => {
@@ -137,6 +222,10 @@ export function joinNetcode(options) {
137
222
  return;
138
223
  if (eventPeer(event.peerNode) !== host)
139
224
  return;
225
+ if (isBinaryFrame(event.payload)) {
226
+ onBinary(event.payload);
227
+ return;
228
+ }
140
229
  const frame = decodeFrame(event.payload, label);
141
230
  if (frame === null)
142
231
  return;
@@ -146,7 +235,47 @@ export function joinNetcode(options) {
146
235
  }
147
236
  if (frame.k !== 's')
148
237
  return;
149
- joined = true;
238
+ onSnapshot(frame);
239
+ });
240
+ /** One snapshot frame, JSON or decoded binary: whole, or a chunk. */
241
+ // `ready()`: settled once, at the first snapshot, the deadline, or close.
242
+ let readySettle = null;
243
+ let readyPromise = null;
244
+ let readyTimer = null;
245
+ let readyFailure = null;
246
+ const settleReady = (failure) => {
247
+ if (readyTimer !== null)
248
+ clearTimeout(readyTimer);
249
+ readyTimer = null;
250
+ const settle = readySettle;
251
+ readySettle = null;
252
+ if (failure === null)
253
+ settle?.resolve();
254
+ else {
255
+ readyFailure = failure;
256
+ settle?.reject(failure);
257
+ }
258
+ };
259
+ const joinFailure = () => {
260
+ const why = lastError === null ? '' : ` (${lastError})`;
261
+ if (connectOutcome === 'noAnnouncement') {
262
+ return new NetcodeError('no-announcement', `no announcement from host ${host}: the host must announce, and this node must have heard it before it can connect${why}`);
263
+ }
264
+ if (stream === null)
265
+ return new NetcodeError('unreachable', `could not open a stream to host ${host}${why}`);
266
+ return new NetcodeError('timeout', `no snapshot from host ${host} within ${String(joinTimeoutMs)} ms`);
267
+ };
268
+ const joinTimeoutMs = Math.max(0, options.joinTimeoutMs ?? 15_000);
269
+ function onSnapshot(frame) {
270
+ lastSnapshotAt = now();
271
+ if (frame.ep !== undefined && frame.tick >= epochTick) {
272
+ snapshotEpoch = frame.ep;
273
+ epochTick = frame.tick;
274
+ }
275
+ if (!joined) {
276
+ joined = true;
277
+ settleReady(null);
278
+ }
150
279
  if (frame.iv !== undefined && frame.iv > interestAcked)
151
280
  interestAcked = frame.iv;
152
281
  if (frame.of === undefined || frame.c === undefined) {
@@ -172,7 +301,100 @@ export function joinNetcode(options) {
172
301
  assembly.ack = Math.max(assembly.ack, frame.ack);
173
302
  if (assembly.parts.size === assembly.of)
174
303
  finish(frame.tick, assembly);
175
- });
304
+ }
305
+ // Binary snapshots: index → id, learnt from the frames that name them,
306
+ // and when a frame last carried the index. One host instance never
307
+ // reuses an index, so a name, once known, stays right for that instance.
308
+ const idOf = new Map();
309
+ let binaryFrames = 0;
310
+ let unnamedEntities = 0;
311
+ // The host instance the names belong to, and instances replaced since
312
+ // (a late frame from one is not a new host).
313
+ let hostInstance = null;
314
+ const retiredInstances = new Set();
315
+ /**
316
+ * A new host instance: the host was re-created under this label. Its
317
+ * indexes start over, and so do its ticks — drop every name, assembly
318
+ * and snapshot the old one left, rather than read the new one through
319
+ * them.
320
+ */
321
+ const newHostInstance = (instance) => {
322
+ if (hostInstance !== null) {
323
+ retiredInstances.add(hostInstance);
324
+ if (retiredInstances.size > 8)
325
+ retiredInstances.delete(retiredInstances.values().next().value);
326
+ idOf.clear();
327
+ assemblies.clear();
328
+ newestTick = null;
329
+ snapshots = new SnapshotBuffer();
330
+ snapshotEpoch = null;
331
+ epochTick = -1;
332
+ }
333
+ hostInstance = instance;
334
+ };
335
+ // Names not carried by a frame for three times the host's forget time
336
+ // are forgotten (see NAME_FORGET_MS), checked about once a second.
337
+ let lastForget = now();
338
+ const forgetNames = (at) => {
339
+ if (at - lastForget < 1000)
340
+ return;
341
+ lastForget = at;
342
+ for (const [index, known] of idOf)
343
+ if (at - known.seen > 3 * NAME_FORGET_MS)
344
+ idOf.delete(index);
345
+ };
346
+ function onBinary(payload) {
347
+ const codec = options.codec;
348
+ if (codec === undefined)
349
+ return;
350
+ const decoded = decodeBinarySnapshot(payload, label, codec);
351
+ if (decoded === null)
352
+ return;
353
+ if (decoded.instance !== undefined && decoded.instance !== hostInstance) {
354
+ if (retiredInstances.has(decoded.instance))
355
+ return;
356
+ newHostInstance(decoded.instance);
357
+ }
358
+ const at = now();
359
+ for (const [index, id] of decoded.names) {
360
+ const known = idOf.get(index);
361
+ if (known !== undefined && known.id !== id) {
362
+ // A different name for a known index is another host's numbering
363
+ // (one from before instances were stamped): start over from this
364
+ // frame's names, and ask for the rest below.
365
+ idOf.clear();
366
+ break;
367
+ }
368
+ }
369
+ for (const [index, id] of decoded.names)
370
+ idOf.set(index, { id, seen: at });
371
+ binaryFrames += 1;
372
+ const entities = {};
373
+ const unknown = [];
374
+ for (const [index, entity] of decoded.entities) {
375
+ const known = idOf.get(index);
376
+ if (known === undefined) {
377
+ unknown.push(index);
378
+ continue;
379
+ }
380
+ known.seen = at;
381
+ Object.defineProperty(entities, known.id, { value: entity, enumerable: true, writable: true, configurable: true });
382
+ }
383
+ forgetNames(at);
384
+ if (unknown.length > 0) {
385
+ unnamedEntities += unknown.length;
386
+ send({ n: label, k: 'u', u: unknown.slice(0, MAX_NAME_REQUESTS) });
387
+ }
388
+ onSnapshot({
389
+ tick: decoded.tick,
390
+ t: decoded.t,
391
+ ack: decoded.ack,
392
+ e: entities,
393
+ ...(decoded.iv === undefined ? {} : { iv: decoded.iv }),
394
+ ...(decoded.ep === undefined ? {} : { ep: decoded.ep }),
395
+ ...(decoded.of === undefined ? {} : { c: decoded.c, of: decoded.of }),
396
+ });
397
+ }
176
398
  /**
177
399
  * A chunked snapshot, done: every chunk, or the chunks that came, with
178
400
  * each missing chunk's entities carried over from the previous snapshot
@@ -207,18 +429,22 @@ export function joinNetcode(options) {
207
429
  snapshots.add({ time, tick, entities });
208
430
  if (newestTick === null || tick > newestTick)
209
431
  newestTick = tick;
432
+ // Acknowledged inputs are done with, predicting or not: a client that
433
+ // never trimmed would send its whole backlog in every input frame. An
434
+ // ack only grows, so an older snapshot's trims nothing.
435
+ if (speaks)
436
+ inputs.pending = inputs.pending.filter(input => input.seq > ack);
210
437
  // Reconcile only on a snapshot newer than any seen: an older one
211
438
  // (reordered by the carrier) must not roll the local entity back.
212
439
  if (options.local && (newest === null || tick > newest.tick)) {
213
- pending = pending.filter(input => input.seq > ack);
214
440
  const authoritative = Object.prototype.hasOwnProperty.call(sent, options.local.id)
215
441
  ? sent[options.local.id]
216
442
  : undefined;
217
443
  if (authoritative !== undefined) {
218
444
  let replayed = authoritative;
219
- for (const input of pending)
445
+ for (const input of inputs.pending)
220
446
  replayed = options.local.predict(replayed, input.data);
221
- if (predicted !== null && JSON.stringify(replayed) !== JSON.stringify(predicted)) {
447
+ if (predicted !== null && !sameState(replayed, predicted)) {
222
448
  corrections += 1;
223
449
  // Blend from what is on screen now, so a correction that lands
224
450
  // mid-blend continues from there rather than jumping back.
@@ -229,36 +455,49 @@ export function joinNetcode(options) {
229
455
  }
230
456
  }
231
457
  }
232
- // Join, and keep the clock honest.
233
- const pingEvery = 1000 / Math.max(0.5, options.pingRate ?? 4);
458
+ // Join, and keep the clock honest. The codec fingerprint rides both.
459
+ const fingerprint = options.codec === undefined ? {} : { f: options.codec.fingerprint };
460
+ const pingRate = Math.max(0.5, options.pingRate ?? 4);
461
+ const pingEvery = 1000 / pingRate;
462
+ // Once the clock has settled, ping on every Nth beat of the timer only.
463
+ const settledEvery = Math.max(1, Math.round(pingRate / Math.max(0.1, Math.min(pingRate, options.settledPingRate ?? 1))));
464
+ const SETTLED_SAMPLES = 8;
465
+ let beat = 0;
234
466
  const timer = setInterval(() => {
235
467
  if (!joined)
236
- send({ n: label, k: 'h' });
237
- send({ n: label, k: 'p', t0: now() });
468
+ send({ n: label, k: 'h', ...fingerprint });
469
+ const settled = (clock.estimate()?.samples ?? 0) >= SETTLED_SAMPLES;
470
+ beat += 1;
471
+ if (!settled || beat % settledEvery === 0)
472
+ send({ n: label, k: 'p', t0: now(), ...fingerprint });
238
473
  sendInterest();
239
474
  // Unacknowledged inputs are repeated even with nothing new to send, so
240
475
  // a lost input frame is repaired without waiting for the next input.
241
- if (pending.length > 0)
242
- send({ n: label, k: 'i', i: pending.slice(-redundancy) });
476
+ // With a shared sequencer, only the client predicting the local entity
477
+ // speaks for the inputs (`speaks`, above).
478
+ if (speaks && inputs.pending.length > 0) {
479
+ sendInputs();
480
+ }
243
481
  }, pingEvery);
244
482
  void open().then(() => {
245
- send({ n: label, k: 'h' });
246
- send({ n: label, k: 'p', t0: now() });
483
+ send({ n: label, k: 'h', ...fingerprint });
484
+ send({ n: label, k: 'p', t0: now(), ...fingerprint });
247
485
  sendInterest();
248
486
  });
249
487
  return {
250
488
  input(data) {
251
489
  if (closed)
252
490
  return;
253
- seq += 1;
491
+ inputs.seq += 1;
492
+ const seq = inputs.seq;
254
493
  const seen = (hostNow() ?? now()) - delay;
255
- pending.push({ seq, seen, data });
494
+ inputs.pending.push({ seq, seen, data });
256
495
  if (options.local && predicted !== null) {
257
496
  predicted = options.local.predict(predicted, data);
258
497
  if (correcting !== null)
259
498
  correcting = { ...correcting, from: options.local.predict(correcting.from, data) };
260
499
  }
261
- send({ n: label, k: 'i', i: pending.slice(-redundancy) });
500
+ sendInputs();
262
501
  },
263
502
  view() {
264
503
  const at = hostNow();
@@ -275,20 +514,45 @@ export function joinNetcode(options) {
275
514
  if (!closed)
276
515
  sendInterest();
277
516
  },
517
+ ready() {
518
+ if (joined)
519
+ return Promise.resolve();
520
+ if (readyFailure !== null && closed)
521
+ return Promise.reject(readyFailure);
522
+ readyPromise ??= new Promise((resolve, reject) => {
523
+ readySettle = { resolve, reject };
524
+ readyTimer = setTimeout(() => {
525
+ // Failed for THIS wait; a later call waits afresh, since the
526
+ // client keeps trying.
527
+ settleReady(joinFailure());
528
+ readyPromise = null;
529
+ }, joinTimeoutMs);
530
+ });
531
+ return readyPromise;
532
+ },
278
533
  hostNow,
534
+ sampleAt: hostTime => snapshots.sample(hostTime, blend),
535
+ latest: () => snapshots.latest(),
279
536
  stats: () => ({
280
537
  clock: clock.estimate(),
281
538
  snapshots: snapshotsSeen,
282
539
  lateSnapshots: snapshots.late,
283
- pendingInputs: pending.length,
540
+ pendingInputs: inputs.pending.length,
284
541
  corrections,
285
542
  partialSnapshots,
543
+ binaryFrames,
544
+ unnamedEntities,
545
+ connectOutcome,
546
+ lastError,
547
+ epoch: snapshotEpoch,
548
+ lastSnapshotAt,
286
549
  }),
287
550
  close() {
288
551
  if (closed)
289
552
  return;
290
553
  closed = true;
291
554
  clearInterval(timer);
555
+ settleReady(new NetcodeError('closed', 'the netcode client was closed before it joined'));
292
556
  stopEvents();
293
557
  stream?.close();
294
558
  },
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Compact snapshots: a game-supplied entity schema, and the binary snapshot
3
+ * frame it makes possible (sharded-match plan, slice S0c).
4
+ *
5
+ * ```ts
6
+ * const shipCodec = entityCodec({
7
+ * x: quantized(0, 512), // 16 bits over the arena: ~8 mm
8
+ * z: quantized(0, 512),
9
+ * yaw: angle(), // 8 bits: ~1.4°
10
+ * hp: uint(8),
11
+ * });
12
+ * hostNetcode({ …, codec: shipCodec });
13
+ * joinNetcode({ …, codec: shipCodec });
14
+ * ```
15
+ *
16
+ * JSON stays the default and the fallback. A player announces its codec's
17
+ * fingerprint in its join and ping frames; the host sends binary snapshots
18
+ * only to a player whose fingerprint matches its own, and JSON to everyone
19
+ * else — so a mismatched or older peer degrades to JSON, never to garbage.
20
+ *
21
+ * **Ids.** On the binary wire an entity is named by a varint index, not its
22
+ * id string. The host assigns indexes in order of first appearance and
23
+ * **never reuses one** within its lifetime, so a late or reordered
24
+ * snapshot cannot name the wrong entity. A snapshot carries an entity's
25
+ * name the first time it shows that entity to a player; a player that sees
26
+ * an index it has no name for does not draw that entity and asks again
27
+ * (`u` frame) — the lossy carrier's repair, with no acknowledgements.
28
+ *
29
+ * Every binary snapshot also carries the host's **instance**, a random
30
+ * 32-bit number drawn when `hostNetcode` starts. A host re-created under
31
+ * the same label starts its indexes over; a player that sees a new instance
32
+ * drops every name (and every snapshot) it learnt from the old one, so the
33
+ * new host's indexes are never read through the old host's names.
34
+ *
35
+ * Names are also forgotten: an id no player has been shown for
36
+ * `NAME_FORGET_MS` (10 s) loses its index, and comes back, if it does,
37
+ * under a fresh one, named again. Transient ids (projectiles) do not grow
38
+ * the tables for the whole match.
39
+ *
40
+ * **Versions.** The fingerprint covers the binary wire's version as well as
41
+ * the schema. Frames carrying an instance are version 2; a peer from before
42
+ * it has another fingerprint for the same schema, so the pair falls back
43
+ * to JSON instead of refusing each other's frames.
44
+ *
45
+ * **Interpolation.** `entityCodec` also supplies an `interpolate` blend
46
+ * that knows its fields: an `angle()` turns the short way round across
47
+ * 0 / 2π. `joinNetcode` uses it when it has a codec and no `interpolate`
48
+ * of its own.
49
+ *
50
+ * **What the schema promises.** An entity is a flat object of the schema's
51
+ * fields and nothing else. Writing an entity with a field the schema does
52
+ * not have, or a value that is not a finite number (or boolean, for
53
+ * `bool()`), throws: the host then sends that player JSON for that tick
54
+ * (counted in `dropped`) rather than silently losing a field.
55
+ */
56
+ /** One field of an entity schema: its bytes, and how it is written and read. */
57
+ export interface FieldCodec {
58
+ /** Bytes it takes. */
59
+ readonly size: number;
60
+ /** A stable description, part of the codec's fingerprint. */
61
+ readonly spec: string;
62
+ write(view: DataView, at: number, value: unknown): void;
63
+ read(view: DataView, at: number): number | boolean;
64
+ /** The largest error a written value may come back with (0: exact). */
65
+ readonly quantum: number;
66
+ /** Two values this field cannot tell apart once quantized (within two quantization steps). */
67
+ near(a: unknown, b: unknown): boolean;
68
+ /**
69
+ * Blend two values of this field for interpolation (`alpha` in `[0, 1]`,
70
+ * above 1 when extrapolating). Omitted: linear. `angle()` turns the short
71
+ * way round.
72
+ */
73
+ blend?(from: number, to: number, alpha: number): number;
74
+ }
75
+ /** A whole-entity codec: fixed size, and a fingerprint both ends compare. */
76
+ export interface EntityCodec<E> {
77
+ readonly size: number;
78
+ /** Equal on host and player iff they encode entities the same way. */
79
+ readonly fingerprint: string;
80
+ write(entity: E, view: DataView, at: number): void;
81
+ read(view: DataView, at: number): E;
82
+ /**
83
+ * Two states equal within what the codec can carry — how a player tells a
84
+ * real correction from quantization. A prediction is never exactly a
85
+ * quantized value, and comparing exactly would read every snapshot as a
86
+ * correction.
87
+ */
88
+ near(a: E, b: E): boolean;
89
+ /**
90
+ * Blend two states of an entity for interpolation, field by field:
91
+ * linear, except where a field blends its own way (an `angle()` turns the
92
+ * short way). `joinNetcode` uses it when given this codec and no
93
+ * `interpolate`. Optional: a hand-written codec may leave it out.
94
+ */
95
+ readonly interpolate?: Interpolator<E>;
96
+ }
97
+ import { type Interpolator } from './interpolate.js';
98
+ type Bits = 8 | 16 | 32;
99
+ /**
100
+ * A number in `[min, max]`, quantized to `bits` (8 or 16; default 16).
101
+ * Values outside the range are clamped — including one a hair past an edge
102
+ * after rounding, which must not wrap around.
103
+ */
104
+ export declare function quantized(min: number, max: number, bits?: 8 | 16): FieldCodec;
105
+ /** An angle in radians, any value, stored as a fraction of a turn in `bits` (default 8). Reads in `[0, 2π)`. */
106
+ export declare function angle(bits?: 8 | 16): FieldCodec;
107
+ /** A whole number in `[0, 2^bits)`, exact (default 8 bits). Out of range throws. */
108
+ export declare function uint(bits?: Bits): FieldCodec;
109
+ /** A whole number in `[-2^(bits-1), 2^(bits-1))`, exact (default 16 bits). Out of range throws. */
110
+ export declare function int(bits?: Bits): FieldCodec;
111
+ /** A 32-bit float: ~7 significant digits, no range to choose. A NaN or an infinity read off the wire refuses the frame. */
112
+ export declare function float32(): FieldCodec;
113
+ /** A boolean, one byte. */
114
+ export declare function bool(): FieldCodec;
115
+ /**
116
+ * An entity codec from a schema: one field codec per property, written in
117
+ * the schema's key order. The fingerprint covers names, order and specs,
118
+ * and the binary wire's version.
119
+ */
120
+ export declare function entityCodec<E extends object>(fields: {
121
+ readonly [K in keyof E]: FieldCodec;
122
+ }): EntityCodec<E>;
123
+ /** First byte of a binary netcode frame. Never `{` (0x7b), so it cannot be JSON. */
124
+ export declare const BINARY_FRAME_MAGIC = 177;
125
+ /** The label's 32-bit FNV-1a: a binary frame's stand-in for the JSON `n`. */
126
+ export declare function labelHash(label: string): number;
127
+ /** What a binary snapshot carries; the same fields as the JSON `s` frame, plus names. */
128
+ export interface BinarySnapshot<E> {
129
+ readonly tick: number;
130
+ readonly t: number;
131
+ readonly ack: number;
132
+ readonly iv?: number;
133
+ readonly c?: number;
134
+ readonly of?: number;
135
+ /** The assignment epoch the host sent it under, if it stamps one. */
136
+ readonly ep?: number;
137
+ /** The sending host's instance (wire version 2), if the frame carries one. */
138
+ readonly instance?: number;
139
+ /** Index → id, for the indexes this frame names. */
140
+ readonly names: ReadonlyMap<number, string>;
141
+ /** Index → entity. */
142
+ readonly entities: ReadonlyMap<number, E>;
143
+ }
144
+ /** The binary size of one snapshot frame, without building it. */
145
+ export declare function binarySnapshotSize(frame: {
146
+ readonly tick: number;
147
+ readonly ack: number;
148
+ readonly iv?: number;
149
+ readonly c?: number;
150
+ readonly of?: number;
151
+ readonly ep?: number;
152
+ readonly instance?: number;
153
+ }, names: Iterable<readonly [number, Uint8Array]>, indexes: Iterable<number>, entitySize: number): number;
154
+ /**
155
+ * Build one binary snapshot frame. `names` are UTF-8 ids by index;
156
+ * `entities` are by index. Throws if the codec refuses an entity.
157
+ */
158
+ export declare function encodeBinarySnapshot<E>(label: string, frame: {
159
+ readonly tick: number;
160
+ readonly t: number;
161
+ readonly ack: number;
162
+ readonly iv?: number;
163
+ readonly c?: number;
164
+ readonly of?: number;
165
+ readonly ep?: number;
166
+ /** The host's instance, a 32-bit unsigned integer: written as wire version 2. */
167
+ readonly instance?: number;
168
+ }, names: ReadonlyMap<number, Uint8Array>, entities: ReadonlyMap<number, E>, codec: EntityCodec<E>): Uint8Array;
169
+ /** True if `payload` is a binary netcode frame (of any label). */
170
+ export declare function isBinaryFrame(payload: Uint8Array | undefined): payload is Uint8Array;
171
+ /**
172
+ * Read a binary snapshot for `label`, or `null` for another label, another
173
+ * kind, or anything malformed (truncated, trailing bytes, bad chunk fields,
174
+ * a duplicate index, a name that is not UTF-8, an entity the codec will not
175
+ * read, such as a NaN `float32()`).
176
+ */
177
+ export declare function decodeBinarySnapshot<E>(payload: Uint8Array, label: string, codec: EntityCodec<E>): BinarySnapshot<E> | null;
178
+ /** UTF-8 of an id, for {@link encodeBinarySnapshot}'s names. */
179
+ export declare function nameBytes(id: string): Uint8Array;
180
+ export {};