@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.
- package/README.md +1037 -0
- package/dist/DesyncDetector.d.ts +80 -0
- package/dist/DesyncDetector.d.ts.map +1 -0
- package/dist/DesyncDetector.js +93 -0
- package/dist/DesyncDetector.js.map +1 -0
- package/dist/DeterministicRandom.d.ts +78 -0
- package/dist/DeterministicRandom.d.ts.map +1 -0
- package/dist/DeterministicRandom.js +122 -0
- package/dist/DeterministicRandom.js.map +1 -0
- package/dist/EventEmitter.d.ts +65 -0
- package/dist/EventEmitter.d.ts.map +1 -0
- package/dist/EventEmitter.js +102 -0
- package/dist/EventEmitter.js.map +1 -0
- package/dist/FixedMath.d.ts +22 -0
- package/dist/FixedMath.d.ts.map +1 -0
- package/dist/FixedMath.js +26 -0
- package/dist/FixedMath.js.map +1 -0
- package/dist/PhalanxClient.d.ts +335 -0
- package/dist/PhalanxClient.d.ts.map +1 -0
- package/dist/PhalanxClient.js +844 -0
- package/dist/PhalanxClient.js.map +1 -0
- package/dist/RenderLoop.d.ts +95 -0
- package/dist/RenderLoop.d.ts.map +1 -0
- package/dist/RenderLoop.js +192 -0
- package/dist/RenderLoop.js.map +1 -0
- package/dist/SocketManager.d.ts +228 -0
- package/dist/SocketManager.d.ts.map +1 -0
- package/dist/SocketManager.js +584 -0
- package/dist/SocketManager.js.map +1 -0
- package/dist/StateHasher.d.ts +76 -0
- package/dist/StateHasher.d.ts.map +1 -0
- package/dist/StateHasher.js +129 -0
- package/dist/StateHasher.js.map +1 -0
- package/dist/auth/AuthManager.d.ts +188 -0
- package/dist/auth/AuthManager.d.ts.map +1 -0
- package/dist/auth/AuthManager.js +462 -0
- package/dist/auth/AuthManager.js.map +1 -0
- package/dist/auth/adapters/GoogleOAuthAdapter.d.ts +164 -0
- package/dist/auth/adapters/GoogleOAuthAdapter.d.ts.map +1 -0
- package/dist/auth/adapters/GoogleOAuthAdapter.js +521 -0
- package/dist/auth/adapters/GoogleOAuthAdapter.js.map +1 -0
- package/dist/auth/index.d.ts +45 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +54 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/storage.d.ts +56 -0
- package/dist/auth/storage.d.ts.map +1 -0
- package/dist/auth/storage.js +78 -0
- package/dist/auth/storage.js.map +1 -0
- package/dist/auth/types.d.ts +212 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +7 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/recovery/BrowserLifecycle.d.ts +33 -0
- package/dist/recovery/BrowserLifecycle.d.ts.map +1 -0
- package/dist/recovery/BrowserLifecycle.js +62 -0
- package/dist/recovery/BrowserLifecycle.js.map +1 -0
- package/dist/recovery/GuestPlayerIdStore.d.ts +17 -0
- package/dist/recovery/GuestPlayerIdStore.d.ts.map +1 -0
- package/dist/recovery/GuestPlayerIdStore.js +31 -0
- package/dist/recovery/GuestPlayerIdStore.js.map +1 -0
- package/dist/recovery/KeyValueStorage.d.ts +32 -0
- package/dist/recovery/KeyValueStorage.d.ts.map +1 -0
- package/dist/recovery/KeyValueStorage.js +58 -0
- package/dist/recovery/KeyValueStorage.js.map +1 -0
- package/dist/recovery/MobileTransport.d.ts +12 -0
- package/dist/recovery/MobileTransport.d.ts.map +1 -0
- package/dist/recovery/MobileTransport.js +24 -0
- package/dist/recovery/MobileTransport.js.map +1 -0
- package/dist/recovery/NetworkQuality.d.ts +22 -0
- package/dist/recovery/NetworkQuality.d.ts.map +1 -0
- package/dist/recovery/NetworkQuality.js +35 -0
- package/dist/recovery/NetworkQuality.js.map +1 -0
- package/dist/recovery/RoomPersistence.d.ts +55 -0
- package/dist/recovery/RoomPersistence.d.ts.map +1 -0
- package/dist/recovery/RoomPersistence.js +68 -0
- package/dist/recovery/RoomPersistence.js.map +1 -0
- package/dist/recovery/RoomRecoveryController.d.ts +146 -0
- package/dist/recovery/RoomRecoveryController.d.ts.map +1 -0
- package/dist/recovery/RoomRecoveryController.js +348 -0
- package/dist/recovery/RoomRecoveryController.js.map +1 -0
- package/dist/recovery/index.d.ts +13 -0
- package/dist/recovery/index.d.ts.map +1 -0
- package/dist/recovery/index.js +8 -0
- package/dist/recovery/index.js.map +1 -0
- package/dist/types.d.ts +501 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- 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
|