@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 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
+ ```
@@ -0,0 +1,5 @@
1
+ export * from './lib/protocol.js';
2
+ export * from './lib/clock-sync.js';
3
+ export * from './lib/input-delay-controller.js';
4
+ export * from './lib/tick-input-buffer.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -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,6 @@
1
+ export * from './lib/protocol.js';
2
+ export * from './lib/clock-sync.js';
3
+ export * from './lib/input-delay-controller.js';
4
+ export * from './lib/tick-input-buffer.js';
5
+
6
+ //# sourceMappingURL=index.js.map
@@ -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"}