@watchtower-sdk/core 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -102
- package/dist/index.d.mts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +56 -8
- package/dist/index.mjs +56 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @watchtower-sdk/core
|
|
2
2
|
|
|
3
|
-
|
|
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_...'
|
|
18
|
+
apiKey: 'wt_live_...' // Get from watchtower.host
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
//
|
|
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
|
|
29
|
-
|
|
30
|
-
//
|
|
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
|
-
|
|
40
|
+
No events. No message handlers. Just read and write your state.
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
---
|
|
36
43
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
const keys = await wt.listSaves() // ['progress', 'settings']
|
|
48
|
+
### Movement Game (Cursor Party, Agar.io)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
### Chat / Lobby
|
|
56
69
|
|
|
57
70
|
```typescript
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
await sync.join('my-room')
|
|
88
|
+
### Turn-Based (Chess, Cards)
|
|
66
89
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
---
|
|
103
198
|
|
|
104
|
-
|
|
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
|
-
|
|
201
|
+
You don't *need* events — just read your state. But if you want notifications:
|
|
112
202
|
|
|
113
203
|
```typescript
|
|
114
|
-
|
|
115
|
-
sync.
|
|
116
|
-
sync.
|
|
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
|
-
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Broadcast Messages
|
|
120
221
|
|
|
121
|
-
|
|
222
|
+
For one-off events that don't belong in state (explosions, sound effects):
|
|
122
223
|
|
|
123
224
|
```typescript
|
|
124
|
-
|
|
125
|
-
sync.
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
236
|
+
---
|
|
131
237
|
|
|
132
|
-
|
|
238
|
+
## Cloud Saves
|
|
239
|
+
|
|
240
|
+
Simple key-value storage per player:
|
|
133
241
|
|
|
134
242
|
```typescript
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
321
|
+
---
|
|
187
322
|
|
|
188
|
-
|
|
323
|
+
## Best Practices
|
|
189
324
|
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
+
3. **Don't store secrets in state.** Everyone sees everything. Use server-side validation for game logic.
|
|
211
330
|
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
sync.off(event: string, callback: Function): void
|
|
224
|
-
```
|
|
335
|
+
---
|
|
225
336
|
|
|
226
337
|
## License
|
|
227
338
|
|
package/dist/index.d.mts
CHANGED
|
@@ -271,6 +271,10 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
271
271
|
get roomId(): string | null;
|
|
272
272
|
/** Whether currently connected to a room */
|
|
273
273
|
get connected(): boolean;
|
|
274
|
+
/** Number of players in the current room */
|
|
275
|
+
get playerCount(): number;
|
|
276
|
+
/** Current latency to server in milliseconds */
|
|
277
|
+
get latency(): number;
|
|
274
278
|
private config;
|
|
275
279
|
private options;
|
|
276
280
|
private _roomId;
|
|
@@ -285,6 +289,11 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
285
289
|
private reconnectTimeout;
|
|
286
290
|
private lastJoinOptions;
|
|
287
291
|
private isReconnecting;
|
|
292
|
+
private serverTimeOffset;
|
|
293
|
+
private _playerCount;
|
|
294
|
+
private _latency;
|
|
295
|
+
private pingStartTime;
|
|
296
|
+
private pingInterval;
|
|
288
297
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
289
298
|
/**
|
|
290
299
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -335,6 +344,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
335
344
|
private findPlayersKey;
|
|
336
345
|
private startSyncLoop;
|
|
337
346
|
private startInterpolationLoop;
|
|
347
|
+
private measureLatency;
|
|
338
348
|
private stopInterpolationLoop;
|
|
339
349
|
private processJitterQueue;
|
|
340
350
|
private stopSyncLoop;
|
package/dist/index.d.ts
CHANGED
|
@@ -271,6 +271,10 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
271
271
|
get roomId(): string | null;
|
|
272
272
|
/** Whether currently connected to a room */
|
|
273
273
|
get connected(): boolean;
|
|
274
|
+
/** Number of players in the current room */
|
|
275
|
+
get playerCount(): number;
|
|
276
|
+
/** Current latency to server in milliseconds */
|
|
277
|
+
get latency(): number;
|
|
274
278
|
private config;
|
|
275
279
|
private options;
|
|
276
280
|
private _roomId;
|
|
@@ -285,6 +289,11 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
285
289
|
private reconnectTimeout;
|
|
286
290
|
private lastJoinOptions;
|
|
287
291
|
private isReconnecting;
|
|
292
|
+
private serverTimeOffset;
|
|
293
|
+
private _playerCount;
|
|
294
|
+
private _latency;
|
|
295
|
+
private pingStartTime;
|
|
296
|
+
private pingInterval;
|
|
288
297
|
constructor(state: T, config: Required<WatchtowerConfig>, options?: SyncOptions);
|
|
289
298
|
/**
|
|
290
299
|
* Join a room - your state will sync with everyone in this room
|
|
@@ -335,6 +344,7 @@ declare class Sync<T extends Record<string, unknown>> {
|
|
|
335
344
|
private findPlayersKey;
|
|
336
345
|
private startSyncLoop;
|
|
337
346
|
private startInterpolationLoop;
|
|
347
|
+
private measureLatency;
|
|
338
348
|
private stopInterpolationLoop;
|
|
339
349
|
private processJitterQueue;
|
|
340
350
|
private stopSyncLoop;
|
package/dist/index.js
CHANGED
|
@@ -300,6 +300,13 @@ var Sync = class {
|
|
|
300
300
|
this.reconnectTimeout = null;
|
|
301
301
|
this.lastJoinOptions = void 0;
|
|
302
302
|
this.isReconnecting = false;
|
|
303
|
+
// Server time sync and metrics
|
|
304
|
+
this.serverTimeOffset = 0;
|
|
305
|
+
// Local time - server time
|
|
306
|
+
this._playerCount = 1;
|
|
307
|
+
this._latency = 0;
|
|
308
|
+
this.pingStartTime = 0;
|
|
309
|
+
this.pingInterval = null;
|
|
303
310
|
this.state = state;
|
|
304
311
|
this.myId = config.playerId;
|
|
305
312
|
this.config = config;
|
|
@@ -321,6 +328,14 @@ var Sync = class {
|
|
|
321
328
|
get connected() {
|
|
322
329
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
323
330
|
}
|
|
331
|
+
/** Number of players in the current room */
|
|
332
|
+
get playerCount() {
|
|
333
|
+
return this._playerCount;
|
|
334
|
+
}
|
|
335
|
+
/** Current latency to server in milliseconds */
|
|
336
|
+
get latency() {
|
|
337
|
+
return this._latency;
|
|
338
|
+
}
|
|
324
339
|
/**
|
|
325
340
|
* Join a room - your state will sync with everyone in this room
|
|
326
341
|
*
|
|
@@ -464,23 +479,41 @@ var Sync = class {
|
|
|
464
479
|
});
|
|
465
480
|
}
|
|
466
481
|
handleMessage(data) {
|
|
482
|
+
if (data.serverTime) {
|
|
483
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
484
|
+
}
|
|
467
485
|
switch (data.type) {
|
|
486
|
+
case "welcome":
|
|
487
|
+
if (data.state) {
|
|
488
|
+
this.applyFullState(data.state);
|
|
489
|
+
}
|
|
490
|
+
this._playerCount = data.playerCount || 1;
|
|
491
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
492
|
+
break;
|
|
468
493
|
case "full_state":
|
|
469
494
|
this.applyFullState(data.state);
|
|
470
495
|
break;
|
|
471
496
|
case "state":
|
|
472
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
497
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
473
498
|
break;
|
|
474
499
|
case "join":
|
|
500
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
475
501
|
this.emit("join", data.playerId);
|
|
476
502
|
break;
|
|
477
503
|
case "leave":
|
|
504
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
478
505
|
this.removePlayer(data.playerId);
|
|
479
506
|
this.emit("leave", data.playerId);
|
|
480
507
|
break;
|
|
481
508
|
case "message":
|
|
482
509
|
this.emit("message", data.from, data.data);
|
|
483
510
|
break;
|
|
511
|
+
case "pong":
|
|
512
|
+
if (this.pingStartTime) {
|
|
513
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
514
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
515
|
+
}
|
|
516
|
+
break;
|
|
484
517
|
}
|
|
485
518
|
}
|
|
486
519
|
applyFullState(fullState) {
|
|
@@ -490,27 +523,29 @@ var Sync = class {
|
|
|
490
523
|
}
|
|
491
524
|
}
|
|
492
525
|
}
|
|
493
|
-
applyPlayerState(playerId, playerState) {
|
|
526
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
527
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
494
528
|
if (this.options.interpolate && this.options.jitterBuffer > 0) {
|
|
495
529
|
this.jitterQueue.push({
|
|
496
|
-
deliverAt:
|
|
530
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
497
531
|
playerId,
|
|
498
|
-
state: { ...playerState }
|
|
532
|
+
state: { ...playerState },
|
|
533
|
+
timestamp
|
|
499
534
|
});
|
|
500
535
|
} else if (this.options.interpolate) {
|
|
501
|
-
this.addSnapshot(playerId, playerState);
|
|
536
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
502
537
|
} else {
|
|
503
538
|
this.applyStateDirect(playerId, playerState);
|
|
504
539
|
}
|
|
505
540
|
}
|
|
506
|
-
addSnapshot(playerId, playerState) {
|
|
541
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
507
542
|
const isNewPlayer = !this.snapshots.has(playerId);
|
|
508
543
|
if (isNewPlayer) {
|
|
509
544
|
this.snapshots.set(playerId, []);
|
|
510
545
|
}
|
|
511
546
|
const playerSnapshots = this.snapshots.get(playerId);
|
|
512
547
|
playerSnapshots.push({
|
|
513
|
-
time: Date.now(),
|
|
548
|
+
time: timestamp || Date.now(),
|
|
514
549
|
state: { ...playerState }
|
|
515
550
|
});
|
|
516
551
|
while (playerSnapshots.length > 10) {
|
|
@@ -598,19 +633,32 @@ var Sync = class {
|
|
|
598
633
|
this.processJitterQueue();
|
|
599
634
|
this.updateInterpolation();
|
|
600
635
|
}, 16);
|
|
636
|
+
this.pingInterval = setInterval(() => {
|
|
637
|
+
this.measureLatency();
|
|
638
|
+
}, 2e3);
|
|
639
|
+
}
|
|
640
|
+
measureLatency() {
|
|
641
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
642
|
+
this.pingStartTime = Date.now();
|
|
643
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
644
|
+
}
|
|
601
645
|
}
|
|
602
646
|
stopInterpolationLoop() {
|
|
603
647
|
if (this.interpolationInterval) {
|
|
604
648
|
clearInterval(this.interpolationInterval);
|
|
605
649
|
this.interpolationInterval = null;
|
|
606
650
|
}
|
|
651
|
+
if (this.pingInterval) {
|
|
652
|
+
clearInterval(this.pingInterval);
|
|
653
|
+
this.pingInterval = null;
|
|
654
|
+
}
|
|
607
655
|
}
|
|
608
656
|
processJitterQueue() {
|
|
609
657
|
const now = Date.now();
|
|
610
658
|
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
611
659
|
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
612
660
|
for (const item of ready) {
|
|
613
|
-
this.addSnapshot(item.playerId, item.state);
|
|
661
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
614
662
|
}
|
|
615
663
|
}
|
|
616
664
|
stopSyncLoop() {
|
package/dist/index.mjs
CHANGED
|
@@ -273,6 +273,13 @@ var Sync = class {
|
|
|
273
273
|
this.reconnectTimeout = null;
|
|
274
274
|
this.lastJoinOptions = void 0;
|
|
275
275
|
this.isReconnecting = false;
|
|
276
|
+
// Server time sync and metrics
|
|
277
|
+
this.serverTimeOffset = 0;
|
|
278
|
+
// Local time - server time
|
|
279
|
+
this._playerCount = 1;
|
|
280
|
+
this._latency = 0;
|
|
281
|
+
this.pingStartTime = 0;
|
|
282
|
+
this.pingInterval = null;
|
|
276
283
|
this.state = state;
|
|
277
284
|
this.myId = config.playerId;
|
|
278
285
|
this.config = config;
|
|
@@ -294,6 +301,14 @@ var Sync = class {
|
|
|
294
301
|
get connected() {
|
|
295
302
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
296
303
|
}
|
|
304
|
+
/** Number of players in the current room */
|
|
305
|
+
get playerCount() {
|
|
306
|
+
return this._playerCount;
|
|
307
|
+
}
|
|
308
|
+
/** Current latency to server in milliseconds */
|
|
309
|
+
get latency() {
|
|
310
|
+
return this._latency;
|
|
311
|
+
}
|
|
297
312
|
/**
|
|
298
313
|
* Join a room - your state will sync with everyone in this room
|
|
299
314
|
*
|
|
@@ -437,23 +452,41 @@ var Sync = class {
|
|
|
437
452
|
});
|
|
438
453
|
}
|
|
439
454
|
handleMessage(data) {
|
|
455
|
+
if (data.serverTime) {
|
|
456
|
+
this.serverTimeOffset = Date.now() - data.serverTime;
|
|
457
|
+
}
|
|
440
458
|
switch (data.type) {
|
|
459
|
+
case "welcome":
|
|
460
|
+
if (data.state) {
|
|
461
|
+
this.applyFullState(data.state);
|
|
462
|
+
}
|
|
463
|
+
this._playerCount = data.playerCount || 1;
|
|
464
|
+
this.emit("welcome", { playerCount: data.playerCount, tick: data.tick });
|
|
465
|
+
break;
|
|
441
466
|
case "full_state":
|
|
442
467
|
this.applyFullState(data.state);
|
|
443
468
|
break;
|
|
444
469
|
case "state":
|
|
445
|
-
this.applyPlayerState(data.playerId, data.data);
|
|
470
|
+
this.applyPlayerState(data.playerId, data.data, data.serverTime);
|
|
446
471
|
break;
|
|
447
472
|
case "join":
|
|
473
|
+
this._playerCount = data.playerCount || this._playerCount + 1;
|
|
448
474
|
this.emit("join", data.playerId);
|
|
449
475
|
break;
|
|
450
476
|
case "leave":
|
|
477
|
+
this._playerCount = data.playerCount || Math.max(1, this._playerCount - 1);
|
|
451
478
|
this.removePlayer(data.playerId);
|
|
452
479
|
this.emit("leave", data.playerId);
|
|
453
480
|
break;
|
|
454
481
|
case "message":
|
|
455
482
|
this.emit("message", data.from, data.data);
|
|
456
483
|
break;
|
|
484
|
+
case "pong":
|
|
485
|
+
if (this.pingStartTime) {
|
|
486
|
+
this._latency = Date.now() - this.pingStartTime;
|
|
487
|
+
this._playerCount = data.playerCount || this._playerCount;
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
457
490
|
}
|
|
458
491
|
}
|
|
459
492
|
applyFullState(fullState) {
|
|
@@ -463,27 +496,29 @@ var Sync = class {
|
|
|
463
496
|
}
|
|
464
497
|
}
|
|
465
498
|
}
|
|
466
|
-
applyPlayerState(playerId, playerState) {
|
|
499
|
+
applyPlayerState(playerId, playerState, serverTime) {
|
|
500
|
+
const timestamp = serverTime ? serverTime + this.serverTimeOffset : Date.now();
|
|
467
501
|
if (this.options.interpolate && this.options.jitterBuffer > 0) {
|
|
468
502
|
this.jitterQueue.push({
|
|
469
|
-
deliverAt:
|
|
503
|
+
deliverAt: timestamp + this.options.jitterBuffer,
|
|
470
504
|
playerId,
|
|
471
|
-
state: { ...playerState }
|
|
505
|
+
state: { ...playerState },
|
|
506
|
+
timestamp
|
|
472
507
|
});
|
|
473
508
|
} else if (this.options.interpolate) {
|
|
474
|
-
this.addSnapshot(playerId, playerState);
|
|
509
|
+
this.addSnapshot(playerId, playerState, timestamp);
|
|
475
510
|
} else {
|
|
476
511
|
this.applyStateDirect(playerId, playerState);
|
|
477
512
|
}
|
|
478
513
|
}
|
|
479
|
-
addSnapshot(playerId, playerState) {
|
|
514
|
+
addSnapshot(playerId, playerState, timestamp) {
|
|
480
515
|
const isNewPlayer = !this.snapshots.has(playerId);
|
|
481
516
|
if (isNewPlayer) {
|
|
482
517
|
this.snapshots.set(playerId, []);
|
|
483
518
|
}
|
|
484
519
|
const playerSnapshots = this.snapshots.get(playerId);
|
|
485
520
|
playerSnapshots.push({
|
|
486
|
-
time: Date.now(),
|
|
521
|
+
time: timestamp || Date.now(),
|
|
487
522
|
state: { ...playerState }
|
|
488
523
|
});
|
|
489
524
|
while (playerSnapshots.length > 10) {
|
|
@@ -571,19 +606,32 @@ var Sync = class {
|
|
|
571
606
|
this.processJitterQueue();
|
|
572
607
|
this.updateInterpolation();
|
|
573
608
|
}, 16);
|
|
609
|
+
this.pingInterval = setInterval(() => {
|
|
610
|
+
this.measureLatency();
|
|
611
|
+
}, 2e3);
|
|
612
|
+
}
|
|
613
|
+
measureLatency() {
|
|
614
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
615
|
+
this.pingStartTime = Date.now();
|
|
616
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
617
|
+
}
|
|
574
618
|
}
|
|
575
619
|
stopInterpolationLoop() {
|
|
576
620
|
if (this.interpolationInterval) {
|
|
577
621
|
clearInterval(this.interpolationInterval);
|
|
578
622
|
this.interpolationInterval = null;
|
|
579
623
|
}
|
|
624
|
+
if (this.pingInterval) {
|
|
625
|
+
clearInterval(this.pingInterval);
|
|
626
|
+
this.pingInterval = null;
|
|
627
|
+
}
|
|
580
628
|
}
|
|
581
629
|
processJitterQueue() {
|
|
582
630
|
const now = Date.now();
|
|
583
631
|
const ready = this.jitterQueue.filter((item) => item.deliverAt <= now);
|
|
584
632
|
this.jitterQueue = this.jitterQueue.filter((item) => item.deliverAt > now);
|
|
585
633
|
for (const item of ready) {
|
|
586
|
-
this.addSnapshot(item.playerId, item.state);
|
|
634
|
+
this.addSnapshot(item.playerId, item.state, item.timestamp);
|
|
587
635
|
}
|
|
588
636
|
}
|
|
589
637
|
stopSyncLoop() {
|