@phalanx-engine/client 0.1.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.
Files changed (94) hide show
  1. package/README.md +1037 -0
  2. package/dist/DesyncDetector.d.ts +80 -0
  3. package/dist/DesyncDetector.d.ts.map +1 -0
  4. package/dist/DesyncDetector.js +93 -0
  5. package/dist/DesyncDetector.js.map +1 -0
  6. package/dist/DeterministicRandom.d.ts +78 -0
  7. package/dist/DeterministicRandom.d.ts.map +1 -0
  8. package/dist/DeterministicRandom.js +122 -0
  9. package/dist/DeterministicRandom.js.map +1 -0
  10. package/dist/EventEmitter.d.ts +65 -0
  11. package/dist/EventEmitter.d.ts.map +1 -0
  12. package/dist/EventEmitter.js +102 -0
  13. package/dist/EventEmitter.js.map +1 -0
  14. package/dist/FixedMath.d.ts +22 -0
  15. package/dist/FixedMath.d.ts.map +1 -0
  16. package/dist/FixedMath.js +26 -0
  17. package/dist/FixedMath.js.map +1 -0
  18. package/dist/PhalanxClient.d.ts +335 -0
  19. package/dist/PhalanxClient.d.ts.map +1 -0
  20. package/dist/PhalanxClient.js +844 -0
  21. package/dist/PhalanxClient.js.map +1 -0
  22. package/dist/RenderLoop.d.ts +95 -0
  23. package/dist/RenderLoop.d.ts.map +1 -0
  24. package/dist/RenderLoop.js +192 -0
  25. package/dist/RenderLoop.js.map +1 -0
  26. package/dist/SocketManager.d.ts +228 -0
  27. package/dist/SocketManager.d.ts.map +1 -0
  28. package/dist/SocketManager.js +584 -0
  29. package/dist/SocketManager.js.map +1 -0
  30. package/dist/StateHasher.d.ts +76 -0
  31. package/dist/StateHasher.d.ts.map +1 -0
  32. package/dist/StateHasher.js +129 -0
  33. package/dist/StateHasher.js.map +1 -0
  34. package/dist/auth/AuthManager.d.ts +188 -0
  35. package/dist/auth/AuthManager.d.ts.map +1 -0
  36. package/dist/auth/AuthManager.js +462 -0
  37. package/dist/auth/AuthManager.js.map +1 -0
  38. package/dist/auth/adapters/GoogleOAuthAdapter.d.ts +164 -0
  39. package/dist/auth/adapters/GoogleOAuthAdapter.d.ts.map +1 -0
  40. package/dist/auth/adapters/GoogleOAuthAdapter.js +521 -0
  41. package/dist/auth/adapters/GoogleOAuthAdapter.js.map +1 -0
  42. package/dist/auth/index.d.ts +45 -0
  43. package/dist/auth/index.d.ts.map +1 -0
  44. package/dist/auth/index.js +54 -0
  45. package/dist/auth/index.js.map +1 -0
  46. package/dist/auth/storage.d.ts +56 -0
  47. package/dist/auth/storage.d.ts.map +1 -0
  48. package/dist/auth/storage.js +78 -0
  49. package/dist/auth/storage.js.map +1 -0
  50. package/dist/auth/types.d.ts +212 -0
  51. package/dist/auth/types.d.ts.map +1 -0
  52. package/dist/auth/types.js +7 -0
  53. package/dist/auth/types.js.map +1 -0
  54. package/dist/index.d.ts +70 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +83 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/recovery/BrowserLifecycle.d.ts +33 -0
  59. package/dist/recovery/BrowserLifecycle.d.ts.map +1 -0
  60. package/dist/recovery/BrowserLifecycle.js +62 -0
  61. package/dist/recovery/BrowserLifecycle.js.map +1 -0
  62. package/dist/recovery/GuestPlayerIdStore.d.ts +17 -0
  63. package/dist/recovery/GuestPlayerIdStore.d.ts.map +1 -0
  64. package/dist/recovery/GuestPlayerIdStore.js +31 -0
  65. package/dist/recovery/GuestPlayerIdStore.js.map +1 -0
  66. package/dist/recovery/KeyValueStorage.d.ts +32 -0
  67. package/dist/recovery/KeyValueStorage.d.ts.map +1 -0
  68. package/dist/recovery/KeyValueStorage.js +58 -0
  69. package/dist/recovery/KeyValueStorage.js.map +1 -0
  70. package/dist/recovery/MobileTransport.d.ts +12 -0
  71. package/dist/recovery/MobileTransport.d.ts.map +1 -0
  72. package/dist/recovery/MobileTransport.js +24 -0
  73. package/dist/recovery/MobileTransport.js.map +1 -0
  74. package/dist/recovery/NetworkQuality.d.ts +22 -0
  75. package/dist/recovery/NetworkQuality.d.ts.map +1 -0
  76. package/dist/recovery/NetworkQuality.js +35 -0
  77. package/dist/recovery/NetworkQuality.js.map +1 -0
  78. package/dist/recovery/RoomPersistence.d.ts +55 -0
  79. package/dist/recovery/RoomPersistence.d.ts.map +1 -0
  80. package/dist/recovery/RoomPersistence.js +68 -0
  81. package/dist/recovery/RoomPersistence.js.map +1 -0
  82. package/dist/recovery/RoomRecoveryController.d.ts +146 -0
  83. package/dist/recovery/RoomRecoveryController.d.ts.map +1 -0
  84. package/dist/recovery/RoomRecoveryController.js +348 -0
  85. package/dist/recovery/RoomRecoveryController.js.map +1 -0
  86. package/dist/recovery/index.d.ts +13 -0
  87. package/dist/recovery/index.d.ts.map +1 -0
  88. package/dist/recovery/index.js +8 -0
  89. package/dist/recovery/index.js.map +1 -0
  90. package/dist/types.d.ts +501 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +6 -0
  93. package/dist/types.js.map +1 -0
  94. package/package.json +66 -0
package/README.md ADDED
@@ -0,0 +1,1037 @@
1
+ # Phalanx Client
2
+
3
+ Client library for [Phalanx Engine](../README.md) - a game-agnostic deterministic lockstep multiplayer engine.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @phalanx-engine/client
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { PhalanxClient } from '@phalanx-engine/client';
15
+
16
+ // Create and connect client
17
+ const client = await PhalanxClient.create({
18
+ serverUrl: 'http://localhost:3000',
19
+ playerId: 'player-123',
20
+ username: 'MyPlayer',
21
+ });
22
+
23
+ // Subscribe to match events
24
+ client.on('matchFound', (match) => {
25
+ console.log(`Match found: ${match.matchId}`);
26
+ });
27
+
28
+ client.on('countdown', (event) => {
29
+ console.log(`Starting in ${event.seconds}...`);
30
+ });
31
+
32
+ client.on('gameStart', () => {
33
+ console.log('Game started!');
34
+ });
35
+
36
+ // Join matchmaking queue
37
+ await client.joinQueue();
38
+
39
+ // --- SIMPLIFIED GAME LOOP API ---
40
+
41
+ // Register tick handler - called for each server tick with all player commands
42
+ client.onTick((tick, commands) => {
43
+ // Process commands from all players
44
+ for (const [playerId, playerCommands] of Object.entries(commands.commands)) {
45
+ for (const cmd of playerCommands) {
46
+ processCommand(playerId, cmd);
47
+ }
48
+ }
49
+ // Run deterministic simulation
50
+ simulation.step();
51
+ });
52
+
53
+ // Register frame handler - called every animation frame (~60fps)
54
+ client.onFrame((alpha, dt) => {
55
+ // Interpolate positions for smooth rendering
56
+ for (const entity of entities) {
57
+ entity.position = lerp(entity.prevPosition, entity.currPosition, alpha);
58
+ }
59
+ // Render the scene
60
+ renderer.render();
61
+ });
62
+
63
+ // Send commands - automatically batched and sent each frame
64
+ client.sendCommand('move', { targetX: 10, targetZ: 20 });
65
+
66
+ // Disconnect when done
67
+ client.destroy();
68
+ ```
69
+
70
+ ## API Reference
71
+
72
+ ### Configuration
73
+
74
+ ```typescript
75
+ interface PhalanxClientConfig {
76
+ serverUrl: string; // Server URL (e.g., 'http://localhost:3000')
77
+ playerId?: string; // Unique player identifier (auto-generated if omitted)
78
+ username?: string; // Display name (auto-generated if omitted)
79
+ authToken?: string; // Auth token for server authentication
80
+ auth?: PhalanxAuthConfig; // OAuth config for managed authentication
81
+ autoReconnect?: boolean; // Auto-reconnect on disconnect (default: true)
82
+ maxReconnectAttempts?: number; // Max reconnection attempts (default: 5)
83
+ reconnectDelayMs?: number; // Delay between attempts (default: 1000)
84
+ connectionTimeoutMs?: number; // Connection timeout (default: 10000)
85
+ tickRate?: number; // Ticks per second, must match server (default: 20)
86
+ pause?: Partial<PauseConfig>; // Pause behavior configuration
87
+ debug?: boolean; // Enable debug logging (default: false)
88
+
89
+ // ── Mobile-friendly transport & recovery ──────────────────────────────────
90
+ mobileFriendlyTransports?: boolean;
91
+ // When true, automatically uses polling on mobile UAs and WebSocket on
92
+ // desktop. Opt-in so games that pin a specific transport are not affected.
93
+ // Ignored when socketTransports is set explicitly.
94
+
95
+ persistGuestPlayerId?: boolean | string;
96
+ // Persist a stable anonymous player id in localStorage across hard reloads.
97
+ // Required for cold-start room recovery of unauthenticated users.
98
+ // Pass true to use the default key 'phalanx:guestPlayerId:v1', or a string
99
+ // to use a custom key. Auth users are unaffected (their id is overwritten
100
+ // by the auth flow as usual).
101
+
102
+ roomRecovery?: PhalanxRoomRecoveryConfig;
103
+ // Configure the mobile-friendly room recovery controller. When omitted, no
104
+ // recovery controller is created. See the Room Recovery section for details.
105
+ }
106
+
107
+ interface PhalanxRoomRecoveryConfig {
108
+ enabled: boolean; // Master switch — must be true to arm the controller
109
+ storageKey?: string; // localStorage key for the room record (default: 'phalanx:activeRoom:v1')
110
+ roomTtlMs?: number; // Local TTL mirroring server RoomService.ROOM_TTL_MS (default: 5 * 60 * 1000)
111
+ storage?: KeyValueStorage; // Custom storage adapter (default: localStorage with memory fallback)
112
+ recoverTimeoutBudget?: RecoverTimeoutBudget; // Per-quality ack timeouts (default: 10s/15s/25s)
113
+ maxRecoverAttempts?: number; // Max backoff retries before emitting 'gave-up' (default: 5)
114
+ preGameStallWatchdog?: boolean; // Auto-arm stall watchdog on matchFound/countdown (default: true)
115
+ preGameStallMs?: number; // Budget before forceRecover fires (default: 4500)
116
+ }
117
+ ```
118
+
119
+ ### Connection
120
+
121
+ ```typescript
122
+ // Recommended: Create and connect in one step
123
+ const client = await PhalanxClient.create({
124
+ serverUrl: 'http://localhost:3000',
125
+ playerId: 'player-123',
126
+ username: 'MyPlayer',
127
+ });
128
+
129
+ // Alternative: Manual connection
130
+ const client = new PhalanxClient(config);
131
+ await client.connect();
132
+
133
+ // Disconnect (stops render loop, clears handlers)
134
+ client.disconnect();
135
+
136
+ // Destroy (disconnect + cleanup all resources)
137
+ client.destroy();
138
+
139
+ // Check connection status
140
+ const connected = client.isConnected();
141
+
142
+ // Get connection state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
143
+ const state = client.getConnectionState();
144
+ ```
145
+
146
+ ### Matchmaking
147
+
148
+ ```typescript
149
+ // Join queue
150
+ const status = await client.joinQueue();
151
+
152
+ // Leave queue
153
+ client.leaveQueue();
154
+
155
+ // Wait for match
156
+ const match = await client.waitForMatch();
157
+
158
+ // Or combine both:
159
+ const match = await client.joinQueueAndWaitForMatch();
160
+ ```
161
+
162
+ ### Game Lifecycle
163
+
164
+ ```typescript
165
+ // Wait for countdown
166
+ await client.waitForCountdown((event) => {
167
+ console.log(`${event.seconds} seconds remaining`);
168
+ });
169
+
170
+ // Wait for game start
171
+ const gameStart = await client.waitForGameStart();
172
+
173
+ // After loading assets and initializing game systems, report ready.
174
+ // The server will not start the tick loop until ALL clients call sendReady().
175
+ await loadAssets();
176
+ initializeGameWorld();
177
+ client.sendReady();
178
+ ```
179
+
180
+ #### sendReady(): void
181
+
182
+ Notify the server that this client has finished loading and is ready to receive ticks. **Must** be called after assets are loaded and game systems are initialized. The server will not start the tick loop until all clients report ready.
183
+
184
+ ```typescript
185
+ // Example: in your game-start handler
186
+ client.on('gameStart', async () => {
187
+ await game.initialize(); // downloads assets, sets up ECS
188
+ client.sendReady(); // signals server to start tick loop
189
+ });
190
+ ```
191
+
192
+ ### Commands
193
+
194
+ ```typescript
195
+ // Submit commands with acknowledgment
196
+ const ack = await client.submitCommands(tick, [
197
+ { type: 'move', data: { x: 10, y: 20 } },
198
+ { type: 'attack', data: { targetId: 'enemy1' } },
199
+ ]);
200
+
201
+ // Submit commands without waiting for ack (fire and forget)
202
+ client.submitCommandsAsync(tick, commands);
203
+ ```
204
+
205
+ ### Simplified Game Loop API (Recommended)
206
+
207
+ The `onTick` and `onFrame` methods provide a simplified API for building game loops. They handle timing, command batching, and interpolation automatically.
208
+
209
+ #### onTick(handler): Unsubscribe
210
+
211
+ Register a callback for simulation ticks. Called when the server sends a tick with commands from all players.
212
+
213
+ ```typescript
214
+ const unsubscribe = client.onTick((tick, commands) => {
215
+ // tick: current tick number
216
+ // commands: { tick, commands: { [playerId]: PlayerCommand[] } }
217
+
218
+ // Process commands from all players
219
+ for (const [playerId, playerCommands] of Object.entries(commands.commands)) {
220
+ for (const cmd of playerCommands) {
221
+ if (cmd.type === 'move') {
222
+ moveEntity(playerId, cmd.data.targetX, cmd.data.targetZ);
223
+ }
224
+ }
225
+ }
226
+
227
+ // Run deterministic simulation step
228
+ physics.update();
229
+ combat.update();
230
+ });
231
+
232
+ // Later: stop receiving tick events
233
+ unsubscribe();
234
+ ```
235
+
236
+ #### onFrame(handler): Unsubscribe
237
+
238
+ Register a callback for render frames. Called every animation frame (~60fps) with interpolation alpha for smooth rendering. The render loop starts automatically when the first handler is registered.
239
+
240
+ ```typescript
241
+ const unsubscribe = client.onFrame((alpha, dt) => {
242
+ // alpha: interpolation value 0-1 (progress between ticks)
243
+ // dt: delta time in seconds since last frame
244
+
245
+ // Interpolate entity positions for smooth visuals
246
+ for (const entity of entities) {
247
+ entity.mesh.position.x = lerp(entity.prevX, entity.currX, alpha);
248
+ entity.mesh.position.z = lerp(entity.prevZ, entity.currZ, alpha);
249
+ }
250
+
251
+ // Render the scene
252
+ scene.render();
253
+ });
254
+
255
+ // Later: stop receiving frame events (render loop stops when no handlers remain)
256
+ unsubscribe();
257
+ ```
258
+
259
+ #### sendCommand(type, data): void
260
+
261
+ Queue a command to be sent to the server. Commands are automatically batched and sent each frame.
262
+
263
+ ```typescript
264
+ // Send movement command
265
+ client.sendCommand('move', { targetX: 10, targetZ: 20 });
266
+
267
+ // Send attack command
268
+ client.sendCommand('attack', { targetId: 'enemy-123' });
269
+
270
+ // Commands are automatically flushed to the server each frame
271
+ ```
272
+
273
+ #### Interpolation Explained
274
+
275
+ The `alpha` value in `onFrame` represents how far we are between the last tick and the next expected tick:
276
+
277
+ - `alpha = 0`: Render at the position from the last received tick
278
+ - `alpha = 0.5`: Render halfway between last tick and expected next tick
279
+ - `alpha = 1`: Render at the expected next tick position
280
+
281
+ This allows smooth 60fps rendering even though the server only sends 20 ticks per second.
282
+
283
+ ### Reconnection
284
+
285
+ ```typescript
286
+ // Manual reconnection to a match
287
+ const state = await client.reconnectToMatch(matchId);
288
+
289
+ // Automatic reconnection with retries
290
+ await client.attemptReconnection();
291
+ ```
292
+
293
+ ### Private Rooms
294
+
295
+ ```typescript
296
+ // Host: create a room and get an invite code
297
+ const { code } = await client.createRoom();
298
+ console.log(`Share this code: ${code}`);
299
+
300
+ // Guest: join by code
301
+ client.joinRoom(code);
302
+
303
+ // Host: cancel the room before anyone joins
304
+ client.cancelRoom();
305
+
306
+ // Host: reclaim a room after a transient socket disconnect
307
+ // (e.g. mobile OS killed the WebSocket while the host was sharing the link)
308
+ await client.recoverRoom(code);
309
+ ```
310
+
311
+ ### Mobile-Friendly Room Recovery
312
+
313
+ Private rooms on mobile browsers face a known failure mode: when the host copies the invite link into a messenger app, the OS may suspend the WebSocket. Without recovery the room is silently lost. The engine's `RoomRecoveryController` handles the full lifecycle — browser-lifecycle wiring, exponential-backoff retry, localStorage persistence, and a pre-game stall watchdog.
314
+
315
+ #### Enabling recovery
316
+
317
+ ```typescript
318
+ const client = new PhalanxClient({
319
+ serverUrl: 'https://game.example.com',
320
+
321
+ // Auto-select polling on mobile UAs, WebSocket on desktop.
322
+ // Opt-in so games that pin a transport are not affected.
323
+ mobileFriendlyTransports: true,
324
+
325
+ // Persist a stable anonymous id across page reloads so cold-start
326
+ // recovery can validate the persisted room against the current player.
327
+ persistGuestPlayerId: true, // or a custom string key
328
+
329
+ // Arm the recovery controller.
330
+ roomRecovery: {
331
+ enabled: true,
332
+ // Optional overrides (defaults mirror server PrivateRoomService values):
333
+ // storageKey: 'myapp:activeRoom:v1',
334
+ // roomTtlMs: 5 * 60 * 1000,
335
+ // maxRecoverAttempts: 5,
336
+ // preGameStallMs: 4500,
337
+ },
338
+ });
339
+ ```
340
+
341
+ #### App startup — cold-start recovery
342
+
343
+ Call `loadColdStartCode()` on app load. If localStorage holds a non-expired host record matching the current player id, it returns the room code so the app can immediately reclaim the room instead of showing the main menu.
344
+
345
+ ```typescript
346
+ const code = client.roomRecovery!.loadColdStartCode();
347
+ if (code) {
348
+ showWaitingScreen(code);
349
+ client.roomRecovery!.resumeTrackingHost(code);
350
+ await client.roomRecovery!.tryRecover();
351
+ // Either the server replays match-found → game-start synchronously
352
+ // (pending-recover path) or we sit on the waiting screen until the
353
+ // guest re-joins or the room TTL expires.
354
+ }
355
+ ```
356
+
357
+ #### Host flow
358
+
359
+ ```typescript
360
+ // After createRoom() — arm browser-lifecycle and socket hooks,
361
+ // persist the room to localStorage.
362
+ const { code } = await client.createRoom();
363
+ client.roomRecovery!.startTrackingHost(code);
364
+
365
+ // When the match is fully underway — stop recovery, clear persistence.
366
+ client.on('gameStart', () => {
367
+ client.roomRecovery!.stop();
368
+ startGame();
369
+ });
370
+ ```
371
+
372
+ #### Guest flow
373
+
374
+ ```typescript
375
+ // After deciding to join — persist in case the browser backgrounds
376
+ // the tab between room-join and match-found.
377
+ client.roomRecovery!.trackGuestJoin(code);
378
+ client.joinRoom(code);
379
+
380
+ // Clear when the match starts.
381
+ client.on('gameStart', () => {
382
+ client.roomRecovery!.stop();
383
+ startGame();
384
+ });
385
+ ```
386
+
387
+ #### Cancelling
388
+
389
+ ```typescript
390
+ // Cancel button / user leaves — stops the state machine and clears localStorage.
391
+ client.cancelRoom();
392
+ client.roomRecovery!.stop();
393
+ ```
394
+
395
+ #### Listening to recovery events
396
+
397
+ The controller emits status on the same `client.on(...)` bus as all other events — no separate emitter to wire up.
398
+
399
+ ```typescript
400
+ client.on('recoveryStatus', (event) => {
401
+ switch (event.phase) {
402
+ case 'idle':
403
+ clearStatusBanner();
404
+ break;
405
+ case 'recovering':
406
+ showStatus(`Reconnecting… (attempt ${event.attempt})`);
407
+ break;
408
+ case 'waiting-network':
409
+ showStatus('Waiting for network…');
410
+ break;
411
+ case 'retrying':
412
+ showStatus(`Lost connection. Retrying in ${Math.ceil(event.nextRetryMs! / 1000)}s…`);
413
+ break;
414
+ case 'gave-up':
415
+ showStatus('Could not reconnect. Use Cancel to go back.');
416
+ break;
417
+ }
418
+ });
419
+
420
+ client.on('roomTerminated', (event) => {
421
+ clearStatusBanner();
422
+ if (event.reason === 'expired') showError('Room expired');
423
+ if (event.reason === 'not-found') showError('Room no longer exists');
424
+ // 'cancelled' is usually silent (own Cancel button triggered it)
425
+ returnToMainMenu();
426
+ });
427
+ ```
428
+
429
+ #### `RoomRecoveryController` API
430
+
431
+ Accessed via `client.roomRecovery` (null when not configured):
432
+
433
+ | Method | Description |
434
+ |---|---|
435
+ | `loadColdStartCode()` | Read persisted host record; returns code or null |
436
+ | `startTrackingHost(code)` | Arm hooks + persist room (host created room) |
437
+ | `resumeTrackingHost(code)` | Arm hooks only (cold-start, record already persisted) |
438
+ | `trackGuestJoin(code)` | Persist guest-side join for cold-start surfacing |
439
+ | `tryRecover()` | Attempt one recovery cycle (idempotent, self-retries) |
440
+ | `forceRecover(reason)` | Force-disconnect then recover (bypasses stale socket) |
441
+ | `stop()` | Disarm all hooks, clear persistence |
442
+ | `hasActiveRoom()` | True while a room is being tracked |
443
+ | `getActiveRoomCode()` | Current tracked room code or null |
444
+ | `armPreGameStallWatchdog(reason)` | Manually arm the stall timer (auto-armed by default) |
445
+ | `clearPreGameStallWatchdog()` | Cancel a pending stall timer |
446
+
447
+ #### Built-in helpers (also exported from `phalanx-client`)
448
+
449
+ | Export | Description |
450
+ |---|---|
451
+ | `isMobileBrowser()` | UA-based mobile detection |
452
+ | `pickMobileFriendlyTransports()` | Returns `['polling']` on mobile, `['websocket']` on desktop |
453
+ | `loadOrCreateGuestPlayerId(key, storage?)` | Stable guest id across reloads |
454
+ | `RoomPersistence` | localStorage room record (configurable key, TTL, storage adapter) |
455
+ | `getRecoverTimeoutMs(budget?)` | Adapt recover ack timeout to `navigator.connection` |
456
+ | `armBrowserLifecycle(handlers)` | Wire visibility/pageshow/online listeners |
457
+ | `LocalStorageAdapter` / `MemoryKeyValueStorage` | Storage adapters for the persistence layer |
458
+
459
+ #### Custom storage adapter
460
+
461
+ React Native / Capacitor / Electron apps that cannot use `localStorage` synchronously:
462
+
463
+ ```typescript
464
+ import type { KeyValueStorage } from '@phalanx-engine/client';
465
+
466
+ // Synchronous wrapper around any native key-value store:
467
+ class CapacitorStorageAdapter implements KeyValueStorage {
468
+ private cache = new Map<string, string>();
469
+ // Populate cache eagerly on startup with Preferences.get(…)
470
+
471
+ getItem(key: string): string | null {
472
+ return this.cache.get(key) ?? null;
473
+ }
474
+ setItem(key: string, value: string): void {
475
+ this.cache.set(key, value);
476
+ void Preferences.set({ key, value }); // fire-and-forget
477
+ }
478
+ removeItem(key: string): void {
479
+ this.cache.delete(key);
480
+ void Preferences.remove({ key });
481
+ }
482
+ }
483
+
484
+ const client = new PhalanxClient({
485
+ serverUrl,
486
+ roomRecovery: {
487
+ enabled: true,
488
+ storage: new CapacitorStorageAdapter(),
489
+ },
490
+ });
491
+ ```
492
+
493
+ ### Desync Detection
494
+
495
+ Desync detection helps identify when game state diverges between clients. This is critical for deterministic lockstep games where all clients must maintain identical simulation state.
496
+
497
+ #### How It Works
498
+
499
+ 1. **Game computes state hashes** at regular intervals (e.g., every 20 ticks)
500
+ 2. **Client submits hashes** to the server via `submitStateHash()`
501
+ 3. **Server compares hashes** from all clients
502
+ 4. **Server broadcasts results** to all clients
503
+ 5. **Client emits `desync` event** if hashes don't match
504
+
505
+ #### Submitting State Hashes
506
+
507
+ ```typescript
508
+ import { PhalanxClient, StateHasher } from '@phalanx-engine/client';
509
+
510
+ // In your tick handler
511
+ client.onTick((tick, commands) => {
512
+ // Run simulation
513
+ simulation.processTick(tick, commands);
514
+
515
+ // Submit state hash every 20 ticks (once per second at 20 TPS)
516
+ if (tick % 20 === 0) {
517
+ const hash = computeStateHash(tick);
518
+ client.submitStateHash(tick, hash);
519
+ }
520
+ });
521
+ ```
522
+
523
+ #### Using StateHasher
524
+
525
+ The `StateHasher` utility provides a deterministic FNV-1a hash implementation:
526
+
527
+ ```typescript
528
+ import { StateHasher } from '@phalanx-engine/client';
529
+
530
+ function computeStateHash(tick: number): string {
531
+ const hasher = new StateHasher();
532
+
533
+ // Add tick number
534
+ hasher.addInt(tick);
535
+
536
+ // Add entity data (sorted by ID for determinism)
537
+ const sortedEntities = [...entities].sort((a, b) => a.id.localeCompare(b.id));
538
+ hasher.addInt(sortedEntities.length);
539
+
540
+ for (const entity of sortedEntities) {
541
+ hasher.addString(entity.id);
542
+ hasher.addFloat(entity.x);
543
+ hasher.addFloat(entity.y);
544
+ hasher.addFloat(entity.z);
545
+ hasher.addInt(entity.health);
546
+ hasher.addString(entity.state);
547
+ }
548
+
549
+ return hasher.finalize();
550
+ }
551
+ ```
552
+
553
+ #### StateHasher API
554
+
555
+ ```typescript
556
+ const hasher = new StateHasher();
557
+
558
+ // Add primitive values
559
+ hasher.addInt(42); // Integer
560
+ hasher.addFloat(3.14159); // Float (converted to fixed-point)
561
+ hasher.addString("entity-123"); // String
562
+ hasher.addBool(true); // Boolean
563
+
564
+ // Add arrays
565
+ hasher.addIntArray([1, 2, 3]); // Array of integers
566
+ hasher.addFloatArray([1.5, 2.5]); // Array of floats
567
+
568
+ // Get final hash (8-char hex string)
569
+ const hash = hasher.finalize(); // e.g., "a1b2c3d4"
570
+
571
+ // Reset for reuse
572
+ hasher.reset();
573
+ ```
574
+
575
+ #### Handling Desync Events
576
+
577
+ ```typescript
578
+ // Listen for desync events
579
+ client.on('desync', (event) => {
580
+ console.error('Desync detected!');
581
+ console.error(`Tick: ${event.tick}`);
582
+ console.error(`Local hash: ${event.localHash}`);
583
+ console.error(`Remote hashes:`, event.remoteHashes);
584
+
585
+ // Options:
586
+ // 1. Log for debugging
587
+ // 2. Show error to players
588
+ // 3. Attempt recovery (rare)
589
+ });
590
+
591
+ // Listen for match end due to desync
592
+ client.on('matchEnd', (event) => {
593
+ if (event.reason === 'desync') {
594
+ console.error('Match ended due to desync');
595
+ console.error('Details:', event.details);
596
+ // event.details contains { tick, hashes }
597
+ }
598
+ });
599
+ ```
600
+
601
+ #### Configuring Desync Detection
602
+
603
+ ```typescript
604
+ // Enable/disable desync detection
605
+ client.configureDesyncDetection({ enabled: true });
606
+
607
+ // Limit stored hashes (for memory optimization)
608
+ client.configureDesyncDetection({ maxStoredHashes: 50 });
609
+ ```
610
+
611
+ #### Server Configuration
612
+
613
+ The server can be configured to take different actions on desync:
614
+
615
+ ```typescript
616
+ // phalanx-server configuration
617
+ const phalanx = new Phalanx({
618
+ enableStateHashing: true,
619
+ desync: {
620
+ enabled: true,
621
+ action: 'end-match', // 'log-only' | 'end-match'
622
+ gracePeriodTicks: 1, // Consecutive desyncs before action
623
+ },
624
+ });
625
+ ```
626
+
627
+ | Option | Description | Default |
628
+ | ------------------ | -------------------------------------------------- | ------------ |
629
+ | `enabled` | Enable desync detection | `true` |
630
+ | `action` | Action on confirmed desync | `'end-match'`|
631
+ | `gracePeriodTicks` | Consecutive desyncs required before taking action | `1` |
632
+
633
+ #### Testing Desync Detection
634
+
635
+ To test desync detection during development:
636
+
637
+ ```typescript
638
+ // Intentionally cause a desync for testing
639
+ client.onTick((tick, commands) => {
640
+ simulation.processTick(tick, commands);
641
+
642
+ if (tick % 20 === 0) {
643
+ let hash = computeStateHash(tick);
644
+
645
+ // Force desync on a specific tick for testing
646
+ if (tick === 100 && client.getPlayerId() === 'player-1') {
647
+ hash = 'intentionally-wrong-hash';
648
+ }
649
+
650
+ client.submitStateHash(tick, hash);
651
+ }
652
+ });
653
+ ```
654
+
655
+ ### Pause / Resume
656
+
657
+ ```typescript
658
+ // Pause the game (notifies server, which broadcasts to all clients)
659
+ client.pauseGame();
660
+
661
+ // Resume the game
662
+ client.resumeGame();
663
+
664
+ // Listen for pause/resume events
665
+ client.on('gamePaused', (event) => {
666
+ console.log(`Game paused by ${event.requestedBy} at tick ${event.lastTick}`);
667
+ });
668
+
669
+ client.on('gameResumed', (event) => {
670
+ console.log(`Game resumed by ${event.requestedBy}`);
671
+ });
672
+ ```
673
+
674
+ ### Authentication
675
+
676
+ For games that require authentication (e.g., ranked matchmaking), `PhalanxClient` supports managed OAuth:
677
+
678
+ ```typescript
679
+ const client = await PhalanxClient.create({
680
+ serverUrl: 'http://localhost:3000',
681
+ auth: {
682
+ provider: 'google',
683
+ google: {
684
+ clientId: 'your-google-client-id',
685
+ tokenExchangeUrl: 'http://localhost:3000/auth/token',
686
+ },
687
+ },
688
+ });
689
+
690
+ // Trigger login flow
691
+ client.login();
692
+
693
+ // Check auth state
694
+ const authState = client.getAuthState();
695
+ console.log(authState.isAuthenticated, authState.user);
696
+
697
+ // Listen for auth changes
698
+ client.on('authStateChanged', (state) => {
699
+ console.log('Auth changed:', state.isAuthenticated);
700
+ });
701
+
702
+ // Logout
703
+ await client.logout();
704
+ ```
705
+
706
+ ### State Getters
707
+
708
+ ```typescript
709
+ const tick = client.getCurrentTick();
710
+ const matchId = client.getMatchId();
711
+ const playerId = client.getPlayerId();
712
+ const username = client.getUsername();
713
+ const clientState = client.getClientState();
714
+ ```
715
+
716
+ ### Events
717
+
718
+ ```typescript
719
+ // Connection events
720
+ client.on('connected', () => {});
721
+ client.on('disconnected', () => {});
722
+ client.on('reconnecting', (attempt) => {});
723
+ client.on('reconnectFailed', () => {});
724
+ client.on('error', (error) => {});
725
+
726
+ // Queue events
727
+ client.on('queueJoined', (status) => {});
728
+ client.on('queueLeft', () => {});
729
+ client.on('queueError', (error) => {});
730
+
731
+ // Match events
732
+ client.on('matchFound', (event) => {});
733
+ client.on('countdown', (event) => {});
734
+ client.on('gameStart', (event) => {});
735
+ client.on('matchEnd', (event) => {});
736
+
737
+ // Pause events
738
+ client.on('gamePaused', (event) => {});
739
+ client.on('gameResumed', (event) => {});
740
+
741
+ // Tick events
742
+ client.on('tick', (event) => {});
743
+ client.on('commands', (event) => {});
744
+
745
+ // Player events
746
+ client.on('playerDisconnected', (event) => {});
747
+ client.on('playerReconnected', (event) => {});
748
+ client.on('playerReady', (event) => {}); // A player reported ready (local or remote)
749
+
750
+ // Reconnection events
751
+ client.on('reconnectState', (event) => {});
752
+ client.on('reconnectStatus', (event) => {});
753
+
754
+ // Auth events
755
+ client.on('authStateChanged', (state) => {});
756
+ client.on('authError', (error) => {});
757
+
758
+ // Desync detection events
759
+ client.on('desync', (event) => {}); // Local hash mismatch detected
760
+
761
+ // Private room events
762
+ client.on('roomCreated', (event) => {}); // { code: string }
763
+ client.on('roomError', (event) => {}); // { message: string }
764
+ client.on('roomExpired', (event) => {}); // { code: string } — server evicted the room (TTL)
765
+ client.on('roomCancelled',(event) => {}); // { code: string } — host explicitly cancelled
766
+ client.on('roomRecovered',(event) => {}); // { code: string } — room reclaimed after disconnect
767
+
768
+ // Room recovery events (emitted by RoomRecoveryController when roomRecovery.enabled: true)
769
+ client.on('recoveryStatus', (event) => {
770
+ // event.phase: 'idle' | 'recovering' | 'waiting-network' | 'retrying' | 'gave-up'
771
+ // event.attempt?: number — 1-based attempt count (when recovering / retrying)
772
+ // event.nextRetryMs?: number — ms until next auto-retry (when retrying)
773
+ });
774
+ client.on('roomTerminated', (event) => {
775
+ // event.reason: 'expired' | 'not-found' | 'cancelled'
776
+ // Terminal: recovery has stopped and the room can no longer be claimed.
777
+ });
778
+
779
+ // Unsubscribe
780
+ const unsubscribe = client.on('tick', handler);
781
+ unsubscribe();
782
+
783
+ // Or manual
784
+ client.off('tick', handler);
785
+
786
+ // Remove all listeners
787
+ client.removeAllListeners();
788
+ ```
789
+
790
+ ## Client States
791
+
792
+ The client tracks its lifecycle state:
793
+
794
+ | State | Description |
795
+ | -------------- | ---------------------------------- |
796
+ | `idle` | Not in queue or match |
797
+ | `in-queue` | Waiting in matchmaking queue |
798
+ | `match-found` | Match found, waiting for countdown |
799
+ | `countdown` | Countdown in progress |
800
+ | `playing` | Game is active |
801
+ | `paused` | Game is paused |
802
+ | `reconnecting` | Attempting to reconnect to match |
803
+ | `finished` | Match has ended |
804
+
805
+
806
+ ## Example: Complete Game Loop (Simplified API)
807
+
808
+ ```typescript
809
+ import { PhalanxClient, type CommandsBatch } from '@phalanx-engine/client';
810
+
811
+ class GameClient {
812
+ private client: PhalanxClient | null = null;
813
+ private entities: Map<string, Entity> = new Map();
814
+ private unsubscribers: (() => void)[] = [];
815
+
816
+ async start(serverUrl: string, playerId: string, username: string): Promise<void> {
817
+ // Create and connect client
818
+ this.client = await PhalanxClient.create({
819
+ serverUrl,
820
+ playerId,
821
+ username,
822
+ autoReconnect: true,
823
+ });
824
+
825
+ // Subscribe to match lifecycle events
826
+ this.unsubscribers.push(
827
+ this.client.on('matchFound', (match) => {
828
+ console.log(`Match found: ${match.matchId}`);
829
+ this.initializeEntities(match);
830
+ })
831
+ );
832
+
833
+ this.unsubscribers.push(
834
+ this.client.on('gameStart', () => {
835
+ console.log('Game started!');
836
+ })
837
+ );
838
+
839
+ this.unsubscribers.push(
840
+ this.client.on('matchEnd', ({ reason }) => {
841
+ console.log(`Match ended: ${reason}`);
842
+ this.stop();
843
+ })
844
+ );
845
+
846
+ // Register tick handler - deterministic simulation
847
+ this.unsubscribers.push(
848
+ this.client.onTick((tick, commands) => {
849
+ this.handleTick(tick, commands);
850
+ })
851
+ );
852
+
853
+ // Register frame handler - rendering with interpolation
854
+ this.unsubscribers.push(
855
+ this.client.onFrame((alpha, dt) => {
856
+ this.handleFrame(alpha, dt);
857
+ })
858
+ );
859
+
860
+ // Join matchmaking queue
861
+ await this.client.joinQueue();
862
+ }
863
+
864
+ private initializeEntities(match: MatchFoundEvent): void {
865
+ // Create entities for all players
866
+ const allPlayers = [
867
+ { playerId: match.playerId, username: 'You' },
868
+ ...match.teammates,
869
+ ...match.opponents,
870
+ ];
871
+ for (const player of allPlayers) {
872
+ this.entities.set(player.playerId, new Entity(player.playerId));
873
+ }
874
+ }
875
+
876
+ private handleTick(tick: number, commands: CommandsBatch): void {
877
+ // Store previous positions for interpolation
878
+ for (const entity of this.entities.values()) {
879
+ entity.prevX = entity.currX;
880
+ entity.prevZ = entity.currZ;
881
+ }
882
+
883
+ // Process commands from all players
884
+ for (const [playerId, playerCommands] of Object.entries(commands.commands)) {
885
+ for (const cmd of playerCommands) {
886
+ this.processCommand(playerId, cmd);
887
+ }
888
+ }
889
+
890
+ // Run deterministic simulation step
891
+ this.simulate();
892
+ }
893
+
894
+ private handleFrame(alpha: number, dt: number): void {
895
+ // Interpolate entity positions for smooth 60fps rendering
896
+ for (const entity of this.entities.values()) {
897
+ entity.renderX = lerp(entity.prevX, entity.currX, alpha);
898
+ entity.renderZ = lerp(entity.prevZ, entity.currZ, alpha);
899
+ }
900
+
901
+ // Render the scene
902
+ renderer.render();
903
+ }
904
+
905
+ private processCommand(playerId: string, cmd: PlayerCommand): void {
906
+ const entity = this.entities.get(playerId);
907
+ if (!entity) return;
908
+
909
+ if (cmd.type === 'move') {
910
+ entity.targetX = cmd.data.targetX;
911
+ entity.targetZ = cmd.data.targetZ;
912
+ }
913
+ }
914
+
915
+ private simulate(): void {
916
+ // Move entities toward their targets
917
+ for (const entity of this.entities.values()) {
918
+ entity.moveTowardTarget();
919
+ }
920
+ }
921
+
922
+ // Call this when player clicks to move
923
+ move(targetX: number, targetZ: number): void {
924
+ this.client?.sendCommand('move', { targetX, targetZ });
925
+ }
926
+
927
+ async stop(): Promise<void> {
928
+ // Unsubscribe from all events
929
+ for (const unsub of this.unsubscribers) {
930
+ unsub();
931
+ }
932
+ this.unsubscribers = [];
933
+
934
+ // Cleanup client
935
+ await this.client?.destroy();
936
+ this.client = null;
937
+ }
938
+ }
939
+
940
+ // Usage
941
+ const game = new GameClient();
942
+ await game.start('http://localhost:3000', 'player-1', 'Alice');
943
+
944
+ // Handle player input
945
+ canvas.addEventListener('click', (e) => {
946
+ const worldPos = screenToWorld(e.clientX, e.clientY);
947
+ game.move(worldPos.x, worldPos.z);
948
+ });
949
+ ```
950
+
951
+ ## Example: Legacy Event-Based API
952
+
953
+ For more control or backward compatibility, you can use the event-based API:
954
+
955
+ ```typescript
956
+ import { PhalanxClient, TickSyncEvent, PlayerCommand } from '@phalanx-engine/client';
957
+
958
+ class LegacyGameClient {
959
+ private client: PhalanxClient;
960
+ private pendingCommands: PlayerCommand[] = [];
961
+
962
+ constructor(serverUrl: string, playerId: string, username: string) {
963
+ this.client = new PhalanxClient({
964
+ serverUrl,
965
+ playerId,
966
+ username,
967
+ autoReconnect: true,
968
+ });
969
+ }
970
+
971
+ async start(): Promise<void> {
972
+ // Connect and find match
973
+ await this.client.connect();
974
+ await this.client.joinQueueAndWaitForMatch();
975
+ await this.client.waitForGameStart();
976
+
977
+ // Setup event handlers
978
+ this.client.on('tick', this.handleTick.bind(this));
979
+ this.client.on('commands', this.handleCommands.bind(this));
980
+ this.client.on('playerDisconnected', ({ playerId }) => {
981
+ console.log(`Player ${playerId} disconnected`);
982
+ });
983
+ this.client.on('matchEnd', ({ reason }) => {
984
+ console.log(`Match ended: ${reason}`);
985
+ });
986
+
987
+ // Start your own render loop
988
+ this.startRenderLoop();
989
+ }
990
+
991
+ private handleTick(event: TickSyncEvent): void {
992
+ // Submit any pending commands for next tick
993
+ if (this.pendingCommands.length > 0) {
994
+ this.client.submitCommandsAsync(event.tick + 1, this.pendingCommands);
995
+ this.pendingCommands = [];
996
+ }
997
+ }
998
+
999
+ private handleCommands(event: { tick: number; commands: PlayerCommand[] }): void {
1000
+ // Process commands from all players for deterministic simulation
1001
+ for (const command of event.commands) {
1002
+ this.processCommand(command);
1003
+ }
1004
+ }
1005
+
1006
+ private processCommand(command: PlayerCommand): void {
1007
+ console.log('Processing command:', command);
1008
+ }
1009
+
1010
+ private startRenderLoop(): void {
1011
+ const loop = () => {
1012
+ // Your rendering logic here
1013
+ requestAnimationFrame(loop);
1014
+ };
1015
+ requestAnimationFrame(loop);
1016
+ }
1017
+
1018
+ addCommand(type: string, data: unknown): void {
1019
+ this.pendingCommands.push({ type, data });
1020
+ }
1021
+
1022
+ stop(): void {
1023
+ this.client.disconnect();
1024
+ }
1025
+ }
1026
+
1027
+ // Usage
1028
+ const game = new GameClient('http://localhost:3000', 'player1', 'Alice');
1029
+ await game.start();
1030
+
1031
+ // Add commands based on player input
1032
+ game.addCommand('move', { x: 100, y: 200 });
1033
+ ```
1034
+
1035
+ ## License
1036
+
1037
+ MIT