@lagless/net-wire 0.0.33
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 +684 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/clock-sync.d.ts +45 -0
- package/dist/lib/clock-sync.d.ts.map +1 -0
- package/dist/lib/clock-sync.js +163 -0
- package/dist/lib/clock-sync.js.map +1 -0
- package/dist/lib/input-delay-controller.d.ts +12 -0
- package/dist/lib/input-delay-controller.d.ts.map +1 -0
- package/dist/lib/input-delay-controller.js +29 -0
- package/dist/lib/input-delay-controller.js.map +1 -0
- package/dist/lib/protocol.d.ts +148 -0
- package/dist/lib/protocol.d.ts.map +1 -0
- package/dist/lib/protocol.js +395 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tick-input-buffer.d.ts +30 -0
- package/dist/lib/tick-input-buffer.d.ts.map +1 -0
- package/dist/lib/tick-input-buffer.js +83 -0
- package/dist/lib/tick-input-buffer.js.map +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
# @lagless/net-wire
|
|
2
|
+
|
|
3
|
+
## 1. Responsibility & Context
|
|
4
|
+
|
|
5
|
+
Defines the binary network protocol for Lagless multiplayer games and provides utilities for network timing synchronization (`ClockSync`), adaptive input delay calculation (`InputDelayController`), and tick-indexed input buffering (`TickInputBuffer`). All network messages use binary schemas from `@lagless/binary` for efficient serialization. This library handles the "wire protocol" between clients and the relay server, ensuring deterministic input delivery and clock synchronization for rollback netcode.
|
|
6
|
+
|
|
7
|
+
## 2. Architecture Role
|
|
8
|
+
|
|
9
|
+
**Network layer** — sits above `@lagless/binary` (for schema definitions) and provides protocol/utilities used by game clients and relay servers.
|
|
10
|
+
|
|
11
|
+
**Downstream consumers:**
|
|
12
|
+
- Game clients — Use `ClockSync` and `InputDelayController` to adapt to network conditions
|
|
13
|
+
- Relay servers — Use protocol structs (`TickInputStruct`, etc.) to pack/unpack messages
|
|
14
|
+
- `@lagless/core` input providers — Use protocol for sending/receiving player inputs
|
|
15
|
+
|
|
16
|
+
**Upstream dependencies:**
|
|
17
|
+
- `@lagless/binary` — `BinarySchema`, `FieldType` for message layout
|
|
18
|
+
|
|
19
|
+
## 3. Public API
|
|
20
|
+
|
|
21
|
+
### Protocol Constants
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
const RELAY_BYTES_CHANNEL = 99; // Colyseus raw-binary channel ID
|
|
25
|
+
|
|
26
|
+
enum WireVersion {
|
|
27
|
+
V1 = 1, // Current protocol version
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
enum MsgType {
|
|
31
|
+
ServerHello, // 0: Server → Client (initial connection)
|
|
32
|
+
TickInput, // 1: Client → Server (player input)
|
|
33
|
+
TickInputFanout, // 2: Server → Client (broadcast inputs + server tick)
|
|
34
|
+
PlayerFinishedGame, // 3: Server → Client (game result)
|
|
35
|
+
CancelInput, // 4: Client/Server (cancel input due to disconnect)
|
|
36
|
+
Ping, // 5: Client → Server (RTT measurement)
|
|
37
|
+
Pong, // 6: Server → Client (RTT response)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enum TickInputKind {
|
|
41
|
+
Client, // Input from human player
|
|
42
|
+
Server, // Input from bot/AI
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Message Schemas (BinarySchema)
|
|
47
|
+
|
|
48
|
+
All messages start with `HeaderStruct`, followed by type-specific payload.
|
|
49
|
+
|
|
50
|
+
#### HeaderStruct
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const HeaderStruct = new BinarySchema({
|
|
54
|
+
version: FieldType.Uint8, // WireVersion (1 byte)
|
|
55
|
+
type: FieldType.Uint8, // MsgType (1 byte)
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
#### ServerHelloStruct
|
|
60
|
+
|
|
61
|
+
Server sends this on connection. Provides seed and player slot.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const ServerHelloStruct = new BinarySchema({
|
|
65
|
+
seed0: FieldType.Float64, // First 8 bytes of PRNG seed
|
|
66
|
+
seed1: FieldType.Float64, // Second 8 bytes of PRNG seed
|
|
67
|
+
playerSlot: FieldType.Uint8, // Assigned player slot (0-based)
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### TickInputStruct
|
|
72
|
+
|
|
73
|
+
Client sends input for a tick. Server relays to all clients.
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
const TickInputStruct = new BinarySchema({
|
|
77
|
+
tick: FieldType.Uint32, // Tick this input applies to
|
|
78
|
+
playerSlot: FieldType.Uint8, // Player who sent input
|
|
79
|
+
kind: FieldType.Uint8, // TickInputKind (Client=0, Server=1)
|
|
80
|
+
seq: FieldType.Uint32, // Sequence number (for duplicate detection)
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Note:** Input payload follows this struct (variable length, game-specific).
|
|
85
|
+
|
|
86
|
+
#### TickInputFanoutStruct
|
|
87
|
+
|
|
88
|
+
Server broadcasts to clients after collecting inputs for a tick. Includes server tick hint for clock sync.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const TickInputFanoutStruct = new BinarySchema({
|
|
92
|
+
serverTick: FieldType.Uint32, // Server's current tick
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Note:** Fanout message includes all `TickInputStruct` messages for the tick (batched).
|
|
97
|
+
|
|
98
|
+
#### CancelInputStruct
|
|
99
|
+
|
|
100
|
+
Server sends when a player disconnects mid-game to cancel their future inputs.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const CancelInputStruct = new BinarySchema({
|
|
104
|
+
tick: FieldType.Uint32, // Tick to cancel
|
|
105
|
+
playerSlot: FieldType.Uint8, // Player whose input is cancelled
|
|
106
|
+
seq: FieldType.Uint32, // Sequence number to cancel
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### PingStruct
|
|
111
|
+
|
|
112
|
+
Client sends to measure RTT.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const PingStruct = new BinarySchema({
|
|
116
|
+
cSend: FieldType.Float32, // Client send timestamp (ms, from performance.now())
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### PongStruct
|
|
121
|
+
|
|
122
|
+
Server responds with timing data for RTT and clock offset calculation.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const PongStruct = new BinarySchema({
|
|
126
|
+
cSend: FieldType.Float32, // Echo of client's cSend
|
|
127
|
+
sRecv: FieldType.Float32, // Server receive timestamp (ms)
|
|
128
|
+
sSend: FieldType.Float32, // Server send timestamp (ms)
|
|
129
|
+
sTick: FieldType.Uint32, // Server's current tick
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### PlayerFinishedGameStruct
|
|
134
|
+
|
|
135
|
+
Server sends when a player finishes the game (final score, MMR change).
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
const PlayerFinishedGameStruct = new BinarySchema({
|
|
139
|
+
tick: FieldType.Uint32, // Tick when player finished
|
|
140
|
+
verifiedTick: FieldType.Uint32, // Verified tick (after input delay)
|
|
141
|
+
playerSlot: FieldType.Uint8, // Player slot
|
|
142
|
+
score: FieldType.Uint32, // Final score
|
|
143
|
+
mmrChange: FieldType.Int32, // MMR change (can be negative)
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### ClockSync
|
|
148
|
+
|
|
149
|
+
Maintains network timing statistics: RTT, jitter, and server time offset. Uses EWMA (Exponentially Weighted Moving Average) with warmup phase for stable estimates.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
class ClockSync {
|
|
153
|
+
constructor(warmupSampleCount?: number); // Default: 5 samples
|
|
154
|
+
|
|
155
|
+
get rttEwmaMs(): number; // Round-trip time EWMA (ms)
|
|
156
|
+
get jitterEwmaMs(): number; // Jitter EWMA (ms)
|
|
157
|
+
get serverTimeOffsetMs(): number; // Server time offset (ms)
|
|
158
|
+
get sampleCount(): number; // Total samples processed
|
|
159
|
+
get isReady(): boolean; // True after warmup phase completes
|
|
160
|
+
|
|
161
|
+
updateFromPong(
|
|
162
|
+
clientReceiveMs: number,
|
|
163
|
+
pong: InferBinarySchemaValues<typeof PongStruct>
|
|
164
|
+
): boolean; // Update stats from pong, returns true if became ready
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**How it works:**
|
|
169
|
+
- **Warmup phase:** Collects `warmupSampleCount` samples (default 5), uses median for initial estimate
|
|
170
|
+
- **After warmup:** Uses EWMA with alpha=0.15 for smooth tracking
|
|
171
|
+
- **RTT calculation:** `clientReceiveMs - pong.cSend`
|
|
172
|
+
- **Server time offset:** `(sRecv + sSend) / 2 - (cSend + cRecv) / 2`
|
|
173
|
+
- **Jitter:** Deviation from EWMA RTT
|
|
174
|
+
|
|
175
|
+
**Constants:**
|
|
176
|
+
- `EWMA_ALPHA = 0.15` — Smoothing factor (higher = more responsive, lower = more stable)
|
|
177
|
+
- `WARMUP_SAMPLE_COUNT = 5` — Samples needed before `isReady = true`
|
|
178
|
+
- `INITIAL_RTT_MS = 100` — Initial guess before any samples
|
|
179
|
+
- `INITIAL_JITTER_MS = 20` — Initial guess for jitter
|
|
180
|
+
|
|
181
|
+
### InputDelayController
|
|
182
|
+
|
|
183
|
+
Calculates adaptive input delay (in ticks) based on network conditions. Uses hysteresis to prevent oscillation.
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
class InputDelayController {
|
|
187
|
+
constructor(minTicks?: number, maxTicks?: number, initial?: number);
|
|
188
|
+
// Defaults: minTicks=1, maxTicks=8, initial=2
|
|
189
|
+
|
|
190
|
+
get deltaTicks(): number; // Current input delay in ticks
|
|
191
|
+
|
|
192
|
+
recompute(
|
|
193
|
+
tickMs: number,
|
|
194
|
+
rttEwmaMs: number,
|
|
195
|
+
jitterEwmaMs: number,
|
|
196
|
+
k?: number, // Jitter multiplier (default: 1.8)
|
|
197
|
+
safetyMs?: number // Safety margin (default: 10ms)
|
|
198
|
+
): number; // Returns new deltaTicks
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Formula:**
|
|
203
|
+
```
|
|
204
|
+
deltaTicks = ceil((RTT_EWMA/2 + k*JITTER_EWMA + SAFETY_ms) / TICK_ms) + 1
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Hysteresis:**
|
|
208
|
+
- **Increase:** Immediate (if formula suggests higher delay)
|
|
209
|
+
- **Decrease:** Gradual (decrease by 1 tick per recompute call)
|
|
210
|
+
|
|
211
|
+
**Why hysteresis?** Prevents rapid oscillation when network conditions fluctuate.
|
|
212
|
+
|
|
213
|
+
### TickInputBuffer
|
|
214
|
+
|
|
215
|
+
Stores tick-indexed input data for late joiner synchronization. Automatically prunes old entries.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
class TickInputBuffer {
|
|
219
|
+
constructor(maxRetentionTicks?: number); // Default: 600 (~10s at 60 FPS)
|
|
220
|
+
|
|
221
|
+
get oldestTick(): number; // Oldest stored tick
|
|
222
|
+
get size(): number; // Number of ticks with data
|
|
223
|
+
|
|
224
|
+
add(tick: number, data: Uint8Array): void; // Add input data for tick
|
|
225
|
+
getFromTick(fromTick: number): ReadonlyMap<number, ReadonlyArray<Uint8Array>>; // Get inputs from tick onwards
|
|
226
|
+
getFlattenedFromTick(fromTick: number): Uint8Array[]; // Get flattened array of inputs
|
|
227
|
+
prune(currentTick: number): number; // Remove entries older than retention window, returns pruned count
|
|
228
|
+
clear(): void; // Clear all data
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Use case:** When a late joiner connects at tick 1000, the server sends all inputs from tick 400 onwards (600-tick window). Client replays 400-1000 to catch up.
|
|
233
|
+
|
|
234
|
+
### RelayRoomOptions
|
|
235
|
+
|
|
236
|
+
Configuration interface for Colyseus relay rooms.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
interface ColyseusRelayRoomOptions {
|
|
240
|
+
frameLength: number; // Frame duration in ms (e.g., 16.666 for 60 FPS)
|
|
241
|
+
maxPlayers: number; // Max players in room
|
|
242
|
+
gameId: string; // Game identifier
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## 4. Preconditions
|
|
247
|
+
|
|
248
|
+
- **`ClockSync.updateFromPong()` requires valid pong data** — Rejects RTT < 0 or RTT > 10000ms
|
|
249
|
+
- **`ClockSync.isReady` must be true before using timing data** — Until ready, RTT/jitter estimates are unreliable
|
|
250
|
+
- **`InputDelayController.recompute()` should be called after ClockSync updates** — Uses RTT/jitter for calculation
|
|
251
|
+
- **`TickInputBuffer.prune()` should be called periodically** — Prevents unbounded memory growth
|
|
252
|
+
|
|
253
|
+
## 5. Postconditions
|
|
254
|
+
|
|
255
|
+
- After `ClockSync.updateFromPong()` processes warmup samples, `isReady = true`
|
|
256
|
+
- After `InputDelayController.recompute()`, `deltaTicks` is clamped to [minTicks, maxTicks]
|
|
257
|
+
- After `TickInputBuffer.add()`, data is retrievable via `getFromTick()`
|
|
258
|
+
|
|
259
|
+
## 6. Invariants & Constraints
|
|
260
|
+
|
|
261
|
+
- **Protocol version:** All messages use `WireVersion.V1 = 1`
|
|
262
|
+
- **Message header:** All messages start with `HeaderStruct` (2 bytes: version + type)
|
|
263
|
+
- **Little-endian byte order:** All multi-byte fields use little-endian (enforced by BinarySchema)
|
|
264
|
+
- **Input delay bounds:** `InputDelayController` clamps deltaTicks to [minTicks, maxTicks]
|
|
265
|
+
- **ClockSync EWMA:** Uses alpha=0.15 for RTT and jitter tracking
|
|
266
|
+
- **TickInputBuffer retention:** Stores up to `maxRetentionTicks` (default 600) ticks
|
|
267
|
+
|
|
268
|
+
## 7. Safety Notes (AI Agent)
|
|
269
|
+
|
|
270
|
+
### DO NOT
|
|
271
|
+
|
|
272
|
+
- **DO NOT use `ClockSync` data before `isReady = true`** — Initial estimates are unreliable (hardcoded guesses)
|
|
273
|
+
- **DO NOT recompute input delay on every frame** — Call `InputDelayController.recompute()` only after ClockSync updates (typically on pong)
|
|
274
|
+
- **DO NOT modify protocol structs without versioning** — Breaking changes require incrementing `WireVersion`
|
|
275
|
+
- **DO NOT forget to prune `TickInputBuffer`** — Without pruning, buffer grows unbounded (memory leak)
|
|
276
|
+
- **DO NOT assume server and client ticks are synchronized** — Use `serverTimeOffsetMs` to convert between server time and local time
|
|
277
|
+
- **DO NOT use `InputDelayController` with negative or zero tickMs** — Formula requires positive tick duration
|
|
278
|
+
|
|
279
|
+
### Common Mistakes
|
|
280
|
+
|
|
281
|
+
- Forgetting to check `isReady` before using ClockSync data → unreliable input delay calculation
|
|
282
|
+
- Not calling `TickInputBuffer.prune()` → server memory leak on long-running rooms
|
|
283
|
+
- Using ClockSync RTT without multiplying jitter by k (default 1.8) → input delay too low, frequent rollbacks
|
|
284
|
+
- Implementing custom input delay formula instead of using `InputDelayController` → likely to oscillate or be too aggressive
|
|
285
|
+
|
|
286
|
+
## 8. Usage Examples
|
|
287
|
+
|
|
288
|
+
### ClockSync Usage
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import { ClockSync, PingStruct, PongStruct } from '@lagless/net-wire';
|
|
292
|
+
import { now } from '@lagless/misc';
|
|
293
|
+
|
|
294
|
+
const clockSync = new ClockSync(5); // 5-sample warmup
|
|
295
|
+
|
|
296
|
+
// Send ping
|
|
297
|
+
const pingData = PingStruct.pack({ cSend: now() });
|
|
298
|
+
websocket.send(pingData);
|
|
299
|
+
|
|
300
|
+
// On pong received
|
|
301
|
+
websocket.on('message', (data) => {
|
|
302
|
+
const header = HeaderStruct.unpack(data);
|
|
303
|
+
if (header.type === MsgType.Pong) {
|
|
304
|
+
const pong = PongStruct.unpack(data, HeaderStruct.byteLength);
|
|
305
|
+
const becameReady = clockSync.updateFromPong(now(), pong);
|
|
306
|
+
|
|
307
|
+
if (becameReady) {
|
|
308
|
+
console.log('ClockSync ready!');
|
|
309
|
+
console.log(`RTT: ${clockSync.rttEwmaMs}ms`);
|
|
310
|
+
console.log(`Jitter: ${clockSync.jitterEwmaMs}ms`);
|
|
311
|
+
console.log(`Server offset: ${clockSync.serverTimeOffsetMs}ms`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### InputDelayController with ClockSync
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { InputDelayController, ClockSync } from '@lagless/net-wire';
|
|
321
|
+
|
|
322
|
+
const clockSync = new ClockSync();
|
|
323
|
+
const inputDelay = new InputDelayController(1, 8, 2); // Min 1, max 8, initial 2 ticks
|
|
324
|
+
|
|
325
|
+
// On pong received (after ClockSync update)
|
|
326
|
+
clockSync.updateFromPong(now(), pong);
|
|
327
|
+
|
|
328
|
+
if (clockSync.isReady) {
|
|
329
|
+
const tickMs = 16.666; // 60 FPS
|
|
330
|
+
const newDelay = inputDelay.recompute(
|
|
331
|
+
tickMs,
|
|
332
|
+
clockSync.rttEwmaMs,
|
|
333
|
+
clockSync.jitterEwmaMs,
|
|
334
|
+
1.8, // k (jitter multiplier)
|
|
335
|
+
10 // safety margin (ms)
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
console.log(`Input delay: ${newDelay} ticks`);
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Sending TickInput
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
import { HeaderStruct, TickInputStruct, MsgType, WireVersion, TickInputKind } from '@lagless/net-wire';
|
|
346
|
+
|
|
347
|
+
// Pack input for tick 100
|
|
348
|
+
const header = HeaderStruct.pack({ version: WireVersion.V1, type: MsgType.TickInput });
|
|
349
|
+
const tickInput = TickInputStruct.pack({
|
|
350
|
+
tick: 100,
|
|
351
|
+
playerSlot: 0,
|
|
352
|
+
kind: TickInputKind.Client,
|
|
353
|
+
seq: sequenceNumber++,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Append game-specific input payload (e.g., Move, LookAt)
|
|
357
|
+
const inputPayload = packMyGameInput({ dx: 0.5, dy: 0.3 });
|
|
358
|
+
|
|
359
|
+
// Concatenate header + tickInput + payload
|
|
360
|
+
const message = new Uint8Array(header.byteLength + tickInput.byteLength + inputPayload.byteLength);
|
|
361
|
+
message.set(header, 0);
|
|
362
|
+
message.set(tickInput, header.byteLength);
|
|
363
|
+
message.set(inputPayload, header.byteLength + tickInput.byteLength);
|
|
364
|
+
|
|
365
|
+
websocket.send(message);
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### TickInputBuffer for Late Joiners
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
import { TickInputBuffer } from '@lagless/net-wire';
|
|
372
|
+
|
|
373
|
+
// Server side: buffer inputs for late joiners
|
|
374
|
+
const buffer = new TickInputBuffer(600); // Keep 600 ticks (~10s at 60 FPS)
|
|
375
|
+
|
|
376
|
+
// On input received
|
|
377
|
+
function onTickInput(tick: number, data: Uint8Array) {
|
|
378
|
+
buffer.add(tick, data);
|
|
379
|
+
|
|
380
|
+
// Prune old data every 60 ticks (~1s)
|
|
381
|
+
if (tick % 60 === 0) {
|
|
382
|
+
const pruned = buffer.prune(tick);
|
|
383
|
+
console.log(`Pruned ${pruned} old ticks`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// When late joiner connects at tick 1000
|
|
388
|
+
function onLateJoinerConnect(joinTick: number) {
|
|
389
|
+
const catchupData = buffer.getFlattenedFromTick(buffer.oldestTick);
|
|
390
|
+
sendToClient(catchupData);
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### Parsing TickInputFanout
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { HeaderStruct, TickInputFanoutStruct, TickInputStruct, MsgType } from '@lagless/net-wire';
|
|
398
|
+
|
|
399
|
+
// On fanout message received
|
|
400
|
+
websocket.on('message', (data) => {
|
|
401
|
+
const header = HeaderStruct.unpack(data);
|
|
402
|
+
|
|
403
|
+
if (header.type === MsgType.TickInputFanout) {
|
|
404
|
+
let offset = HeaderStruct.byteLength;
|
|
405
|
+
|
|
406
|
+
// Read fanout header
|
|
407
|
+
const fanout = TickInputFanoutStruct.unpack(data, offset);
|
|
408
|
+
offset += TickInputFanoutStruct.byteLength;
|
|
409
|
+
|
|
410
|
+
console.log(`Server tick: ${fanout.serverTick}`);
|
|
411
|
+
|
|
412
|
+
// Read all tick inputs in the fanout
|
|
413
|
+
while (offset < data.byteLength) {
|
|
414
|
+
const tickInput = TickInputStruct.unpack(data, offset);
|
|
415
|
+
offset += TickInputStruct.byteLength;
|
|
416
|
+
|
|
417
|
+
// Read game-specific input payload (variable length)
|
|
418
|
+
const payload = unpackMyGameInput(data, offset);
|
|
419
|
+
offset += payload.byteLength;
|
|
420
|
+
|
|
421
|
+
processInput(tickInput.tick, tickInput.playerSlot, payload);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## 9. Testing Guidance
|
|
428
|
+
|
|
429
|
+
No tests currently exist for this library. When adding tests, consider:
|
|
430
|
+
|
|
431
|
+
**Framework suggestion:** Vitest (used by other Lagless libraries)
|
|
432
|
+
|
|
433
|
+
**Test coverage priorities:**
|
|
434
|
+
1. **Protocol packing/unpacking** — Verify all BinarySchema structs pack and unpack correctly
|
|
435
|
+
2. **ClockSync warmup** — Verify median calculation during warmup phase
|
|
436
|
+
3. **ClockSync EWMA** — Verify smooth tracking after warmup
|
|
437
|
+
4. **InputDelayController hysteresis** — Verify gradual decrease, immediate increase
|
|
438
|
+
5. **TickInputBuffer pruning** — Verify old entries are removed correctly
|
|
439
|
+
|
|
440
|
+
**Example test pattern:**
|
|
441
|
+
```typescript
|
|
442
|
+
import { describe, it, expect } from 'vitest';
|
|
443
|
+
import { ClockSync } from '@lagless/net-wire';
|
|
444
|
+
|
|
445
|
+
describe('ClockSync', () => {
|
|
446
|
+
it('should not be ready until warmup completes', () => {
|
|
447
|
+
const sync = new ClockSync(3); // 3-sample warmup
|
|
448
|
+
expect(sync.isReady).toBe(false);
|
|
449
|
+
|
|
450
|
+
sync.updateFromPong(100, { cSend: 0, sRecv: 25, sSend: 26, sTick: 10 });
|
|
451
|
+
expect(sync.isReady).toBe(false);
|
|
452
|
+
|
|
453
|
+
sync.updateFromPong(200, { cSend: 100, sRecv: 125, sSend: 126, sTick: 20 });
|
|
454
|
+
expect(sync.isReady).toBe(false);
|
|
455
|
+
|
|
456
|
+
const becameReady = sync.updateFromPong(300, { cSend: 200, sRecv: 225, sSend: 226, sTick: 30 });
|
|
457
|
+
expect(becameReady).toBe(true);
|
|
458
|
+
expect(sync.isReady).toBe(true);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## 10. Change Checklist
|
|
464
|
+
|
|
465
|
+
When modifying this module:
|
|
466
|
+
|
|
467
|
+
1. **Increment `WireVersion` for breaking changes** — Clients and servers must use same version
|
|
468
|
+
2. **Update protocol docs** — Document all message formats in this README
|
|
469
|
+
3. **Test on high-latency network** — Simulate 200ms+ RTT to verify ClockSync/InputDelayController behavior
|
|
470
|
+
4. **Profile memory usage** — Ensure `TickInputBuffer` pruning prevents leaks
|
|
471
|
+
5. **Update this README:** Document new APIs in Public API section
|
|
472
|
+
6. **Preserve binary layout** — BinarySchema field order must remain stable (breaking change)
|
|
473
|
+
7. **Test EWMA stability** — Verify ClockSync doesn't oscillate with noisy samples
|
|
474
|
+
|
|
475
|
+
## 11. Integration Notes
|
|
476
|
+
|
|
477
|
+
### Used By
|
|
478
|
+
|
|
479
|
+
- **Game clients:**
|
|
480
|
+
- Use `ClockSync` to estimate RTT, jitter, server time offset
|
|
481
|
+
- Use `InputDelayController` to calculate adaptive input delay
|
|
482
|
+
- Use protocol structs to pack/unpack network messages
|
|
483
|
+
|
|
484
|
+
- **Relay servers:**
|
|
485
|
+
- Use protocol structs to parse incoming messages and pack responses
|
|
486
|
+
- Use `TickInputBuffer` to store inputs for late joiners
|
|
487
|
+
|
|
488
|
+
### Common Integration Patterns
|
|
489
|
+
|
|
490
|
+
**Client-side network stack:**
|
|
491
|
+
```typescript
|
|
492
|
+
import { ClockSync, InputDelayController, HeaderStruct, MsgType } from '@lagless/net-wire';
|
|
493
|
+
import { SimulationClock } from '@lagless/misc';
|
|
494
|
+
|
|
495
|
+
class NetworkClient {
|
|
496
|
+
private clockSync = new ClockSync();
|
|
497
|
+
private inputDelay = new InputDelayController();
|
|
498
|
+
private simClock: SimulationClock;
|
|
499
|
+
|
|
500
|
+
constructor(simClock: SimulationClock) {
|
|
501
|
+
this.simClock = simClock;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Send ping every second
|
|
505
|
+
startPingLoop() {
|
|
506
|
+
setInterval(() => {
|
|
507
|
+
const ping = PingStruct.pack({ cSend: now() });
|
|
508
|
+
this.sendMessage(MsgType.Ping, ping);
|
|
509
|
+
}, 1000);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// On pong received
|
|
513
|
+
onPong(pong: InferBinarySchemaValues<typeof PongStruct>) {
|
|
514
|
+
const becameReady = this.clockSync.updateFromPong(now(), pong);
|
|
515
|
+
|
|
516
|
+
if (becameReady) {
|
|
517
|
+
this.simClock.phaseNudger.activate();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (this.clockSync.isReady) {
|
|
521
|
+
this.inputDelay.recompute(
|
|
522
|
+
16.666,
|
|
523
|
+
this.clockSync.rttEwmaMs,
|
|
524
|
+
this.clockSync.jitterEwmaMs
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Send input with delay
|
|
530
|
+
sendInput(localTick: number, inputData: Uint8Array) {
|
|
531
|
+
const delayedTick = localTick + this.inputDelay.deltaTicks;
|
|
532
|
+
const tickInput = TickInputStruct.pack({
|
|
533
|
+
tick: delayedTick,
|
|
534
|
+
playerSlot: this.playerSlot,
|
|
535
|
+
kind: TickInputKind.Client,
|
|
536
|
+
seq: this.nextSeq++,
|
|
537
|
+
});
|
|
538
|
+
this.sendMessage(MsgType.TickInput, concat(tickInput, inputData));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**Server-side relay:**
|
|
544
|
+
```typescript
|
|
545
|
+
import { TickInputBuffer, HeaderStruct, TickInputStruct } from '@lagless/net-wire';
|
|
546
|
+
|
|
547
|
+
class RelayRoom {
|
|
548
|
+
private inputBuffer = new TickInputBuffer(600);
|
|
549
|
+
private currentTick = 0;
|
|
550
|
+
|
|
551
|
+
onClientInput(client: Client, data: Uint8Array) {
|
|
552
|
+
const header = HeaderStruct.unpack(data);
|
|
553
|
+
|
|
554
|
+
if (header.type === MsgType.TickInput) {
|
|
555
|
+
const tickInput = TickInputStruct.unpack(data, HeaderStruct.byteLength);
|
|
556
|
+
|
|
557
|
+
// Store for late joiners
|
|
558
|
+
this.inputBuffer.add(tickInput.tick, data);
|
|
559
|
+
|
|
560
|
+
// Broadcast to all clients
|
|
561
|
+
this.broadcast(data, { except: client });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
onTick() {
|
|
566
|
+
this.currentTick++;
|
|
567
|
+
|
|
568
|
+
// Prune old inputs every 60 ticks
|
|
569
|
+
if (this.currentTick % 60 === 0) {
|
|
570
|
+
this.inputBuffer.prune(this.currentTick);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
onLateJoinerConnect(client: Client) {
|
|
575
|
+
// Send all buffered inputs
|
|
576
|
+
const inputs = this.inputBuffer.getFlattenedFromTick(this.inputBuffer.oldestTick);
|
|
577
|
+
client.send(inputs);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
## 12. Appendix
|
|
583
|
+
|
|
584
|
+
### Message Format Table
|
|
585
|
+
|
|
586
|
+
| Message | Size (bytes) | Direction | Purpose |
|
|
587
|
+
|---------|--------------|-----------|---------|
|
|
588
|
+
| ServerHello | 2 + 17 = 19 | Server → Client | Initial connection: seed + player slot |
|
|
589
|
+
| TickInput | 2 + 10 + payload | Client → Server | Player input for tick |
|
|
590
|
+
| TickInputFanout | 2 + 4 + inputs | Server → Client | Broadcast inputs + server tick |
|
|
591
|
+
| CancelInput | 2 + 9 = 11 | Server → Client | Cancel input due to disconnect |
|
|
592
|
+
| Ping | 2 + 4 = 6 | Client → Server | RTT measurement request |
|
|
593
|
+
| Pong | 2 + 16 = 18 | Server → Client | RTT measurement response |
|
|
594
|
+
| PlayerFinishedGame | 2 + 17 = 19 | Server → Client | Game result |
|
|
595
|
+
|
|
596
|
+
**Header:** 2 bytes (version:Uint8 + type:Uint8)
|
|
597
|
+
|
|
598
|
+
### ClockSync Algorithm
|
|
599
|
+
|
|
600
|
+
**Warmup phase (first `warmupSampleCount` samples):**
|
|
601
|
+
1. Collect samples: `{rtt, serverTimeOffset}`
|
|
602
|
+
2. After collecting all samples → Calculate median RTT and median offset
|
|
603
|
+
3. Set `rttEwmaMs = medianRTT`, `serverTimeOffsetMs = medianOffset`
|
|
604
|
+
4. Set `isReady = true`
|
|
605
|
+
|
|
606
|
+
**After warmup (EWMA tracking):**
|
|
607
|
+
1. Calculate RTT: `clientReceiveMs - pong.cSend`
|
|
608
|
+
2. Update RTT EWMA: `rttEwma = alpha * rtt + (1 - alpha) * rttEwma`
|
|
609
|
+
3. Calculate jitter: `abs(rtt - rttEwma)`
|
|
610
|
+
4. Update jitter EWMA: `jitterEwma = alpha * jitter + (1 - alpha) * jitterEwma`
|
|
611
|
+
5. Calculate server time offset: `(sRecv + sSend) / 2 - (cSend + cRecv) / 2`
|
|
612
|
+
6. Update offset EWMA: `offsetEwma = alpha * offset + (1 - alpha) * offsetEwma`
|
|
613
|
+
|
|
614
|
+
**Why median for warmup?** Initial samples may include connection setup overhead. Median is robust to outliers.
|
|
615
|
+
|
|
616
|
+
**Why EWMA after warmup?** Smooth tracking of changing network conditions without being too sensitive to individual samples.
|
|
617
|
+
|
|
618
|
+
### InputDelayController Formula Breakdown
|
|
619
|
+
|
|
620
|
+
```
|
|
621
|
+
deltaTicks = ceil((RTT_EWMA/2 + k*JITTER_EWMA + SAFETY_ms) / TICK_ms) + 1
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
**Components:**
|
|
625
|
+
- `RTT_EWMA/2` — Half-RTT (one-way latency estimate)
|
|
626
|
+
- `k*JITTER_EWMA` — Jitter buffer (k=1.8 covers ~90% of jitter spikes)
|
|
627
|
+
- `SAFETY_ms` — Fixed safety margin (default 10ms)
|
|
628
|
+
- `/ TICK_ms` — Convert ms to ticks
|
|
629
|
+
- `ceil(...)` — Round up to next tick
|
|
630
|
+
- `+ 1` — Extra tick for processing/scheduling margin
|
|
631
|
+
|
|
632
|
+
**Example:**
|
|
633
|
+
- RTT = 100ms → Half-RTT = 50ms
|
|
634
|
+
- Jitter = 10ms → Jitter buffer = 18ms
|
|
635
|
+
- Safety = 10ms
|
|
636
|
+
- Need = 50 + 18 + 10 = 78ms
|
|
637
|
+
- Ticks = ceil(78 / 16.666) + 1 = 5 + 1 = 6 ticks
|
|
638
|
+
|
|
639
|
+
**Hysteresis prevents oscillation:**
|
|
640
|
+
- If formula suggests 7 ticks → immediately jump to 7
|
|
641
|
+
- If formula suggests 5 ticks → decrease by 1 per recompute call (7 → 6 → 5)
|
|
642
|
+
|
|
643
|
+
### Byte Layout Examples
|
|
644
|
+
|
|
645
|
+
**ServerHello message:**
|
|
646
|
+
```
|
|
647
|
+
Offset | Size | Field
|
|
648
|
+
-------|------|-------
|
|
649
|
+
0 | 1 | version (1)
|
|
650
|
+
1 | 1 | type (MsgType.ServerHello = 0)
|
|
651
|
+
2 | 8 | seed0 (Float64)
|
|
652
|
+
10 | 8 | seed1 (Float64)
|
|
653
|
+
18 | 1 | playerSlot (Uint8)
|
|
654
|
+
-------|------|-------
|
|
655
|
+
Total: 19 bytes
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
**TickInput message (with 8-byte payload):**
|
|
659
|
+
```
|
|
660
|
+
Offset | Size | Field
|
|
661
|
+
-------|------|-------
|
|
662
|
+
0 | 1 | version (1)
|
|
663
|
+
1 | 1 | type (MsgType.TickInput = 1)
|
|
664
|
+
2 | 4 | tick (Uint32)
|
|
665
|
+
6 | 1 | playerSlot (Uint8)
|
|
666
|
+
7 | 1 | kind (Uint8)
|
|
667
|
+
8 | 4 | seq (Uint32)
|
|
668
|
+
12 | N | payload (game-specific input data)
|
|
669
|
+
-------|------|-------
|
|
670
|
+
Total: 12 + N bytes
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**TickInputFanout message (with 2 TickInputs):**
|
|
674
|
+
```
|
|
675
|
+
Offset | Size | Field
|
|
676
|
+
-------|------|-------
|
|
677
|
+
0 | 1 | version (1)
|
|
678
|
+
1 | 1 | type (MsgType.TickInputFanout = 2)
|
|
679
|
+
2 | 4 | serverTick (Uint32)
|
|
680
|
+
6 | M | TickInput #1 (TickInputStruct + payload)
|
|
681
|
+
6+M | N | TickInput #2 (TickInputStruct + payload)
|
|
682
|
+
-------|------|-------
|
|
683
|
+
Total: 6 + M + N bytes
|
|
684
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './lib/protocol.js';\nexport * from './lib/clock-sync.js';\nexport * from './lib/input-delay-controller.js';\nexport * from './lib/tick-input-buffer.js';\n"],"names":[],"rangeMappings":";;;","mappings":"AAAA,cAAc,oBAAoB;AAClC,cAAc,sBAAsB;AACpC,cAAc,kCAAkC;AAChD,cAAc,6BAA6B"}
|