murow 0.0.1
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/README.md +61 -0
- package/dist/core/binary-codec/binary-codec.d.ts +159 -0
- package/dist/core/binary-codec/binary-codec.js +336 -0
- package/dist/core/binary-codec/index.d.ts +1 -0
- package/dist/core/binary-codec/index.js +1 -0
- package/dist/core/events/event-system.d.ts +71 -0
- package/dist/core/events/event-system.js +88 -0
- package/dist/core/events/index.d.ts +1 -0
- package/dist/core/events/index.js +1 -0
- package/dist/core/fixed-ticker/fixed-ticker.d.ts +105 -0
- package/dist/core/fixed-ticker/fixed-ticker.js +91 -0
- package/dist/core/fixed-ticker/index.d.ts +1 -0
- package/dist/core/fixed-ticker/index.js +1 -0
- package/dist/core/generate-id/generate-id.d.ts +21 -0
- package/dist/core/generate-id/generate-id.js +25 -0
- package/dist/core/generate-id/index.d.ts +1 -0
- package/dist/core/generate-id/index.js +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/lerp/index.d.ts +1 -0
- package/dist/core/lerp/index.js +1 -0
- package/dist/core/lerp/lerp.d.ts +40 -0
- package/dist/core/lerp/lerp.js +42 -0
- package/dist/core/navmesh/index.d.ts +1 -0
- package/dist/core/navmesh/index.js +1 -0
- package/dist/core/navmesh/navmesh.d.ts +116 -0
- package/dist/core/navmesh/navmesh.js +666 -0
- package/dist/core/pooled-codec/index.d.ts +1 -0
- package/dist/core/pooled-codec/index.js +1 -0
- package/dist/core/pooled-codec/pooled-codec.d.ts +140 -0
- package/dist/core/pooled-codec/pooled-codec.js +213 -0
- package/dist/core/prediction/index.d.ts +1 -0
- package/dist/core/prediction/index.js +1 -0
- package/dist/core/prediction/prediction.d.ts +64 -0
- package/dist/core/prediction/prediction.js +90 -0
- package/dist/core.esm.js +1 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +18 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +43 -0
- package/dist/protocol/intent/index.d.ts +39 -0
- package/dist/protocol/intent/index.js +38 -0
- package/dist/protocol/intent/intent-registry.d.ts +54 -0
- package/dist/protocol/intent/intent-registry.js +73 -0
- package/dist/protocol/intent/intent.d.ts +12 -0
- package/dist/protocol/intent/intent.js +1 -0
- package/dist/protocol/snapshot/index.d.ts +44 -0
- package/dist/protocol/snapshot/index.js +43 -0
- package/dist/protocol/snapshot/snapshot-codec.d.ts +48 -0
- package/dist/protocol/snapshot/snapshot-codec.js +56 -0
- package/dist/protocol/snapshot/snapshot-registry.d.ts +100 -0
- package/dist/protocol/snapshot/snapshot-registry.js +136 -0
- package/dist/protocol/snapshot/snapshot.d.ts +19 -0
- package/dist/protocol/snapshot/snapshot.js +30 -0
- package/package.json +54 -0
- package/src/core/binary-codec/README.md +60 -0
- package/src/core/binary-codec/binary-codec.test.ts +300 -0
- package/src/core/binary-codec/binary-codec.ts +430 -0
- package/src/core/binary-codec/index.ts +1 -0
- package/src/core/events/README.md +47 -0
- package/src/core/events/event-system.test.ts +243 -0
- package/src/core/events/event-system.ts +140 -0
- package/src/core/events/index.ts +1 -0
- package/src/core/fixed-ticker/README.md +77 -0
- package/src/core/fixed-ticker/fixed-ticker.test.ts +151 -0
- package/src/core/fixed-ticker/fixed-ticker.ts +158 -0
- package/src/core/fixed-ticker/index.ts +1 -0
- package/src/core/generate-id/README.md +18 -0
- package/src/core/generate-id/generate-id.test.ts +79 -0
- package/src/core/generate-id/generate-id.ts +37 -0
- package/src/core/generate-id/index.ts +1 -0
- package/src/core/index.ts +8 -0
- package/src/core/lerp/README.md +79 -0
- package/src/core/lerp/index.ts +1 -0
- package/src/core/lerp/lerp.test.ts +90 -0
- package/src/core/lerp/lerp.ts +42 -0
- package/src/core/navmesh/README.md +124 -0
- package/src/core/navmesh/index.ts +1 -0
- package/src/core/navmesh/navmesh.test.ts +344 -0
- package/src/core/navmesh/navmesh.ts +850 -0
- package/src/core/pooled-codec/README.md +70 -0
- package/src/core/pooled-codec/index.ts +1 -0
- package/src/core/pooled-codec/pooled-codec.test.ts +349 -0
- package/src/core/pooled-codec/pooled-codec.ts +239 -0
- package/src/core/prediction/README.md +64 -0
- package/src/core/prediction/index.ts +1 -0
- package/src/core/prediction/prediction.test.ts +422 -0
- package/src/core/prediction/prediction.ts +101 -0
- package/src/index.ts +20 -0
- package/src/protocol/README.md +310 -0
- package/src/protocol/index.ts +44 -0
- package/src/protocol/intent/index.ts +40 -0
- package/src/protocol/intent/intent-registry.test.ts +237 -0
- package/src/protocol/intent/intent-registry.ts +88 -0
- package/src/protocol/intent/intent.ts +12 -0
- package/src/protocol/snapshot/index.ts +45 -0
- package/src/protocol/snapshot/snapshot-codec.test.ts +138 -0
- package/src/protocol/snapshot/snapshot-codec.ts +71 -0
- package/src/protocol/snapshot/snapshot-registry.test.ts +302 -0
- package/src/protocol/snapshot/snapshot-registry.ts +162 -0
- package/src/protocol/snapshot/snapshot.test.ts +76 -0
- package/src/protocol/snapshot/snapshot.ts +41 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Prediction & Reconciliation (IntentTracker + Reconciliator)
|
|
2
|
+
|
|
3
|
+
Client-side utility for **server-authoritative multiplayer games**.
|
|
4
|
+
Tracks unconfirmed player intents and reconciles them with authoritative snapshots from the server.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { IntentTracker, Reconciliator } from './prediction';
|
|
12
|
+
````
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Usage Example
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const positionRecon = new Reconciliator<MoveIntent, PositionSnapshot>({
|
|
20
|
+
onLoadState: (state) => gameClient.setPositions(state), // rewind to server-authoritative state
|
|
21
|
+
onReplay: (remainingIntents) => remainingIntents.forEach((i) => gameClient.applyMove(i)), // replay remaining unconfirmed intents
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ticker = new FixedTicker({
|
|
25
|
+
rate: 12, // ticks per second
|
|
26
|
+
onTick: (deltaTime, tick) => {
|
|
27
|
+
// Track input
|
|
28
|
+
if (inputs.has('position')) {
|
|
29
|
+
const intent = inputs.getAndRemove('position');
|
|
30
|
+
positionRecon.trackIntent(tick, intent);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Update game client simulation
|
|
34
|
+
gameClient.update(deltaTime);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function onServerSnapshot(snapshot: { tick: number; state: PositionSnapshot }) {
|
|
39
|
+
positionRecon.onSnapshot(snapshot);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let lastTime = 0;
|
|
43
|
+
function rafStep(now: number) {
|
|
44
|
+
const delta = (now - lastTime) / 1000; // seconds
|
|
45
|
+
lastTime = now;
|
|
46
|
+
|
|
47
|
+
ticker.update(delta); // manually drive the ticker
|
|
48
|
+
|
|
49
|
+
requestAnimationFrame(rafStep);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function start() {
|
|
53
|
+
lastTime = performance.now();
|
|
54
|
+
requestAnimationFrame(rafStep);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### Key Concepts
|
|
61
|
+
|
|
62
|
+
* **IntentTracker**: Tracks client-side intents that are sent but not yet confirmed by the server.
|
|
63
|
+
* **Reconciliator**: Resets client state to authoritative snapshots and replays unconfirmed intents to maintain smooth prediction.
|
|
64
|
+
* Works for **any type of server-authoritative game**, not just movement.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './prediction';
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { IntentTracker, Reconciliator } from "./prediction";
|
|
3
|
+
|
|
4
|
+
describe("IntentTracker", () => {
|
|
5
|
+
test("should initialize with size 0", () => {
|
|
6
|
+
const tracker = new IntentTracker<string>();
|
|
7
|
+
expect(tracker.size).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("should track intent for a tick", () => {
|
|
11
|
+
const tracker = new IntentTracker<string>();
|
|
12
|
+
tracker.track(1, "move_forward");
|
|
13
|
+
expect(tracker.size).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("should track multiple intents", () => {
|
|
17
|
+
const tracker = new IntentTracker<string>();
|
|
18
|
+
tracker.track(1, "move_forward");
|
|
19
|
+
tracker.track(2, "jump");
|
|
20
|
+
tracker.track(3, "shoot");
|
|
21
|
+
expect(tracker.size).toBe(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("should return all intents in ascending tick order", () => {
|
|
25
|
+
const tracker = new IntentTracker<string>();
|
|
26
|
+
tracker.track(3, "third");
|
|
27
|
+
tracker.track(1, "first");
|
|
28
|
+
tracker.track(2, "second");
|
|
29
|
+
|
|
30
|
+
const values = tracker.values();
|
|
31
|
+
expect(values).toEqual(["first", "second", "third"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("should drop intents up to specified tick", () => {
|
|
35
|
+
const tracker = new IntentTracker<string>();
|
|
36
|
+
tracker.track(1, "intent1");
|
|
37
|
+
tracker.track(2, "intent2");
|
|
38
|
+
tracker.track(3, "intent3");
|
|
39
|
+
tracker.track(4, "intent4");
|
|
40
|
+
|
|
41
|
+
const remaining = tracker.dropUpTo(2);
|
|
42
|
+
expect(remaining).toEqual(["intent3", "intent4"]);
|
|
43
|
+
expect(tracker.size).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("should drop all intents when tick is at the end", () => {
|
|
47
|
+
const tracker = new IntentTracker<string>();
|
|
48
|
+
tracker.track(1, "intent1");
|
|
49
|
+
tracker.track(2, "intent2");
|
|
50
|
+
tracker.track(3, "intent3");
|
|
51
|
+
|
|
52
|
+
const remaining = tracker.dropUpTo(5);
|
|
53
|
+
expect(remaining).toEqual([]);
|
|
54
|
+
expect(tracker.size).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("should keep all intents when drop tick is before all", () => {
|
|
58
|
+
const tracker = new IntentTracker<string>();
|
|
59
|
+
tracker.track(5, "intent1");
|
|
60
|
+
tracker.track(6, "intent2");
|
|
61
|
+
tracker.track(7, "intent3");
|
|
62
|
+
|
|
63
|
+
const remaining = tracker.dropUpTo(4);
|
|
64
|
+
expect(remaining).toEqual(["intent1", "intent2", "intent3"]);
|
|
65
|
+
expect(tracker.size).toBe(3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("should handle dropping from empty tracker", () => {
|
|
69
|
+
const tracker = new IntentTracker<string>();
|
|
70
|
+
const remaining = tracker.dropUpTo(10);
|
|
71
|
+
expect(remaining).toEqual([]);
|
|
72
|
+
expect(tracker.size).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("should handle complex intent objects", () => {
|
|
76
|
+
interface MoveIntent {
|
|
77
|
+
action: string;
|
|
78
|
+
x: number;
|
|
79
|
+
y: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const tracker = new IntentTracker<MoveIntent>();
|
|
83
|
+
tracker.track(1, { action: "move", x: 10, y: 20 });
|
|
84
|
+
tracker.track(2, { action: "move", x: 15, y: 25 });
|
|
85
|
+
|
|
86
|
+
const values = tracker.values();
|
|
87
|
+
expect(values.length).toBe(2);
|
|
88
|
+
expect(values[0]).toEqual({ action: "move", x: 10, y: 20 });
|
|
89
|
+
expect(values[1]).toEqual({ action: "move", x: 15, y: 25 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("should handle overwriting intent at same tick", () => {
|
|
93
|
+
const tracker = new IntentTracker<string>();
|
|
94
|
+
tracker.track(1, "first");
|
|
95
|
+
tracker.track(1, "second"); // Overwrite
|
|
96
|
+
|
|
97
|
+
expect(tracker.size).toBe(1);
|
|
98
|
+
const values = tracker.values();
|
|
99
|
+
expect(values).toEqual(["second"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("should maintain correct size after operations", () => {
|
|
103
|
+
const tracker = new IntentTracker<number>();
|
|
104
|
+
expect(tracker.size).toBe(0);
|
|
105
|
+
|
|
106
|
+
tracker.track(1, 100);
|
|
107
|
+
expect(tracker.size).toBe(1);
|
|
108
|
+
|
|
109
|
+
tracker.track(2, 200);
|
|
110
|
+
expect(tracker.size).toBe(2);
|
|
111
|
+
|
|
112
|
+
tracker.dropUpTo(1);
|
|
113
|
+
expect(tracker.size).toBe(1);
|
|
114
|
+
|
|
115
|
+
tracker.dropUpTo(10);
|
|
116
|
+
expect(tracker.size).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("Reconciliator", () => {
|
|
121
|
+
interface PlayerState {
|
|
122
|
+
x: number;
|
|
123
|
+
y: number;
|
|
124
|
+
health: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface PlayerIntent {
|
|
128
|
+
dx: number;
|
|
129
|
+
dy: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test("should initialize with callbacks", () => {
|
|
133
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
134
|
+
onLoadState: () => {},
|
|
135
|
+
onReplay: () => {},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(reconciliator).toBeDefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("should track intents", () => {
|
|
142
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
143
|
+
onLoadState: () => {},
|
|
144
|
+
onReplay: () => {},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
reconciliator.trackIntent(1, { dx: 1, dy: 0 });
|
|
148
|
+
reconciliator.trackIntent(2, { dx: 0, dy: 1 });
|
|
149
|
+
|
|
150
|
+
// No direct way to check size, but we can verify through snapshot
|
|
151
|
+
expect(reconciliator).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("should load state and replay intents on snapshot", () => {
|
|
155
|
+
let loadedState: PlayerState | null = null;
|
|
156
|
+
let replayedIntents: PlayerIntent[] = [];
|
|
157
|
+
|
|
158
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
159
|
+
onLoadState: (state) => {
|
|
160
|
+
loadedState = state;
|
|
161
|
+
},
|
|
162
|
+
onReplay: (intents) => {
|
|
163
|
+
replayedIntents = intents;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Track some intents
|
|
168
|
+
reconciliator.trackIntent(1, { dx: 1, dy: 0 });
|
|
169
|
+
reconciliator.trackIntent(2, { dx: 0, dy: 1 });
|
|
170
|
+
reconciliator.trackIntent(3, { dx: -1, dy: 0 });
|
|
171
|
+
|
|
172
|
+
// Receive snapshot at tick 2
|
|
173
|
+
const snapshot = {
|
|
174
|
+
tick: 2,
|
|
175
|
+
state: { x: 10, y: 20, health: 100 },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
reconciliator.onSnapshot(snapshot);
|
|
179
|
+
|
|
180
|
+
// Should load state
|
|
181
|
+
expect(loadedState!).toEqual({ x: 10, y: 20, health: 100 });
|
|
182
|
+
|
|
183
|
+
// Should replay only intent at tick 3 (after tick 2)
|
|
184
|
+
expect(replayedIntents).toEqual([{ dx: -1, dy: 0 }]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("should drop all intents when snapshot is ahead", () => {
|
|
188
|
+
let replayedIntents: PlayerIntent[] = [];
|
|
189
|
+
|
|
190
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
191
|
+
onLoadState: () => {},
|
|
192
|
+
onReplay: (intents) => {
|
|
193
|
+
replayedIntents = intents;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
reconciliator.trackIntent(1, { dx: 1, dy: 0 });
|
|
198
|
+
reconciliator.trackIntent(2, { dx: 0, dy: 1 });
|
|
199
|
+
|
|
200
|
+
// Snapshot ahead of all intents
|
|
201
|
+
reconciliator.onSnapshot({
|
|
202
|
+
tick: 10,
|
|
203
|
+
state: { x: 100, y: 100, health: 50 },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(replayedIntents).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("should keep all intents when snapshot is behind", () => {
|
|
210
|
+
let replayedIntents: PlayerIntent[] = [];
|
|
211
|
+
|
|
212
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
213
|
+
onLoadState: () => {},
|
|
214
|
+
onReplay: (intents) => {
|
|
215
|
+
replayedIntents = intents;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
reconciliator.trackIntent(5, { dx: 1, dy: 0 });
|
|
220
|
+
reconciliator.trackIntent(6, { dx: 0, dy: 1 });
|
|
221
|
+
reconciliator.trackIntent(7, { dx: -1, dy: 0 });
|
|
222
|
+
|
|
223
|
+
// Snapshot before all intents
|
|
224
|
+
reconciliator.onSnapshot({
|
|
225
|
+
tick: 3,
|
|
226
|
+
state: { x: 0, y: 0, health: 100 },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(replayedIntents.length).toBe(3);
|
|
230
|
+
expect(replayedIntents).toEqual([
|
|
231
|
+
{ dx: 1, dy: 0 },
|
|
232
|
+
{ dx: 0, dy: 1 },
|
|
233
|
+
{ dx: -1, dy: 0 },
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("should handle multiple snapshots", () => {
|
|
238
|
+
let loadedStates: PlayerState[] = [];
|
|
239
|
+
let allReplayedIntents: PlayerIntent[][] = [];
|
|
240
|
+
|
|
241
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
242
|
+
onLoadState: (state) => {
|
|
243
|
+
loadedStates.push({ ...state });
|
|
244
|
+
},
|
|
245
|
+
onReplay: (intents) => {
|
|
246
|
+
allReplayedIntents.push([...intents]);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Track intents 1-5
|
|
251
|
+
for (let i = 1; i <= 5; i++) {
|
|
252
|
+
reconciliator.trackIntent(i, { dx: i, dy: i });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// First snapshot at tick 2
|
|
256
|
+
reconciliator.onSnapshot({
|
|
257
|
+
tick: 2,
|
|
258
|
+
state: { x: 20, y: 20, health: 100 },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Track more intents 6-8
|
|
262
|
+
for (let i = 6; i <= 8; i++) {
|
|
263
|
+
reconciliator.trackIntent(i, { dx: i, dy: i });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Second snapshot at tick 6
|
|
267
|
+
reconciliator.onSnapshot({
|
|
268
|
+
tick: 6,
|
|
269
|
+
state: { x: 60, y: 60, health: 90 },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(loadedStates.length).toBe(2);
|
|
273
|
+
expect(loadedStates[0]).toEqual({ x: 20, y: 20, health: 100 });
|
|
274
|
+
expect(loadedStates[1]).toEqual({ x: 60, y: 60, health: 90 });
|
|
275
|
+
|
|
276
|
+
// After first snapshot, should replay ticks 3, 4, 5
|
|
277
|
+
expect(allReplayedIntents[0].length).toBe(3);
|
|
278
|
+
|
|
279
|
+
// After second snapshot, should replay ticks 7, 8
|
|
280
|
+
expect(allReplayedIntents[1].length).toBe(2);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("should handle empty intent list", () => {
|
|
284
|
+
let replayedIntents: PlayerIntent[] | null = null;
|
|
285
|
+
|
|
286
|
+
const reconciliator = new Reconciliator<PlayerIntent, PlayerState>({
|
|
287
|
+
onLoadState: () => {},
|
|
288
|
+
onReplay: (intents) => {
|
|
289
|
+
replayedIntents = intents;
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Receive snapshot with no tracked intents
|
|
294
|
+
reconciliator.onSnapshot({
|
|
295
|
+
tick: 5,
|
|
296
|
+
state: { x: 50, y: 50, health: 100 },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(replayedIntents!).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("should work with complex intent types", () => {
|
|
303
|
+
interface ComplexIntent {
|
|
304
|
+
type: "move" | "attack" | "defend";
|
|
305
|
+
target?: { x: number; y: number };
|
|
306
|
+
data?: Record<string, any>;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface ComplexState {
|
|
310
|
+
position: { x: number; y: number };
|
|
311
|
+
inventory: string[];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let capturedState: ComplexState | null = null;
|
|
315
|
+
let capturedIntents: ComplexIntent[] = [];
|
|
316
|
+
|
|
317
|
+
const reconciliator = new Reconciliator<ComplexIntent, ComplexState>({
|
|
318
|
+
onLoadState: (state) => {
|
|
319
|
+
capturedState = state;
|
|
320
|
+
},
|
|
321
|
+
onReplay: (intents) => {
|
|
322
|
+
capturedIntents = intents;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
reconciliator.trackIntent(1, { type: "move", target: { x: 10, y: 10 } });
|
|
327
|
+
reconciliator.trackIntent(2, { type: "attack", data: { damage: 50 } });
|
|
328
|
+
reconciliator.trackIntent(3, { type: "defend" });
|
|
329
|
+
|
|
330
|
+
reconciliator.onSnapshot({
|
|
331
|
+
tick: 1,
|
|
332
|
+
state: {
|
|
333
|
+
position: { x: 5, y: 5 },
|
|
334
|
+
inventory: ["sword", "shield"],
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(capturedState!).toEqual({
|
|
339
|
+
position: { x: 5, y: 5 },
|
|
340
|
+
inventory: ["sword", "shield"],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(capturedIntents.length).toBe(2);
|
|
344
|
+
expect(capturedIntents[0].type).toBe("attack");
|
|
345
|
+
expect(capturedIntents[1].type).toBe("defend");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("should handle rapid intent tracking", () => {
|
|
349
|
+
let replayCount = 0;
|
|
350
|
+
|
|
351
|
+
const reconciliator = new Reconciliator<number, { value: number }>({
|
|
352
|
+
onLoadState: () => {},
|
|
353
|
+
onReplay: (intents) => {
|
|
354
|
+
replayCount = intents.length;
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Track 1000 intents rapidly
|
|
359
|
+
for (let i = 1; i <= 1000; i++) {
|
|
360
|
+
reconciliator.trackIntent(i, i);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Snapshot in the middle
|
|
364
|
+
reconciliator.onSnapshot({
|
|
365
|
+
tick: 500,
|
|
366
|
+
state: { value: 500 },
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Should replay remaining 500 intents
|
|
370
|
+
expect(replayCount).toBe(500);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("Reconciliator - Integration", () => {
|
|
375
|
+
test("should correctly reconcile game state", () => {
|
|
376
|
+
interface Position {
|
|
377
|
+
x: number;
|
|
378
|
+
y: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
interface MoveIntent {
|
|
382
|
+
dx: number;
|
|
383
|
+
dy: number;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let playerPos: Position = { x: 0, y: 0 };
|
|
387
|
+
|
|
388
|
+
const reconciliator = new Reconciliator<MoveIntent, Position>({
|
|
389
|
+
onLoadState: (state) => {
|
|
390
|
+
playerPos = { ...state };
|
|
391
|
+
},
|
|
392
|
+
onReplay: (intents) => {
|
|
393
|
+
// Apply all remaining intents
|
|
394
|
+
for (const intent of intents) {
|
|
395
|
+
playerPos.x += intent.dx;
|
|
396
|
+
playerPos.y += intent.dy;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Client predicts movements
|
|
402
|
+
reconciliator.trackIntent(1, { dx: 1, dy: 0 });
|
|
403
|
+
playerPos.x += 1;
|
|
404
|
+
|
|
405
|
+
reconciliator.trackIntent(2, { dx: 1, dy: 0 });
|
|
406
|
+
playerPos.x += 1;
|
|
407
|
+
|
|
408
|
+
reconciliator.trackIntent(3, { dx: 0, dy: 1 });
|
|
409
|
+
playerPos.y += 1;
|
|
410
|
+
|
|
411
|
+
// Server snapshot arrives (slightly different due to lag)
|
|
412
|
+
// Snapshot at tick 2 means intents 1 and 2 are confirmed
|
|
413
|
+
reconciliator.onSnapshot({
|
|
414
|
+
tick: 2,
|
|
415
|
+
state: { x: 1.9, y: 0 }, // Server processed ticks 1 and 2
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// After reconciliation: server state + replayed intent (3 only)
|
|
419
|
+
expect(playerPos.x).toBeCloseTo(1.9, 1); // 1.9 + 0
|
|
420
|
+
expect(playerPos.y).toBeCloseTo(1, 1); // 0 + 1
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @template T
|
|
3
|
+
* @description
|
|
4
|
+
* Tracks client-side intents that have been sent to the server but not yet confirmed.
|
|
5
|
+
* Used for prediction and reconciliation in a server-authoritative architecture.
|
|
6
|
+
*/
|
|
7
|
+
export class IntentTracker<T> {
|
|
8
|
+
private tracker = new Map<number, T>();
|
|
9
|
+
|
|
10
|
+
get size() {
|
|
11
|
+
return this.tracker.size;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Adds a new intent for a specific tick.
|
|
16
|
+
* @param {number} tick - The tick number associated with the intent.
|
|
17
|
+
* @param {T} intent - The intent data.
|
|
18
|
+
*/
|
|
19
|
+
track(tick: number, intent: T): T {
|
|
20
|
+
this.tracker.set(tick, intent);
|
|
21
|
+
return intent;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Removes all intents up to and including a given tick.
|
|
26
|
+
* Returns the remaining intents in ascending tick order.
|
|
27
|
+
* @param {number} tick - The tick up to which intents should be dropped.
|
|
28
|
+
* @returns {T[]} Array of remaining intents.
|
|
29
|
+
*/
|
|
30
|
+
dropUpTo(tick: number): T[] {
|
|
31
|
+
const remaining: [number, T][] = [];
|
|
32
|
+
|
|
33
|
+
for (const [t, intent] of this.tracker) {
|
|
34
|
+
if (t <= tick) this.tracker.delete(t);
|
|
35
|
+
else remaining.push([t, intent]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// sort by tick ascending
|
|
39
|
+
remaining.sort(([a], [b]) => a - b);
|
|
40
|
+
return remaining.map(([_, intent]) => intent);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns all currently tracked intents in ascending tick order.
|
|
45
|
+
* @returns {T[]}
|
|
46
|
+
*/
|
|
47
|
+
values(): T[] {
|
|
48
|
+
return Array.from(this.tracker.entries())
|
|
49
|
+
.sort(([a], [b]) => a - b)
|
|
50
|
+
.map(([_, intent]) => intent);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @template T,U
|
|
56
|
+
* @description
|
|
57
|
+
* Handles client-side reconciliation of authoritative snapshots with unconfirmed intents.
|
|
58
|
+
* Used for prediction correction in server-authoritative multiplayer games.
|
|
59
|
+
*/
|
|
60
|
+
export class Reconciliator<T, U> {
|
|
61
|
+
private tracker: IntentTracker<T> = new IntentTracker<T>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Object} options - Callbacks for applying snapshot state and replaying intents.
|
|
65
|
+
* @param {(snapshotState: U) => void} options.onLoadState - Called to load authoritative snapshot state.
|
|
66
|
+
* @param {(remainingIntents: T[]) => void} options.onReplay - Called to reapply remaining intents for prediction.
|
|
67
|
+
*/
|
|
68
|
+
constructor(
|
|
69
|
+
private options: {
|
|
70
|
+
onLoadState: (snapshotState: U) => void;
|
|
71
|
+
onReplay: (remainingIntents: T[]) => void;
|
|
72
|
+
}
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adds a new intent to the tracker.
|
|
77
|
+
* @param {number} tick - Tick number associated with the intent.
|
|
78
|
+
* @param {T} intent - The intent data.
|
|
79
|
+
*/
|
|
80
|
+
trackIntent(tick: number, intent: T) {
|
|
81
|
+
this.tracker.track(tick, intent);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Called when an authoritative snapshot is received from the server.
|
|
86
|
+
* Resets client state and replays unconfirmed intents.
|
|
87
|
+
* @param {Object} snapshot - The snapshot from the server.
|
|
88
|
+
* @param {number} snapshot.tick - Tick number of the snapshot.
|
|
89
|
+
* @param {U} snapshot.state - The authoritative state.
|
|
90
|
+
*/
|
|
91
|
+
onSnapshot(snapshot: { tick: number; state: U }) {
|
|
92
|
+
// 1. Load authoritative state
|
|
93
|
+
this.options.onLoadState(snapshot.state);
|
|
94
|
+
|
|
95
|
+
// 2. Remove confirmed intents and get remaining
|
|
96
|
+
const remainingIntents = this.tracker.dropUpTo(snapshot.tick);
|
|
97
|
+
|
|
98
|
+
// 3. Replay remaining intents for prediction
|
|
99
|
+
this.options.onReplay(remainingIntents);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GameDev Utils
|
|
3
|
+
*
|
|
4
|
+
* A collection of utilities for game development, including:
|
|
5
|
+
* - Binary codecs for efficient serialization
|
|
6
|
+
* - Event system for decoupled communication
|
|
7
|
+
* - Fixed-timestep ticker for deterministic simulation
|
|
8
|
+
* - ID generation utilities
|
|
9
|
+
* - Linear interpolation (lerp) utilities
|
|
10
|
+
* - NavMesh pathfinding with obstacle management
|
|
11
|
+
* - Pooled codecs for zero-allocation networking
|
|
12
|
+
* - Prediction system for client-side prediction
|
|
13
|
+
* - Protocol layer for networked multiplayer games
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Core utilities
|
|
17
|
+
export * from "./core";
|
|
18
|
+
|
|
19
|
+
// Protocol layer for networking
|
|
20
|
+
export * from "./protocol";
|