@watchtower-sdk/core 0.2.2 → 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
@@ -211,6 +211,14 @@ interface SyncOptions {
211
211
  tickRate?: number;
212
212
  /** Enable interpolation for remote entities (default: true) */
213
213
  interpolate?: boolean;
214
+ /** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
215
+ interpolationDelay?: number;
216
+ /** Jitter buffer size in ms - smooths network variance (default: 50) */
217
+ jitterBuffer?: number;
218
+ /** Enable auto-reconnection on disconnect (default: true) */
219
+ autoReconnect?: boolean;
220
+ /** Max reconnection attempts (default: 10) */
221
+ maxReconnectAttempts?: number;
214
222
  }
215
223
  interface JoinOptions {
216
224
  /** Create room if it doesn't exist */
@@ -263,14 +271,29 @@ declare class Sync<T extends Record<string, unknown>> {
263
271
  get roomId(): string | null;
264
272
  /** Whether currently connected to a room */
265
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;
266
278
  private config;
267
279
  private options;
268
280
  private _roomId;
269
281
  private ws;
270
282
  private syncInterval;
283
+ private interpolationInterval;
271
284
  private lastSentState;
272
- private interpolationTargets;
273
285
  private listeners;
286
+ private snapshots;
287
+ private jitterQueue;
288
+ private reconnectAttempts;
289
+ private reconnectTimeout;
290
+ private lastJoinOptions;
291
+ private isReconnecting;
292
+ private serverTimeOffset;
293
+ private _playerCount;
294
+ private _latency;
295
+ private pingStartTime;
296
+ private pingInterval;
274
297
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
275
298
  /**
276
299
  * Join a room - your state will sync with everyone in this room
@@ -303,7 +326,7 @@ declare class Sync<T extends Record<string, unknown>> {
303
326
  /**
304
327
  * Subscribe to sync events
305
328
  */
306
- on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'message', callback: Function): void;
329
+ on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
307
330
  /**
308
331
  * Unsubscribe from sync events
309
332
  */
@@ -313,13 +336,21 @@ declare class Sync<T extends Record<string, unknown>> {
313
336
  private handleMessage;
314
337
  private applyFullState;
315
338
  private applyPlayerState;
339
+ private addSnapshot;
340
+ private applyStateDirect;
316
341
  private removePlayer;
342
+ private attemptReconnect;
317
343
  private clearRemotePlayers;
318
344
  private findPlayersKey;
319
345
  private startSyncLoop;
346
+ private startInterpolationLoop;
347
+ private measureLatency;
348
+ private stopInterpolationLoop;
349
+ private processJitterQueue;
320
350
  private stopSyncLoop;
321
351
  private syncMyState;
322
352
  private updateInterpolation;
353
+ private lerpState;
323
354
  private generateRoomCode;
324
355
  private getHeaders;
325
356
  }
package/dist/index.d.ts CHANGED
@@ -211,6 +211,14 @@ interface SyncOptions {
211
211
  tickRate?: number;
212
212
  /** Enable interpolation for remote entities (default: true) */
213
213
  interpolate?: boolean;
214
+ /** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
215
+ interpolationDelay?: number;
216
+ /** Jitter buffer size in ms - smooths network variance (default: 50) */
217
+ jitterBuffer?: number;
218
+ /** Enable auto-reconnection on disconnect (default: true) */
219
+ autoReconnect?: boolean;
220
+ /** Max reconnection attempts (default: 10) */
221
+ maxReconnectAttempts?: number;
214
222
  }
215
223
  interface JoinOptions {
216
224
  /** Create room if it doesn't exist */
@@ -263,14 +271,29 @@ declare class Sync<T extends Record<string, unknown>> {
263
271
  get roomId(): string | null;
264
272
  /** Whether currently connected to a room */
265
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;
266
278
  private config;
267
279
  private options;
268
280
  private _roomId;
269
281
  private ws;
270
282
  private syncInterval;
283
+ private interpolationInterval;
271
284
  private lastSentState;
272
- private interpolationTargets;
273
285
  private listeners;
286
+ private snapshots;
287
+ private jitterQueue;
288
+ private reconnectAttempts;
289
+ private reconnectTimeout;
290
+ private lastJoinOptions;
291
+ private isReconnecting;
292
+ private serverTimeOffset;
293
+ private _playerCount;
294
+ private _latency;
295
+ private pingStartTime;
296
+ private pingInterval;
274
297
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
275
298
  /**
276
299
  * Join a room - your state will sync with everyone in this room
@@ -303,7 +326,7 @@ declare class Sync<T extends Record<string, unknown>> {
303
326
  /**
304
327
  * Subscribe to sync events
305
328
  */
306
- on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'message', callback: Function): void;
329
+ on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
307
330
  /**
308
331
  * Unsubscribe from sync events
309
332
  */
@@ -313,13 +336,21 @@ declare class Sync<T extends Record<string, unknown>> {
313
336
  private handleMessage;
314
337
  private applyFullState;
315
338
  private applyPlayerState;
339
+ private addSnapshot;
340
+ private applyStateDirect;
316
341
  private removePlayer;
342
+ private attemptReconnect;
317
343
  private clearRemotePlayers;
318
344
  private findPlayersKey;
319
345
  private startSyncLoop;
346
+ private startInterpolationLoop;
347
+ private measureLatency;
348
+ private stopInterpolationLoop;
349
+ private processJitterQueue;
320
350
  private stopSyncLoop;
321
351
  private syncMyState;
322
352
  private updateInterpolation;
353
+ private lerpState;
323
354
  private generateRoomCode;
324
355
  private getHeaders;
325
356
  }
package/dist/index.js CHANGED
@@ -288,15 +288,36 @@ var Sync = class {
288
288
  this._roomId = null;
289
289
  this.ws = null;
290
290
  this.syncInterval = null;
291
+ this.interpolationInterval = null;
291
292
  this.lastSentState = "";
292
- this.interpolationTargets = /* @__PURE__ */ new Map();
293
293
  this.listeners = /* @__PURE__ */ new Map();
294
+ // Snapshot-based interpolation: store timestamped snapshots per player
295
+ this.snapshots = /* @__PURE__ */ new Map();
296
+ // Jitter buffer: queue incoming updates before applying
297
+ this.jitterQueue = [];
298
+ // Auto-reconnect state
299
+ this.reconnectAttempts = 0;
300
+ this.reconnectTimeout = null;
301
+ this.lastJoinOptions = void 0;
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;
294
310
  this.state = state;
295
311
  this.myId = config.playerId;
296
312
  this.config = config;
297
313
  this.options = {
298
314
  tickRate: options?.tickRate ?? 20,
299
- interpolate: options?.interpolate ?? true
315
+ interpolate: options?.interpolate ?? true,
316
+ interpolationDelay: options?.interpolationDelay ?? 100,
317
+ jitterBuffer: options?.jitterBuffer ?? 0,
318
+ // 0 = immediate, set to 50+ for smoothing
319
+ autoReconnect: options?.autoReconnect ?? true,
320
+ maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
300
321
  };
301
322
  }
302
323
  /** Current room ID (null if not in a room) */
@@ -307,6 +328,14 @@ var Sync = class {
307
328
  get connected() {
308
329
  return this.ws?.readyState === WebSocket.OPEN;
309
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
+ }
310
339
  /**
311
340
  * Join a room - your state will sync with everyone in this room
312
341
  *
@@ -314,23 +343,34 @@ var Sync = class {
314
343
  * @param options - Join options
315
344
  */
316
345
  async join(roomId, options) {
317
- if (this._roomId) {
346
+ if (this._roomId && !this.isReconnecting) {
318
347
  await this.leave();
319
348
  }
320
349
  this._roomId = roomId;
350
+ this.lastJoinOptions = options;
351
+ this.reconnectAttempts = 0;
321
352
  await this.connectWebSocket(roomId, options);
322
353
  this.startSyncLoop();
354
+ this.startInterpolationLoop();
323
355
  }
324
356
  /**
325
357
  * Leave the current room
326
358
  */
327
359
  async leave() {
360
+ if (this.reconnectTimeout) {
361
+ clearTimeout(this.reconnectTimeout);
362
+ this.reconnectTimeout = null;
363
+ }
364
+ this.isReconnecting = false;
328
365
  this.stopSyncLoop();
366
+ this.stopInterpolationLoop();
329
367
  if (this.ws) {
330
368
  this.ws.close();
331
369
  this.ws = null;
332
370
  }
333
371
  this.clearRemotePlayers();
372
+ this.snapshots.clear();
373
+ this.jitterQueue = [];
334
374
  this._roomId = null;
335
375
  }
336
376
  /**
@@ -424,6 +464,9 @@ var Sync = class {
424
464
  this.ws.onclose = () => {
425
465
  this.stopSyncLoop();
426
466
  this.emit("disconnected");
467
+ if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
468
+ this.attemptReconnect();
469
+ }
427
470
  };
428
471
  this.ws.onmessage = (event) => {
429
472
  try {
@@ -436,23 +479,41 @@ var Sync = class {
436
479
  });
437
480
  }
438
481
  handleMessage(data) {
482
+ if (data.serverTime) {
483
+ this.serverTimeOffset = Date.now() - data.serverTime;
484
+ }
439
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;
440
493
  case "full_state":
441
494
  this.applyFullState(data.state);
442
495
  break;
443
496
  case "state":
444
- this.applyPlayerState(data.playerId, data.data);
497
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
445
498
  break;
446
499
  case "join":
500
+ this._playerCount = data.playerCount || this._playerCount + 1;
447
501
  this.emit("join", data.playerId);
448
502
  break;
449
503
  case "leave":
504
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
450
505
  this.removePlayer(data.playerId);
451
506
  this.emit("leave", data.playerId);
452
507
  break;
453
508
  case "message":
454
509
  this.emit("message", data.from, data.data);
455
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;
456
517
  }
457
518
  }
458
519
  applyFullState(fullState) {
@@ -462,16 +523,46 @@ var Sync = class {
462
523
  }
463
524
  }
464
525
  }
465
- applyPlayerState(playerId, playerState) {
526
+ applyPlayerState(playerId, playerState, serverTime) {
527
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
528
+ if (this.options.interpolate && this.options.jitterBuffer > 0) {
529
+ this.jitterQueue.push({
530
+ deliverAt: timestamp + this.options.jitterBuffer,
531
+ playerId,
532
+ state: { ...playerState },
533
+ timestamp
534
+ });
535
+ } else if (this.options.interpolate) {
536
+ this.addSnapshot(playerId, playerState, timestamp);
537
+ } else {
538
+ this.applyStateDirect(playerId, playerState);
539
+ }
540
+ }
541
+ addSnapshot(playerId, playerState, timestamp) {
542
+ const isNewPlayer = !this.snapshots.has(playerId);
543
+ if (isNewPlayer) {
544
+ this.snapshots.set(playerId, []);
545
+ }
546
+ const playerSnapshots = this.snapshots.get(playerId);
547
+ playerSnapshots.push({
548
+ time: timestamp || Date.now(),
549
+ state: { ...playerState }
550
+ });
551
+ while (playerSnapshots.length > 10) {
552
+ playerSnapshots.shift();
553
+ }
554
+ const playersKey = this.findPlayersKey();
555
+ if (playersKey) {
556
+ const players = this.state[playersKey];
557
+ if (isNewPlayer || !players[playerId]) {
558
+ players[playerId] = { ...playerState };
559
+ }
560
+ }
561
+ }
562
+ applyStateDirect(playerId, playerState) {
466
563
  const playersKey = this.findPlayersKey();
467
564
  if (!playersKey) return;
468
565
  const players = this.state[playersKey];
469
- if (this.options.interpolate && players[playerId]) {
470
- this.interpolationTargets.set(playerId, {
471
- target: { ...playerState },
472
- current: { ...players[playerId] }
473
- });
474
- }
475
566
  players[playerId] = playerState;
476
567
  }
477
568
  removePlayer(playerId) {
@@ -479,7 +570,28 @@ var Sync = class {
479
570
  if (!playersKey) return;
480
571
  const players = this.state[playersKey];
481
572
  delete players[playerId];
482
- this.interpolationTargets.delete(playerId);
573
+ this.snapshots.delete(playerId);
574
+ }
575
+ attemptReconnect() {
576
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
577
+ this.emit("error", new Error("Max reconnection attempts reached"));
578
+ return;
579
+ }
580
+ this.isReconnecting = true;
581
+ this.reconnectAttempts++;
582
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
583
+ this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
584
+ this.reconnectTimeout = setTimeout(async () => {
585
+ try {
586
+ await this.connectWebSocket(this._roomId, this.lastJoinOptions);
587
+ this.startSyncLoop();
588
+ this.isReconnecting = false;
589
+ this.reconnectAttempts = 0;
590
+ this.emit("reconnected");
591
+ } catch (e) {
592
+ this.isReconnecting = false;
593
+ }
594
+ }, delay);
483
595
  }
484
596
  clearRemotePlayers() {
485
597
  const playersKey = this.findPlayersKey();
@@ -490,7 +602,8 @@ var Sync = class {
490
602
  delete players[playerId];
491
603
  }
492
604
  }
493
- this.interpolationTargets.clear();
605
+ this.snapshots.clear();
606
+ this.jitterQueue = [];
494
607
  }
495
608
  findPlayersKey() {
496
609
  const candidates = ["players", "entities", "gnomes", "users", "clients"];
@@ -511,11 +624,43 @@ var Sync = class {
511
624
  const intervalMs = 1e3 / this.options.tickRate;
512
625
  this.syncInterval = setInterval(() => {
513
626
  this.syncMyState();
514
- if (this.options.interpolate) {
515
- this.updateInterpolation();
516
- }
517
627
  }, intervalMs);
518
628
  }
629
+ startInterpolationLoop() {
630
+ if (this.interpolationInterval) return;
631
+ if (!this.options.interpolate) return;
632
+ this.interpolationInterval = setInterval(() => {
633
+ this.processJitterQueue();
634
+ this.updateInterpolation();
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
+ }
645
+ }
646
+ stopInterpolationLoop() {
647
+ if (this.interpolationInterval) {
648
+ clearInterval(this.interpolationInterval);
649
+ this.interpolationInterval = null;
650
+ }
651
+ if (this.pingInterval) {
652
+ clearInterval(this.pingInterval);
653
+ this.pingInterval = null;
654
+ }
655
+ }
656
+ processJitterQueue() {
657
+ const now = Date.now();
658
+ const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
659
+ this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
660
+ for (const item of ready) {
661
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
662
+ }
663
+ }
519
664
  stopSyncLoop() {
520
665
  if (this.syncInterval) {
521
666
  clearInterval(this.syncInterval);
@@ -541,16 +686,41 @@ var Sync = class {
541
686
  const playersKey = this.findPlayersKey();
542
687
  if (!playersKey) return;
543
688
  const players = this.state[playersKey];
544
- const lerpFactor = 0.2;
545
- for (const [playerId, interp] of this.interpolationTargets) {
689
+ const renderTime = Date.now() - this.options.interpolationDelay;
690
+ for (const [playerId, playerSnapshots] of this.snapshots) {
691
+ if (playerId === this.myId) continue;
546
692
  const player = players[playerId];
547
693
  if (!player) continue;
548
- for (const [key, targetValue] of Object.entries(interp.target)) {
549
- if (typeof targetValue === "number" && typeof interp.current[key] === "number") {
550
- const current = interp.current[key];
551
- const newValue = current + (targetValue - current) * lerpFactor;
552
- interp.current[key] = newValue;
553
- player[key] = newValue;
694
+ let before = null;
695
+ let after = null;
696
+ for (const snapshot of playerSnapshots) {
697
+ if (snapshot.time <= renderTime) {
698
+ before = snapshot;
699
+ } else if (!after) {
700
+ after = snapshot;
701
+ }
702
+ }
703
+ if (before && after) {
704
+ const total = after.time - before.time;
705
+ const elapsed = renderTime - before.time;
706
+ const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
707
+ this.lerpState(player, before.state, after.state, alpha);
708
+ } else if (before) {
709
+ this.lerpState(player, player, before.state, 0.3);
710
+ } else if (after) {
711
+ this.lerpState(player, player, after.state, 0.3);
712
+ }
713
+ }
714
+ }
715
+ lerpState(target, from, to, alpha) {
716
+ for (const key of Object.keys(to)) {
717
+ const fromVal = from[key];
718
+ const toVal = to[key];
719
+ if (typeof fromVal === "number" && typeof toVal === "number") {
720
+ target[key] = fromVal + (toVal - fromVal) * alpha;
721
+ } else {
722
+ if (alpha > 0.5) {
723
+ target[key] = toVal;
554
724
  }
555
725
  }
556
726
  }
package/dist/index.mjs CHANGED
@@ -261,15 +261,36 @@ var Sync = class {
261
261
  this._roomId = null;
262
262
  this.ws = null;
263
263
  this.syncInterval = null;
264
+ this.interpolationInterval = null;
264
265
  this.lastSentState = "";
265
- this.interpolationTargets = /* @__PURE__ */ new Map();
266
266
  this.listeners = /* @__PURE__ */ new Map();
267
+ // Snapshot-based interpolation: store timestamped snapshots per player
268
+ this.snapshots = /* @__PURE__ */ new Map();
269
+ // Jitter buffer: queue incoming updates before applying
270
+ this.jitterQueue = [];
271
+ // Auto-reconnect state
272
+ this.reconnectAttempts = 0;
273
+ this.reconnectTimeout = null;
274
+ this.lastJoinOptions = void 0;
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;
267
283
  this.state = state;
268
284
  this.myId = config.playerId;
269
285
  this.config = config;
270
286
  this.options = {
271
287
  tickRate: options?.tickRate ?? 20,
272
- interpolate: options?.interpolate ?? true
288
+ interpolate: options?.interpolate ?? true,
289
+ interpolationDelay: options?.interpolationDelay ?? 100,
290
+ jitterBuffer: options?.jitterBuffer ?? 0,
291
+ // 0 = immediate, set to 50+ for smoothing
292
+ autoReconnect: options?.autoReconnect ?? true,
293
+ maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
273
294
  };
274
295
  }
275
296
  /** Current room ID (null if not in a room) */
@@ -280,6 +301,14 @@ var Sync = class {
280
301
  get connected() {
281
302
  return this.ws?.readyState === WebSocket.OPEN;
282
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
+ }
283
312
  /**
284
313
  * Join a room - your state will sync with everyone in this room
285
314
  *
@@ -287,23 +316,34 @@ var Sync = class {
287
316
  * @param options - Join options
288
317
  */
289
318
  async join(roomId, options) {
290
- if (this._roomId) {
319
+ if (this._roomId && !this.isReconnecting) {
291
320
  await this.leave();
292
321
  }
293
322
  this._roomId = roomId;
323
+ this.lastJoinOptions = options;
324
+ this.reconnectAttempts = 0;
294
325
  await this.connectWebSocket(roomId, options);
295
326
  this.startSyncLoop();
327
+ this.startInterpolationLoop();
296
328
  }
297
329
  /**
298
330
  * Leave the current room
299
331
  */
300
332
  async leave() {
333
+ if (this.reconnectTimeout) {
334
+ clearTimeout(this.reconnectTimeout);
335
+ this.reconnectTimeout = null;
336
+ }
337
+ this.isReconnecting = false;
301
338
  this.stopSyncLoop();
339
+ this.stopInterpolationLoop();
302
340
  if (this.ws) {
303
341
  this.ws.close();
304
342
  this.ws = null;
305
343
  }
306
344
  this.clearRemotePlayers();
345
+ this.snapshots.clear();
346
+ this.jitterQueue = [];
307
347
  this._roomId = null;
308
348
  }
309
349
  /**
@@ -397,6 +437,9 @@ var Sync = class {
397
437
  this.ws.onclose = () => {
398
438
  this.stopSyncLoop();
399
439
  this.emit("disconnected");
440
+ if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
441
+ this.attemptReconnect();
442
+ }
400
443
  };
401
444
  this.ws.onmessage = (event) => {
402
445
  try {
@@ -409,23 +452,41 @@ var Sync = class {
409
452
  });
410
453
  }
411
454
  handleMessage(data) {
455
+ if (data.serverTime) {
456
+ this.serverTimeOffset = Date.now() - data.serverTime;
457
+ }
412
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;
413
466
  case "full_state":
414
467
  this.applyFullState(data.state);
415
468
  break;
416
469
  case "state":
417
- this.applyPlayerState(data.playerId, data.data);
470
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
418
471
  break;
419
472
  case "join":
473
+ this._playerCount = data.playerCount || this._playerCount + 1;
420
474
  this.emit("join", data.playerId);
421
475
  break;
422
476
  case "leave":
477
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
423
478
  this.removePlayer(data.playerId);
424
479
  this.emit("leave", data.playerId);
425
480
  break;
426
481
  case "message":
427
482
  this.emit("message", data.from, data.data);
428
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;
429
490
  }
430
491
  }
431
492
  applyFullState(fullState) {
@@ -435,16 +496,46 @@ var Sync = class {
435
496
  }
436
497
  }
437
498
  }
438
- applyPlayerState(playerId, playerState) {
499
+ applyPlayerState(playerId, playerState, serverTime) {
500
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
501
+ if (this.options.interpolate && this.options.jitterBuffer > 0) {
502
+ this.jitterQueue.push({
503
+ deliverAt: timestamp + this.options.jitterBuffer,
504
+ playerId,
505
+ state: { ...playerState },
506
+ timestamp
507
+ });
508
+ } else if (this.options.interpolate) {
509
+ this.addSnapshot(playerId, playerState, timestamp);
510
+ } else {
511
+ this.applyStateDirect(playerId, playerState);
512
+ }
513
+ }
514
+ addSnapshot(playerId, playerState, timestamp) {
515
+ const isNewPlayer = !this.snapshots.has(playerId);
516
+ if (isNewPlayer) {
517
+ this.snapshots.set(playerId, []);
518
+ }
519
+ const playerSnapshots = this.snapshots.get(playerId);
520
+ playerSnapshots.push({
521
+ time: timestamp || Date.now(),
522
+ state: { ...playerState }
523
+ });
524
+ while (playerSnapshots.length > 10) {
525
+ playerSnapshots.shift();
526
+ }
527
+ const playersKey = this.findPlayersKey();
528
+ if (playersKey) {
529
+ const players = this.state[playersKey];
530
+ if (isNewPlayer || !players[playerId]) {
531
+ players[playerId] = { ...playerState };
532
+ }
533
+ }
534
+ }
535
+ applyStateDirect(playerId, playerState) {
439
536
  const playersKey = this.findPlayersKey();
440
537
  if (!playersKey) return;
441
538
  const players = this.state[playersKey];
442
- if (this.options.interpolate && players[playerId]) {
443
- this.interpolationTargets.set(playerId, {
444
- target: { ...playerState },
445
- current: { ...players[playerId] }
446
- });
447
- }
448
539
  players[playerId] = playerState;
449
540
  }
450
541
  removePlayer(playerId) {
@@ -452,7 +543,28 @@ var Sync = class {
452
543
  if (!playersKey) return;
453
544
  const players = this.state[playersKey];
454
545
  delete players[playerId];
455
- this.interpolationTargets.delete(playerId);
546
+ this.snapshots.delete(playerId);
547
+ }
548
+ attemptReconnect() {
549
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
550
+ this.emit("error", new Error("Max reconnection attempts reached"));
551
+ return;
552
+ }
553
+ this.isReconnecting = true;
554
+ this.reconnectAttempts++;
555
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
556
+ this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
557
+ this.reconnectTimeout = setTimeout(async () => {
558
+ try {
559
+ await this.connectWebSocket(this._roomId, this.lastJoinOptions);
560
+ this.startSyncLoop();
561
+ this.isReconnecting = false;
562
+ this.reconnectAttempts = 0;
563
+ this.emit("reconnected");
564
+ } catch (e) {
565
+ this.isReconnecting = false;
566
+ }
567
+ }, delay);
456
568
  }
457
569
  clearRemotePlayers() {
458
570
  const playersKey = this.findPlayersKey();
@@ -463,7 +575,8 @@ var Sync = class {
463
575
  delete players[playerId];
464
576
  }
465
577
  }
466
- this.interpolationTargets.clear();
578
+ this.snapshots.clear();
579
+ this.jitterQueue = [];
467
580
  }
468
581
  findPlayersKey() {
469
582
  const candidates = ["players", "entities", "gnomes", "users", "clients"];
@@ -484,11 +597,43 @@ var Sync = class {
484
597
  const intervalMs = 1e3 / this.options.tickRate;
485
598
  this.syncInterval = setInterval(() => {
486
599
  this.syncMyState();
487
- if (this.options.interpolate) {
488
- this.updateInterpolation();
489
- }
490
600
  }, intervalMs);
491
601
  }
602
+ startInterpolationLoop() {
603
+ if (this.interpolationInterval) return;
604
+ if (!this.options.interpolate) return;
605
+ this.interpolationInterval = setInterval(() => {
606
+ this.processJitterQueue();
607
+ this.updateInterpolation();
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
+ }
618
+ }
619
+ stopInterpolationLoop() {
620
+ if (this.interpolationInterval) {
621
+ clearInterval(this.interpolationInterval);
622
+ this.interpolationInterval = null;
623
+ }
624
+ if (this.pingInterval) {
625
+ clearInterval(this.pingInterval);
626
+ this.pingInterval = null;
627
+ }
628
+ }
629
+ processJitterQueue() {
630
+ const now = Date.now();
631
+ const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
632
+ this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
633
+ for (const item of ready) {
634
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
635
+ }
636
+ }
492
637
  stopSyncLoop() {
493
638
  if (this.syncInterval) {
494
639
  clearInterval(this.syncInterval);
@@ -514,16 +659,41 @@ var Sync = class {
514
659
  const playersKey = this.findPlayersKey();
515
660
  if (!playersKey) return;
516
661
  const players = this.state[playersKey];
517
- const lerpFactor = 0.2;
518
- for (const [playerId, interp] of this.interpolationTargets) {
662
+ const renderTime = Date.now() - this.options.interpolationDelay;
663
+ for (const [playerId, playerSnapshots] of this.snapshots) {
664
+ if (playerId === this.myId) continue;
519
665
  const player = players[playerId];
520
666
  if (!player) continue;
521
- for (const [key, targetValue] of Object.entries(interp.target)) {
522
- if (typeof targetValue === "number" && typeof interp.current[key] === "number") {
523
- const current = interp.current[key];
524
- const newValue = current + (targetValue - current) * lerpFactor;
525
- interp.current[key] = newValue;
526
- player[key] = newValue;
667
+ let before = null;
668
+ let after = null;
669
+ for (const snapshot of playerSnapshots) {
670
+ if (snapshot.time <= renderTime) {
671
+ before = snapshot;
672
+ } else if (!after) {
673
+ after = snapshot;
674
+ }
675
+ }
676
+ if (before && after) {
677
+ const total = after.time - before.time;
678
+ const elapsed = renderTime - before.time;
679
+ const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
680
+ this.lerpState(player, before.state, after.state, alpha);
681
+ } else if (before) {
682
+ this.lerpState(player, player, before.state, 0.3);
683
+ } else if (after) {
684
+ this.lerpState(player, player, after.state, 0.3);
685
+ }
686
+ }
687
+ }
688
+ lerpState(target, from, to, alpha) {
689
+ for (const key of Object.keys(to)) {
690
+ const fromVal = from[key];
691
+ const toVal = to[key];
692
+ if (typeof fromVal === "number" && typeof toVal === "number") {
693
+ target[key] = fromVal + (toVal - fromVal) * alpha;
694
+ } else {
695
+ if (alpha > 0.5) {
696
+ target[key] = toVal;
527
697
  }
528
698
  }
529
699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchtower-sdk/core",
3
- "version": "0.2.2",
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",