@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +125 -0
- package/dist/authority.d.ts +170 -0
- package/dist/authority.js +235 -0
- package/dist/fingerprint.d.ts +64 -0
- package/dist/fingerprint.js +108 -0
- package/dist/fixture.d.ts +32 -0
- package/dist/fixture.js +136 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +32 -0
- package/dist/inputLog.d.ts +117 -0
- package/dist/inputLog.js +232 -0
- package/dist/lockstep.d.ts +181 -0
- package/dist/lockstep.js +291 -0
- package/dist/loopback.d.ts +115 -0
- package/dist/loopback.js +207 -0
- package/dist/rewind.d.ts +206 -0
- package/dist/rewind.js +212 -0
- package/dist/scriptSession.d.ts +77 -0
- package/dist/scriptSession.js +81 -0
- package/dist/simNumber.d.ts +103 -0
- package/dist/simNumber.js +156 -0
- package/dist/snapshotter.d.ts +43 -0
- package/dist/snapshotter.js +81 -0
- package/dist/transport.d.ts +63 -0
- package/dist/transport.js +40 -0
- package/dist/webrtc.d.ts +74 -0
- package/dist/webrtc.js +108 -0
- package/dist/websocket.d.ts +70 -0
- package/dist/websocket.js +105 -0
- package/dist/wire.d.ts +94 -0
- package/dist/wire.js +238 -0
- package/package.json +61 -0
- package/src/authority.ts +340 -0
- package/src/fingerprint.ts +143 -0
- package/src/fixture.ts +188 -0
- package/src/index.ts +95 -0
- package/src/inputLog.ts +262 -0
- package/src/lockstep.ts +374 -0
- package/src/loopback.ts +250 -0
- package/src/rewind.ts +337 -0
- package/src/scriptSession.ts +137 -0
- package/src/simNumber.ts +176 -0
- package/src/snapshotter.ts +98 -0
- package/src/transport.ts +69 -0
- package/src/webrtc.ts +141 -0
- package/src/websocket.ts +140 -0
- package/src/wire.ts +269 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
|
|
22
|
+
export { InputLog, repeatLastInput } from './inputLog.ts';
|
|
23
|
+
export type { InputLogOptions, InputPredictor } from './inputLog.ts';
|
|
24
|
+
|
|
25
|
+
export { BROADCAST } from './transport.ts';
|
|
26
|
+
export type { MessageSink, PeerId, SessionRole, Transport, TransportState } from './transport.ts';
|
|
27
|
+
|
|
28
|
+
export { ScriptSession } from './scriptSession.ts';
|
|
29
|
+
export type { ScriptSessionOptions, SessionStatus } from './scriptSession.ts';
|
|
30
|
+
|
|
31
|
+
export { createFixture, FIXTURE_BODIES } from './fixture.ts';
|
|
32
|
+
export type { FixtureMath, FixtureResult } from './fixture.ts';
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
SIM_FRACTION_BITS,
|
|
36
|
+
SIM_MAX,
|
|
37
|
+
SIM_MIN,
|
|
38
|
+
SIM_ONE,
|
|
39
|
+
simAbs,
|
|
40
|
+
simAdd,
|
|
41
|
+
simCompare,
|
|
42
|
+
simDiv,
|
|
43
|
+
simFrom,
|
|
44
|
+
simFromInt,
|
|
45
|
+
simMax,
|
|
46
|
+
simMin,
|
|
47
|
+
simMul,
|
|
48
|
+
simNeg,
|
|
49
|
+
simSqrt,
|
|
50
|
+
simSub,
|
|
51
|
+
simTo,
|
|
52
|
+
} from './simNumber.ts';
|
|
53
|
+
export type { Sim } from './simNumber.ts';
|
|
54
|
+
|
|
55
|
+
export { AuthorityHost, PredictingClient, StateInterpolator } from './authority.ts';
|
|
56
|
+
export type { AuthorityOptions, ClientOptions, Replicator } from './authority.ts';
|
|
57
|
+
|
|
58
|
+
export { LockstepSession } from './lockstep.ts';
|
|
59
|
+
export type { Desync, LockstepOptions, LockstepStatus } from './lockstep.ts';
|
|
60
|
+
|
|
61
|
+
export { WebSocketTransport } from './websocket.ts';
|
|
62
|
+
export type { SocketLike, WebSocketTransportOptions } from './websocket.ts';
|
|
63
|
+
|
|
64
|
+
export { WebRtcTransport, unreliableChannel } from './webrtc.ts';
|
|
65
|
+
export type { DataChannelLike, PeerConnectionLike, WebRtcTransportOptions } from './webrtc.ts';
|
|
66
|
+
|
|
67
|
+
export { LoopbackNetwork, LoopbackTransport } from './loopback.ts';
|
|
68
|
+
export type { Impairment, LoopbackOptions } from './loopback.ts';
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
FINGERPRINT_BYTES,
|
|
72
|
+
INPUT_HEADER_BYTES,
|
|
73
|
+
MESSAGE_ACK,
|
|
74
|
+
MESSAGE_FINGERPRINT,
|
|
75
|
+
MESSAGE_INPUT,
|
|
76
|
+
MESSAGE_JOIN,
|
|
77
|
+
MESSAGE_PING,
|
|
78
|
+
MESSAGE_STATE,
|
|
79
|
+
MESSAGE_WELCOME,
|
|
80
|
+
PROTOCOL_VERSION,
|
|
81
|
+
STATE_HEADER_BYTES,
|
|
82
|
+
createDecoded,
|
|
83
|
+
decode,
|
|
84
|
+
encodeAck,
|
|
85
|
+
encodeFingerprint,
|
|
86
|
+
encodeInput,
|
|
87
|
+
encodeJoin,
|
|
88
|
+
encodeState,
|
|
89
|
+
encodeWelcome,
|
|
90
|
+
} from './wire.ts';
|
|
91
|
+
export type { DecodedMessage } from './wire.ts';
|
|
92
|
+
|
|
93
|
+
export { Fingerprint, fingerprintSnapshot } from './fingerprint.ts';
|
|
94
|
+
export { combineSnapshotters, randomSnapshotter, worldSnapshotter } from './snapshotter.ts';
|
|
95
|
+
export type { SavablePosition } from './snapshotter.ts';
|
package/src/inputLog.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every participant did on every retained tick, and what was guessed for them.
|
|
3
|
+
*
|
|
4
|
+
* **An input is a fixed number of bytes a consumer declares.** That is a constraint and it buys the
|
|
5
|
+
* whole allocation story: one `Uint8Array` for the session, indexed by participant and tick, so
|
|
6
|
+
* neither recording an input nor reading one back during a replay allocates anything. It costs a
|
|
7
|
+
* consumer having to fit an input into a known size, which for a simulation input is what they
|
|
8
|
+
* already are — a bitfield of buttons and a couple of axes — and it refuses the one shape that
|
|
9
|
+
* would break it, which is an input carrying a variable-length payload like a chat message. Those
|
|
10
|
+
* are not simulation inputs and do not belong on this path.
|
|
11
|
+
*
|
|
12
|
+
* **A predicted input is stored, not computed on read.** A replay has to see the same guess the
|
|
13
|
+
* first run saw, or the ticks it re-runs differ from the ticks it is replaying for a reason that has
|
|
14
|
+
* nothing to do with the correction being applied. So a missing input is guessed *once*, written
|
|
15
|
+
* into its slot and marked unconfirmed; a later real input overwrites it and says whether it
|
|
16
|
+
* differed.
|
|
17
|
+
*
|
|
18
|
+
* ---
|
|
19
|
+
*
|
|
20
|
+
* ## The window is explicit, because a ring that evicts silently is the bug here
|
|
21
|
+
*
|
|
22
|
+
* The obvious shape is a ring indexed by `tick % depth`, and it is wrong in a way that only shows
|
|
23
|
+
* up on a real link. Inputs arrive **ahead** of the local simulation — a peer three ticks in front
|
|
24
|
+
* sends tick 45 while this one is simulating 42 — and with a depth of eight, tick 45 lands in the
|
|
25
|
+
* slot tick 37 occupies. Tick 37 is inside the rewind window and its confirmed input has just been
|
|
26
|
+
* thrown away, so a rewind to 37 re-predicts an input it already knew, and the world quietly
|
|
27
|
+
* changes under a correction that was supposed to be about something else.
|
|
28
|
+
*
|
|
29
|
+
* So the window is a range this log is told about. `retain(oldest)` moves it, dropping what falls
|
|
30
|
+
* off the back; a tick outside it is **refused** rather than stored somewhere convenient. A depth
|
|
31
|
+
* therefore has to cover the rewind distance behind the current tick *and* the lookahead in front
|
|
32
|
+
* of it, which is why the session that owns one sizes it as the sum and not as the rewind depth.
|
|
33
|
+
*
|
|
34
|
+
* ## Repeat-the-last, and why the default is stated rather than hidden
|
|
35
|
+
*
|
|
36
|
+
* A missing input is guessed by repeating that participant's last known one. That is right for a
|
|
37
|
+
* held control and wrong for a tap, and nothing in an engine can know which a given game has: a
|
|
38
|
+
* racing game's throttle is held, a fighting game's punch is not. So the default is documented and
|
|
39
|
+
* `predict` is replaceable.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** How a missing input is guessed. `previous` is that participant's last confirmed input. */
|
|
43
|
+
export type InputPredictor = (
|
|
44
|
+
participant: number,
|
|
45
|
+
tick: number,
|
|
46
|
+
previous: Uint8Array,
|
|
47
|
+
out: Uint8Array,
|
|
48
|
+
) => void;
|
|
49
|
+
|
|
50
|
+
/** Repeat the last input seen from that participant. See the header for when this is wrong. */
|
|
51
|
+
export const repeatLastInput: InputPredictor = (_participant, _tick, previous, out) => {
|
|
52
|
+
out.set(previous);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface InputLogOptions {
|
|
56
|
+
readonly participants: number;
|
|
57
|
+
/**
|
|
58
|
+
* Retained ticks, covering the rewind window behind the current tick **and** the inputs that
|
|
59
|
+
* arrive ahead of it. See the header: this is not the rewind depth.
|
|
60
|
+
*/
|
|
61
|
+
readonly depth: number;
|
|
62
|
+
readonly inputBytes: number;
|
|
63
|
+
readonly predict?: InputPredictor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class InputLog {
|
|
67
|
+
readonly participants: number;
|
|
68
|
+
readonly depth: number;
|
|
69
|
+
readonly inputBytes: number;
|
|
70
|
+
|
|
71
|
+
private readonly bytes: Uint8Array;
|
|
72
|
+
/** Which tick each (participant, slot) holds, or -1. */
|
|
73
|
+
private readonly slotTick: Int32Array;
|
|
74
|
+
/** 1 when the value in the slot came from that participant, 0 when it was guessed. */
|
|
75
|
+
private readonly confirmed: Uint8Array;
|
|
76
|
+
/** The last confirmed input per participant, for the predictor. */
|
|
77
|
+
private readonly previous: Uint8Array;
|
|
78
|
+
private readonly scratch: Uint8Array;
|
|
79
|
+
private readonly predict: InputPredictor;
|
|
80
|
+
|
|
81
|
+
/** The oldest tick this log will accept. The window is `[oldest, oldest + depth)`. */
|
|
82
|
+
private oldest = 0;
|
|
83
|
+
|
|
84
|
+
constructor(options: InputLogOptions) {
|
|
85
|
+
this.participants = Math.max(1, Math.trunc(options.participants));
|
|
86
|
+
this.depth = Math.max(1, Math.trunc(options.depth));
|
|
87
|
+
this.inputBytes = Math.max(1, Math.trunc(options.inputBytes));
|
|
88
|
+
this.predict = options.predict ?? repeatLastInput;
|
|
89
|
+
|
|
90
|
+
const slots = this.participants * this.depth;
|
|
91
|
+
this.bytes = new Uint8Array(slots * this.inputBytes);
|
|
92
|
+
this.slotTick = new Int32Array(slots).fill(-1);
|
|
93
|
+
this.confirmed = new Uint8Array(slots);
|
|
94
|
+
this.previous = new Uint8Array(this.participants * this.inputBytes);
|
|
95
|
+
this.scratch = new Uint8Array(this.inputBytes);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The oldest tick this log holds. Everything below it has been dropped. */
|
|
99
|
+
get windowStart(): number {
|
|
100
|
+
return this.oldest;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One past the newest tick this log will accept. */
|
|
104
|
+
get windowEnd(): number {
|
|
105
|
+
return this.oldest + this.depth;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether a tick is inside the window at all. */
|
|
109
|
+
holds(tick: number): boolean {
|
|
110
|
+
return tick >= this.oldest && tick < this.oldest + this.depth;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Move the window so `oldest` is the earliest tick kept, forgetting what falls off.
|
|
115
|
+
*
|
|
116
|
+
* The slots that leave are cleared rather than left, because their `slotTick` would otherwise
|
|
117
|
+
* match a tick a whole window later and answer a stale input as a confirmed one.
|
|
118
|
+
*/
|
|
119
|
+
retain(oldest: number): void {
|
|
120
|
+
const next = Math.max(0, Math.trunc(oldest));
|
|
121
|
+
if (next <= this.oldest) return;
|
|
122
|
+
|
|
123
|
+
for (let p = 0; p < this.participants; p++) {
|
|
124
|
+
for (let slot = 0; slot < this.depth; slot++) {
|
|
125
|
+
const index = p * this.depth + slot;
|
|
126
|
+
const at = this.slotTick[index] as number;
|
|
127
|
+
if (at >= 0 && at < next) {
|
|
128
|
+
this.slotTick[index] = -1;
|
|
129
|
+
this.confirmed[index] = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.oldest = next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record a real input. **`true` when this changed what the slot held**, which is what tells a
|
|
138
|
+
* rewind whether it has anything to do. `false` also means refused, for a tick outside the
|
|
139
|
+
* window — see `holds` to tell the two apart when it matters.
|
|
140
|
+
*
|
|
141
|
+
* A confirmed input matching the guess is the common case on a held control, and replaying for
|
|
142
|
+
* it would burn a rewind to produce the identical world.
|
|
143
|
+
*/
|
|
144
|
+
set(participant: number, tick: number, payload: Uint8Array): boolean {
|
|
145
|
+
const slot = this.slotOf(participant, tick);
|
|
146
|
+
if (slot < 0) return false;
|
|
147
|
+
const at = slot * this.inputBytes;
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* **Changed means the bytes changed, and deliberately not "was unconfirmed and now is".**
|
|
151
|
+
* A guess that turns out byte-identical to the real input costs a replay nothing, because the
|
|
152
|
+
* replay would re-run the same ticks with the same values and arrive at the same world. On any
|
|
153
|
+
* held control that is the common case, so treating confirmation as a change would burn a
|
|
154
|
+
* rewind per packet to reproduce what is already on screen. What confirmation changes is
|
|
155
|
+
* `confirmedThrough`, which is a different question from whether the world is wrong.
|
|
156
|
+
*/
|
|
157
|
+
const held = this.slotTick[slot] === tick;
|
|
158
|
+
let changed = !held;
|
|
159
|
+
if (held) {
|
|
160
|
+
for (let i = 0; i < this.inputBytes; i++) {
|
|
161
|
+
if (this.bytes[at + i] !== (payload[i] ?? 0)) {
|
|
162
|
+
changed = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < this.inputBytes; i++) this.bytes[at + i] = payload[i] ?? 0;
|
|
169
|
+
this.slotTick[slot] = tick;
|
|
170
|
+
this.confirmed[slot] = 1;
|
|
171
|
+
|
|
172
|
+
/* The newest confirmed input is what the predictor repeats, so an input arriving out of order
|
|
173
|
+
must not overwrite a newer one. */
|
|
174
|
+
if (this.newestConfirmed(participant) <= tick) {
|
|
175
|
+
const previousAt = participant * this.inputBytes;
|
|
176
|
+
for (let i = 0; i < this.inputBytes; i++) this.previous[previousAt + i] = payload[i] ?? 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return changed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The input to simulate with, guessing and storing one if none has arrived.
|
|
184
|
+
*
|
|
185
|
+
* **`false` means it was guessed**, or that the tick is outside the window, in which case `out` is
|
|
186
|
+
* zeroed. A caller deciding whether a tick can be confirmed reads that; a caller simulating does
|
|
187
|
+
* not care, because either way `out` holds the bytes this tick ran with, now and on every replay.
|
|
188
|
+
*/
|
|
189
|
+
into(participant: number, tick: number, out: Uint8Array): boolean {
|
|
190
|
+
const slot = this.slotOf(participant, tick);
|
|
191
|
+
if (slot < 0) {
|
|
192
|
+
out.fill(0);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const at = slot * this.inputBytes;
|
|
196
|
+
|
|
197
|
+
if (this.slotTick[slot] !== tick) {
|
|
198
|
+
const previousAt = participant * this.inputBytes;
|
|
199
|
+
for (let i = 0; i < this.inputBytes; i++) {
|
|
200
|
+
this.scratch[i] = this.previous[previousAt + i] as number;
|
|
201
|
+
}
|
|
202
|
+
this.predict(participant, tick, this.scratch, out);
|
|
203
|
+
for (let i = 0; i < this.inputBytes; i++) this.bytes[at + i] = out[i] ?? 0;
|
|
204
|
+
this.slotTick[slot] = tick;
|
|
205
|
+
this.confirmed[slot] = 0;
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < this.inputBytes; i++) out[i] = this.bytes[at + i] as number;
|
|
210
|
+
return this.confirmed[slot] === 1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether a real input for this participant and tick has arrived. */
|
|
214
|
+
isConfirmed(participant: number, tick: number): boolean {
|
|
215
|
+
const slot = this.slotOf(participant, tick);
|
|
216
|
+
if (slot < 0) return false;
|
|
217
|
+
return this.slotTick[slot] === tick && this.confirmed[slot] === 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Whether every participant's input for this tick has arrived. What lockstep advances on. */
|
|
221
|
+
isComplete(tick: number): boolean {
|
|
222
|
+
for (let p = 0; p < this.participants; p++) {
|
|
223
|
+
if (!this.isConfirmed(p, tick)) return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The highest tick at or below `upTo` such that **every** tick from the window's start up to it is
|
|
230
|
+
* complete, or -1.
|
|
231
|
+
*
|
|
232
|
+
* Walked upward from the start of the window and stopped at the first gap, which is the property
|
|
233
|
+
* that matters: ticks 40 and 42 confirmed with 41 missing is not a session confirmed to 42, and
|
|
234
|
+
* answering 42 would let a peer discard the snapshot it still needs to correct 41.
|
|
235
|
+
*/
|
|
236
|
+
confirmedThrough(upTo: number): number {
|
|
237
|
+
let answer = -1;
|
|
238
|
+
for (let at = this.oldest; at <= upTo && at < this.oldest + this.depth; at++) {
|
|
239
|
+
if (!this.isComplete(at)) break;
|
|
240
|
+
answer = at;
|
|
241
|
+
}
|
|
242
|
+
return answer;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The newest tick this participant has a confirmed input for, or -1. */
|
|
246
|
+
private newestConfirmed(participant: number): number {
|
|
247
|
+
let newest = -1;
|
|
248
|
+
for (let slot = 0; slot < this.depth; slot++) {
|
|
249
|
+
const index = participant * this.depth + slot;
|
|
250
|
+
if (this.confirmed[index] !== 1) continue;
|
|
251
|
+
const at = this.slotTick[index] as number;
|
|
252
|
+
if (at > newest) newest = at;
|
|
253
|
+
}
|
|
254
|
+
return newest;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private slotOf(participant: number, tick: number): number {
|
|
258
|
+
if (participant < 0 || participant >= this.participants) return -1;
|
|
259
|
+
if (!this.holds(tick)) return -1;
|
|
260
|
+
return participant * this.depth + (tick % this.depth);
|
|
261
|
+
}
|
|
262
|
+
}
|