@watchtower-sdk/core 0.2.1 → 0.3.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
@@ -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 */
@@ -268,9 +276,15 @@ declare class Sync<T extends Record<string, unknown>> {
268
276
  private _roomId;
269
277
  private ws;
270
278
  private syncInterval;
279
+ private interpolationInterval;
271
280
  private lastSentState;
272
- private interpolationTargets;
273
281
  private listeners;
282
+ private snapshots;
283
+ private jitterQueue;
284
+ private reconnectAttempts;
285
+ private reconnectTimeout;
286
+ private lastJoinOptions;
287
+ private isReconnecting;
274
288
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
275
289
  /**
276
290
  * Join a room - your state will sync with everyone in this room
@@ -283,6 +297,12 @@ declare class Sync<T extends Record<string, unknown>> {
283
297
  * Leave the current room
284
298
  */
285
299
  leave(): Promise<void>;
300
+ /**
301
+ * Send a one-off message to all players in the room
302
+ *
303
+ * @param data - Any JSON-serializable data
304
+ */
305
+ broadcast(data: unknown): void;
286
306
  /**
287
307
  * Create a new room and join it
288
308
  *
@@ -297,7 +317,7 @@ declare class Sync<T extends Record<string, unknown>> {
297
317
  /**
298
318
  * Subscribe to sync events
299
319
  */
300
- on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected', callback: Function): void;
320
+ on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
301
321
  /**
302
322
  * Unsubscribe from sync events
303
323
  */
@@ -307,13 +327,20 @@ declare class Sync<T extends Record<string, unknown>> {
307
327
  private handleMessage;
308
328
  private applyFullState;
309
329
  private applyPlayerState;
330
+ private addSnapshot;
331
+ private applyStateDirect;
310
332
  private removePlayer;
333
+ private attemptReconnect;
311
334
  private clearRemotePlayers;
312
335
  private findPlayersKey;
313
336
  private startSyncLoop;
337
+ private startInterpolationLoop;
338
+ private stopInterpolationLoop;
339
+ private processJitterQueue;
314
340
  private stopSyncLoop;
315
341
  private syncMyState;
316
342
  private updateInterpolation;
343
+ private lerpState;
317
344
  private generateRoomCode;
318
345
  private getHeaders;
319
346
  }
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 */
@@ -268,9 +276,15 @@ declare class Sync<T extends Record<string, unknown>> {
268
276
  private _roomId;
269
277
  private ws;
270
278
  private syncInterval;
279
+ private interpolationInterval;
271
280
  private lastSentState;
272
- private interpolationTargets;
273
281
  private listeners;
282
+ private snapshots;
283
+ private jitterQueue;
284
+ private reconnectAttempts;
285
+ private reconnectTimeout;
286
+ private lastJoinOptions;
287
+ private isReconnecting;
274
288
  constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
275
289
  /**
276
290
  * Join a room - your state will sync with everyone in this room
@@ -283,6 +297,12 @@ declare class Sync<T extends Record<string, unknown>> {
283
297
  * Leave the current room
284
298
  */
285
299
  leave(): Promise<void>;
300
+ /**
301
+ * Send a one-off message to all players in the room
302
+ *
303
+ * @param data - Any JSON-serializable data
304
+ */
305
+ broadcast(data: unknown): void;
286
306
  /**
287
307
  * Create a new room and join it
288
308
  *
@@ -297,7 +317,7 @@ declare class Sync<T extends Record<string, unknown>> {
297
317
  /**
298
318
  * Subscribe to sync events
299
319
  */
300
- on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected', callback: Function): void;
320
+ on(event: 'join' | 'leave' | 'error' | 'connected' | 'disconnected' | 'reconnecting' | 'reconnected' | 'message', callback: Function): void;
301
321
  /**
302
322
  * Unsubscribe from sync events
303
323
  */
@@ -307,13 +327,20 @@ declare class Sync<T extends Record<string, unknown>> {
307
327
  private handleMessage;
308
328
  private applyFullState;
309
329
  private applyPlayerState;
330
+ private addSnapshot;
331
+ private applyStateDirect;
310
332
  private removePlayer;
333
+ private attemptReconnect;
311
334
  private clearRemotePlayers;
312
335
  private findPlayersKey;
313
336
  private startSyncLoop;
337
+ private startInterpolationLoop;
338
+ private stopInterpolationLoop;
339
+ private processJitterQueue;
314
340
  private stopSyncLoop;
315
341
  private syncMyState;
316
342
  private updateInterpolation;
343
+ private lerpState;
317
344
  private generateRoomCode;
318
345
  private getHeaders;
319
346
  }
package/dist/index.js CHANGED
@@ -288,15 +288,29 @@ 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;
294
303
  this.state = state;
295
304
  this.myId = config.playerId;
296
305
  this.config = config;
297
306
  this.options = {
298
307
  tickRate: options?.tickRate ?? 20,
299
- interpolate: options?.interpolate ?? true
308
+ interpolate: options?.interpolate ?? true,
309
+ interpolationDelay: options?.interpolationDelay ?? 100,
310
+ jitterBuffer: options?.jitterBuffer ?? 0,
311
+ // 0 = immediate, set to 50+ for smoothing
312
+ autoReconnect: options?.autoReconnect ?? true,
313
+ maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
300
314
  };
301
315
  }
302
316
  /** Current room ID (null if not in a room) */
@@ -314,25 +328,46 @@ var Sync = class {
314
328
  * @param options - Join options
315
329
  */
316
330
  async join(roomId, options) {
317
- if (this._roomId) {
331
+ if (this._roomId && !this.isReconnecting) {
318
332
  await this.leave();
319
333
  }
320
334
  this._roomId = roomId;
335
+ this.lastJoinOptions = options;
336
+ this.reconnectAttempts = 0;
321
337
  await this.connectWebSocket(roomId, options);
322
338
  this.startSyncLoop();
339
+ this.startInterpolationLoop();
323
340
  }
324
341
  /**
325
342
  * Leave the current room
326
343
  */
327
344
  async leave() {
345
+ if (this.reconnectTimeout) {
346
+ clearTimeout(this.reconnectTimeout);
347
+ this.reconnectTimeout = null;
348
+ }
349
+ this.isReconnecting = false;
328
350
  this.stopSyncLoop();
351
+ this.stopInterpolationLoop();
329
352
  if (this.ws) {
330
353
  this.ws.close();
331
354
  this.ws = null;
332
355
  }
333
356
  this.clearRemotePlayers();
357
+ this.snapshots.clear();
358
+ this.jitterQueue = [];
334
359
  this._roomId = null;
335
360
  }
361
+ /**
362
+ * Send a one-off message to all players in the room
363
+ *
364
+ * @param data - Any JSON-serializable data
365
+ */
366
+ broadcast(data) {
367
+ if (this.ws?.readyState === WebSocket.OPEN) {
368
+ this.ws.send(JSON.stringify({ type: "broadcast", data }));
369
+ }
370
+ }
336
371
  /**
337
372
  * Create a new room and join it
338
373
  *
@@ -414,6 +449,9 @@ var Sync = class {
414
449
  this.ws.onclose = () => {
415
450
  this.stopSyncLoop();
416
451
  this.emit("disconnected");
452
+ if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
453
+ this.attemptReconnect();
454
+ }
417
455
  };
418
456
  this.ws.onmessage = (event) => {
419
457
  try {
@@ -440,6 +478,9 @@ var Sync = class {
440
478
  this.removePlayer(data.playerId);
441
479
  this.emit("leave", data.playerId);
442
480
  break;
481
+ case "message":
482
+ this.emit("message", data.from, data.data);
483
+ break;
443
484
  }
444
485
  }
445
486
  applyFullState(fullState) {
@@ -450,15 +491,43 @@ var Sync = class {
450
491
  }
451
492
  }
452
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);
504
+ }
505
+ }
506
+ addSnapshot(playerId, playerState) {
507
+ const isNewPlayer = !this.snapshots.has(playerId);
508
+ if (isNewPlayer) {
509
+ this.snapshots.set(playerId, []);
510
+ }
511
+ const playerSnapshots = this.snapshots.get(playerId);
512
+ playerSnapshots.push({
513
+ time: Date.now(),
514
+ state: { ...playerState }
515
+ });
516
+ while (playerSnapshots.length > 10) {
517
+ playerSnapshots.shift();
518
+ }
519
+ const playersKey = this.findPlayersKey();
520
+ if (playersKey) {
521
+ const players = this.state[playersKey];
522
+ if (isNewPlayer || !players[playerId]) {
523
+ players[playerId] = { ...playerState };
524
+ }
525
+ }
526
+ }
527
+ applyStateDirect(playerId, playerState) {
453
528
  const playersKey = this.findPlayersKey();
454
529
  if (!playersKey) return;
455
530
  const players = this.state[playersKey];
456
- if (this.options.interpolate && players[playerId]) {
457
- this.interpolationTargets.set(playerId, {
458
- target: { ...playerState },
459
- current: { ...players[playerId] }
460
- });
461
- }
462
531
  players[playerId] = playerState;
463
532
  }
464
533
  removePlayer(playerId) {
@@ -466,7 +535,28 @@ var Sync = class {
466
535
  if (!playersKey) return;
467
536
  const players = this.state[playersKey];
468
537
  delete players[playerId];
469
- this.interpolationTargets.delete(playerId);
538
+ this.snapshots.delete(playerId);
539
+ }
540
+ attemptReconnect() {
541
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
542
+ this.emit("error", new Error("Max reconnection attempts reached"));
543
+ return;
544
+ }
545
+ this.isReconnecting = true;
546
+ this.reconnectAttempts++;
547
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
548
+ this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
549
+ this.reconnectTimeout = setTimeout(async () => {
550
+ try {
551
+ await this.connectWebSocket(this._roomId, this.lastJoinOptions);
552
+ this.startSyncLoop();
553
+ this.isReconnecting = false;
554
+ this.reconnectAttempts = 0;
555
+ this.emit("reconnected");
556
+ } catch (e) {
557
+ this.isReconnecting = false;
558
+ }
559
+ }, delay);
470
560
  }
471
561
  clearRemotePlayers() {
472
562
  const playersKey = this.findPlayersKey();
@@ -477,7 +567,8 @@ var Sync = class {
477
567
  delete players[playerId];
478
568
  }
479
569
  }
480
- this.interpolationTargets.clear();
570
+ this.snapshots.clear();
571
+ this.jitterQueue = [];
481
572
  }
482
573
  findPlayersKey() {
483
574
  const candidates = ["players", "entities", "gnomes", "users", "clients"];
@@ -498,11 +589,30 @@ var Sync = class {
498
589
  const intervalMs = 1e3 / this.options.tickRate;
499
590
  this.syncInterval = setInterval(() => {
500
591
  this.syncMyState();
501
- if (this.options.interpolate) {
502
- this.updateInterpolation();
503
- }
504
592
  }, intervalMs);
505
593
  }
594
+ startInterpolationLoop() {
595
+ if (this.interpolationInterval) return;
596
+ if (!this.options.interpolate) return;
597
+ this.interpolationInterval = setInterval(() => {
598
+ this.processJitterQueue();
599
+ this.updateInterpolation();
600
+ }, 16);
601
+ }
602
+ stopInterpolationLoop() {
603
+ if (this.interpolationInterval) {
604
+ clearInterval(this.interpolationInterval);
605
+ this.interpolationInterval = null;
606
+ }
607
+ }
608
+ processJitterQueue() {
609
+ const now = Date.now();
610
+ const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
611
+ this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
612
+ for (const item of ready) {
613
+ this.addSnapshot(item.playerId, item.state);
614
+ }
615
+ }
506
616
  stopSyncLoop() {
507
617
  if (this.syncInterval) {
508
618
  clearInterval(this.syncInterval);
@@ -528,16 +638,41 @@ var Sync = class {
528
638
  const playersKey = this.findPlayersKey();
529
639
  if (!playersKey) return;
530
640
  const players = this.state[playersKey];
531
- const lerpFactor = 0.2;
532
- for (const [playerId, interp] of this.interpolationTargets) {
641
+ const renderTime = Date.now() - this.options.interpolationDelay;
642
+ for (const [playerId, playerSnapshots] of this.snapshots) {
643
+ if (playerId === this.myId) continue;
533
644
  const player = players[playerId];
534
645
  if (!player) continue;
535
- for (const [key, targetValue] of Object.entries(interp.target)) {
536
- if (typeof targetValue === "number" && typeof interp.current[key] === "number") {
537
- const current = interp.current[key];
538
- const newValue = current + (targetValue - current) * lerpFactor;
539
- interp.current[key] = newValue;
540
- player[key] = newValue;
646
+ let before = null;
647
+ let after = null;
648
+ for (const snapshot of playerSnapshots) {
649
+ if (snapshot.time <= renderTime) {
650
+ before = snapshot;
651
+ } else if (!after) {
652
+ after = snapshot;
653
+ }
654
+ }
655
+ if (before && after) {
656
+ const total = after.time - before.time;
657
+ const elapsed = renderTime - before.time;
658
+ const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
659
+ this.lerpState(player, before.state, after.state, alpha);
660
+ } else if (before) {
661
+ this.lerpState(player, player, before.state, 0.3);
662
+ } else if (after) {
663
+ this.lerpState(player, player, after.state, 0.3);
664
+ }
665
+ }
666
+ }
667
+ lerpState(target, from, to, alpha) {
668
+ for (const key of Object.keys(to)) {
669
+ const fromVal = from[key];
670
+ const toVal = to[key];
671
+ if (typeof fromVal === "number" && typeof toVal === "number") {
672
+ target[key] = fromVal + (toVal - fromVal) * alpha;
673
+ } else {
674
+ if (alpha > 0.5) {
675
+ target[key] = toVal;
541
676
  }
542
677
  }
543
678
  }
package/dist/index.mjs CHANGED
@@ -261,15 +261,29 @@ 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;
267
276
  this.state = state;
268
277
  this.myId = config.playerId;
269
278
  this.config = config;
270
279
  this.options = {
271
280
  tickRate: options?.tickRate ?? 20,
272
- interpolate: options?.interpolate ?? true
281
+ interpolate: options?.interpolate ?? true,
282
+ interpolationDelay: options?.interpolationDelay ?? 100,
283
+ jitterBuffer: options?.jitterBuffer ?? 0,
284
+ // 0 = immediate, set to 50+ for smoothing
285
+ autoReconnect: options?.autoReconnect ?? true,
286
+ maxReconnectAttempts: options?.maxReconnectAttempts ?? 10
273
287
  };
274
288
  }
275
289
  /** Current room ID (null if not in a room) */
@@ -287,25 +301,46 @@ var Sync = class {
287
301
  * @param options - Join options
288
302
  */
289
303
  async join(roomId, options) {
290
- if (this._roomId) {
304
+ if (this._roomId && !this.isReconnecting) {
291
305
  await this.leave();
292
306
  }
293
307
  this._roomId = roomId;
308
+ this.lastJoinOptions = options;
309
+ this.reconnectAttempts = 0;
294
310
  await this.connectWebSocket(roomId, options);
295
311
  this.startSyncLoop();
312
+ this.startInterpolationLoop();
296
313
  }
297
314
  /**
298
315
  * Leave the current room
299
316
  */
300
317
  async leave() {
318
+ if (this.reconnectTimeout) {
319
+ clearTimeout(this.reconnectTimeout);
320
+ this.reconnectTimeout = null;
321
+ }
322
+ this.isReconnecting = false;
301
323
  this.stopSyncLoop();
324
+ this.stopInterpolationLoop();
302
325
  if (this.ws) {
303
326
  this.ws.close();
304
327
  this.ws = null;
305
328
  }
306
329
  this.clearRemotePlayers();
330
+ this.snapshots.clear();
331
+ this.jitterQueue = [];
307
332
  this._roomId = null;
308
333
  }
334
+ /**
335
+ * Send a one-off message to all players in the room
336
+ *
337
+ * @param data - Any JSON-serializable data
338
+ */
339
+ broadcast(data) {
340
+ if (this.ws?.readyState === WebSocket.OPEN) {
341
+ this.ws.send(JSON.stringify({ type: "broadcast", data }));
342
+ }
343
+ }
309
344
  /**
310
345
  * Create a new room and join it
311
346
  *
@@ -387,6 +422,9 @@ var Sync = class {
387
422
  this.ws.onclose = () => {
388
423
  this.stopSyncLoop();
389
424
  this.emit("disconnected");
425
+ if (this.options.autoReconnect && this._roomId && !this.isReconnecting) {
426
+ this.attemptReconnect();
427
+ }
390
428
  };
391
429
  this.ws.onmessage = (event) => {
392
430
  try {
@@ -413,6 +451,9 @@ var Sync = class {
413
451
  this.removePlayer(data.playerId);
414
452
  this.emit("leave", data.playerId);
415
453
  break;
454
+ case "message":
455
+ this.emit("message", data.from, data.data);
456
+ break;
416
457
  }
417
458
  }
418
459
  applyFullState(fullState) {
@@ -423,15 +464,43 @@ var Sync = class {
423
464
  }
424
465
  }
425
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);
477
+ }
478
+ }
479
+ addSnapshot(playerId, playerState) {
480
+ const isNewPlayer = !this.snapshots.has(playerId);
481
+ if (isNewPlayer) {
482
+ this.snapshots.set(playerId, []);
483
+ }
484
+ const playerSnapshots = this.snapshots.get(playerId);
485
+ playerSnapshots.push({
486
+ time: Date.now(),
487
+ state: { ...playerState }
488
+ });
489
+ while (playerSnapshots.length > 10) {
490
+ playerSnapshots.shift();
491
+ }
492
+ const playersKey = this.findPlayersKey();
493
+ if (playersKey) {
494
+ const players = this.state[playersKey];
495
+ if (isNewPlayer || !players[playerId]) {
496
+ players[playerId] = { ...playerState };
497
+ }
498
+ }
499
+ }
500
+ applyStateDirect(playerId, playerState) {
426
501
  const playersKey = this.findPlayersKey();
427
502
  if (!playersKey) return;
428
503
  const players = this.state[playersKey];
429
- if (this.options.interpolate && players[playerId]) {
430
- this.interpolationTargets.set(playerId, {
431
- target: { ...playerState },
432
- current: { ...players[playerId] }
433
- });
434
- }
435
504
  players[playerId] = playerState;
436
505
  }
437
506
  removePlayer(playerId) {
@@ -439,7 +508,28 @@ var Sync = class {
439
508
  if (!playersKey) return;
440
509
  const players = this.state[playersKey];
441
510
  delete players[playerId];
442
- this.interpolationTargets.delete(playerId);
511
+ this.snapshots.delete(playerId);
512
+ }
513
+ attemptReconnect() {
514
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
515
+ this.emit("error", new Error("Max reconnection attempts reached"));
516
+ return;
517
+ }
518
+ this.isReconnecting = true;
519
+ this.reconnectAttempts++;
520
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
521
+ this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
522
+ this.reconnectTimeout = setTimeout(async () => {
523
+ try {
524
+ await this.connectWebSocket(this._roomId, this.lastJoinOptions);
525
+ this.startSyncLoop();
526
+ this.isReconnecting = false;
527
+ this.reconnectAttempts = 0;
528
+ this.emit("reconnected");
529
+ } catch (e) {
530
+ this.isReconnecting = false;
531
+ }
532
+ }, delay);
443
533
  }
444
534
  clearRemotePlayers() {
445
535
  const playersKey = this.findPlayersKey();
@@ -450,7 +540,8 @@ var Sync = class {
450
540
  delete players[playerId];
451
541
  }
452
542
  }
453
- this.interpolationTargets.clear();
543
+ this.snapshots.clear();
544
+ this.jitterQueue = [];
454
545
  }
455
546
  findPlayersKey() {
456
547
  const candidates = ["players", "entities", "gnomes", "users", "clients"];
@@ -471,11 +562,30 @@ var Sync = class {
471
562
  const intervalMs = 1e3 / this.options.tickRate;
472
563
  this.syncInterval = setInterval(() => {
473
564
  this.syncMyState();
474
- if (this.options.interpolate) {
475
- this.updateInterpolation();
476
- }
477
565
  }, intervalMs);
478
566
  }
567
+ startInterpolationLoop() {
568
+ if (this.interpolationInterval) return;
569
+ if (!this.options.interpolate) return;
570
+ this.interpolationInterval = setInterval(() => {
571
+ this.processJitterQueue();
572
+ this.updateInterpolation();
573
+ }, 16);
574
+ }
575
+ stopInterpolationLoop() {
576
+ if (this.interpolationInterval) {
577
+ clearInterval(this.interpolationInterval);
578
+ this.interpolationInterval = null;
579
+ }
580
+ }
581
+ processJitterQueue() {
582
+ const now = Date.now();
583
+ const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
584
+ this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
585
+ for (const item of ready) {
586
+ this.addSnapshot(item.playerId, item.state);
587
+ }
588
+ }
479
589
  stopSyncLoop() {
480
590
  if (this.syncInterval) {
481
591
  clearInterval(this.syncInterval);
@@ -501,16 +611,41 @@ var Sync = class {
501
611
  const playersKey = this.findPlayersKey();
502
612
  if (!playersKey) return;
503
613
  const players = this.state[playersKey];
504
- const lerpFactor = 0.2;
505
- for (const [playerId, interp] of this.interpolationTargets) {
614
+ const renderTime = Date.now() - this.options.interpolationDelay;
615
+ for (const [playerId, playerSnapshots] of this.snapshots) {
616
+ if (playerId === this.myId) continue;
506
617
  const player = players[playerId];
507
618
  if (!player) continue;
508
- for (const [key, targetValue] of Object.entries(interp.target)) {
509
- if (typeof targetValue === "number" && typeof interp.current[key] === "number") {
510
- const current = interp.current[key];
511
- const newValue = current + (targetValue - current) * lerpFactor;
512
- interp.current[key] = newValue;
513
- player[key] = newValue;
619
+ let before = null;
620
+ let after = null;
621
+ for (const snapshot of playerSnapshots) {
622
+ if (snapshot.time <= renderTime) {
623
+ before = snapshot;
624
+ } else if (!after) {
625
+ after = snapshot;
626
+ }
627
+ }
628
+ if (before && after) {
629
+ const total = after.time - before.time;
630
+ const elapsed = renderTime - before.time;
631
+ const alpha = total > 0 ? Math.min(1, elapsed / total) : 1;
632
+ this.lerpState(player, before.state, after.state, alpha);
633
+ } else if (before) {
634
+ this.lerpState(player, player, before.state, 0.3);
635
+ } else if (after) {
636
+ this.lerpState(player, player, after.state, 0.3);
637
+ }
638
+ }
639
+ }
640
+ lerpState(target, from, to, alpha) {
641
+ for (const key of Object.keys(to)) {
642
+ const fromVal = from[key];
643
+ const toVal = to[key];
644
+ if (typeof fromVal === "number" && typeof toVal === "number") {
645
+ target[key] = fromVal + (toVal - fromVal) * alpha;
646
+ } else {
647
+ if (alpha > 0.5) {
648
+ target[key] = toVal;
514
649
  }
515
650
  }
516
651
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchtower-sdk/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",