@watchtower-sdk/core 0.3.0 → 0.4.0

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
@@ -209,11 +209,20 @@ declare class Room {
209
209
  interface SyncOptions {
210
210
  /** Updates per second (default: 20) */
211
211
  tickRate?: number;
212
- /** Enable interpolation for remote entities (default: true) */
212
+ /**
213
+ * Smoothing mode for remote players (default: 'lerp')
214
+ * - 'lerp': Frame-based lerping toward latest position. Zero latency, simple, great for casual games.
215
+ * - 'interpolate': Time-based snapshot interpolation. Adds latency but more accurate for competitive games.
216
+ * - 'none': No smoothing, positions snap immediately.
217
+ */
218
+ smoothing?: 'lerp' | 'interpolate' | 'none';
219
+ /** Lerp factor - how fast to catch up to target (default: 0.15). Only used in 'lerp' mode. */
220
+ lerpFactor?: number;
221
+ /** @deprecated Use smoothing: 'interpolate' instead */
213
222
  interpolate?: boolean;
214
- /** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
223
+ /** Interpolation delay in ms - how far "in the past" to render others (default: 100). Only used in 'interpolate' mode. */
215
224
  interpolationDelay?: number;
216
- /** Jitter buffer size in ms - smooths network variance (default: 50) */
225
+ /** Jitter buffer size in ms - smooths network variance (default: 0). Only used in 'interpolate' mode. */
217
226
  jitterBuffer?: number;
218
227
  /** Enable auto-reconnection on disconnect (default: true) */
219
228
  autoReconnect?: boolean;
@@ -271,6 +280,10 @@ declare class Sync<T extends Record<string, unknown>> {
271
280
  get roomId(): string | null;
272
281
  /** Whether currently connected to a room */
273
282
  get connected(): boolean;
283
+ /** Number of players in the current room */
284
+ get playerCount(): number;
285
+ /** Current latency to server in milliseconds */
286
+ get latency(): number;
274
287
  private config;
275
288
  private options;
276
289
  private _roomId;
@@ -280,11 +293,17 @@ declare class Sync<T extends Record<string, unknown>> {
280
293
  private lastSentState;
281
294
  private listeners;
282
295
  private snapshots;
296
+ private lerpTargets;
283
297
  private jitterQueue;
284
298
  private reconnectAttempts;
285
299
  private reconnectTimeout;
286
300
  private lastJoinOptions;
287
301
  private isReconnecting;
302
+ private serverTimeOffset;
303
+ private _playerCount;
304
+ private _latency;
305
+ private pingStartTime;
306
+ private pingInterval;
288
307
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
289
308
  /**
290
309
  * Join a room - your state will sync with everyone in this room
@@ -327,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
327
346
  private handleMessage;
328
347
  private applyFullState;
329
348
  private applyPlayerState;
349
+ private setLerpTarget;
330
350
  private addSnapshot;
331
351
  private applyStateDirect;
332
352
  private removePlayer;
@@ -335,6 +355,13 @@ declare class Sync<T extends Record<string, unknown>> {
335
355
  private findPlayersKey;
336
356
  private startSyncLoop;
337
357
  private startInterpolationLoop;
358
+ /**
359
+ * Frame-based lerping (gnome-chat style)
360
+ * Lerps each remote player's position toward their target by lerpFactor each frame.
361
+ * Simple, zero latency, great for casual games.
362
+ */
363
+ private updateLerp;
364
+ private measureLatency;
338
365
  private stopInterpolationLoop;
339
366
  private processJitterQueue;
340
367
  private stopSyncLoop;
package/dist/index.d.ts CHANGED
@@ -209,11 +209,20 @@ declare class Room {
209
209
  interface SyncOptions {
210
210
  /** Updates per second (default: 20) */
211
211
  tickRate?: number;
212
- /** Enable interpolation for remote entities (default: true) */
212
+ /**
213
+ * Smoothing mode for remote players (default: 'lerp')
214
+ * - 'lerp': Frame-based lerping toward latest position. Zero latency, simple, great for casual games.
215
+ * - 'interpolate': Time-based snapshot interpolation. Adds latency but more accurate for competitive games.
216
+ * - 'none': No smoothing, positions snap immediately.
217
+ */
218
+ smoothing?: 'lerp' | 'interpolate' | 'none';
219
+ /** Lerp factor - how fast to catch up to target (default: 0.15). Only used in 'lerp' mode. */
220
+ lerpFactor?: number;
221
+ /** @deprecated Use smoothing: 'interpolate' instead */
213
222
  interpolate?: boolean;
214
- /** Interpolation delay in ms - how far "in the past" to render others (default: 100) */
223
+ /** Interpolation delay in ms - how far "in the past" to render others (default: 100). Only used in 'interpolate' mode. */
215
224
  interpolationDelay?: number;
216
- /** Jitter buffer size in ms - smooths network variance (default: 50) */
225
+ /** Jitter buffer size in ms - smooths network variance (default: 0). Only used in 'interpolate' mode. */
217
226
  jitterBuffer?: number;
218
227
  /** Enable auto-reconnection on disconnect (default: true) */
219
228
  autoReconnect?: boolean;
@@ -271,6 +280,10 @@ declare class Sync<T extends Record<string, unknown>> {
271
280
  get roomId(): string | null;
272
281
  /** Whether currently connected to a room */
273
282
  get connected(): boolean;
283
+ /** Number of players in the current room */
284
+ get playerCount(): number;
285
+ /** Current latency to server in milliseconds */
286
+ get latency(): number;
274
287
  private config;
275
288
  private options;
276
289
  private _roomId;
@@ -280,11 +293,17 @@ declare class Sync<T extends Record<string, unknown>> {
280
293
  private lastSentState;
281
294
  private listeners;
282
295
  private snapshots;
296
+ private lerpTargets;
283
297
  private jitterQueue;
284
298
  private reconnectAttempts;
285
299
  private reconnectTimeout;
286
300
  private lastJoinOptions;
287
301
  private isReconnecting;
302
+ private serverTimeOffset;
303
+ private _playerCount;
304
+ private _latency;
305
+ private pingStartTime;
306
+ private pingInterval;
288
307
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
289
308
  /**
290
309
  * Join a room - your state will sync with everyone in this room
@@ -327,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
327
346
  private handleMessage;
328
347
  private applyFullState;
329
348
  private applyPlayerState;
349
+ private setLerpTarget;
330
350
  private addSnapshot;
331
351
  private applyStateDirect;
332
352
  private removePlayer;
@@ -335,6 +355,13 @@ declare class Sync<T extends Record<string, unknown>> {
335
355
  private findPlayersKey;
336
356
  private startSyncLoop;
337
357
  private startInterpolationLoop;
358
+ /**
359
+ * Frame-based lerping (gnome-chat style)
360
+ * Lerps each remote player's position toward their target by lerpFactor each frame.
361
+ * Simple, zero latency, great for casual games.
362
+ */
363
+ private updateLerp;
364
+ private measureLatency;
338
365
  private stopInterpolationLoop;
339
366
  private processJitterQueue;
340
367
  private stopSyncLoop;
package/dist/index.js CHANGED
@@ -293,6 +293,8 @@ var Sync = class {
293
293
  this.listeners = /* @__PURE__ */ new Map();
294
294
  // Snapshot-based interpolation: store timestamped snapshots per player
295
295
  this.snapshots = /* @__PURE__ */ new Map();
296
+ // Lerp-based smoothing: store target positions per player (for 'lerp' mode)
297
+ this.lerpTargets = /* @__PURE__ */ new Map();
296
298
  // Jitter buffer: queue incoming updates before applying
297
299
  this.jitterQueue = [];
298
300
  // Auto-reconnect state
@@ -300,15 +302,30 @@ var Sync = class {
300
302
  this.reconnectTimeout = null;
301
303
  this.lastJoinOptions = void 0;
302
304
  this.isReconnecting = false;
305
+ // Server time sync and metrics
306
+ this.serverTimeOffset = 0;
307
+ // Local time - server time
308
+ this._playerCount = 1;
309
+ this._latency = 0;
310
+ this.pingStartTime = 0;
311
+ this.pingInterval = null;
303
312
  this.state = state;
304
313
  this.myId = config.playerId;
305
314
  this.config = config;
315
+ let smoothing = options?.smoothing ?? "lerp";
316
+ if (options?.interpolate === false) {
317
+ smoothing = "none";
318
+ } else if (options?.interpolate === true && !options?.smoothing) {
319
+ smoothing = "lerp";
320
+ }
306
321
  this.options = {
307
322
  tickRate: options?.tickRate ?? 20,
308
- interpolate: options?.interpolate ?? true,
323
+ smoothing,
324
+ lerpFactor: options?.lerpFactor ?? 0.15,
325
+ interpolate: smoothing !== "none",
326
+ // Legacy compat
309
327
  interpolationDelay: options?.interpolationDelay ?? 100,
310
328
  jitterBuffer: options?.jitterBuffer ?? 0,
311
- // 0 = immediate, set to 50+ for smoothing
312
329
  autoReconnect: options?.autoReconnect ?? true,
313
330
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
314
331
  };
@@ -321,6 +338,14 @@ var Sync = class {
321
338
  get connected() {
322
339
  return this.ws?.readyState === WebSocket.OPEN;
323
340
  }
341
+ /** Number of players in the current room */
342
+ get playerCount() {
343
+ return this._playerCount;
344
+ }
345
+ /** Current latency to server in milliseconds */
346
+ get latency() {
347
+ return this._latency;
348
+ }
324
349
  /**
325
350
  * Join a room - your state will sync with everyone in this room
326
351
  *
@@ -464,23 +489,41 @@ var Sync = class {
464
489
  });
465
490
  }
466
491
  handleMessage(data) {
492
+ if (data.serverTime) {
493
+ this.serverTimeOffset = Date.now() - data.serverTime;
494
+ }
467
495
  switch (data.type) {
496
+ case "welcome":
497
+ if (data.state) {
498
+ this.applyFullState(data.state);
499
+ }
500
+ this._playerCount = data.playerCount || 1;
501
+ this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
502
+ break;
468
503
  case "full_state":
469
504
  this.applyFullState(data.state);
470
505
  break;
471
506
  case "state":
472
- this.applyPlayerState(data.playerId, data.data);
507
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
473
508
  break;
474
509
  case "join":
510
+ this._playerCount = data.playerCount || this._playerCount + 1;
475
511
  this.emit("join", data.playerId);
476
512
  break;
477
513
  case "leave":
514
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
478
515
  this.removePlayer(data.playerId);
479
516
  this.emit("leave", data.playerId);
480
517
  break;
481
518
  case "message":
482
519
  this.emit("message", data.from, data.data);
483
520
  break;
521
+ case "pong":
522
+ if (this.pingStartTime) {
523
+ this._latency = Date.now() - this.pingStartTime;
524
+ this._playerCount = data.playerCount || this._playerCount;
525
+ }
526
+ break;
484
527
  }
485
528
  }
486
529
  applyFullState(fullState) {
@@ -490,27 +533,49 @@ var Sync = class {
490
533
  }
491
534
  }
492
535
  }
493
- applyPlayerState(playerId, playerState) {
494
- if (this.options.interpolate && this.options.jitterBuffer > 0) {
495
- this.jitterQueue.push({
496
- deliverAt: Date.now() + this.options.jitterBuffer,
497
- playerId,
498
- state: { ...playerState }
499
- });
500
- } else if (this.options.interpolate) {
501
- this.addSnapshot(playerId, playerState);
502
- } else {
503
- this.applyStateDirect(playerId, playerState);
536
+ applyPlayerState(playerId, playerState, serverTime) {
537
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
538
+ switch (this.options.smoothing) {
539
+ case "lerp":
540
+ this.setLerpTarget(playerId, playerState);
541
+ break;
542
+ case "interpolate":
543
+ if (this.options.jitterBuffer > 0) {
544
+ this.jitterQueue.push({
545
+ deliverAt: timestamp + this.options.jitterBuffer,
546
+ playerId,
547
+ state: { ...playerState },
548
+ timestamp
549
+ });
550
+ } else {
551
+ this.addSnapshot(playerId, playerState, timestamp);
552
+ }
553
+ break;
554
+ case "none":
555
+ default:
556
+ this.applyStateDirect(playerId, playerState);
557
+ break;
504
558
  }
505
559
  }
506
- addSnapshot(playerId, playerState) {
560
+ setLerpTarget(playerId, playerState) {
561
+ const isNewPlayer = !this.lerpTargets.has(playerId);
562
+ this.lerpTargets.set(playerId, { ...playerState });
563
+ const playersKey = this.findPlayersKey();
564
+ if (playersKey) {
565
+ const players = this.state[playersKey];
566
+ if (isNewPlayer || !players[playerId]) {
567
+ players[playerId] = { ...playerState };
568
+ }
569
+ }
570
+ }
571
+ addSnapshot(playerId, playerState, timestamp) {
507
572
  const isNewPlayer = !this.snapshots.has(playerId);
508
573
  if (isNewPlayer) {
509
574
  this.snapshots.set(playerId, []);
510
575
  }
511
576
  const playerSnapshots = this.snapshots.get(playerId);
512
577
  playerSnapshots.push({
513
- time: Date.now(),
578
+ time: timestamp || Date.now(),
514
579
  state: { ...playerState }
515
580
  });
516
581
  while (playerSnapshots.length > 10) {
@@ -536,6 +601,7 @@ var Sync = class {
536
601
  const players = this.state[playersKey];
537
602
  delete players[playerId];
538
603
  this.snapshots.delete(playerId);
604
+ this.lerpTargets.delete(playerId);
539
605
  }
540
606
  attemptReconnect() {
541
607
  if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
@@ -568,6 +634,7 @@ var Sync = class {
568
634
  }
569
635
  }
570
636
  this.snapshots.clear();
637
+ this.lerpTargets.clear();
571
638
  this.jitterQueue = [];
572
639
  }
573
640
  findPlayersKey() {
@@ -593,24 +660,65 @@ var Sync = class {
593
660
  }
594
661
  startInterpolationLoop() {
595
662
  if (this.interpolationInterval) return;
596
- if (!this.options.interpolate) return;
663
+ if (this.options.smoothing === "none") return;
597
664
  this.interpolationInterval = setInterval(() => {
598
- this.processJitterQueue();
599
- this.updateInterpolation();
665
+ if (this.options.smoothing === "lerp") {
666
+ this.updateLerp();
667
+ } else if (this.options.smoothing === "interpolate") {
668
+ this.processJitterQueue();
669
+ this.updateInterpolation();
670
+ }
600
671
  }, 16);
672
+ this.pingInterval = setInterval(() => {
673
+ this.measureLatency();
674
+ }, 2e3);
675
+ }
676
+ /**
677
+ * Frame-based lerping (gnome-chat style)
678
+ * Lerps each remote player's position toward their target by lerpFactor each frame.
679
+ * Simple, zero latency, great for casual games.
680
+ */
681
+ updateLerp() {
682
+ const playersKey = this.findPlayersKey();
683
+ if (!playersKey) return;
684
+ const players = this.state[playersKey];
685
+ const lerpFactor = this.options.lerpFactor;
686
+ for (const [playerId, target] of this.lerpTargets) {
687
+ if (playerId === this.myId) continue;
688
+ const player = players[playerId];
689
+ if (!player) continue;
690
+ for (const [key, targetValue] of Object.entries(target)) {
691
+ if (typeof targetValue === "number" && typeof player[key] === "number") {
692
+ const current = player[key];
693
+ player[key] = current + (targetValue - current) * lerpFactor;
694
+ } else if (typeof targetValue !== "number") {
695
+ player[key] = targetValue;
696
+ }
697
+ }
698
+ }
699
+ }
700
+ measureLatency() {
701
+ if (this.ws?.readyState === WebSocket.OPEN) {
702
+ this.pingStartTime = Date.now();
703
+ this.ws.send(JSON.stringify({ type: "ping" }));
704
+ }
601
705
  }
602
706
  stopInterpolationLoop() {
603
707
  if (this.interpolationInterval) {
604
708
  clearInterval(this.interpolationInterval);
605
709
  this.interpolationInterval = null;
606
710
  }
711
+ if (this.pingInterval) {
712
+ clearInterval(this.pingInterval);
713
+ this.pingInterval = null;
714
+ }
607
715
  }
608
716
  processJitterQueue() {
609
717
  const now = Date.now();
610
718
  const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
611
719
  this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
612
720
  for (const item of ready) {
613
- this.addSnapshot(item.playerId, item.state);
721
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
614
722
  }
615
723
  }
616
724
  stopSyncLoop() {
package/dist/index.mjs CHANGED
@@ -266,6 +266,8 @@ var Sync = class {
266
266
  this.listeners = /* @__PURE__ */ new Map();
267
267
  // Snapshot-based interpolation: store timestamped snapshots per player
268
268
  this.snapshots = /* @__PURE__ */ new Map();
269
+ // Lerp-based smoothing: store target positions per player (for 'lerp' mode)
270
+ this.lerpTargets = /* @__PURE__ */ new Map();
269
271
  // Jitter buffer: queue incoming updates before applying
270
272
  this.jitterQueue = [];
271
273
  // Auto-reconnect state
@@ -273,15 +275,30 @@ var Sync = class {
273
275
  this.reconnectTimeout = null;
274
276
  this.lastJoinOptions = void 0;
275
277
  this.isReconnecting = false;
278
+ // Server time sync and metrics
279
+ this.serverTimeOffset = 0;
280
+ // Local time - server time
281
+ this._playerCount = 1;
282
+ this._latency = 0;
283
+ this.pingStartTime = 0;
284
+ this.pingInterval = null;
276
285
  this.state = state;
277
286
  this.myId = config.playerId;
278
287
  this.config = config;
288
+ let smoothing = options?.smoothing ?? "lerp";
289
+ if (options?.interpolate === false) {
290
+ smoothing = "none";
291
+ } else if (options?.interpolate === true && !options?.smoothing) {
292
+ smoothing = "lerp";
293
+ }
279
294
  this.options = {
280
295
  tickRate: options?.tickRate ?? 20,
281
- interpolate: options?.interpolate ?? true,
296
+ smoothing,
297
+ lerpFactor: options?.lerpFactor ?? 0.15,
298
+ interpolate: smoothing !== "none",
299
+ // Legacy compat
282
300
  interpolationDelay: options?.interpolationDelay ?? 100,
283
301
  jitterBuffer: options?.jitterBuffer ?? 0,
284
- // 0 = immediate, set to 50+ for smoothing
285
302
  autoReconnect: options?.autoReconnect ?? true,
286
303
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
287
304
  };
@@ -294,6 +311,14 @@ var Sync = class {
294
311
  get connected() {
295
312
  return this.ws?.readyState === WebSocket.OPEN;
296
313
  }
314
+ /** Number of players in the current room */
315
+ get playerCount() {
316
+ return this._playerCount;
317
+ }
318
+ /** Current latency to server in milliseconds */
319
+ get latency() {
320
+ return this._latency;
321
+ }
297
322
  /**
298
323
  * Join a room - your state will sync with everyone in this room
299
324
  *
@@ -437,23 +462,41 @@ var Sync = class {
437
462
  });
438
463
  }
439
464
  handleMessage(data) {
465
+ if (data.serverTime) {
466
+ this.serverTimeOffset = Date.now() - data.serverTime;
467
+ }
440
468
  switch (data.type) {
469
+ case "welcome":
470
+ if (data.state) {
471
+ this.applyFullState(data.state);
472
+ }
473
+ this._playerCount = data.playerCount || 1;
474
+ this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
475
+ break;
441
476
  case "full_state":
442
477
  this.applyFullState(data.state);
443
478
  break;
444
479
  case "state":
445
- this.applyPlayerState(data.playerId, data.data);
480
+ this.applyPlayerState(data.playerId, data.data, data.serverTime);
446
481
  break;
447
482
  case "join":
483
+ this._playerCount = data.playerCount || this._playerCount + 1;
448
484
  this.emit("join", data.playerId);
449
485
  break;
450
486
  case "leave":
487
+ this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
451
488
  this.removePlayer(data.playerId);
452
489
  this.emit("leave", data.playerId);
453
490
  break;
454
491
  case "message":
455
492
  this.emit("message", data.from, data.data);
456
493
  break;
494
+ case "pong":
495
+ if (this.pingStartTime) {
496
+ this._latency = Date.now() - this.pingStartTime;
497
+ this._playerCount = data.playerCount || this._playerCount;
498
+ }
499
+ break;
457
500
  }
458
501
  }
459
502
  applyFullState(fullState) {
@@ -463,27 +506,49 @@ var Sync = class {
463
506
  }
464
507
  }
465
508
  }
466
- applyPlayerState(playerId, playerState) {
467
- if (this.options.interpolate && this.options.jitterBuffer > 0) {
468
- this.jitterQueue.push({
469
- deliverAt: Date.now() + this.options.jitterBuffer,
470
- playerId,
471
- state: { ...playerState }
472
- });
473
- } else if (this.options.interpolate) {
474
- this.addSnapshot(playerId, playerState);
475
- } else {
476
- this.applyStateDirect(playerId, playerState);
509
+ applyPlayerState(playerId, playerState, serverTime) {
510
+ const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
511
+ switch (this.options.smoothing) {
512
+ case "lerp":
513
+ this.setLerpTarget(playerId, playerState);
514
+ break;
515
+ case "interpolate":
516
+ if (this.options.jitterBuffer > 0) {
517
+ this.jitterQueue.push({
518
+ deliverAt: timestamp + this.options.jitterBuffer,
519
+ playerId,
520
+ state: { ...playerState },
521
+ timestamp
522
+ });
523
+ } else {
524
+ this.addSnapshot(playerId, playerState, timestamp);
525
+ }
526
+ break;
527
+ case "none":
528
+ default:
529
+ this.applyStateDirect(playerId, playerState);
530
+ break;
477
531
  }
478
532
  }
479
- addSnapshot(playerId, playerState) {
533
+ setLerpTarget(playerId, playerState) {
534
+ const isNewPlayer = !this.lerpTargets.has(playerId);
535
+ this.lerpTargets.set(playerId, { ...playerState });
536
+ const playersKey = this.findPlayersKey();
537
+ if (playersKey) {
538
+ const players = this.state[playersKey];
539
+ if (isNewPlayer || !players[playerId]) {
540
+ players[playerId] = { ...playerState };
541
+ }
542
+ }
543
+ }
544
+ addSnapshot(playerId, playerState, timestamp) {
480
545
  const isNewPlayer = !this.snapshots.has(playerId);
481
546
  if (isNewPlayer) {
482
547
  this.snapshots.set(playerId, []);
483
548
  }
484
549
  const playerSnapshots = this.snapshots.get(playerId);
485
550
  playerSnapshots.push({
486
- time: Date.now(),
551
+ time: timestamp || Date.now(),
487
552
  state: { ...playerState }
488
553
  });
489
554
  while (playerSnapshots.length > 10) {
@@ -509,6 +574,7 @@ var Sync = class {
509
574
  const players = this.state[playersKey];
510
575
  delete players[playerId];
511
576
  this.snapshots.delete(playerId);
577
+ this.lerpTargets.delete(playerId);
512
578
  }
513
579
  attemptReconnect() {
514
580
  if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
@@ -541,6 +607,7 @@ var Sync = class {
541
607
  }
542
608
  }
543
609
  this.snapshots.clear();
610
+ this.lerpTargets.clear();
544
611
  this.jitterQueue = [];
545
612
  }
546
613
  findPlayersKey() {
@@ -566,24 +633,65 @@ var Sync = class {
566
633
  }
567
634
  startInterpolationLoop() {
568
635
  if (this.interpolationInterval) return;
569
- if (!this.options.interpolate) return;
636
+ if (this.options.smoothing === "none") return;
570
637
  this.interpolationInterval = setInterval(() => {
571
- this.processJitterQueue();
572
- this.updateInterpolation();
638
+ if (this.options.smoothing === "lerp") {
639
+ this.updateLerp();
640
+ } else if (this.options.smoothing === "interpolate") {
641
+ this.processJitterQueue();
642
+ this.updateInterpolation();
643
+ }
573
644
  }, 16);
645
+ this.pingInterval = setInterval(() => {
646
+ this.measureLatency();
647
+ }, 2e3);
648
+ }
649
+ /**
650
+ * Frame-based lerping (gnome-chat style)
651
+ * Lerps each remote player's position toward their target by lerpFactor each frame.
652
+ * Simple, zero latency, great for casual games.
653
+ */
654
+ updateLerp() {
655
+ const playersKey = this.findPlayersKey();
656
+ if (!playersKey) return;
657
+ const players = this.state[playersKey];
658
+ const lerpFactor = this.options.lerpFactor;
659
+ for (const [playerId, target] of this.lerpTargets) {
660
+ if (playerId === this.myId) continue;
661
+ const player = players[playerId];
662
+ if (!player) continue;
663
+ for (const [key, targetValue] of Object.entries(target)) {
664
+ if (typeof targetValue === "number" && typeof player[key] === "number") {
665
+ const current = player[key];
666
+ player[key] = current + (targetValue - current) * lerpFactor;
667
+ } else if (typeof targetValue !== "number") {
668
+ player[key] = targetValue;
669
+ }
670
+ }
671
+ }
672
+ }
673
+ measureLatency() {
674
+ if (this.ws?.readyState === WebSocket.OPEN) {
675
+ this.pingStartTime = Date.now();
676
+ this.ws.send(JSON.stringify({ type: "ping" }));
677
+ }
574
678
  }
575
679
  stopInterpolationLoop() {
576
680
  if (this.interpolationInterval) {
577
681
  clearInterval(this.interpolationInterval);
578
682
  this.interpolationInterval = null;
579
683
  }
684
+ if (this.pingInterval) {
685
+ clearInterval(this.pingInterval);
686
+ this.pingInterval = null;
687
+ }
580
688
  }
581
689
  processJitterQueue() {
582
690
  const now = Date.now();
583
691
  const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
584
692
  this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
585
693
  for (const item of ready) {
586
- this.addSnapshot(item.playerId, item.state);
694
+ this.addSnapshot(item.playerId, item.state, item.timestamp);
587
695
  }
588
696
  }
589
697
  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.4.0",
4
4
  "description": "Simple game backend SDK - saves, multiplayer rooms, and more",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",