@watchtower-sdk/core 0.3.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @watchtower-sdk/core
2
2
 
3
- Simple game backend SDK - cloud saves and multiplayer.
3
+ The simplest way to add multiplayer to your game. Point at your state, join a room, done.
4
4
 
5
5
  ## Installation
6
6
 
@@ -15,74 +15,169 @@ import { Watchtower } from '@watchtower-sdk/core'
15
15
 
16
16
  const wt = new Watchtower({
17
17
  gameId: 'my-game',
18
- apiKey: 'wt_live_...' // Get from dashboard
18
+ apiKey: 'wt_live_...' // Get from watchtower.host
19
19
  })
20
20
 
21
- // Cloud saves
22
- await wt.save('progress', { level: 5 })
23
- const data = await wt.load('progress')
24
-
25
- // Multiplayer
21
+ // Your game state
26
22
  const state = { players: {} }
23
+
24
+ // Make it multiplayer
27
25
  const sync = wt.sync(state)
28
- await sync.join('room-code')
29
- state.players[sync.myId] = { x: 0, y: 0 }
30
- // Others appear in state.players automatically!
26
+ await sync.join('my-room')
27
+
28
+ // Add yourself
29
+ state.players[sync.myId] = { x: 0, y: 0, name: 'Player1' }
30
+
31
+ // Move (automatically syncs!)
32
+ state.players[sync.myId].x += 10
33
+
34
+ // Others appear automatically in state.players
35
+ for (const [id, player] of Object.entries(state.players)) {
36
+ drawPlayer(player.x, player.y)
37
+ }
31
38
  ```
32
39
 
33
- ## Cloud Saves
40
+ No events. No message handlers. Just read and write your state.
34
41
 
35
- Key-value storage per player. JSON in, JSON out.
42
+ ---
36
43
 
37
- ```typescript
38
- // Save
39
- await wt.save('progress', { level: 5, coins: 100 })
40
- await wt.save('settings', { music: true, sfx: true })
44
+ ## State Templates
41
45
 
42
- // Load
43
- const progress = await wt.load('progress')
44
- const settings = await wt.load('settings')
46
+ Pick the pattern that matches your game:
45
47
 
46
- // List all saves
47
- const keys = await wt.listSaves() // ['progress', 'settings']
48
+ ### Movement Game (Cursor Party, Agar.io)
48
49
 
49
- // Delete
50
- await wt.deleteSave('progress')
51
- ```
50
+ ```typescript
51
+ interface GameState {
52
+ players: Record<string, {
53
+ x: number
54
+ y: number
55
+ name: string
56
+ color: string
57
+ }>
58
+ }
52
59
 
53
- ## Multiplayer
60
+ const state: GameState = { players: {} }
61
+ const sync = wt.sync(state, {
62
+ interpolate: true,
63
+ interpolationDelay: 100,
64
+ jitterBuffer: 50
65
+ })
66
+ ```
54
67
 
55
- Point at your game state. Join a room. State syncs automatically.
68
+ ### Chat / Lobby
56
69
 
57
70
  ```typescript
58
- // Your game state
59
- const state = { players: {} }
71
+ interface GameState {
72
+ players: Record<string, {
73
+ name: string
74
+ avatar: string
75
+ ready: boolean
76
+ }>
77
+ messages: Array<{
78
+ from: string
79
+ text: string
80
+ ts: number
81
+ }>
82
+ }
60
83
 
61
- // Connect to Watchtower
62
- const sync = wt.sync(state)
84
+ const state: GameState = { players: {}, messages: [] }
85
+ const sync = wt.sync(state, { interpolate: false }) // No movement = no interpolation needed
86
+ ```
63
87
 
64
- // Join a room
65
- await sync.join('my-room')
88
+ ### Turn-Based (Chess, Cards)
66
89
 
67
- // Add yourself
68
- state.players[sync.myId] = {
69
- x: 0,
70
- y: 0,
71
- name: 'Player1'
90
+ ```typescript
91
+ interface GameState {
92
+ players: Record<string, {
93
+ name: string
94
+ hand?: Card[] // Hidden from others in real implementation
95
+ }>
96
+ currentTurn: string
97
+ board: BoardState
98
+ phase: 'waiting' | 'playing' | 'finished'
72
99
  }
73
100
 
74
- // Move (automatically syncs to others!)
75
- state.players[sync.myId].x += 5
101
+ const state: GameState = {
102
+ players: {},
103
+ currentTurn: '',
104
+ board: initialBoard,
105
+ phase: 'waiting'
106
+ }
107
+ const sync = wt.sync(state, { interpolate: false })
108
+ ```
76
109
 
77
- // Draw everyone (others appear automatically!)
78
- for (const [id, player] of Object.entries(state.players)) {
79
- drawPlayer(player.x, player.y)
110
+ ### Action Game (Shooter, Brawler)
111
+
112
+ ```typescript
113
+ interface GameState {
114
+ players: Record<string, {
115
+ x: number
116
+ y: number
117
+ vx: number // Velocity helps with prediction
118
+ vy: number
119
+ health: number
120
+ facing: 'left' | 'right'
121
+ animation: string
122
+ }>
80
123
  }
124
+
125
+ const state: GameState = { players: {} }
126
+ const sync = wt.sync(state, {
127
+ interpolate: true,
128
+ interpolationDelay: 50, // Lower for faster games
129
+ jitterBuffer: 25
130
+ })
81
131
  ```
82
132
 
83
- No events. No message handlers. Just read and write your state.
133
+ ---
134
+
135
+ ## Smoothing Options
136
+
137
+ The SDK automatically smooths remote player movement. Configure based on your game type:
138
+
139
+ ```typescript
140
+ const sync = wt.sync(state, {
141
+ // Core settings
142
+ tickRate: 20, // Updates per second (default: 20)
143
+ interpolate: true, // Smooth remote movement (default: true)
144
+
145
+ // Smoothing tuning
146
+ interpolationDelay: 100, // Render others Xms in the past (default: 100)
147
+ jitterBuffer: 50, // Buffer packets Xms to smooth delivery (default: 0)
148
+
149
+ // Connection handling
150
+ autoReconnect: true, // Auto-reconnect on disconnect (default: true)
151
+ maxReconnectAttempts: 10 // Give up after X attempts (default: 10)
152
+ })
153
+ ```
154
+
155
+ ### Recommended Presets
156
+
157
+ | Game Type | interpolationDelay | jitterBuffer | Notes |
158
+ |-----------|-------------------|--------------|-------|
159
+ | Casual (cursor party) | 100ms | 50ms | Buttery smooth, slight delay |
160
+ | Action (platformer) | 50ms | 25ms | Responsive, still smooth |
161
+ | Fast (shooter) | 40ms | 20ms | Very responsive |
162
+ | Turn-based | 0ms | 0ms | Instant, no movement to smooth |
84
163
 
85
- ### Creating & Joining Rooms
164
+ **Why the delay?** The SDK renders other players slightly "in the past" using server timestamps. This allows it to interpolate between known positions instead of guessing. 100ms is imperceptible for casual games but makes movement silky smooth.
165
+
166
+ ---
167
+
168
+ ## Properties
169
+
170
+ ```typescript
171
+ sync.myId // Your player ID
172
+ sync.roomId // Current room ID, or null
173
+ sync.connected // WebSocket connected?
174
+ sync.playerCount // Players in room
175
+ sync.latency // RTT to server in ms
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Rooms
86
181
 
87
182
  ```typescript
88
183
  // Create a new room
@@ -99,53 +194,84 @@ await sync.leave()
99
194
  const rooms = await sync.listRooms()
100
195
  ```
101
196
 
102
- ### Options
197
+ ---
103
198
 
104
- ```typescript
105
- const sync = wt.sync(state, {
106
- tickRate: 20, // Updates per second (default: 20)
107
- interpolate: true // Smooth remote movement (default: true)
108
- })
109
- ```
199
+ ## Events
110
200
 
111
- ### Properties
201
+ You don't *need* events — just read your state. But if you want notifications:
112
202
 
113
203
  ```typescript
114
- sync.myId // Your player ID
115
- sync.roomId // Current room, or null
116
- sync.connected // WebSocket connected?
204
+ // Player events
205
+ sync.on('join', (playerId) => console.log(`${playerId} joined`))
206
+ sync.on('leave', (playerId) => console.log(`${playerId} left`))
207
+
208
+ // Connection events
209
+ sync.on('connected', () => console.log('Connected!'))
210
+ sync.on('disconnected', () => console.log('Disconnected'))
211
+ sync.on('reconnecting', ({ attempt, delay }) => console.log(`Reconnecting in ${delay}ms...`))
212
+ sync.on('reconnected', () => console.log('Reconnected!'))
213
+
214
+ // Error handling
215
+ sync.on('error', (err) => console.error(err))
117
216
  ```
118
217
 
119
- ### Events (Optional)
218
+ ---
219
+
220
+ ## Broadcast Messages
120
221
 
121
- You don't need events—just read your state. But if you want notifications:
222
+ For one-off events that don't belong in state (explosions, sound effects):
122
223
 
123
224
  ```typescript
124
- sync.on('join', (playerId) => console.log(playerId, 'joined'))
125
- sync.on('leave', (playerId) => console.log(playerId, 'left'))
126
- sync.on('connected', () => console.log('Connected'))
127
- sync.on('disconnected', () => console.log('Disconnected'))
225
+ // Send
226
+ sync.broadcast({ type: 'explosion', x: 100, y: 200 })
227
+
228
+ // Receive
229
+ sync.on('message', (from, data) => {
230
+ if (data.type === 'explosion') {
231
+ playExplosion(data.x, data.y)
232
+ }
233
+ })
128
234
  ```
129
235
 
130
- ### Chat & Messages
236
+ ---
131
237
 
132
- Messages are just state:
238
+ ## Cloud Saves
239
+
240
+ Simple key-value storage per player:
133
241
 
134
242
  ```typescript
135
- state.chat = [
136
- ...state.chat.slice(-50), // Keep last 50
137
- { from: sync.myId, text: 'Hello!', ts: Date.now() }
138
- ]
243
+ // Save
244
+ await wt.save('progress', { level: 5, coins: 100 })
245
+
246
+ // Load
247
+ const progress = await wt.load('progress')
248
+
249
+ // List all saves
250
+ const keys = await wt.listSaves() // ['progress']
251
+
252
+ // Delete
253
+ await wt.deleteSave('progress')
139
254
  ```
140
255
 
256
+ ---
257
+
141
258
  ## Full Example
142
259
 
143
260
  ```typescript
144
261
  import { Watchtower } from '@watchtower-sdk/core'
145
262
 
146
263
  const wt = new Watchtower({ gameId: 'my-game', apiKey: 'wt_...' })
147
- const state = { players: {} }
148
- const sync = wt.sync(state)
264
+
265
+ // State template: movement game
266
+ const state = {
267
+ players: {} as Record<string, { x: number; y: number; color: string }>
268
+ }
269
+
270
+ const sync = wt.sync(state, {
271
+ interpolate: true,
272
+ interpolationDelay: 100,
273
+ jitterBuffer: 50
274
+ })
149
275
 
150
276
  // Join or create room
151
277
  const code = prompt('Room code? (blank to create)')
@@ -171,57 +297,42 @@ function loop() {
171
297
  if (keys.up) state.players[sync.myId].y -= 5
172
298
  if (keys.down) state.players[sync.myId].y += 5
173
299
 
174
- // Draw everyone
300
+ // Draw everyone (others are auto-interpolated!)
175
301
  ctx.clearRect(0, 0, 800, 600)
176
302
  for (const [id, p] of Object.entries(state.players)) {
177
303
  ctx.fillStyle = p.color
178
304
  ctx.fillRect(p.x - 10, p.y - 10, 20, 20)
305
+
306
+ // Show latency for your player
307
+ if (id === sync.myId) {
308
+ ctx.fillText(`${sync.latency}ms`, p.x, p.y - 15)
309
+ }
179
310
  }
180
311
 
312
+ // Debug info
313
+ ctx.fillStyle = '#fff'
314
+ ctx.fillText(`Players: ${sync.playerCount}`, 10, 20)
315
+
181
316
  requestAnimationFrame(loop)
182
317
  }
183
318
  loop()
184
319
  ```
185
320
 
186
- ## API Reference
321
+ ---
187
322
 
188
- ### Watchtower
323
+ ## Best Practices
189
324
 
190
- ```typescript
191
- const wt = new Watchtower({
192
- gameId: string, // From dashboard
193
- apiKey?: string, // From dashboard
194
- playerId?: string // Auto-generated if not provided
195
- })
196
-
197
- wt.playerId // Current player ID
198
- wt.gameId // Game ID
199
-
200
- // Saves
201
- await wt.save(key: string, data: any): Promise<void>
202
- await wt.load<T>(key: string): Promise<T | null>
203
- await wt.listSaves(): Promise<string[]>
204
- await wt.deleteSave(key: string): Promise<void>
325
+ 1. **Keep state flat.** Nested objects sync fine, but flat is faster to diff.
205
326
 
206
- // Multiplayer
207
- wt.sync(state: object, options?: SyncOptions): Sync
208
- ```
327
+ 2. **Use the `players` key.** The SDK auto-detects `players`, `entities`, `users`, or `clients`.
209
328
 
210
- ### Sync
329
+ 3. **Don't store secrets in state.** Everyone sees everything. Use server-side validation for game logic.
211
330
 
212
- ```typescript
213
- sync.myId: string
214
- sync.roomId: string | null
215
- sync.connected: boolean
331
+ 4. **Let the SDK interpolate.** Don't add your own smoothing on top — it'll fight the SDK.
216
332
 
217
- await sync.join(roomId: string, options?: JoinOptions): Promise<void>
218
- await sync.leave(): Promise<void>
219
- await sync.create(options?: CreateOptions): Promise<string>
220
- await sync.listRooms(): Promise<RoomListing[]>
333
+ 5. **Test with "Open another tab".** Easiest way to see multiplayer working.
221
334
 
222
- sync.on(event: string, callback: Function): void
223
- sync.off(event: string, callback: Function): void
224
- ```
335
+ ---
225
336
 
226
337
  ## License
227
338
 
package/dist/index.d.mts CHANGED
@@ -271,6 +271,10 @@ declare class Sync<T extends Record<string, unknown>> {
271
271
  get roomId(): string | null;
272
272
  /** Whether currently connected to a room */
273
273
  get connected(): boolean;
274
+ /** Number of players in the current room */
275
+ get playerCount(): number;
276
+ /** Current latency to server in milliseconds */
277
+ get latency(): number;
274
278
  private config;
275
279
  private options;
276
280
  private _roomId;
@@ -285,6 +289,11 @@ declare class Sync<T extends Record<string, unknown>> {
285
289
  private reconnectTimeout;
286
290
  private lastJoinOptions;
287
291
  private isReconnecting;
292
+ private serverTimeOffset;
293
+ private _playerCount;
294
+ private _latency;
295
+ private pingStartTime;
296
+ private pingInterval;
288
297
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
289
298
  /**
290
299
  * Join a room - your state will sync with everyone in this room
@@ -335,6 +344,7 @@ declare class Sync<T extends Record<string, unknown>> {
335
344
  private findPlayersKey;
336
345
  private startSyncLoop;
337
346
  private startInterpolationLoop;
347
+ private measureLatency;
338
348
  private stopInterpolationLoop;
339
349
  private processJitterQueue;
340
350
  private stopSyncLoop;
package/dist/index.d.ts CHANGED
@@ -271,6 +271,10 @@ declare class Sync<T extends Record<string, unknown>> {
271
271
  get roomId(): string | null;
272
272
  /** Whether currently connected to a room */
273
273
  get connected(): boolean;
274
+ /** Number of players in the current room */
275
+ get playerCount(): number;
276
+ /** Current latency to server in milliseconds */
277
+ get latency(): number;
274
278
  private config;
275
279
  private options;
276
280
  private _roomId;
@@ -285,6 +289,11 @@ declare class Sync<T extends Record<string, unknown>> {
285
289
  private reconnectTimeout;
286
290
  private lastJoinOptions;
287
291
  private isReconnecting;
292
+ private serverTimeOffset;
293
+ private _playerCount;
294
+ private _latency;
295
+ private pingStartTime;
296
+ private pingInterval;
288
297
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
289
298
  /**
290
299
  * Join a room - your state will sync with everyone in this room
@@ -335,6 +344,7 @@ declare class Sync<T extends Record<string, unknown>> {
335
344
  private findPlayersKey;
336
345
  private startSyncLoop;
337
346
  private startInterpolationLoop;
347
+ private measureLatency;
338
348
  private stopInterpolationLoop;
339
349
  private processJitterQueue;
340
350
  private stopSyncLoop;
package/dist/index.js CHANGED
@@ -300,6 +300,13 @@ var Sync = class {
300
300
  this.reconnectTimeout = null;
301
301
  this.lastJoinOptions = void 0;
302
302
  this.isReconnecting = false;
303
+ // Server time sync and metrics
304
+ this.serverTimeOffset = 0;
305
+ // Local time - server time
306
+ this._playerCount = 1;
307
+ this._latency = 0;
308
+ this.pingStartTime = 0;
309
+ this.pingInterval = null;
303
310
  this.state = state;
304
311
  this.myId = config.playerId;
305
312
  this.config = config;
@@ -321,6 +328,14 @@ var Sync = class {
321
328
  get connected() {
322
329
  return this.ws?.readyState === WebSocket.OPEN;
323
330
  }
331
+ /** Number of players in the current room */
332
+ get playerCount() {
333
+ return this._playerCount;
334
+ }
335
+ /** Current latency to server in milliseconds */
336
+ get latency() {
337
+ return this._latency;
338
+ }
324
339
  /**
325
340
  * Join a room - your state will sync with everyone in this room
326
341
  *
@@ -464,23 +479,41 @@ var Sync = class {
464
479
  });
465
480
  }
466
481
  handleMessage(data) {
482
+ if (data.serverTime) {
483
+ this.serverTimeOffset = Date.now() - data.serverTime;
484
+ }
467
485
  switch (data.type) {
486
+ case "welcome":
487
+ if (data.state) {
488
+ this.applyFullState(data.state);
489
+ }
490
+ this._playerCount = data.playerCount || 1;
491
+ this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
492
+ break;
468
493
  case "full_state":
469
494
  this.applyFullState(data.state);
470
495
  break;
471
496
  case "state":
472
- this.applyPlayerState(data.playerId, data.data);
497
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
473
498
  break;
474
499
  case "join":
500
+ this._playerCount = data.playerCount || this._playerCount + 1;
475
501
  this.emit("join", data.playerId);
476
502
  break;
477
503
  case "leave":
504
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
478
505
  this.removePlayer(data.playerId);
479
506
  this.emit("leave", data.playerId);
480
507
  break;
481
508
  case "message":
482
509
  this.emit("message", data.from, data.data);
483
510
  break;
511
+ case "pong":
512
+ if (this.pingStartTime) {
513
+ this._latency = Date.now() - this.pingStartTime;
514
+ this._playerCount = data.playerCount || this._playerCount;
515
+ }
516
+ break;
484
517
  }
485
518
  }
486
519
  applyFullState(fullState) {
@@ -490,27 +523,29 @@ var Sync = class {
490
523
  }
491
524
  }
492
525
  }
493
- applyPlayerState(playerId, playerState) {
526
+ applyPlayerState(playerId, playerState, serverTime) {
527
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
494
528
  if (this.options.interpolate && this.options.jitterBuffer > 0) {
495
529
  this.jitterQueue.push({
496
- deliverAt: Date.now() + this.options.jitterBuffer,
530
+ deliverAt: timestamp + this.options.jitterBuffer,
497
531
  playerId,
498
- state: { ...playerState }
532
+ state: { ...playerState },
533
+ timestamp
499
534
  });
500
535
  } else if (this.options.interpolate) {
501
- this.addSnapshot(playerId, playerState);
536
+ this.addSnapshot(playerId, playerState, timestamp);
502
537
  } else {
503
538
  this.applyStateDirect(playerId, playerState);
504
539
  }
505
540
  }
506
- addSnapshot(playerId, playerState) {
541
+ addSnapshot(playerId, playerState, timestamp) {
507
542
  const isNewPlayer = !this.snapshots.has(playerId);
508
543
  if (isNewPlayer) {
509
544
  this.snapshots.set(playerId, []);
510
545
  }
511
546
  const playerSnapshots = this.snapshots.get(playerId);
512
547
  playerSnapshots.push({
513
- time: Date.now(),
548
+ time: timestamp || Date.now(),
514
549
  state: { ...playerState }
515
550
  });
516
551
  while (playerSnapshots.length > 10) {
@@ -598,19 +633,32 @@ var Sync = class {
598
633
  this.processJitterQueue();
599
634
  this.updateInterpolation();
600
635
  }, 16);
636
+ this.pingInterval = setInterval(() => {
637
+ this.measureLatency();
638
+ }, 2e3);
639
+ }
640
+ measureLatency() {
641
+ if (this.ws?.readyState === WebSocket.OPEN) {
642
+ this.pingStartTime = Date.now();
643
+ this.ws.send(JSON.stringify({ type: "ping" }));
644
+ }
601
645
  }
602
646
  stopInterpolationLoop() {
603
647
  if (this.interpolationInterval) {
604
648
  clearInterval(this.interpolationInterval);
605
649
  this.interpolationInterval = null;
606
650
  }
651
+ if (this.pingInterval) {
652
+ clearInterval(this.pingInterval);
653
+ this.pingInterval = null;
654
+ }
607
655
  }
608
656
  processJitterQueue() {
609
657
  const now = Date.now();
610
658
  const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
611
659
  this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
612
660
  for (const item of ready) {
613
- this.addSnapshot(item.playerId, item.state);
661
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
614
662
  }
615
663
  }
616
664
  stopSyncLoop() {
package/dist/index.mjs CHANGED
@@ -273,6 +273,13 @@ var Sync = class {
273
273
  this.reconnectTimeout = null;
274
274
  this.lastJoinOptions = void 0;
275
275
  this.isReconnecting = false;
276
+ // Server time sync and metrics
277
+ this.serverTimeOffset = 0;
278
+ // Local time - server time
279
+ this._playerCount = 1;
280
+ this._latency = 0;
281
+ this.pingStartTime = 0;
282
+ this.pingInterval = null;
276
283
  this.state = state;
277
284
  this.myId = config.playerId;
278
285
  this.config = config;
@@ -294,6 +301,14 @@ var Sync = class {
294
301
  get connected() {
295
302
  return this.ws?.readyState === WebSocket.OPEN;
296
303
  }
304
+ /** Number of players in the current room */
305
+ get playerCount() {
306
+ return this._playerCount;
307
+ }
308
+ /** Current latency to server in milliseconds */
309
+ get latency() {
310
+ return this._latency;
311
+ }
297
312
  /**
298
313
  * Join a room - your state will sync with everyone in this room
299
314
  *
@@ -437,23 +452,41 @@ var Sync = class {
437
452
  });
438
453
  }
439
454
  handleMessage(data) {
455
+ if (data.serverTime) {
456
+ this.serverTimeOffset = Date.now() - data.serverTime;
457
+ }
440
458
  switch (data.type) {
459
+ case "welcome":
460
+ if (data.state) {
461
+ this.applyFullState(data.state);
462
+ }
463
+ this._playerCount = data.playerCount || 1;
464
+ this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
465
+ break;
441
466
  case "full_state":
442
467
  this.applyFullState(data.state);
443
468
  break;
444
469
  case "state":
445
- this.applyPlayerState(data.playerId, data.data);
470
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
446
471
  break;
447
472
  case "join":
473
+ this._playerCount = data.playerCount || this._playerCount + 1;
448
474
  this.emit("join", data.playerId);
449
475
  break;
450
476
  case "leave":
477
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
451
478
  this.removePlayer(data.playerId);
452
479
  this.emit("leave", data.playerId);
453
480
  break;
454
481
  case "message":
455
482
  this.emit("message", data.from, data.data);
456
483
  break;
484
+ case "pong":
485
+ if (this.pingStartTime) {
486
+ this._latency = Date.now() - this.pingStartTime;
487
+ this._playerCount = data.playerCount || this._playerCount;
488
+ }
489
+ break;
457
490
  }
458
491
  }
459
492
  applyFullState(fullState) {
@@ -463,27 +496,29 @@ var Sync = class {
463
496
  }
464
497
  }
465
498
  }
466
- applyPlayerState(playerId, playerState) {
499
+ applyPlayerState(playerId, playerState, serverTime) {
500
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
467
501
  if (this.options.interpolate && this.options.jitterBuffer > 0) {
468
502
  this.jitterQueue.push({
469
- deliverAt: Date.now() + this.options.jitterBuffer,
503
+ deliverAt: timestamp + this.options.jitterBuffer,
470
504
  playerId,
471
- state: { ...playerState }
505
+ state: { ...playerState },
506
+ timestamp
472
507
  });
473
508
  } else if (this.options.interpolate) {
474
- this.addSnapshot(playerId, playerState);
509
+ this.addSnapshot(playerId, playerState, timestamp);
475
510
  } else {
476
511
  this.applyStateDirect(playerId, playerState);
477
512
  }
478
513
  }
479
- addSnapshot(playerId, playerState) {
514
+ addSnapshot(playerId, playerState, timestamp) {
480
515
  const isNewPlayer = !this.snapshots.has(playerId);
481
516
  if (isNewPlayer) {
482
517
  this.snapshots.set(playerId, []);
483
518
  }
484
519
  const playerSnapshots = this.snapshots.get(playerId);
485
520
  playerSnapshots.push({
486
- time: Date.now(),
521
+ time: timestamp || Date.now(),
487
522
  state: { ...playerState }
488
523
  });
489
524
  while (playerSnapshots.length > 10) {
@@ -571,19 +606,32 @@ var Sync = class {
571
606
  this.processJitterQueue();
572
607
  this.updateInterpolation();
573
608
  }, 16);
609
+ this.pingInterval = setInterval(() => {
610
+ this.measureLatency();
611
+ }, 2e3);
612
+ }
613
+ measureLatency() {
614
+ if (this.ws?.readyState === WebSocket.OPEN) {
615
+ this.pingStartTime = Date.now();
616
+ this.ws.send(JSON.stringify({ type: "ping" }));
617
+ }
574
618
  }
575
619
  stopInterpolationLoop() {
576
620
  if (this.interpolationInterval) {
577
621
  clearInterval(this.interpolationInterval);
578
622
  this.interpolationInterval = null;
579
623
  }
624
+ if (this.pingInterval) {
625
+ clearInterval(this.pingInterval);
626
+ this.pingInterval = null;
627
+ }
580
628
  }
581
629
  processJitterQueue() {
582
630
  const now = Date.now();
583
631
  const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
584
632
  this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
585
633
  for (const item of ready) {
586
- this.addSnapshot(item.playerId, item.state);
634
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
587
635
  }
588
636
  }
589
637
  stopSyncLoop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchtower-sdk/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Simple game backend SDK - saves, multiplayer rooms, and more",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",