@driftengine/network 3.61.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 (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +125 -0
  4. package/dist/authority.d.ts +170 -0
  5. package/dist/authority.js +235 -0
  6. package/dist/fingerprint.d.ts +64 -0
  7. package/dist/fingerprint.js +108 -0
  8. package/dist/fixture.d.ts +32 -0
  9. package/dist/fixture.js +136 -0
  10. package/dist/index.d.ts +45 -0
  11. package/dist/index.js +32 -0
  12. package/dist/inputLog.d.ts +117 -0
  13. package/dist/inputLog.js +232 -0
  14. package/dist/lockstep.d.ts +181 -0
  15. package/dist/lockstep.js +291 -0
  16. package/dist/loopback.d.ts +115 -0
  17. package/dist/loopback.js +207 -0
  18. package/dist/rewind.d.ts +206 -0
  19. package/dist/rewind.js +212 -0
  20. package/dist/scriptSession.d.ts +77 -0
  21. package/dist/scriptSession.js +81 -0
  22. package/dist/simNumber.d.ts +103 -0
  23. package/dist/simNumber.js +156 -0
  24. package/dist/snapshotter.d.ts +43 -0
  25. package/dist/snapshotter.js +81 -0
  26. package/dist/transport.d.ts +63 -0
  27. package/dist/transport.js +40 -0
  28. package/dist/webrtc.d.ts +74 -0
  29. package/dist/webrtc.js +108 -0
  30. package/dist/websocket.d.ts +70 -0
  31. package/dist/websocket.js +105 -0
  32. package/dist/wire.d.ts +94 -0
  33. package/dist/wire.js +238 -0
  34. package/package.json +61 -0
  35. package/src/authority.ts +340 -0
  36. package/src/fingerprint.ts +143 -0
  37. package/src/fixture.ts +188 -0
  38. package/src/index.ts +95 -0
  39. package/src/inputLog.ts +262 -0
  40. package/src/lockstep.ts +374 -0
  41. package/src/loopback.ts +250 -0
  42. package/src/rewind.ts +337 -0
  43. package/src/scriptSession.ts +137 -0
  44. package/src/simNumber.ts +176 -0
  45. package/src/snapshotter.ts +98 -0
  46. package/src/transport.ts +69 -0
  47. package/src/webrtc.ts +141 -0
  48. package/src/websocket.ts +140 -0
  49. package/src/wire.ts +269 -0
@@ -0,0 +1,235 @@
1
+ import { BROADCAST } from './transport.js';
2
+ import { MESSAGE_INPUT, MESSAGE_STATE, createDecoded, decode, encodeInput, encodeState, } from './wire.js';
3
+ /** The machine whose world is the truth. */
4
+ export class AuthorityHost {
5
+ transport;
6
+ loop;
7
+ inputs;
8
+ replicator;
9
+ stateEvery;
10
+ outgoing;
11
+ decoded = createDecoded();
12
+ published = 0;
13
+ constructor(options) {
14
+ this.transport = options.transport;
15
+ this.loop = options.loop;
16
+ this.inputs = options.inputs;
17
+ this.replicator = options.replicator;
18
+ this.stateEvery = Math.max(1, Math.trunc(options.stateEvery ?? 3));
19
+ this.outgoing = new Uint8Array(16 + Math.max(64, options.maxStateBytes ?? 8192));
20
+ }
21
+ /** How many state messages have gone out. For a consumer measuring what replication costs. */
22
+ get publishedStates() {
23
+ return this.published;
24
+ }
25
+ poll() {
26
+ this.transport.drain((_from, message) => {
27
+ if (!decode(message, this.decoded))
28
+ return;
29
+ if (this.decoded.kind !== MESSAGE_INPUT)
30
+ return;
31
+ const stride = this.decoded.payloadBytes;
32
+ for (let i = 0; i < this.decoded.count; i++) {
33
+ const tick = this.decoded.tick + i;
34
+ const at = this.decoded.payloadAt + i * stride;
35
+ if (this.inputs.isConfirmed(this.decoded.participant, tick))
36
+ continue;
37
+ this.loop.supply(this.decoded.participant, tick, message.subarray(at, at + stride));
38
+ }
39
+ });
40
+ }
41
+ /**
42
+ * Take a tick, then publish if this is a publishing tick.
43
+ *
44
+ * **The state is encoded *after* the step**, so it describes the world at the end of `tick`, and
45
+ * a client applying it reconciles at `tick + 1`. Publishing before the step would send the world
46
+ * as it was when the tick began, which is the state the client already had.
47
+ */
48
+ advance(dt, tick) {
49
+ this.loop.advance(dt, tick);
50
+ const earliest = this.loop.earliest;
51
+ if (earliest > 0)
52
+ this.inputs.retain(earliest);
53
+ if (tick % this.stateEvery !== 0)
54
+ return;
55
+ const bytes = this.replicator.encode(this.outgoing.subarray(16), tick);
56
+ if (bytes < 0)
57
+ return;
58
+ const length = encodeState(this.outgoing, tick + 1, this.outgoing.subarray(16, 16 + bytes));
59
+ if (length < 0)
60
+ return;
61
+ this.published += 1;
62
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
63
+ }
64
+ }
65
+ /** A machine that predicts its own player and corrects toward the authority. */
66
+ export class PredictingClient {
67
+ self;
68
+ transport;
69
+ loop;
70
+ inputs;
71
+ replicator;
72
+ predict;
73
+ redundancy;
74
+ recent;
75
+ outgoing;
76
+ decoded = createDecoded();
77
+ recentFrom = -1;
78
+ recentCount = 0;
79
+ correctionsValue = 0;
80
+ lastAppliedState = -1;
81
+ constructor(options) {
82
+ this.transport = options.transport;
83
+ this.loop = options.loop;
84
+ this.inputs = options.inputs;
85
+ this.replicator = options.replicator;
86
+ this.self = options.self;
87
+ this.predict = options.predict ?? true;
88
+ this.redundancy = Math.max(1, Math.trunc(options.redundancy ?? 4));
89
+ this.recent = new Uint8Array(this.redundancy * this.inputs.inputBytes);
90
+ this.outgoing = new Uint8Array(64 + this.redundancy * this.inputs.inputBytes);
91
+ }
92
+ /** How many authoritative states have been applied. Each one is a rewind. */
93
+ get corrections() {
94
+ return this.correctionsValue;
95
+ }
96
+ /**
97
+ * Send this input and, when predicting, apply it locally on the same tick.
98
+ *
99
+ * **No input delay**, unlike lockstep. A delay there buys exactness for everybody; here the
100
+ * authority is the source of truth anyway, so delaying the local input would add latency to the
101
+ * player's own controls and buy nothing.
102
+ */
103
+ submit(tick, payload) {
104
+ if (this.predict)
105
+ this.inputs.set(this.self, tick, payload);
106
+ this.remember(tick, payload);
107
+ const length = encodeInput(this.outgoing, this.self, this.recentFrom, this.recent, this.inputs.inputBytes, this.recentCount);
108
+ if (length < 0)
109
+ return;
110
+ this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
111
+ }
112
+ poll() {
113
+ this.transport.drain((_from, message) => {
114
+ if (!decode(message, this.decoded))
115
+ return;
116
+ if (this.decoded.kind !== MESSAGE_STATE)
117
+ return;
118
+ const tick = this.decoded.tick;
119
+ /* An older state than one already applied is a reordered packet, not news. */
120
+ if (tick <= this.lastAppliedState)
121
+ return;
122
+ const payload = message.subarray(this.decoded.payloadAt, this.decoded.payloadAt + this.decoded.payloadBytes);
123
+ /*
124
+ * **A state for a tick the ring no longer holds is applied whole rather than reconciled.**
125
+ * There is nothing left to replay over it — the local history that far back is gone — so the
126
+ * honest response is to accept the authority's world and carry on from there. That is a
127
+ * visible jump, and it is what a client that has been away should see.
128
+ */
129
+ const applied = this.loop.reconcileSnapshot(tick, () => this.replicator.apply(payload, tick));
130
+ if (!applied)
131
+ this.replicator.apply(payload, tick);
132
+ this.lastAppliedState = tick;
133
+ this.correctionsValue += 1;
134
+ });
135
+ }
136
+ advance(dt, tick) {
137
+ this.loop.advance(dt, tick);
138
+ const earliest = this.loop.earliest;
139
+ if (earliest > 0)
140
+ this.inputs.retain(earliest);
141
+ }
142
+ remember(tick, payload) {
143
+ const stride = this.inputs.inputBytes;
144
+ if (this.recentFrom < 0 || tick !== this.recentFrom + this.recentCount) {
145
+ this.recentFrom = tick;
146
+ this.recentCount = 0;
147
+ }
148
+ if (this.recentCount === this.redundancy) {
149
+ this.recent.copyWithin(0, stride);
150
+ this.recentFrom += 1;
151
+ this.recentCount -= 1;
152
+ }
153
+ const at = this.recentCount * stride;
154
+ for (let i = 0; i < stride; i++)
155
+ this.recent[at + i] = payload[i] ?? 0;
156
+ this.recentCount += 1;
157
+ }
158
+ }
159
+ /**
160
+ * Two authoritative states and where between them to draw.
161
+ *
162
+ * **Rendering at a delay is what stops a remote body teleporting.** States arrive every few ticks
163
+ * and unevenly; drawing the newest one puts a body wherever the last packet said, which on a jittery
164
+ * link is a stutter. Drawing *between* the two most recent, a fixed distance behind the newest, costs
165
+ * that distance in latency and buys smooth motion — and the latency is honest, because the position
166
+ * shown is one the authority actually published rather than one guessed forward.
167
+ *
168
+ * This owns the timing and none of the content: `sample` answers which two payloads and how far
169
+ * between, and the consumer decodes and blends. It cannot do otherwise, since the payload's shape is
170
+ * the consumer's.
171
+ */
172
+ export class StateInterpolator {
173
+ delayTicks;
174
+ ticks;
175
+ payloads;
176
+ lengths;
177
+ count = 0;
178
+ cursor = 0;
179
+ constructor(capacity = 8, maxBytes = 8192, delayTicks = 4) {
180
+ const slots = Math.max(2, Math.trunc(capacity));
181
+ this.ticks = new Int32Array(slots).fill(-1);
182
+ this.lengths = new Int32Array(slots);
183
+ this.payloads = Array.from({ length: slots }, () => new Uint8Array(maxBytes));
184
+ this.delayTicks = Math.max(0, Math.trunc(delayTicks));
185
+ }
186
+ /** Keep a state. The bytes are copied, because the transport reuses its buffer. */
187
+ push(tick, payload) {
188
+ const slot = this.cursor;
189
+ const room = this.payloads[slot];
190
+ const length = Math.min(payload.length, room.length);
191
+ room.set(payload.subarray(0, length));
192
+ this.lengths[slot] = length;
193
+ this.ticks[slot] = tick;
194
+ this.cursor = (this.cursor + 1) % this.ticks.length;
195
+ if (this.count < this.ticks.length)
196
+ this.count += 1;
197
+ }
198
+ /**
199
+ * The two states to blend at `renderTick`, and how far between them, or null.
200
+ *
201
+ * `alpha` is clamped to [0, 1]: past the newest state it holds there rather than extrapolating,
202
+ * which is the same choice `TickTrace.sample` makes at the end of a recording and for the same
203
+ * reason — a character who has finished should look like they finished.
204
+ */
205
+ sample(renderTick) {
206
+ if (this.count === 0)
207
+ return null;
208
+ const target = renderTick - this.delayTicks;
209
+ let before = -1;
210
+ let after = -1;
211
+ for (let i = 0; i < this.ticks.length; i++) {
212
+ const at = this.ticks[i];
213
+ if (at < 0)
214
+ continue;
215
+ if (at <= target && (before < 0 || at > this.ticks[before]))
216
+ before = i;
217
+ if (at > target && (after < 0 || at < this.ticks[after]))
218
+ after = i;
219
+ }
220
+ if (before < 0 && after < 0)
221
+ return null;
222
+ if (before < 0)
223
+ return { from: this.slice(after), to: this.slice(after), alpha: 0 };
224
+ if (after < 0)
225
+ return { from: this.slice(before), to: this.slice(before), alpha: 1 };
226
+ const fromTick = this.ticks[before];
227
+ const toTick = this.ticks[after];
228
+ const span = toTick - fromTick;
229
+ const alpha = span <= 0 ? 1 : Math.min(1, Math.max(0, (target - fromTick) / span));
230
+ return { from: this.slice(before), to: this.slice(after), alpha };
231
+ }
232
+ slice(slot) {
233
+ return this.payloads[slot].subarray(0, this.lengths[slot]);
234
+ }
235
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A hash of simulation state, so a divergence has a tick number attached to it.
3
+ *
4
+ * **The value is not detection, it is *localisation*.** Two peers whose worlds have drifted apart
5
+ * will notice eventually, because something visible will differ; what they cannot do without this is
6
+ * say *when* it started. A fingerprint per confirmed tick turns "we are out of sync" into "we
7
+ * disagreed first at tick 4,182", and the tick is the whole of the debugging: it names the inputs
8
+ * that were live, the entities that moved, and the one step to re-run under a debugger.
9
+ *
10
+ * Two FNV-1a streams over the bytes, which is the shape `fingerprintColliders` and
11
+ * `fingerprintChemistry` already use here. Sixty-four bits from two thirty-two-bit streams rather
12
+ * than one, because a thirty-two-bit hash collides at about seventy thousand samples by the birthday
13
+ * bound and a session is sixty samples a second.
14
+ *
15
+ * ---
16
+ *
17
+ * ## Floats are hashed as bits, and that is the point
18
+ *
19
+ * A hash of `x.toString()` or of a rounded value would agree across a difference of one ulp, which
20
+ * is exactly the difference this exists to find. So every number goes in as its eight bytes.
21
+ *
22
+ * **A consequence worth stating: `-0` and `+0` hash differently, and `NaN` hashes as whatever
23
+ * payload produced it.** Both are correct — two peers holding different zeroes have genuinely
24
+ * diverged, and a simulation that has produced a NaN has a bigger problem than a fingerprint.
25
+ */
26
+ import type { WorldSnapshot } from '@driftengine/entities';
27
+ /**
28
+ * An FNV-1a pair, fed bytes and numbers, answering sixteen hex characters.
29
+ *
30
+ * A class rather than a function over one buffer, because state worth fingerprinting is rarely one
31
+ * buffer: a consumer combines a world, a generator's position and whatever they keep beside both,
32
+ * and the order they feed it in is theirs to fix.
33
+ */
34
+ export declare class Fingerprint {
35
+ private low;
36
+ private high;
37
+ private readonly scratch;
38
+ /** Start again, so one instance serves a whole session without allocating. */
39
+ reset(): this;
40
+ byte(value: number): this;
41
+ /** A 32-bit integer, low byte first. */
42
+ int32(value: number): this;
43
+ /** A double, as its eight bytes. See the header for why not as text. */
44
+ float(value: number): this;
45
+ /** A typed array's bytes, up to `count` elements, or all of them. */
46
+ array(values: ArrayLike<number> & {
47
+ BYTES_PER_ELEMENT?: number;
48
+ }, count?: number): this;
49
+ /** Sixteen lowercase hex characters. */
50
+ digest(): string;
51
+ }
52
+ /**
53
+ * A world snapshot's fingerprint.
54
+ *
55
+ * **The stores are walked in id order rather than in map order.** A `Map` iterates in insertion
56
+ * order, and two peers create their stores in whatever order their code first asked for them —
57
+ * which is a property of the load path, not of the world. Sorting makes the hash a function of the
58
+ * state instead of a function of the state and the startup sequence.
59
+ *
60
+ * The allocator goes in first, and `highWater` with it: two worlds holding identical components on
61
+ * identical handles but differing in how many slots have ever been used are genuinely different,
62
+ * because the next spawn differs.
63
+ */
64
+ export declare function fingerprintSnapshot(slot: WorldSnapshot, into?: Fingerprint): string;
@@ -0,0 +1,108 @@
1
+ const OFFSET_BASIS = 0x811c9dc5;
2
+ const PRIME = 0x01000193;
3
+ /**
4
+ * An FNV-1a pair, fed bytes and numbers, answering sixteen hex characters.
5
+ *
6
+ * A class rather than a function over one buffer, because state worth fingerprinting is rarely one
7
+ * buffer: a consumer combines a world, a generator's position and whatever they keep beside both,
8
+ * and the order they feed it in is theirs to fix.
9
+ */
10
+ export class Fingerprint {
11
+ low = OFFSET_BASIS;
12
+ high = OFFSET_BASIS;
13
+ scratch = new DataView(new ArrayBuffer(8));
14
+ /** Start again, so one instance serves a whole session without allocating. */
15
+ reset() {
16
+ this.low = OFFSET_BASIS;
17
+ this.high = OFFSET_BASIS;
18
+ return this;
19
+ }
20
+ byte(value) {
21
+ const b = value & 0xff;
22
+ this.low = Math.imul(this.low ^ b, PRIME) >>> 0;
23
+ /*
24
+ * The high stream consumes the low stream's *current* state rather than the same byte, which is
25
+ * what makes the two halves independent. Two streams fed identical input with different bases
26
+ * stay correlated and the pair is worth barely more than one of them.
27
+ */
28
+ this.high = Math.imul(this.high ^ (this.low & 0xff), PRIME) >>> 0;
29
+ return this;
30
+ }
31
+ /** A 32-bit integer, low byte first. */
32
+ int32(value) {
33
+ const v = value | 0;
34
+ this.byte(v);
35
+ this.byte(v >>> 8);
36
+ this.byte(v >>> 16);
37
+ this.byte(v >>> 24);
38
+ return this;
39
+ }
40
+ /** A double, as its eight bytes. See the header for why not as text. */
41
+ float(value) {
42
+ this.scratch.setFloat64(0, value);
43
+ for (let i = 0; i < 8; i++)
44
+ this.byte(this.scratch.getUint8(i));
45
+ return this;
46
+ }
47
+ /** A typed array's bytes, up to `count` elements, or all of them. */
48
+ array(values, count = values.length) {
49
+ const limit = Math.min(count, values.length);
50
+ this.int32(limit);
51
+ if (values instanceof Float64Array || values instanceof Float32Array) {
52
+ for (let i = 0; i < limit; i++)
53
+ this.float(values[i]);
54
+ return this;
55
+ }
56
+ for (let i = 0; i < limit; i++)
57
+ this.int32(values[i]);
58
+ return this;
59
+ }
60
+ /** Sixteen lowercase hex characters. */
61
+ digest() {
62
+ return hex(this.high) + hex(this.low);
63
+ }
64
+ }
65
+ function hex(value) {
66
+ return (value >>> 0).toString(16).padStart(8, '0');
67
+ }
68
+ /**
69
+ * A world snapshot's fingerprint.
70
+ *
71
+ * **The stores are walked in id order rather than in map order.** A `Map` iterates in insertion
72
+ * order, and two peers create their stores in whatever order their code first asked for them —
73
+ * which is a property of the load path, not of the world. Sorting makes the hash a function of the
74
+ * state instead of a function of the state and the startup sequence.
75
+ *
76
+ * The allocator goes in first, and `highWater` with it: two worlds holding identical components on
77
+ * identical handles but differing in how many slots have ever been used are genuinely different,
78
+ * because the next spawn differs.
79
+ */
80
+ export function fingerprintSnapshot(slot, into = new Fingerprint()) {
81
+ into.reset();
82
+ const allocator = slot.allocator;
83
+ into.int32(allocator.highWater);
84
+ into.int32(allocator.aliveCount);
85
+ into.array(allocator.generations, allocator.highWater);
86
+ into.array(allocator.live, allocator.highWater);
87
+ into.array(allocator.free, allocator.freeCount);
88
+ const ids = [...slot.stores.keys()].sort((a, b) => a - b);
89
+ into.int32(ids.length);
90
+ for (const id of ids) {
91
+ const store = slot.stores.get(id);
92
+ if (store === undefined)
93
+ continue;
94
+ into.int32(id);
95
+ into.int32(store.count);
96
+ into.array(store.dense, store.count);
97
+ /* Columns by name, sorted, for the reason the stores are: a schema's field order is a property
98
+ of the declaration and a map's order is a property of construction. */
99
+ const fields = [...store.columns.keys()].sort();
100
+ for (const field of fields) {
101
+ const column = store.columns.get(field);
102
+ if (column === undefined || Array.isArray(column))
103
+ continue;
104
+ into.array(column, store.count);
105
+ }
106
+ }
107
+ return into.digest();
108
+ }
@@ -0,0 +1,32 @@
1
+ export declare const FIXTURE_BODIES = 12;
2
+ /**
3
+ * The trigonometry, supplied rather than imported.
4
+ *
5
+ * **`@driftengine/core` is a devDependency here and must stay one.** This package imports
6
+ * `@driftengine/entities` at runtime and nothing else, which is the property that lets an
7
+ * authoritative host run it with no renderer in its module graph — and `boundaries.test.mjs`
8
+ * asserts it against the manifest rather than trusting the prose. So the fixture takes its
9
+ * reproducible sine and cosine as a capability, the way everything else in this package takes its
10
+ * dependencies, and a caller passes core's `exactSin` and `exactCos`.
11
+ */
12
+ export interface FixtureMath {
13
+ sin(radians: number): number;
14
+ cos(radians: number): number;
15
+ }
16
+ export interface FixtureResult {
17
+ /** A hash of the whole state at the end. What another machine compares. */
18
+ readonly digest: string;
19
+ /** The first body's position, for a check that wants a number rather than a hash. */
20
+ readonly x: number;
21
+ readonly y: number;
22
+ }
23
+ /**
24
+ * The pair of arms, sharing one sine table and one starting state.
25
+ *
26
+ * `runFloat` uses only what `scripts/determinism.mjs` permits: arithmetic, `Math.sqrt`, and the
27
+ * supplied transcendentals. `runFixed` uses only `simNumber`.
28
+ */
29
+ export declare function createFixture(math: FixtureMath): {
30
+ runFloat(ticks: number): FixtureResult;
31
+ runFixed(ticks: number): FixtureResult;
32
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * One simulation, written twice: once in doubles behind the exactness gate, once in fixed point.
3
+ *
4
+ * **This is `SimNumber`'s consumer, and it exists because of a rule.** `AGENTS.md`: a feature
5
+ * nothing turns on is written rather than ported. An opt-in numeric type with no user in the
6
+ * repository would ship as untested surface area whose first real consumer finds its bugs — so the
7
+ * conformance fixture is written in it, and the exit test, the cross-machine script and the browser
8
+ * check all run both arms.
9
+ *
10
+ * **The two arms are not expected to agree with each other**, and a test asserting they did would be
11
+ * asserting something false: fixed point truncates where floating point rounds, so the arms diverge
12
+ * by design within the first few ticks. What each must be is *reproducible* — identical to itself
13
+ * across a rewind, across a process, and across a machine.
14
+ *
15
+ * ---
16
+ *
17
+ * ## What it simulates, and why it is this and not something simpler
18
+ *
19
+ * Bodies on a ring, each steered toward the one ahead and slowed by drag. It is chosen for what it
20
+ * exercises rather than for what it depicts:
21
+ *
22
+ * - **A transcendental per body per tick**, because that is where two engines disagree. The
23
+ * floating arm calls `exactSin`/`exactCos`; the fixed arm uses a table, since a fixed-point sine
24
+ * is a table by construction.
25
+ * - **A square root**, which is the operation `Math.sqrt` is exactly rounded for and `simSqrt` has
26
+ * to earn by Newton iteration.
27
+ * - **A division**, which is the one operation the fixed arm is deterministic at rather than exact.
28
+ * - **Accumulated rotation**, so a one-ulp difference on tick one is visible by tick a thousand
29
+ * instead of staying below the noise.
30
+ *
31
+ * A simulation of one body moving in a straight line would be reproducible on any implementation of
32
+ * anything, and would prove nothing.
33
+ */
34
+ import { SIM_ONE, simAdd, simDiv, simFrom, simMul, simSqrt, simSub, simTo, } from './simNumber.js';
35
+ import { Fingerprint } from './fingerprint.js';
36
+ export const FIXTURE_BODIES = 12;
37
+ /** How many entries the fixed arm's sine table holds over a full turn. A power of two, so the */
38
+ /* index arithmetic is a mask rather than a modulo, and the same index is reached on every engine. */
39
+ const TABLE_SIZE = 1024;
40
+ const TABLE_MASK = TABLE_SIZE - 1;
41
+ const TWO_PI = 6.283185307179586;
42
+ /**
43
+ * The pair of arms, sharing one sine table and one starting state.
44
+ *
45
+ * `runFloat` uses only what `scripts/determinism.mjs` permits: arithmetic, `Math.sqrt`, and the
46
+ * supplied transcendentals. `runFixed` uses only `simNumber`.
47
+ */
48
+ export function createFixture(math) {
49
+ /*
50
+ * The fixed arm's sine, as a table built once from the floating arm's own function.
51
+ *
52
+ * **A table rather than a fixed-point polynomial**, and the reason is the point of the whole arm:
53
+ * a polynomial would be arithmetic whose exactness needs an argument, and a table is a list of
54
+ * numbers. It is built from the same `sin` the floating arm calls, so the two arms start from one
55
+ * function rather than from two people's idea of one, and it is built here rather than on a tick.
56
+ */
57
+ const table = new Int32Array(TABLE_SIZE);
58
+ for (let i = 0; i < TABLE_SIZE; i++) {
59
+ table[i] = Math.round(math.sin((i / TABLE_SIZE) * TWO_PI) * SIM_ONE);
60
+ }
61
+ const simSin = (angle) => {
62
+ /* Angle in turns rather than radians, so the index is a multiply and a mask. */
63
+ const turns = simDiv(angle, simFrom(TWO_PI));
64
+ const index = Math.floor((turns / SIM_ONE) * TABLE_SIZE) & TABLE_MASK;
65
+ return table[index];
66
+ };
67
+ const simCos = (angle) => simSin(simAdd(angle, simFrom(TWO_PI / 4)));
68
+ return { runFloat, runFixed };
69
+ function runFloat(ticks) {
70
+ const x = new Float64Array(FIXTURE_BODIES);
71
+ const y = new Float64Array(FIXTURE_BODIES);
72
+ const angle = new Float64Array(FIXTURE_BODIES);
73
+ const speed = new Float64Array(FIXTURE_BODIES);
74
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
75
+ x[i] = i * 0.37;
76
+ y[i] = -i * 0.11;
77
+ angle[i] = i * 0.19;
78
+ speed[i] = 1 + i * 0.013;
79
+ }
80
+ const dt = 1 / 60;
81
+ for (let tick = 0; tick < ticks; tick++) {
82
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
83
+ const ahead = (i + 1) % FIXTURE_BODIES;
84
+ const dx = x[ahead] - x[i];
85
+ const dy = y[ahead] - y[i];
86
+ const distance = Math.sqrt(dx * dx + dy * dy);
87
+ const steer = distance > 0 ? dy / (distance + 1) : 0;
88
+ angle[i] = angle[i] + steer * dt;
89
+ speed[i] = speed[i] * 0.99 + 0.02;
90
+ x[i] = x[i] + math.cos(angle[i]) * speed[i] * dt;
91
+ y[i] = y[i] + math.sin(angle[i]) * speed[i] * dt;
92
+ }
93
+ }
94
+ const fingerprint = new Fingerprint();
95
+ fingerprint.array(x).array(y).array(angle).array(speed);
96
+ return { digest: fingerprint.digest(), x: x[0], y: y[0] };
97
+ }
98
+ /** The fixed arm. The same simulation, in `Sim`. */
99
+ function runFixed(ticks) {
100
+ const x = new Float64Array(FIXTURE_BODIES);
101
+ const y = new Float64Array(FIXTURE_BODIES);
102
+ const angle = new Float64Array(FIXTURE_BODIES);
103
+ const speed = new Float64Array(FIXTURE_BODIES);
104
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
105
+ x[i] = simFrom(i * 0.37);
106
+ y[i] = simFrom(-i * 0.11);
107
+ angle[i] = simFrom(i * 0.19);
108
+ speed[i] = simFrom(1 + i * 0.013);
109
+ }
110
+ const dt = simFrom(1 / 60);
111
+ const one = simFrom(1);
112
+ const drag = simFrom(0.99);
113
+ const gain = simFrom(0.02);
114
+ for (let tick = 0; tick < ticks; tick++) {
115
+ for (let i = 0; i < FIXTURE_BODIES; i++) {
116
+ const ahead = (i + 1) % FIXTURE_BODIES;
117
+ const dx = simSub(x[ahead], x[i]);
118
+ const dy = simSub(y[ahead], y[i]);
119
+ const distance = simSqrt(simAdd(simMul(dx, dx), simMul(dy, dy)));
120
+ const steer = distance > 0 ? simDiv(dy, simAdd(distance, one)) : 0;
121
+ angle[i] = simAdd(angle[i], simMul(steer, dt));
122
+ speed[i] = simAdd(simMul(speed[i], drag), gain);
123
+ const step = simMul(speed[i], dt);
124
+ x[i] = simAdd(x[i], simMul(simCos(angle[i]), step));
125
+ y[i] = simAdd(y[i], simMul(simSin(angle[i]), step));
126
+ }
127
+ }
128
+ const fingerprint = new Fingerprint();
129
+ fingerprint.array(x).array(y).array(angle).array(speed);
130
+ return {
131
+ digest: fingerprint.digest(),
132
+ x: simTo(x[0]),
133
+ y: simTo(y[0]),
134
+ };
135
+ }
136
+ }
@@ -0,0 +1,45 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/network` — a transport seam, rewind and replay, and the two models built on them.
4
+ *
5
+ * **One rewind core under both models**, because prediction and rollback are the same operation:
6
+ * put the state back to tick *T*, correct what was wrong about *T*, step forward to where we were.
7
+ * A lockstep peer does it when a guessed input turns out wrong; a predicting client does it when
8
+ * its authority disagrees.
9
+ *
10
+ * **It imports `@driftengine/entities` and nothing else.** `boundaries.test.mjs` already makes the
11
+ * argument, about physics: a deterministic simulation with no renderer in its module graph is what
12
+ * an authoritative host runs. A network package that reaches no renderer is one such a host can
13
+ * run, and the test asserts it rather than this paragraph claiming it.
14
+ *
15
+ * **Nothing here knows what an input is.** A payload is a fixed number of bytes a consumer encodes,
16
+ * per `AGENTS.md`: an engine that knows a player presses a brake pedal has stopped being
17
+ * game-agnostic, and this is the place most netcode gives that up.
18
+ */
19
+ export { RewindLoop } from './rewind.ts';
20
+ export type { RewindOptions, SimStep, Snapshotter } from './rewind.ts';
21
+ export { InputLog, repeatLastInput } from './inputLog.ts';
22
+ export type { InputLogOptions, InputPredictor } from './inputLog.ts';
23
+ export { BROADCAST } from './transport.ts';
24
+ export type { MessageSink, PeerId, SessionRole, Transport, TransportState } from './transport.ts';
25
+ export { ScriptSession } from './scriptSession.ts';
26
+ export type { ScriptSessionOptions, SessionStatus } from './scriptSession.ts';
27
+ export { createFixture, FIXTURE_BODIES } from './fixture.ts';
28
+ export type { FixtureMath, FixtureResult } from './fixture.ts';
29
+ export { SIM_FRACTION_BITS, SIM_MAX, SIM_MIN, SIM_ONE, simAbs, simAdd, simCompare, simDiv, simFrom, simFromInt, simMax, simMin, simMul, simNeg, simSqrt, simSub, simTo, } from './simNumber.ts';
30
+ export type { Sim } from './simNumber.ts';
31
+ export { AuthorityHost, PredictingClient, StateInterpolator } from './authority.ts';
32
+ export type { AuthorityOptions, ClientOptions, Replicator } from './authority.ts';
33
+ export { LockstepSession } from './lockstep.ts';
34
+ export type { Desync, LockstepOptions, LockstepStatus } from './lockstep.ts';
35
+ export { WebSocketTransport } from './websocket.ts';
36
+ export type { SocketLike, WebSocketTransportOptions } from './websocket.ts';
37
+ export { WebRtcTransport, unreliableChannel } from './webrtc.ts';
38
+ export type { DataChannelLike, PeerConnectionLike, WebRtcTransportOptions } from './webrtc.ts';
39
+ export { LoopbackNetwork, LoopbackTransport } from './loopback.ts';
40
+ export type { Impairment, LoopbackOptions } from './loopback.ts';
41
+ export { FINGERPRINT_BYTES, INPUT_HEADER_BYTES, MESSAGE_ACK, MESSAGE_FINGERPRINT, MESSAGE_INPUT, MESSAGE_JOIN, MESSAGE_PING, MESSAGE_STATE, MESSAGE_WELCOME, PROTOCOL_VERSION, STATE_HEADER_BYTES, createDecoded, decode, encodeAck, encodeFingerprint, encodeInput, encodeJoin, encodeState, encodeWelcome, } from './wire.ts';
42
+ export type { DecodedMessage } from './wire.ts';
43
+ export { Fingerprint, fingerprintSnapshot } from './fingerprint.ts';
44
+ export { combineSnapshotters, randomSnapshotter, worldSnapshotter } from './snapshotter.ts';
45
+ export type { SavablePosition } from './snapshotter.ts';
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/network` — a transport seam, rewind and replay, and the two models built on them.
4
+ *
5
+ * **One rewind core under both models**, because prediction and rollback are the same operation:
6
+ * put the state back to tick *T*, correct what was wrong about *T*, step forward to where we were.
7
+ * A lockstep peer does it when a guessed input turns out wrong; a predicting client does it when
8
+ * its authority disagrees.
9
+ *
10
+ * **It imports `@driftengine/entities` and nothing else.** `boundaries.test.mjs` already makes the
11
+ * argument, about physics: a deterministic simulation with no renderer in its module graph is what
12
+ * an authoritative host runs. A network package that reaches no renderer is one such a host can
13
+ * run, and the test asserts it rather than this paragraph claiming it.
14
+ *
15
+ * **Nothing here knows what an input is.** A payload is a fixed number of bytes a consumer encodes,
16
+ * per `AGENTS.md`: an engine that knows a player presses a brake pedal has stopped being
17
+ * game-agnostic, and this is the place most netcode gives that up.
18
+ */
19
+ export { RewindLoop } from './rewind.js';
20
+ export { InputLog, repeatLastInput } from './inputLog.js';
21
+ export { BROADCAST } from './transport.js';
22
+ export { ScriptSession } from './scriptSession.js';
23
+ export { createFixture, FIXTURE_BODIES } from './fixture.js';
24
+ export { SIM_FRACTION_BITS, SIM_MAX, SIM_MIN, SIM_ONE, simAbs, simAdd, simCompare, simDiv, simFrom, simFromInt, simMax, simMin, simMul, simNeg, simSqrt, simSub, simTo, } from './simNumber.js';
25
+ export { AuthorityHost, PredictingClient, StateInterpolator } from './authority.js';
26
+ export { LockstepSession } from './lockstep.js';
27
+ export { WebSocketTransport } from './websocket.js';
28
+ export { WebRtcTransport, unreliableChannel } from './webrtc.js';
29
+ export { LoopbackNetwork, LoopbackTransport } from './loopback.js';
30
+ export { FINGERPRINT_BYTES, INPUT_HEADER_BYTES, MESSAGE_ACK, MESSAGE_FINGERPRINT, MESSAGE_INPUT, MESSAGE_JOIN, MESSAGE_PING, MESSAGE_STATE, MESSAGE_WELCOME, PROTOCOL_VERSION, STATE_HEADER_BYTES, createDecoded, decode, encodeAck, encodeFingerprint, encodeInput, encodeJoin, encodeState, encodeWelcome, } from './wire.js';
31
+ export { Fingerprint, fingerprintSnapshot } from './fingerprint.js';
32
+ export { combineSnapshotters, randomSnapshotter, worldSnapshotter } from './snapshotter.js';