@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
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
/** How a missing input is guessed. `previous` is that participant's last confirmed input. */
|
|
42
|
+
export type InputPredictor = (participant: number, tick: number, previous: Uint8Array, out: Uint8Array) => void;
|
|
43
|
+
/** Repeat the last input seen from that participant. See the header for when this is wrong. */
|
|
44
|
+
export declare const repeatLastInput: InputPredictor;
|
|
45
|
+
export interface InputLogOptions {
|
|
46
|
+
readonly participants: number;
|
|
47
|
+
/**
|
|
48
|
+
* Retained ticks, covering the rewind window behind the current tick **and** the inputs that
|
|
49
|
+
* arrive ahead of it. See the header: this is not the rewind depth.
|
|
50
|
+
*/
|
|
51
|
+
readonly depth: number;
|
|
52
|
+
readonly inputBytes: number;
|
|
53
|
+
readonly predict?: InputPredictor;
|
|
54
|
+
}
|
|
55
|
+
export declare class InputLog {
|
|
56
|
+
readonly participants: number;
|
|
57
|
+
readonly depth: number;
|
|
58
|
+
readonly inputBytes: number;
|
|
59
|
+
private readonly bytes;
|
|
60
|
+
/** Which tick each (participant, slot) holds, or -1. */
|
|
61
|
+
private readonly slotTick;
|
|
62
|
+
/** 1 when the value in the slot came from that participant, 0 when it was guessed. */
|
|
63
|
+
private readonly confirmed;
|
|
64
|
+
/** The last confirmed input per participant, for the predictor. */
|
|
65
|
+
private readonly previous;
|
|
66
|
+
private readonly scratch;
|
|
67
|
+
private readonly predict;
|
|
68
|
+
/** The oldest tick this log will accept. The window is `[oldest, oldest + depth)`. */
|
|
69
|
+
private oldest;
|
|
70
|
+
constructor(options: InputLogOptions);
|
|
71
|
+
/** The oldest tick this log holds. Everything below it has been dropped. */
|
|
72
|
+
get windowStart(): number;
|
|
73
|
+
/** One past the newest tick this log will accept. */
|
|
74
|
+
get windowEnd(): number;
|
|
75
|
+
/** Whether a tick is inside the window at all. */
|
|
76
|
+
holds(tick: number): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Move the window so `oldest` is the earliest tick kept, forgetting what falls off.
|
|
79
|
+
*
|
|
80
|
+
* The slots that leave are cleared rather than left, because their `slotTick` would otherwise
|
|
81
|
+
* match a tick a whole window later and answer a stale input as a confirmed one.
|
|
82
|
+
*/
|
|
83
|
+
retain(oldest: number): void;
|
|
84
|
+
/**
|
|
85
|
+
* Record a real input. **`true` when this changed what the slot held**, which is what tells a
|
|
86
|
+
* rewind whether it has anything to do. `false` also means refused, for a tick outside the
|
|
87
|
+
* window — see `holds` to tell the two apart when it matters.
|
|
88
|
+
*
|
|
89
|
+
* A confirmed input matching the guess is the common case on a held control, and replaying for
|
|
90
|
+
* it would burn a rewind to produce the identical world.
|
|
91
|
+
*/
|
|
92
|
+
set(participant: number, tick: number, payload: Uint8Array): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The input to simulate with, guessing and storing one if none has arrived.
|
|
95
|
+
*
|
|
96
|
+
* **`false` means it was guessed**, or that the tick is outside the window, in which case `out` is
|
|
97
|
+
* zeroed. A caller deciding whether a tick can be confirmed reads that; a caller simulating does
|
|
98
|
+
* not care, because either way `out` holds the bytes this tick ran with, now and on every replay.
|
|
99
|
+
*/
|
|
100
|
+
into(participant: number, tick: number, out: Uint8Array): boolean;
|
|
101
|
+
/** Whether a real input for this participant and tick has arrived. */
|
|
102
|
+
isConfirmed(participant: number, tick: number): boolean;
|
|
103
|
+
/** Whether every participant's input for this tick has arrived. What lockstep advances on. */
|
|
104
|
+
isComplete(tick: number): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The highest tick at or below `upTo` such that **every** tick from the window's start up to it is
|
|
107
|
+
* complete, or -1.
|
|
108
|
+
*
|
|
109
|
+
* Walked upward from the start of the window and stopped at the first gap, which is the property
|
|
110
|
+
* that matters: ticks 40 and 42 confirmed with 41 missing is not a session confirmed to 42, and
|
|
111
|
+
* answering 42 would let a peer discard the snapshot it still needs to correct 41.
|
|
112
|
+
*/
|
|
113
|
+
confirmedThrough(upTo: number): number;
|
|
114
|
+
/** The newest tick this participant has a confirmed input for, or -1. */
|
|
115
|
+
private newestConfirmed;
|
|
116
|
+
private slotOf;
|
|
117
|
+
}
|
package/dist/inputLog.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
/** Repeat the last input seen from that participant. See the header for when this is wrong. */
|
|
42
|
+
export const repeatLastInput = (_participant, _tick, previous, out) => {
|
|
43
|
+
out.set(previous);
|
|
44
|
+
};
|
|
45
|
+
export class InputLog {
|
|
46
|
+
participants;
|
|
47
|
+
depth;
|
|
48
|
+
inputBytes;
|
|
49
|
+
bytes;
|
|
50
|
+
/** Which tick each (participant, slot) holds, or -1. */
|
|
51
|
+
slotTick;
|
|
52
|
+
/** 1 when the value in the slot came from that participant, 0 when it was guessed. */
|
|
53
|
+
confirmed;
|
|
54
|
+
/** The last confirmed input per participant, for the predictor. */
|
|
55
|
+
previous;
|
|
56
|
+
scratch;
|
|
57
|
+
predict;
|
|
58
|
+
/** The oldest tick this log will accept. The window is `[oldest, oldest + depth)`. */
|
|
59
|
+
oldest = 0;
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.participants = Math.max(1, Math.trunc(options.participants));
|
|
62
|
+
this.depth = Math.max(1, Math.trunc(options.depth));
|
|
63
|
+
this.inputBytes = Math.max(1, Math.trunc(options.inputBytes));
|
|
64
|
+
this.predict = options.predict ?? repeatLastInput;
|
|
65
|
+
const slots = this.participants * this.depth;
|
|
66
|
+
this.bytes = new Uint8Array(slots * this.inputBytes);
|
|
67
|
+
this.slotTick = new Int32Array(slots).fill(-1);
|
|
68
|
+
this.confirmed = new Uint8Array(slots);
|
|
69
|
+
this.previous = new Uint8Array(this.participants * this.inputBytes);
|
|
70
|
+
this.scratch = new Uint8Array(this.inputBytes);
|
|
71
|
+
}
|
|
72
|
+
/** The oldest tick this log holds. Everything below it has been dropped. */
|
|
73
|
+
get windowStart() {
|
|
74
|
+
return this.oldest;
|
|
75
|
+
}
|
|
76
|
+
/** One past the newest tick this log will accept. */
|
|
77
|
+
get windowEnd() {
|
|
78
|
+
return this.oldest + this.depth;
|
|
79
|
+
}
|
|
80
|
+
/** Whether a tick is inside the window at all. */
|
|
81
|
+
holds(tick) {
|
|
82
|
+
return tick >= this.oldest && tick < this.oldest + this.depth;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Move the window so `oldest` is the earliest tick kept, forgetting what falls off.
|
|
86
|
+
*
|
|
87
|
+
* The slots that leave are cleared rather than left, because their `slotTick` would otherwise
|
|
88
|
+
* match a tick a whole window later and answer a stale input as a confirmed one.
|
|
89
|
+
*/
|
|
90
|
+
retain(oldest) {
|
|
91
|
+
const next = Math.max(0, Math.trunc(oldest));
|
|
92
|
+
if (next <= this.oldest)
|
|
93
|
+
return;
|
|
94
|
+
for (let p = 0; p < this.participants; p++) {
|
|
95
|
+
for (let slot = 0; slot < this.depth; slot++) {
|
|
96
|
+
const index = p * this.depth + slot;
|
|
97
|
+
const at = this.slotTick[index];
|
|
98
|
+
if (at >= 0 && at < next) {
|
|
99
|
+
this.slotTick[index] = -1;
|
|
100
|
+
this.confirmed[index] = 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.oldest = next;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Record a real input. **`true` when this changed what the slot held**, which is what tells a
|
|
108
|
+
* rewind whether it has anything to do. `false` also means refused, for a tick outside the
|
|
109
|
+
* window — see `holds` to tell the two apart when it matters.
|
|
110
|
+
*
|
|
111
|
+
* A confirmed input matching the guess is the common case on a held control, and replaying for
|
|
112
|
+
* it would burn a rewind to produce the identical world.
|
|
113
|
+
*/
|
|
114
|
+
set(participant, tick, payload) {
|
|
115
|
+
const slot = this.slotOf(participant, tick);
|
|
116
|
+
if (slot < 0)
|
|
117
|
+
return false;
|
|
118
|
+
const at = slot * this.inputBytes;
|
|
119
|
+
/*
|
|
120
|
+
* **Changed means the bytes changed, and deliberately not "was unconfirmed and now is".**
|
|
121
|
+
* A guess that turns out byte-identical to the real input costs a replay nothing, because the
|
|
122
|
+
* replay would re-run the same ticks with the same values and arrive at the same world. On any
|
|
123
|
+
* held control that is the common case, so treating confirmation as a change would burn a
|
|
124
|
+
* rewind per packet to reproduce what is already on screen. What confirmation changes is
|
|
125
|
+
* `confirmedThrough`, which is a different question from whether the world is wrong.
|
|
126
|
+
*/
|
|
127
|
+
const held = this.slotTick[slot] === tick;
|
|
128
|
+
let changed = !held;
|
|
129
|
+
if (held) {
|
|
130
|
+
for (let i = 0; i < this.inputBytes; i++) {
|
|
131
|
+
if (this.bytes[at + i] !== (payload[i] ?? 0)) {
|
|
132
|
+
changed = true;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (let i = 0; i < this.inputBytes; i++)
|
|
138
|
+
this.bytes[at + i] = payload[i] ?? 0;
|
|
139
|
+
this.slotTick[slot] = tick;
|
|
140
|
+
this.confirmed[slot] = 1;
|
|
141
|
+
/* The newest confirmed input is what the predictor repeats, so an input arriving out of order
|
|
142
|
+
must not overwrite a newer one. */
|
|
143
|
+
if (this.newestConfirmed(participant) <= tick) {
|
|
144
|
+
const previousAt = participant * this.inputBytes;
|
|
145
|
+
for (let i = 0; i < this.inputBytes; i++)
|
|
146
|
+
this.previous[previousAt + i] = payload[i] ?? 0;
|
|
147
|
+
}
|
|
148
|
+
return changed;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The input to simulate with, guessing and storing one if none has arrived.
|
|
152
|
+
*
|
|
153
|
+
* **`false` means it was guessed**, or that the tick is outside the window, in which case `out` is
|
|
154
|
+
* zeroed. A caller deciding whether a tick can be confirmed reads that; a caller simulating does
|
|
155
|
+
* not care, because either way `out` holds the bytes this tick ran with, now and on every replay.
|
|
156
|
+
*/
|
|
157
|
+
into(participant, tick, out) {
|
|
158
|
+
const slot = this.slotOf(participant, tick);
|
|
159
|
+
if (slot < 0) {
|
|
160
|
+
out.fill(0);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const at = slot * this.inputBytes;
|
|
164
|
+
if (this.slotTick[slot] !== tick) {
|
|
165
|
+
const previousAt = participant * this.inputBytes;
|
|
166
|
+
for (let i = 0; i < this.inputBytes; i++) {
|
|
167
|
+
this.scratch[i] = this.previous[previousAt + i];
|
|
168
|
+
}
|
|
169
|
+
this.predict(participant, tick, this.scratch, out);
|
|
170
|
+
for (let i = 0; i < this.inputBytes; i++)
|
|
171
|
+
this.bytes[at + i] = out[i] ?? 0;
|
|
172
|
+
this.slotTick[slot] = tick;
|
|
173
|
+
this.confirmed[slot] = 0;
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < this.inputBytes; i++)
|
|
177
|
+
out[i] = this.bytes[at + i];
|
|
178
|
+
return this.confirmed[slot] === 1;
|
|
179
|
+
}
|
|
180
|
+
/** Whether a real input for this participant and tick has arrived. */
|
|
181
|
+
isConfirmed(participant, tick) {
|
|
182
|
+
const slot = this.slotOf(participant, tick);
|
|
183
|
+
if (slot < 0)
|
|
184
|
+
return false;
|
|
185
|
+
return this.slotTick[slot] === tick && this.confirmed[slot] === 1;
|
|
186
|
+
}
|
|
187
|
+
/** Whether every participant's input for this tick has arrived. What lockstep advances on. */
|
|
188
|
+
isComplete(tick) {
|
|
189
|
+
for (let p = 0; p < this.participants; p++) {
|
|
190
|
+
if (!this.isConfirmed(p, tick))
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The highest tick at or below `upTo` such that **every** tick from the window's start up to it is
|
|
197
|
+
* complete, or -1.
|
|
198
|
+
*
|
|
199
|
+
* Walked upward from the start of the window and stopped at the first gap, which is the property
|
|
200
|
+
* that matters: ticks 40 and 42 confirmed with 41 missing is not a session confirmed to 42, and
|
|
201
|
+
* answering 42 would let a peer discard the snapshot it still needs to correct 41.
|
|
202
|
+
*/
|
|
203
|
+
confirmedThrough(upTo) {
|
|
204
|
+
let answer = -1;
|
|
205
|
+
for (let at = this.oldest; at <= upTo && at < this.oldest + this.depth; at++) {
|
|
206
|
+
if (!this.isComplete(at))
|
|
207
|
+
break;
|
|
208
|
+
answer = at;
|
|
209
|
+
}
|
|
210
|
+
return answer;
|
|
211
|
+
}
|
|
212
|
+
/** The newest tick this participant has a confirmed input for, or -1. */
|
|
213
|
+
newestConfirmed(participant) {
|
|
214
|
+
let newest = -1;
|
|
215
|
+
for (let slot = 0; slot < this.depth; slot++) {
|
|
216
|
+
const index = participant * this.depth + slot;
|
|
217
|
+
if (this.confirmed[index] !== 1)
|
|
218
|
+
continue;
|
|
219
|
+
const at = this.slotTick[index];
|
|
220
|
+
if (at > newest)
|
|
221
|
+
newest = at;
|
|
222
|
+
}
|
|
223
|
+
return newest;
|
|
224
|
+
}
|
|
225
|
+
slotOf(participant, tick) {
|
|
226
|
+
if (participant < 0 || participant >= this.participants)
|
|
227
|
+
return -1;
|
|
228
|
+
if (!this.holds(tick))
|
|
229
|
+
return -1;
|
|
230
|
+
return participant * this.depth + (tick % this.depth);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every peer simulates every tick, and a wrong guess is unwound.
|
|
3
|
+
*
|
|
4
|
+
* **Classic lockstep stalls; this predicts.** The original design waits until every participant's
|
|
5
|
+
* input for a tick has arrived and only then advances, which makes the whole session run at the
|
|
6
|
+
* latency of its worst link and turns a single dropped packet into a visible freeze for everyone.
|
|
7
|
+
* With a rewind loop underneath, a missing input can be guessed and corrected — so the session runs
|
|
8
|
+
* at local speed and pays for a wrong guess only when the guess was wrong.
|
|
9
|
+
*
|
|
10
|
+
* ---
|
|
11
|
+
*
|
|
12
|
+
* ## The input delay, and why it is not just latency tolerance
|
|
13
|
+
*
|
|
14
|
+
* A local input for tick *T* is published for tick *T + delay*. That buys `delay` ticks for it to
|
|
15
|
+
* reach everyone before anybody needs it, so on a link inside that budget **nobody predicts and
|
|
16
|
+
* nobody rewinds** — the session is exact, not merely convergent.
|
|
17
|
+
*
|
|
18
|
+
* What it costs is that a player's own control is `delay` ticks late on their own screen. That is a
|
|
19
|
+
* real cost and it is a game's to choose: two ticks is 33 ms and unnoticeable in a racing game,
|
|
20
|
+
* and unacceptable in a fighting game where it is the whole argument for rollback.
|
|
21
|
+
*
|
|
22
|
+
* ## Halting is a feature
|
|
23
|
+
*
|
|
24
|
+
* Two things stop the session rather than degrade it, because both mean the local world can no
|
|
25
|
+
* longer be made correct:
|
|
26
|
+
*
|
|
27
|
+
* - **An input arrives for a tick outside the rewind window.** It cannot be applied, so this peer's
|
|
28
|
+
* world is permanently different from the one that input describes.
|
|
29
|
+
* - **A fingerprint disagrees at a confirmed tick.** Two peers computed different worlds from the
|
|
30
|
+
* same inputs, which is a determinism bug rather than a networking one, and continuing produces
|
|
31
|
+
* two games that both believe they are right.
|
|
32
|
+
*
|
|
33
|
+
* A session that carried on would be showing each player a plausible world with no relationship to
|
|
34
|
+
* anyone else's. Halting names the tick, which is where the debugging starts.
|
|
35
|
+
*/
|
|
36
|
+
import { Fingerprint } from './fingerprint.ts';
|
|
37
|
+
import type { InputLog } from './inputLog.ts';
|
|
38
|
+
import type { RewindLoop } from './rewind.ts';
|
|
39
|
+
import { type PeerId, type Transport } from './transport.ts';
|
|
40
|
+
export type LockstepStatus = 'running' | 'halted';
|
|
41
|
+
export interface LockstepOptions<S> {
|
|
42
|
+
readonly transport: Transport;
|
|
43
|
+
readonly loop: RewindLoop<S>;
|
|
44
|
+
readonly inputs: InputLog;
|
|
45
|
+
/** This peer's id, and the index its inputs occupy in the log. */
|
|
46
|
+
readonly self: PeerId;
|
|
47
|
+
readonly participants: number;
|
|
48
|
+
/**
|
|
49
|
+
* How many ticks ahead a local input is published. Two is 33 ms at 60 Hz.
|
|
50
|
+
*
|
|
51
|
+
* See the header: inside this budget the session is exact rather than convergent.
|
|
52
|
+
*/
|
|
53
|
+
readonly inputDelay?: number;
|
|
54
|
+
/**
|
|
55
|
+
* How often a fingerprint is published, in ticks. Every tick is wasteful and every hundred is
|
|
56
|
+
* slow to notice; the default is every eight, which at 60 Hz is seven a second.
|
|
57
|
+
*/
|
|
58
|
+
readonly fingerprintEvery?: number;
|
|
59
|
+
/** Publish fingerprints at all. Off needs no digest from the snapshotter. */
|
|
60
|
+
readonly compareStates?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* How many recent inputs ride along in every packet. Four by default.
|
|
63
|
+
*
|
|
64
|
+
* **This is what makes lockstep survive loss, and one is not enough.** An input is only useful
|
|
65
|
+
* for the tick it names, so asking for a lost one and waiting a round trip delivers it after that
|
|
66
|
+
* tick has gone — the peer's world is then permanently different and nothing local repairs it.
|
|
67
|
+
* Sending the last few in every packet covers a run of drops before anybody notices, for a
|
|
68
|
+
* handful of bytes on a message that already has a header.
|
|
69
|
+
*
|
|
70
|
+
* Four covers three consecutive losses. A link losing four in a row at 60 Hz has stopped being a
|
|
71
|
+
* link.
|
|
72
|
+
*
|
|
73
|
+
* **It may reach further back than the rewind window without any arrangement between the two.**
|
|
74
|
+
* The session forgets inputs below the oldest tick a rewind can reach, so past that depth every
|
|
75
|
+
* packet's oldest words name ticks the log can no longer say anything about. Those are skipped on
|
|
76
|
+
* the strength of what this world already applied, not on what the log still holds — see the
|
|
77
|
+
* watermark in `receive`. Choose this number for the link and the rewind depth for the game.
|
|
78
|
+
*/
|
|
79
|
+
readonly redundancy?: number;
|
|
80
|
+
}
|
|
81
|
+
export interface Desync {
|
|
82
|
+
readonly tick: number;
|
|
83
|
+
readonly peer: PeerId;
|
|
84
|
+
readonly ours: string;
|
|
85
|
+
readonly theirs: string;
|
|
86
|
+
}
|
|
87
|
+
export declare class LockstepSession<S> {
|
|
88
|
+
readonly self: PeerId;
|
|
89
|
+
readonly participants: number;
|
|
90
|
+
readonly inputDelay: number;
|
|
91
|
+
private readonly transport;
|
|
92
|
+
private readonly loop;
|
|
93
|
+
private readonly inputs;
|
|
94
|
+
private readonly fingerprintEvery;
|
|
95
|
+
private readonly compareStates;
|
|
96
|
+
/** One buffer for the process. Sized for the largest message this session sends. */
|
|
97
|
+
private readonly outgoing;
|
|
98
|
+
/** The last `redundancy` inputs this peer submitted, oldest first, flat. */
|
|
99
|
+
private readonly recent;
|
|
100
|
+
private readonly redundancy;
|
|
101
|
+
/** The tick the first payload in `recent` belongs to, or -1 before anything is submitted. */
|
|
102
|
+
private recentFrom;
|
|
103
|
+
private recentCount;
|
|
104
|
+
private readonly decoded;
|
|
105
|
+
private readonly scratchDigest;
|
|
106
|
+
/** Digests we published, by tick, so a peer's claim has something to be compared against. */
|
|
107
|
+
private readonly ourDigests;
|
|
108
|
+
private statusValue;
|
|
109
|
+
private desyncValue;
|
|
110
|
+
private haltReason;
|
|
111
|
+
private confirmedValue;
|
|
112
|
+
/**
|
|
113
|
+
* The highest tick this world has ever had every participant's real input for.
|
|
114
|
+
*
|
|
115
|
+
* **Separate from `confirmedValue`, and monotonic where that one is not.** `confirmedThrough`
|
|
116
|
+
* answers for the window the log currently holds, and the session moves that window forward every
|
|
117
|
+
* tick — so the moment retention passes a tick, the log stops being able to say anything about it
|
|
118
|
+
* and the live watermark can drop. What was applied stays applied, which is the fact a redundant
|
|
119
|
+
* copy has to be judged against, so it is kept here rather than asked of the log.
|
|
120
|
+
*/
|
|
121
|
+
private appliedThrough;
|
|
122
|
+
constructor(options: LockstepOptions<S>);
|
|
123
|
+
get status(): LockstepStatus;
|
|
124
|
+
/** Why the session halted, or an empty string. */
|
|
125
|
+
get reason(): string;
|
|
126
|
+
/** The disagreement that halted it, naming the tick it began at. */
|
|
127
|
+
get desync(): Desync | null;
|
|
128
|
+
/** The highest tick every participant's real input has arrived for. */
|
|
129
|
+
get confirmed(): number;
|
|
130
|
+
/**
|
|
131
|
+
* Publish this peer's input for `tick + inputDelay`, and record it locally at the same tick.
|
|
132
|
+
*
|
|
133
|
+
* **Recorded locally as well as sent, and that is not redundancy.** A peer's own input is not
|
|
134
|
+
* echoed back to it, so without this the local simulation would predict its own controls — and a
|
|
135
|
+
* player would feel their own input arrive late and get corrected, which is the one thing
|
|
136
|
+
* prediction is supposed to prevent.
|
|
137
|
+
*/
|
|
138
|
+
submit(tick: number, payload: Uint8Array): void;
|
|
139
|
+
/**
|
|
140
|
+
* Keep this input, and the few before it, for the next packet to carry.
|
|
141
|
+
*
|
|
142
|
+
* Shifted rather than kept in a ring, because the run has to be **contiguous and in order** on
|
|
143
|
+
* the wire: a receiver reads it as "these are the inputs for ticks `firstTick` onward", and a ring
|
|
144
|
+
* would have to be unwrapped before sending anyway. `redundancy` is four, so the shift is three
|
|
145
|
+
* copies of a handful of bytes.
|
|
146
|
+
*/
|
|
147
|
+
private remember;
|
|
148
|
+
/**
|
|
149
|
+
* Take everything the transport has and apply it.
|
|
150
|
+
*
|
|
151
|
+
* Called once per tick, before advancing. Never from a transport callback — see `transport.ts`
|
|
152
|
+
* for why a message must not arrive in the middle of a step.
|
|
153
|
+
*/
|
|
154
|
+
poll(): void;
|
|
155
|
+
/**
|
|
156
|
+
* Advance one tick, then publish a fingerprint if this is a tick to publish one at.
|
|
157
|
+
*
|
|
158
|
+
* The rewind loop performs any pending correction first, so a late input that arrived during
|
|
159
|
+
* `poll` is applied before this tick runs rather than after it.
|
|
160
|
+
*/
|
|
161
|
+
advance(dt: number, tick: number): void;
|
|
162
|
+
/**
|
|
163
|
+
* Publish a hash of the state at a **confirmed** tick, which is the only state worth comparing.
|
|
164
|
+
*
|
|
165
|
+
* **Not the live world, and this cost a healthy session at tick 32 during development.** The
|
|
166
|
+
* newest tick is speculative: each peer has predicted inputs the other already knows, so their
|
|
167
|
+
* live worlds differ constantly and correctly. A snapshot at a tick every participant's input has
|
|
168
|
+
* arrived for was computed from the same inputs on both ends, and a disagreement there is a real
|
|
169
|
+
* one.
|
|
170
|
+
*
|
|
171
|
+
* The slot for tick *T* holds the state as *T* began, so it is final once every input below *T*
|
|
172
|
+
* is confirmed — which is what `confirmedThrough` answers.
|
|
173
|
+
*/
|
|
174
|
+
private publishDigest;
|
|
175
|
+
/** Stop the session with a reason. Public so a consumer can halt on their own condition. */
|
|
176
|
+
halt(reason: string): void;
|
|
177
|
+
private receive;
|
|
178
|
+
private forgetDigestsBefore;
|
|
179
|
+
/** The shared digest builder, for a consumer whose `digestAt` wants one without allocating. */
|
|
180
|
+
get digestBuilder(): Fingerprint;
|
|
181
|
+
}
|