@watchtower-sdk/core 0.3.1 → 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/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;
@@ -284,6 +293,7 @@ declare class Sync<T extends Record<string, unknown>> {
284
293
  private lastSentState;
285
294
  private listeners;
286
295
  private snapshots;
296
+ private lerpTargets;
287
297
  private jitterQueue;
288
298
  private reconnectAttempts;
289
299
  private reconnectTimeout;
@@ -336,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
336
346
  private handleMessage;
337
347
  private applyFullState;
338
348
  private applyPlayerState;
349
+ private setLerpTarget;
339
350
  private addSnapshot;
340
351
  private applyStateDirect;
341
352
  private removePlayer;
@@ -344,6 +355,12 @@ declare class Sync<T extends Record<string, unknown>> {
344
355
  private findPlayersKey;
345
356
  private startSyncLoop;
346
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;
347
364
  private measureLatency;
348
365
  private stopInterpolationLoop;
349
366
  private processJitterQueue;
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;
@@ -284,6 +293,7 @@ declare class Sync<T extends Record<string, unknown>> {
284
293
  private lastSentState;
285
294
  private listeners;
286
295
  private snapshots;
296
+ private lerpTargets;
287
297
  private jitterQueue;
288
298
  private reconnectAttempts;
289
299
  private reconnectTimeout;
@@ -336,6 +346,7 @@ declare class Sync<T extends Record<string, unknown>> {
336
346
  private handleMessage;
337
347
  private applyFullState;
338
348
  private applyPlayerState;
349
+ private setLerpTarget;
339
350
  private addSnapshot;
340
351
  private applyStateDirect;
341
352
  private removePlayer;
@@ -344,6 +355,12 @@ declare class Sync<T extends Record<string, unknown>> {
344
355
  private findPlayersKey;
345
356
  private startSyncLoop;
346
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;
347
364
  private measureLatency;
348
365
  private stopInterpolationLoop;
349
366
  private processJitterQueue;
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
@@ -310,12 +312,20 @@ var Sync = class {
310
312
  this.state = state;
311
313
  this.myId = config.playerId;
312
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
+ }
313
321
  this.options = {
314
322
  tickRate: options?.tickRate ?? 20,
315
- interpolate: options?.interpolate ?? true,
323
+ smoothing,
324
+ lerpFactor: options?.lerpFactor ?? 0.15,
325
+ interpolate: smoothing !== "none",
326
+ // Legacy compat
316
327
  interpolationDelay: options?.interpolationDelay ?? 100,
317
328
  jitterBuffer: options?.jitterBuffer ?? 0,
318
- // 0 = immediate, set to 50+ for smoothing
319
329
  autoReconnect: options?.autoReconnect ?? true,
320
330
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
321
331
  };
@@ -525,17 +535,37 @@ var Sync = class {
525
535
  }
526
536
  applyPlayerState(playerId, playerState, serverTime) {
527
537
  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);
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;
558
+ }
559
+ }
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
+ }
539
569
  }
540
570
  }
541
571
  addSnapshot(playerId, playerState, timestamp) {
@@ -571,6 +601,7 @@ var Sync = class {
571
601
  const players = this.state[playersKey];
572
602
  delete players[playerId];
573
603
  this.snapshots.delete(playerId);
604
+ this.lerpTargets.delete(playerId);
574
605
  }
575
606
  attemptReconnect() {
576
607
  if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
@@ -603,6 +634,7 @@ var Sync = class {
603
634
  }
604
635
  }
605
636
  this.snapshots.clear();
637
+ this.lerpTargets.clear();
606
638
  this.jitterQueue = [];
607
639
  }
608
640
  findPlayersKey() {
@@ -628,15 +660,43 @@ var Sync = class {
628
660
  }
629
661
  startInterpolationLoop() {
630
662
  if (this.interpolationInterval) return;
631
- if (!this.options.interpolate) return;
663
+ if (this.options.smoothing === "none") return;
632
664
  this.interpolationInterval = setInterval(() => {
633
- this.processJitterQueue();
634
- 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
+ }
635
671
  }, 16);
636
672
  this.pingInterval = setInterval(() => {
637
673
  this.measureLatency();
638
674
  }, 2e3);
639
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
+ }
640
700
  measureLatency() {
641
701
  if (this.ws?.readyState === WebSocket.OPEN) {
642
702
  this.pingStartTime = Date.now();
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
@@ -283,12 +285,20 @@ var Sync = class {
283
285
  this.state = state;
284
286
  this.myId = config.playerId;
285
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
+ }
286
294
  this.options = {
287
295
  tickRate: options?.tickRate ?? 20,
288
- interpolate: options?.interpolate ?? true,
296
+ smoothing,
297
+ lerpFactor: options?.lerpFactor ?? 0.15,
298
+ interpolate: smoothing !== "none",
299
+ // Legacy compat
289
300
  interpolationDelay: options?.interpolationDelay ?? 100,
290
301
  jitterBuffer: options?.jitterBuffer ?? 0,
291
- // 0 = immediate, set to 50+ for smoothing
292
302
  autoReconnect: options?.autoReconnect ?? true,
293
303
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
294
304
  };
@@ -498,17 +508,37 @@ var Sync = class {
498
508
  }
499
509
  applyPlayerState(playerId, playerState, serverTime) {
500
510
  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);
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;
531
+ }
532
+ }
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
+ }
512
542
  }
513
543
  }
514
544
  addSnapshot(playerId, playerState, timestamp) {
@@ -544,6 +574,7 @@ var Sync = class {
544
574
  const players = this.state[playersKey];
545
575
  delete players[playerId];
546
576
  this.snapshots.delete(playerId);
577
+ this.lerpTargets.delete(playerId);
547
578
  }
548
579
  attemptReconnect() {
549
580
  if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
@@ -576,6 +607,7 @@ var Sync = class {
576
607
  }
577
608
  }
578
609
  this.snapshots.clear();
610
+ this.lerpTargets.clear();
579
611
  this.jitterQueue = [];
580
612
  }
581
613
  findPlayersKey() {
@@ -601,15 +633,43 @@ var Sync = class {
601
633
  }
602
634
  startInterpolationLoop() {
603
635
  if (this.interpolationInterval) return;
604
- if (!this.options.interpolate) return;
636
+ if (this.options.smoothing === "none") return;
605
637
  this.interpolationInterval = setInterval(() => {
606
- this.processJitterQueue();
607
- 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
+ }
608
644
  }, 16);
609
645
  this.pingInterval = setInterval(() => {
610
646
  this.measureLatency();
611
647
  }, 2e3);
612
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
+ }
613
673
  measureLatency() {
614
674
  if (this.ws?.readyState === WebSocket.OPEN) {
615
675
  this.pingStartTime = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchtower-sdk/core",
3
- "version": "0.3.1",
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",