@net-mesh/browser 0.37.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 (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `hostNetcode` — the authoritative side of netcode model 2: a fixed-rate
3
+ * tick loop that applies each player's inputs, broadcasts snapshots on the
4
+ * lossy carrier, answers clock pings, and keeps a short history for lag
5
+ * compensation.
6
+ *
7
+ * ```ts
8
+ * const net = hostNetcode({
9
+ * transport: node, // or meshStoreTransport(mesh) on a dedicated host
10
+ * label: 'my-game.movement',
11
+ * tickRate: 30,
12
+ * step: ({ inputs }) => { // apply this tick's inputs to your world
13
+ * for (const [peer, list] of inputs) for (const { data } of list) move(world, peer, data);
14
+ * },
15
+ * snapshot: () => world.ships, // entity id → state, sent to every player
16
+ * });
17
+ * ```
18
+ */
19
+ import { type Frame, type NetcodeTransport, type Now } from './wire.js';
20
+ /** One input as `step` receives it. */
21
+ export interface TickInput<I> {
22
+ readonly seq: number;
23
+ /** The host time the player was rendering when it issued the input. */
24
+ readonly seen: number;
25
+ readonly data: I;
26
+ }
27
+ /** What `step` gets each tick. */
28
+ export interface TickContext<I> {
29
+ readonly tick: number;
30
+ /** Host clock, ms, at this tick. */
31
+ readonly time: number;
32
+ /** ms since the previous tick (1000 / tickRate, nominally). */
33
+ readonly dtMs: number;
34
+ /** Each player's inputs since the last tick, oldest first, each applied
35
+ * exactly once however many times the lossy carrier repeated it. */
36
+ readonly inputs: ReadonlyMap<string, readonly TickInput<I>[]>;
37
+ }
38
+ /** What {@link HostNetcode.rewind} returns. */
39
+ export interface Rewound<E> {
40
+ /** The entities as of the returned `time`. */
41
+ readonly entities: Readonly<Record<string, E>>;
42
+ /** The host time rewound to — `seen`, unless the cap clamped it. */
43
+ readonly time: number;
44
+ /** `true` when `seen` was further back than `maxRewindMs` allows. */
45
+ readonly clamped: boolean;
46
+ }
47
+ /** {@link hostNetcode}'s argument. */
48
+ export interface HostNetcodeOptions<E, I> {
49
+ readonly transport: NetcodeTransport;
50
+ /** Names this netcode instance; players join the same label. */
51
+ readonly label: string;
52
+ /** Ticks per second. Default 30. */
53
+ readonly tickRate?: number;
54
+ /** Advance the world one tick. */
55
+ step(context: TickContext<I>): void;
56
+ /** The world as players should see it: entity id → state. */
57
+ snapshot(tick: number): Readonly<Record<string, E>>;
58
+ /** Which entities `peer` receives. Default: all. */
59
+ visible?(peer: string, id: string, entity: E): boolean;
60
+ /**
61
+ * Interest management: the key an entity is found under (a grid cell, a
62
+ * room; `null`: always delivered). A player that states interest keys
63
+ * (`joinNetcode({ interest })` / `setInterest`) then receives only the
64
+ * entities whose key it named — after `visible`, which stays the
65
+ * permission; interest is only a filter. A player that states none
66
+ * receives everything `visible` allows.
67
+ */
68
+ interest?(id: string, entity: E): string | null;
69
+ /** Admit `peer` as a player. Default: everyone. */
70
+ authorize?(peer: string): boolean;
71
+ /**
72
+ * How far back {@link HostNetcode.rewind} may go, in ms. Default 200 —
73
+ * lag compensation that trusts any claimed latency lets a player buy an
74
+ * advantage by faking lag.
75
+ */
76
+ readonly maxRewindMs?: number;
77
+ /**
78
+ * Largest snapshot frame, bytes. Default 8000 — under one event, so a
79
+ * snapshot never rides as fragments (on the lossy carrier one lost
80
+ * fragment loses them all). A larger snapshot is sent as independent
81
+ * chunks; a lost chunk costs the entities it carried, for one tick.
82
+ */
83
+ readonly maxFrameBytes?: number;
84
+ /** Drop a player silent this long, ms. Default 5000. */
85
+ readonly playerTimeoutMs?: number;
86
+ /** `false` to drive ticks yourself with {@link HostNetcode.tick}. Default `true`. */
87
+ readonly autoTick?: boolean;
88
+ readonly now?: Now;
89
+ }
90
+ /** The running host. */
91
+ export interface HostNetcode<E> {
92
+ /** Players currently connected, 16-hex peer ids. */
93
+ players(): readonly string[];
94
+ /** The tick about to run. */
95
+ readonly currentTick: number;
96
+ /** Run one tick now (what the auto loop calls). */
97
+ tick(): void;
98
+ /**
99
+ * Lag compensation: the entities as a player saw them at host time
100
+ * `seen` (from {@link TickInput.seen}), from this host's own history —
101
+ * never further back than `maxRewindMs` before now.
102
+ */
103
+ rewind(seen: number): Rewound<E>;
104
+ /** Frames dropped and why. */
105
+ readonly dropped: Readonly<Record<string, number>>;
106
+ /** The last error a send or stream open raised, for diagnosis; `null` if none. */
107
+ readonly lastSendError: string | null;
108
+ close(): void;
109
+ }
110
+ /**
111
+ * One snapshot as frames: one, or — over `maxBytes` — chunks by
112
+ * {@link chunkOf}, as many as it takes for each to fit (up to
113
+ * {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
114
+ * ride as fragments).
115
+ */
116
+ export declare function snapshotFrames<E>(label: string, tick: number, time: number, ack: number, entities: Readonly<Record<string, E>>, maxBytes: number, interestVersion?: number): Frame<E, never>[];
117
+ /** Start the authoritative netcode host. */
118
+ export declare function hostNetcode<E, I>(options: HostNetcodeOptions<E, I>): HostNetcode<E>;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `hostNetcode` — the authoritative side of netcode model 2: a fixed-rate
3
+ * tick loop that applies each player's inputs, broadcasts snapshots on the
4
+ * lossy carrier, answers clock pings, and keeps a short history for lag
5
+ * compensation.
6
+ *
7
+ * ```ts
8
+ * const net = hostNetcode({
9
+ * transport: node, // or meshStoreTransport(mesh) on a dedicated host
10
+ * label: 'my-game.movement',
11
+ * tickRate: 30,
12
+ * step: ({ inputs }) => { // apply this tick's inputs to your world
13
+ * for (const [peer, list] of inputs) for (const { data } of list) move(world, peer, data);
14
+ * },
15
+ * snapshot: () => world.ships, // entity id → state, sent to every player
16
+ * });
17
+ * ```
18
+ */
19
+ import { MAX_SNAPSHOT_CHUNKS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, } from './wire.js';
20
+ /** History kept: enough ticks to cover the rewind cap with room. */
21
+ function historySize(tickRate, maxRewindMs) {
22
+ return Math.max(8, Math.ceil((maxRewindMs / 1000) * tickRate) + 4);
23
+ }
24
+ /**
25
+ * One snapshot as frames: one, or — over `maxBytes` — chunks by
26
+ * {@link chunkOf}, as many as it takes for each to fit (up to
27
+ * {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
28
+ * ride as fragments).
29
+ */
30
+ export function snapshotFrames(label, tick, time, ack, entities, maxBytes, interestVersion = 0) {
31
+ // The interest version the view was filtered by, echoed so the player
32
+ // stops repeating its `w` frame.
33
+ const iv = interestVersion > 0 ? { iv: interestVersion } : {};
34
+ const whole = { n: label, k: 's', tick, t: time, ack, e: entities, ...iv };
35
+ const size = encodeFrame(whole).length;
36
+ if (size <= maxBytes)
37
+ return [whole];
38
+ const ids = Object.keys(entities);
39
+ let of = Math.min(MAX_SNAPSHOT_CHUNKS, Math.max(2, Math.ceil(size / (maxBytes * 0.75))));
40
+ for (;;) {
41
+ const parts = Array.from({ length: of }, () => ({}));
42
+ for (const id of ids)
43
+ parts[chunkOf(id, of)][id] = entities[id];
44
+ const frames = parts.map((e, c) => ({ n: label, k: 's', tick, t: time, ack, e, c, of, ...iv }));
45
+ if (of >= MAX_SNAPSHOT_CHUNKS || frames.every(frame => encodeFrame(frame).length <= maxBytes)) {
46
+ return frames;
47
+ }
48
+ of = Math.min(MAX_SNAPSHOT_CHUNKS, of * 2);
49
+ }
50
+ }
51
+ /** Start the authoritative netcode host. */
52
+ export function hostNetcode(options) {
53
+ const now = options.now ?? defaultNow;
54
+ const tickRate = options.tickRate ?? 30;
55
+ if (!(tickRate > 0 && tickRate <= 240))
56
+ throw new RangeError('tickRate must be in (0, 240]');
57
+ const tickMs = 1000 / tickRate;
58
+ const maxRewindMs = options.maxRewindMs ?? 200;
59
+ const maxFrameBytes = Math.max(256, options.maxFrameBytes ?? 8000);
60
+ const playerTimeoutMs = options.playerTimeoutMs ?? 5000;
61
+ const players = new Map();
62
+ const history = [];
63
+ const keep = historySize(tickRate, maxRewindMs);
64
+ const dropped = {};
65
+ const drop = (why) => {
66
+ dropped[why] = (dropped[why] ?? 0) + 1;
67
+ };
68
+ let tick = 0;
69
+ let lastError = null;
70
+ let lastTickAt = now();
71
+ let closed = false;
72
+ const send = (peer, player, frame) => {
73
+ if (player.stream === null) {
74
+ if (!player.opening) {
75
+ player.opening = true;
76
+ Promise.resolve(options.transport.openStream({ reliability: 'fireAndForget', peer, label: options.label, lossy: true }))
77
+ .then(stream => {
78
+ if (closed || !players.has(peer)) {
79
+ stream.close();
80
+ return;
81
+ }
82
+ player.stream = stream;
83
+ })
84
+ .catch(error => {
85
+ lastError = String(error?.message ?? error);
86
+ drop('stream-open-failed');
87
+ })
88
+ .finally(() => {
89
+ player.opening = false;
90
+ });
91
+ }
92
+ drop('stream-not-open');
93
+ return;
94
+ }
95
+ // A failed send retires the stream: the session under it may have
96
+ // been replaced (a reconnect, a relayed → direct upgrade), and a stream
97
+ // handle is fenced to the session it was opened on — every later send
98
+ // on it would fail too. The next send reopens on the current session.
99
+ const stream = player.stream;
100
+ const retire = (error) => {
101
+ lastError = String(error?.message ?? error);
102
+ drop('send-failed');
103
+ if (player.stream === stream) {
104
+ player.stream = null;
105
+ try {
106
+ stream.close();
107
+ }
108
+ catch {
109
+ // Already unusable.
110
+ }
111
+ }
112
+ };
113
+ try {
114
+ void Promise.resolve(stream.send(encodeFrame(frame))).catch(retire);
115
+ }
116
+ catch (error) {
117
+ retire(error);
118
+ }
119
+ };
120
+ const playerFor = (peer) => {
121
+ const existing = players.get(peer);
122
+ if (existing)
123
+ return existing;
124
+ if (options.authorize && !options.authorize(peer)) {
125
+ drop('unauthorized');
126
+ return null;
127
+ }
128
+ const player = {
129
+ stream: null,
130
+ opening: false,
131
+ lastApplied: 0,
132
+ pending: new Map(),
133
+ lastHeard: now(),
134
+ interest: null,
135
+ interestVersion: 0,
136
+ };
137
+ players.set(peer, player);
138
+ return player;
139
+ };
140
+ const stopEvents = options.transport.onEvent(event => {
141
+ if (closed || event.type !== 'stream_data')
142
+ return;
143
+ const frame = decodeFrame(event.payload, options.label);
144
+ if (frame === null)
145
+ return;
146
+ const peer = eventPeer(event.peerNode);
147
+ if (peer === null) {
148
+ drop('no-authenticated-peer');
149
+ return;
150
+ }
151
+ const player = playerFor(peer);
152
+ if (player === null)
153
+ return;
154
+ const t1 = now();
155
+ player.lastHeard = t1;
156
+ switch (frame.k) {
157
+ case 'h':
158
+ return;
159
+ case 'p':
160
+ send(peer, player, { n: options.label, k: 'q', t0: frame.t0, t1, t2: now() });
161
+ return;
162
+ case 'w':
163
+ // The lossy carrier reorders and repeats: only a newer version counts.
164
+ if (frame.v > player.interestVersion) {
165
+ player.interestVersion = frame.v;
166
+ player.interest = new Set(frame.w);
167
+ }
168
+ return;
169
+ case 'i':
170
+ for (const input of frame.i) {
171
+ if (!Number.isInteger(input.seq) || input.seq <= player.lastApplied)
172
+ continue;
173
+ if (!player.pending.has(input.seq)) {
174
+ player.pending.set(input.seq, { seq: input.seq, seen: Number(input.seen) || 0, data: input.data });
175
+ }
176
+ }
177
+ return;
178
+ default:
179
+ drop('unexpected-frame');
180
+ }
181
+ });
182
+ const runTick = () => {
183
+ if (closed)
184
+ return;
185
+ const time = now();
186
+ const dtMs = tick === 0 ? tickMs : time - lastTickAt;
187
+ lastTickAt = time;
188
+ for (const [peer, player] of players) {
189
+ if (time - player.lastHeard > playerTimeoutMs) {
190
+ player.stream?.close();
191
+ players.delete(peer);
192
+ }
193
+ }
194
+ const inputs = new Map();
195
+ for (const [peer, player] of players) {
196
+ if (player.pending.size === 0)
197
+ continue;
198
+ const ordered = [...player.pending.values()].sort((a, b) => a.seq - b.seq);
199
+ player.pending.clear();
200
+ // A gap (inputs lost despite redundancy) is skipped, not waited on:
201
+ // a tick cannot stall for one player's packet.
202
+ player.lastApplied = ordered.at(-1).seq;
203
+ inputs.set(peer, ordered);
204
+ }
205
+ options.step({ tick, time, dtMs, inputs });
206
+ const entities = options.snapshot(tick);
207
+ history.push({ tick, time, entities });
208
+ if (history.length > keep)
209
+ history.shift();
210
+ for (const [peer, player] of players) {
211
+ let view = entities;
212
+ const wanted = options.interest && player.interest;
213
+ if (options.visible || wanted) {
214
+ const filtered = {};
215
+ for (const [id, entity] of Object.entries(entities)) {
216
+ if (options.visible && !options.visible(peer, id, entity))
217
+ continue;
218
+ if (wanted) {
219
+ let key;
220
+ try {
221
+ key = options.interest(id, entity);
222
+ }
223
+ catch {
224
+ key = null;
225
+ }
226
+ if (typeof key === 'string' && !wanted.has(key))
227
+ continue;
228
+ }
229
+ filtered[id] = entity;
230
+ }
231
+ view = filtered;
232
+ }
233
+ const frames = snapshotFrames(options.label, tick, time, player.lastApplied, view, maxFrameBytes, player.interestVersion);
234
+ for (const frame of frames)
235
+ send(peer, player, frame);
236
+ }
237
+ tick += 1;
238
+ };
239
+ const timer = options.autoTick === false ? null : setInterval(runTick, tickMs);
240
+ return {
241
+ players: () => [...players.keys()],
242
+ get currentTick() {
243
+ return tick;
244
+ },
245
+ tick: runTick,
246
+ rewind(seen) {
247
+ const floor = now() - maxRewindMs;
248
+ const clamped = seen < floor;
249
+ const time = clamped ? floor : seen;
250
+ // The newest recorded state at or before `time`.
251
+ let chosen = history[0];
252
+ for (const entry of history) {
253
+ if (entry.time <= time)
254
+ chosen = entry;
255
+ else
256
+ break;
257
+ }
258
+ return { entities: chosen?.entities ?? {}, time, clamped };
259
+ },
260
+ dropped,
261
+ get lastSendError() {
262
+ return lastError;
263
+ },
264
+ close() {
265
+ if (closed)
266
+ return;
267
+ closed = true;
268
+ if (timer !== null)
269
+ clearInterval(timer);
270
+ stopEvents();
271
+ for (const player of players.values())
272
+ player.stream?.close();
273
+ players.clear();
274
+ },
275
+ };
276
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@net-mesh/browser/netcode` — responsive movement over the lossy carrier
3
+ * (netcode model 2): an authoritative host tick loop with lag compensation,
4
+ * and on each player snapshot interpolation for others plus prediction and
5
+ * reconciliation for yourself.
6
+ *
7
+ * Use it beside the store, not instead of it: the store holds durable,
8
+ * validated state (inventory, score, doors); netcode carries high-rate
9
+ * transforms that are superseded many times a second.
10
+ */
11
+ export { hostNetcode } from './host.js';
12
+ export type { HostNetcode, HostNetcodeOptions, Rewound, TickContext, TickInput } from './host.js';
13
+ export { joinNetcode } from './client.js';
14
+ export type { JoinNetcodeOptions, LocalEntity, NetcodeClient, NetcodeStats } from './client.js';
15
+ export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
16
+ export type { ClockEstimate, Millis } from './clock.js';
17
+ export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
18
+ export type { Interpolator, TimedSnapshot } from './interpolate.js';
19
+ export type { NetcodeStream, NetcodeTransport } from './wire.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@net-mesh/browser/netcode` — responsive movement over the lossy carrier
3
+ * (netcode model 2): an authoritative host tick loop with lag compensation,
4
+ * and on each player snapshot interpolation for others plus prediction and
5
+ * reconciliation for yourself.
6
+ *
7
+ * Use it beside the store, not instead of it: the store holds durable,
8
+ * validated state (inventory, score, doors); netcode carries high-rate
9
+ * transforms that are superseded many times a second.
10
+ */
11
+ export { hostNetcode } from './host.js';
12
+ export { joinNetcode } from './client.js';
13
+ export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
14
+ export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Snapshot interpolation: render remote entities a little in the past,
3
+ * between two snapshots the host already sent, so lost and late packets
4
+ * do not make them stutter.
5
+ */
6
+ /** A snapshot as the client keeps it. */
7
+ export interface TimedSnapshot<E> {
8
+ /** Host clock, ms, when the host produced it. */
9
+ readonly time: number;
10
+ readonly tick: number;
11
+ readonly entities: Readonly<Record<string, E>>;
12
+ }
13
+ /**
14
+ * Blend two states of one entity; `alpha` is in `[0, 1]` — or above 1 when
15
+ * extrapolating past the newest snapshot (`extrapolateMs`), where `to` is
16
+ * carried on along the line from `from`.
17
+ */
18
+ export type Interpolator<E> = (from: E, to: E, alpha: number) => E;
19
+ /**
20
+ * The default blend: every numeric field is linearly interpolated, every
21
+ * other field is taken from `to`. Nested objects are blended the same way,
22
+ * one level at a time. Angles are not special-cased: a game with wrapping
23
+ * angles supplies its own.
24
+ */
25
+ export declare function lerpNumbers<E>(from: E, to: E, alpha: number): E;
26
+ /** Snapshots kept, at most. */
27
+ export declare const SNAPSHOT_BUFFER = 64;
28
+ /** A time-ordered buffer of snapshots with interpolated reads. */
29
+ export declare class SnapshotBuffer<E> {
30
+ #private;
31
+ /**
32
+ * Add a snapshot. One older than the newest already held is still
33
+ * inserted in order (an unordered carrier reorders); one for a tick
34
+ * already held is ignored.
35
+ */
36
+ add(snapshot: TimedSnapshot<E>): void;
37
+ /** Snapshots that arrived after a newer one (reordered by the carrier). */
38
+ get late(): number;
39
+ /** The newest snapshot, or `null`. */
40
+ latest(): TimedSnapshot<E> | null;
41
+ /**
42
+ * The entities as of host time `at`: interpolated between the two
43
+ * snapshots around it. Before the oldest held, the oldest. Past the
44
+ * newest: held there, or — with `extrapolateMs` — carried on along the
45
+ * last two snapshots' line for at most that long, then held. An entity
46
+ * present only in the later snapshot appears as it is there; one gone
47
+ * from the later snapshot is gone.
48
+ */
49
+ at(time: number, blend: Interpolator<E>, extrapolateMs?: number): Readonly<Record<string, E>>;
50
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Snapshot interpolation: render remote entities a little in the past,
3
+ * between two snapshots the host already sent, so lost and late packets
4
+ * do not make them stutter.
5
+ */
6
+ /**
7
+ * The default blend: every numeric field is linearly interpolated, every
8
+ * other field is taken from `to`. Nested objects are blended the same way,
9
+ * one level at a time. Angles are not special-cased: a game with wrapping
10
+ * angles supplies its own.
11
+ */
12
+ export function lerpNumbers(from, to, alpha) {
13
+ if (typeof from === 'number' && typeof to === 'number') {
14
+ return (from + (to - from) * alpha);
15
+ }
16
+ if (from === null || to === null || typeof from !== 'object' || typeof to !== 'object' || Array.isArray(to)) {
17
+ return to;
18
+ }
19
+ const out = {};
20
+ for (const [key, value] of Object.entries(to)) {
21
+ const previous = from[key];
22
+ out[key] = previous === undefined ? value : lerpNumbers(previous, value, alpha);
23
+ }
24
+ return out;
25
+ }
26
+ /** Snapshots kept, at most. */
27
+ export const SNAPSHOT_BUFFER = 64;
28
+ /** A time-ordered buffer of snapshots with interpolated reads. */
29
+ export class SnapshotBuffer {
30
+ #snapshots = [];
31
+ #late = 0;
32
+ /**
33
+ * Add a snapshot. One older than the newest already held is still
34
+ * inserted in order (an unordered carrier reorders); one for a tick
35
+ * already held is ignored.
36
+ */
37
+ add(snapshot) {
38
+ const list = this.#snapshots;
39
+ if (list.some(s => s.tick === snapshot.tick))
40
+ return;
41
+ let at = list.length;
42
+ while (at > 0 && list[at - 1].time > snapshot.time)
43
+ at -= 1;
44
+ if (at < list.length)
45
+ this.#late += 1;
46
+ list.splice(at, 0, snapshot);
47
+ if (list.length > SNAPSHOT_BUFFER)
48
+ list.shift();
49
+ }
50
+ /** Snapshots that arrived after a newer one (reordered by the carrier). */
51
+ get late() {
52
+ return this.#late;
53
+ }
54
+ /** The newest snapshot, or `null`. */
55
+ latest() {
56
+ return this.#snapshots.at(-1) ?? null;
57
+ }
58
+ /**
59
+ * The entities as of host time `at`: interpolated between the two
60
+ * snapshots around it. Before the oldest held, the oldest. Past the
61
+ * newest: held there, or — with `extrapolateMs` — carried on along the
62
+ * last two snapshots' line for at most that long, then held. An entity
63
+ * present only in the later snapshot appears as it is there; one gone
64
+ * from the later snapshot is gone.
65
+ */
66
+ at(time, blend, extrapolateMs = 0) {
67
+ const list = this.#snapshots;
68
+ if (list.length === 0)
69
+ return {};
70
+ if (time <= list[0].time)
71
+ return list[0].entities;
72
+ const last = list.at(-1);
73
+ if (time >= last.time) {
74
+ const before = list.at(-2);
75
+ if (extrapolateMs <= 0 || before === undefined || last.time === before.time || time === last.time) {
76
+ return last.entities;
77
+ }
78
+ const ahead = Math.min(time - last.time, extrapolateMs);
79
+ const alpha = 1 + ahead / (last.time - before.time);
80
+ const out = {};
81
+ for (const [id, to] of Object.entries(last.entities)) {
82
+ const from = before.entities[id];
83
+ out[id] = from === undefined ? to : blend(from, to, alpha);
84
+ }
85
+ return out;
86
+ }
87
+ let i = 1;
88
+ while (list[i].time < time)
89
+ i += 1;
90
+ const a = list[i - 1];
91
+ const b = list[i];
92
+ const alpha = b.time === a.time ? 1 : (time - a.time) / (b.time - a.time);
93
+ const out = {};
94
+ for (const [id, to] of Object.entries(b.entities)) {
95
+ const from = a.entities[id];
96
+ out[id] = from === undefined ? to : blend(from, to, alpha);
97
+ }
98
+ return out;
99
+ }
100
+ }