@phalanx-engine/client
v0.1.4
Published
Client library for Phalanx Engine - a game-agnostic deterministic lockstep multiplayer engine
Maintainers
Readme
Phalanx Client
Client library for Phalanx Engine - a game-agnostic deterministic lockstep multiplayer engine.
Table of Contents
- Installation
- Requirements
- Quick Start
- Complete Game Loop Example
- Common Tasks
- Choosing an API
- API Reference
- Advanced Topics
- State Getters
- Client States
- Example: Legacy Event-Based API
- License
Installation
npm install @phalanx-engine/clientRequirements
- Node.js
>= 24.0.0
Quick Start
import { PhalanxClient } from '@phalanx-engine/client';
// Create and connect client
const client = await PhalanxClient.create({
serverUrl: 'http://localhost:3000',
playerId: 'player-123',
username: 'MyPlayer',
});
// Subscribe to match events
client.on('matchFound', (match) => {
console.log(`Match found: ${match.matchId}`);
});
client.on('countdown', (event) => {
console.log(`Starting in ${event.seconds}...`);
});
client.on('gameStart', () => {
console.log('Game started!');
});
// Join matchmaking queue
await client.joinQueue();
// --- SIMPLIFIED GAME LOOP API ---
// Register tick handler - called for each server tick with all player commands
client.onTick((tick, commands) => {
// Process commands from all players
for (const [playerId, playerCommands] of Object.entries(commands.commands)) {
for (const cmd of playerCommands) {
processCommand(playerId, cmd);
}
}
// Run deterministic simulation
simulation.step();
});
// Register frame handler - called every animation frame (~60fps)
client.onFrame((alpha, dt) => {
// Interpolate positions for smooth rendering
for (const entity of entities) {
entity.position = lerp(entity.prevPosition, entity.currPosition, alpha);
}
// Render the scene
renderer.render();
});
// Send commands - automatically batched and sent each frame
client.sendCommand('move', { targetX: 10, targetZ: 20 });
// Disconnect when done
client.destroy();Complete Game Loop Example
This is the recommended way to wire a full game client:
import { PhalanxClient, type CommandsBatch } from '@phalanx-engine/client';
class GameClient {
private client: PhalanxClient | null = null;
private entities: Map<string, Entity> = new Map();
private unsubscribers: (() => void)[] = [];
async start(
serverUrl: string,
playerId: string,
username: string
): Promise<void> {
// Create and connect client
this.client = await PhalanxClient.create({
serverUrl,
playerId,
username,
autoReconnect: true,
});
// Subscribe to match lifecycle events
this.unsubscribers.push(
this.client.on('matchFound', (match) => {
console.log(`Match found: ${match.matchId}`);
this.initializeEntities(match);
})
);
this.unsubscribers.push(
this.client.on('gameStart', () => {
console.log('Game started!');
})
);
this.unsubscribers.push(
this.client.on('matchEnd', ({ reason }) => {
console.log(`Match ended: ${reason}`);
this.stop();
})
);
// Register tick handler - deterministic simulation
this.unsubscribers.push(
this.client.onTick((tick, commands) => {
this.handleTick(tick, commands);
})
);
// Register frame handler - rendering with interpolation
this.unsubscribers.push(
this.client.onFrame((alpha, dt) => {
this.handleFrame(alpha, dt);
})
);
// Join matchmaking queue
await this.client.joinQueue();
}
private initializeEntities(match: MatchFoundEvent): void {
// Create entities for all players
const allPlayers = [
{ playerId: match.playerId, username: 'You' },
...match.teammates,
...match.opponents,
];
for (const player of allPlayers) {
this.entities.set(player.playerId, new Entity(player.playerId));
}
}
private handleTick(tick: number, commands: CommandsBatch): void {
// Store previous positions for interpolation
for (const entity of this.entities.values()) {
entity.prevX = entity.currX;
entity.prevZ = entity.currZ;
}
// Process commands from all players
for (const [playerId, playerCommands] of Object.entries(
commands.commands
)) {
for (const cmd of playerCommands) {
this.processCommand(playerId, cmd);
}
}
// Run deterministic simulation step
this.simulate();
}
private handleFrame(alpha: number, dt: number): void {
// Interpolate entity positions for smooth 60fps rendering
for (const entity of this.entities.values()) {
entity.renderX = lerp(entity.prevX, entity.currX, alpha);
entity.renderZ = lerp(entity.prevZ, entity.currZ, alpha);
}
// Render the scene
renderer.render();
}
private processCommand(playerId: string, cmd: PlayerCommand): void {
const entity = this.entities.get(playerId);
if (!entity) return;
if (cmd.type === 'move') {
entity.targetX = cmd.data.targetX;
entity.targetZ = cmd.data.targetZ;
}
}
private simulate(): void {
// Move entities toward their targets
for (const entity of this.entities.values()) {
entity.moveTowardTarget();
}
}
// Call this when player clicks to move
move(targetX: number, targetZ: number): void {
this.client?.sendCommand('move', { targetX, targetZ });
}
async stop(): Promise<void> {
// Unsubscribe from all events
for (const unsub of this.unsubscribers) {
unsub();
}
this.unsubscribers = [];
// Cleanup client
await this.client?.destroy();
this.client = null;
}
}
// Usage
const game = new GameClient();
await game.start('http://localhost:3000', 'player-1', 'Alice');
// Handle player input
canvas.addEventListener('click', (e) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
game.move(worldPos.x, worldPos.z);
});Common Tasks
Connect to a server
// Recommended: create and connect in one step
const client = await PhalanxClient.create({
serverUrl: 'http://localhost:3000',
playerId: 'player-123',
username: 'MyPlayer',
});
// Alternative: manual connection
const client = new PhalanxClient(config);
await client.connect();Join matchmaking
// Join and wait in one call
const match = await client.joinQueueAndWaitForMatch();
// Or manage the steps yourself
await client.joinQueue();
const match = await client.waitForMatch();React to game start
After the countdown, the server emits game-start. Load your assets, initialize the world, then call sendReady(). The server will not start the tick loop until all clients report ready.
client.on('gameStart', async () => {
await loadAssets();
initializeGameWorld();
client.sendReady();
});See the server documentation for the full protocol.
Send commands
// Simplified API - queue a command, automatically batched each frame
client.sendCommand('move', { targetX: 10, targetZ: 20 });
// Or submit commands manually for a specific tick
await client.submitCommands(tick + 1, [
{ type: 'move', data: { x: 10, y: 20 } },
]);Disconnect and clean up
// Disconnect (stops render loop, clears handlers)
client.disconnect();
// Destroy (disconnect + cleanup all resources)
await client.destroy();Choosing an API
Phalanx Client offers two ways to drive your game loop:
| API | Best for | Effort |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------- |
| Simplified Game Loop API (onTick / onFrame / sendCommand) | Most games | Low — timing, batching, and interpolation are handled for you. |
| Legacy Event-Based API (on('tick') / on('commands') / submitCommandsAsync) | Custom timing or integrating into an existing engine | Higher — you manage your own render loop and command flushing. |
The simplified API is recommended for new projects.
API Reference
Configuration
interface PhalanxClientConfig {
serverUrl: string; // Server URL (e.g., 'http://localhost:3000')
playerId?: string; // Unique player identifier (auto-generated if omitted)
username?: string; // Display name (auto-generated if omitted)
authToken?: string; // Auth token for server authentication
auth?: PhalanxAuthConfig; // OAuth config for managed authentication
autoReconnect?: boolean; // Auto-reconnect on disconnect (default: true)
maxReconnectAttempts?: number; // Max reconnection attempts (default: 5)
reconnectDelayMs?: number; // Delay between attempts (default: 1000)
connectionTimeoutMs?: number; // Connection timeout (default: 10000)
tickRate?: number; // Ticks per second, must match server (default: 20)
pause?: Partial<PauseConfig>; // Pause behavior configuration
debug?: boolean; // Enable debug logging (default: false)
// ── Mobile-friendly transport & recovery ──────────────────────────────────
mobileFriendlyTransports?: boolean;
// When true, automatically uses polling on mobile UAs and WebSocket on
// desktop. Opt-in so games that pin a specific transport are not affected.
// Ignored when socketTransports is set explicitly.
persistGuestPlayerId?: boolean | string;
// Persist a stable anonymous player id in localStorage across hard reloads.
// Required for cold-start room recovery of unauthenticated users.
// Pass true to use the default key 'phalanx:guestPlayerId:v1', or a string
// to use a custom key. Auth users are unaffected (their id is overwritten
// by the auth flow as usual).
roomRecovery?: PhalanxRoomRecoveryConfig;
// Configure the mobile-friendly room recovery controller. When omitted, no
// recovery controller is created. See the Room Recovery section for details.
}
interface PhalanxRoomRecoveryConfig {
enabled: boolean; // Master switch — must be true to arm the controller
storageKey?: string; // localStorage key for the room record (default: 'phalanx:activeRoom:v1')
roomTtlMs?: number; // Local TTL mirroring server RoomService.ROOM_TTL_MS (default: 5 * 60 * 1000)
storage?: KeyValueStorage; // Custom storage adapter (default: localStorage with memory fallback)
recoverTimeoutBudget?: RecoverTimeoutBudget; // Per-quality ack timeouts (default: 10s/15s/25s)
maxRecoverAttempts?: number; // Max backoff retries before emitting 'gave-up' (default: 5)
preGameStallWatchdog?: boolean; // Auto-arm stall watchdog on matchFound/countdown (default: true)
preGameStallMs?: number; // Budget before forceRecover fires (default: 4500)
}Connection
// Recommended: Create and connect in one step
const client = await PhalanxClient.create({
serverUrl: 'http://localhost:3000',
playerId: 'player-123',
username: 'MyPlayer',
});
// Alternative: Manual connection
const client = new PhalanxClient(config);
await client.connect();
// Disconnect (stops render loop, clears handlers)
client.disconnect();
// Destroy (disconnect + cleanup all resources)
client.destroy();
// Check connection status
const connected = client.isConnected();
// Get connection state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
const state = client.getConnectionState();Matchmaking
// Join queue
const status = await client.joinQueue();
// Leave queue
client.leaveQueue();
// Wait for match
const match = await client.waitForMatch();
// Or combine both:
const match = await client.joinQueueAndWaitForMatch();Game Lifecycle
// Wait for countdown
await client.waitForCountdown((event) => {
console.log(`${event.seconds} seconds remaining`);
});
// Wait for game start
const gameStart = await client.waitForGameStart();
// After loading assets and initializing game systems, report ready.
// The server will not start the tick loop until ALL clients call sendReady().
await loadAssets();
initializeGameWorld();
client.sendReady();sendReady(): void
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.
// Example: in your game-start handler
client.on('gameStart', async () => {
await game.initialize(); // downloads assets, sets up ECS
client.sendReady(); // signals server to start tick loop
});Simplified Game Loop API
The onTick and onFrame methods provide a simplified API for building game loops. They handle timing, command batching, and interpolation automatically.
onTick(handler): Unsubscribe
Register a callback for simulation ticks. Called when the server sends a tick with commands from all players.
const unsubscribe = client.onTick((tick, commands) => {
// tick: current tick number
// commands: { tick, commands: { [playerId]: PlayerCommand[] } }
// Process commands from all players
for (const [playerId, playerCommands] of Object.entries(commands.commands)) {
for (const cmd of playerCommands) {
if (cmd.type === 'move') {
moveEntity(playerId, cmd.data.targetX, cmd.data.targetZ);
}
}
}
// Run deterministic simulation step
physics.update();
combat.update();
});
// Later: stop receiving tick events
unsubscribe();onFrame(handler): Unsubscribe
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.
const unsubscribe = client.onFrame((alpha, dt) => {
// alpha: interpolation value 0-1 (progress between ticks)
// dt: delta time in seconds since last frame
// Interpolate entity positions for smooth visuals
for (const entity of entities) {
entity.mesh.position.x = lerp(entity.prevX, entity.currX, alpha);
entity.mesh.position.z = lerp(entity.prevZ, entity.currZ, alpha);
}
// Render the scene
scene.render();
});
// Later: stop receiving frame events (render loop stops when no handlers remain)
unsubscribe();sendCommand(type, data): void
Queue a command to be sent to the server. Commands are automatically batched and sent each frame.
// Send movement command
client.sendCommand('move', { targetX: 10, targetZ: 20 });
// Send attack command
client.sendCommand('attack', { targetId: 'enemy-123' });
// Commands are automatically flushed to the server each frameInterpolation Explained
The alpha value in onFrame represents how far we are between the last tick and the next expected tick:
alpha = 0: Render at the position from the last received tickalpha = 0.5: Render halfway between last tick and expected next tickalpha = 1: Render at the expected next tick position
This allows smooth 60fps rendering even though the server only sends 20 ticks per second.
Commands
// Submit commands with acknowledgment
const ack = await client.submitCommands(tick, [
{ type: 'move', data: { x: 10, y: 20 } },
{ type: 'attack', data: { targetId: 'enemy1' } },
]);
// Submit commands without waiting for ack (fire and forget)
client.submitCommandsAsync(tick, commands);Private Rooms
// Host: create a room and get an invite code
const { code } = await client.createRoom();
console.log(`Share this code: ${code}`);
// Guest: join by code
client.joinRoom(code);
// Host: cancel the room before anyone joins
client.cancelRoom();
// Host: reclaim a room after a transient socket disconnect
// (e.g. mobile OS killed the WebSocket while the host was sharing the link)
await client.recoverRoom(code);Reconnection
// Manual reconnection to a match
const state = await client.reconnectToMatch(matchId);
// Automatic reconnection with retries
await client.attemptReconnection();Events
// Connection events
client.on('connected', () => {});
client.on('disconnected', () => {});
client.on('reconnecting', (attempt) => {});
client.on('reconnectFailed', () => {});
client.on('error', (error) => {});
// Queue events
client.on('queueJoined', (status) => {});
client.on('queueLeft', () => {});
client.on('queueError', (error) => {});
// Match events
client.on('matchFound', (event) => {});
client.on('countdown', (event) => {});
client.on('gameStart', (event) => {});
client.on('matchEnd', (event) => {});
// Pause events
client.on('gamePaused', (event) => {});
client.on('gameResumed', (event) => {});
// Tick events
client.on('tick', (event) => {});
client.on('commands', (event) => {});
// Player events
client.on('playerDisconnected', (event) => {});
client.on('playerReconnected', (event) => {});
client.on('playerReady', (event) => {}); // A player reported ready (local or remote)
// Reconnection events
client.on('reconnectState', (event) => {});
client.on('reconnectStatus', (event) => {});
// Auth events
client.on('authStateChanged', (state) => {});
client.on('authError', (error) => {});
// Desync detection events
client.on('desync', (event) => {}); // Local hash mismatch detected
// Private room events
client.on('roomCreated', (event) => {}); // { code: string }
client.on('roomError', (event) => {}); // { message: string }
client.on('roomExpired', (event) => {}); // { code: string } — server evicted the room (TTL)
client.on('roomCancelled', (event) => {}); // { code: string } — host explicitly cancelled
client.on('roomRecovered', (event) => {}); // { code: string } — room reclaimed after disconnect
// Room recovery events (emitted by RoomRecoveryController when roomRecovery.enabled: true)
client.on('recoveryStatus', (event) => {
// event.phase: 'idle' | 'recovering' | 'waiting-network' | 'retrying' | 'gave-up'
// event.attempt?: number — 1-based attempt count (when recovering / retrying)
// event.nextRetryMs?: number — ms until next auto-retry (when retrying)
});
client.on('roomTerminated', (event) => {
// event.reason: 'expired' | 'not-found' | 'cancelled'
// Terminal: recovery has stopped and the room can no longer be claimed.
});
// Unsubscribe
const unsubscribe = client.on('tick', handler);
unsubscribe();
// Or manual
client.off('tick', handler);
// Remove all listeners
client.removeAllListeners();Advanced Topics
Mobile-Friendly Room Recovery
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.
client.roomRecovery is null when the controller is not configured.
Enabling recovery
const client = new PhalanxClient({
serverUrl: 'https://game.example.com',
// Auto-select polling on mobile UAs, WebSocket on desktop.
// Opt-in so games that pin a transport are not affected.
mobileFriendlyTransports: true,
// Persist a stable anonymous id across page reloads so cold-start
// recovery can validate the persisted room against the current player.
persistGuestPlayerId: true, // or a custom string key
// Arm the recovery controller.
roomRecovery: {
enabled: true,
// Optional overrides (defaults mirror server PrivateRoomService values):
// storageKey: 'myapp:activeRoom:v1',
// roomTtlMs: 5 * 60 * 1000,
// maxRecoverAttempts: 5,
// preGameStallMs: 4500,
},
});App startup — cold-start recovery
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.
const code = client.roomRecovery!.loadColdStartCode();
if (code) {
showWaitingScreen(code);
client.roomRecovery!.resumeTrackingHost(code);
await client.roomRecovery!.tryRecover();
// Either the server replays match-found → game-start synchronously
// (pending-recover path) or we sit on the waiting screen until the
// guest re-joins or the room TTL expires.
}Host flow
// After createRoom() — arm browser-lifecycle and socket hooks,
// persist the room to localStorage.
const { code } = await client.createRoom();
client.roomRecovery!.startTrackingHost(code);
// When the match is fully underway — stop recovery, clear persistence.
client.on('gameStart', () => {
client.roomRecovery!.stop();
startGame();
});Guest flow
// After deciding to join — persist in case the browser backgrounds
// the tab between room-join and match-found.
client.roomRecovery!.trackGuestJoin(code);
client.joinRoom(code);
// Clear when the match starts.
client.on('gameStart', () => {
client.roomRecovery!.stop();
startGame();
});Cancelling
// Cancel button / user leaves — stops the state machine and clears localStorage.
client.cancelRoom();
client.roomRecovery!.stop();Listening to recovery events
The controller emits status on the same client.on(...) bus as all other events — no separate emitter to wire up.
client.on('recoveryStatus', (event) => {
switch (event.phase) {
case 'idle':
clearStatusBanner();
break;
case 'recovering':
showStatus(`Reconnecting… (attempt ${event.attempt})`);
break;
case 'waiting-network':
showStatus('Waiting for network…');
break;
case 'retrying':
showStatus(
`Lost connection. Retrying in ${Math.ceil(event.nextRetryMs! / 1000)}s…`
);
break;
case 'gave-up':
showStatus('Could not reconnect. Use Cancel to go back.');
break;
}
});
client.on('roomTerminated', (event) => {
clearStatusBanner();
if (event.reason === 'expired') showError('Room expired');
if (event.reason === 'not-found') showError('Room no longer exists');
// 'cancelled' is usually silent (own Cancel button triggered it)
returnToMainMenu();
});RoomRecoveryController API
Accessed via client.roomRecovery (null when not configured):
| Method | Description |
| --------------------------------- | ----------------------------------------------------- |
| loadColdStartCode() | Read persisted host record; returns code or null |
| startTrackingHost(code) | Arm hooks + persist room (host created room) |
| resumeTrackingHost(code) | Arm hooks only (cold-start, record already persisted) |
| trackGuestJoin(code) | Persist guest-side join for cold-start surfacing |
| tryRecover() | Attempt one recovery cycle (idempotent, self-retries) |
| forceRecover(reason) | Force-disconnect then recover (bypasses stale socket) |
| stop() | Disarm all hooks, clear persistence |
| hasActiveRoom() | True while a room is being tracked |
| getActiveRoomCode() | Current tracked room code or null |
| armPreGameStallWatchdog(reason) | Manually arm the stall timer (auto-armed by default) |
| clearPreGameStallWatchdog() | Cancel a pending stall timer |
Built-in helpers (also exported from phalanx-client)
| Export | Description |
| ----------------------------------------------- | ----------------------------------------------------------------- |
| isMobileBrowser() | UA-based mobile detection |
| pickMobileFriendlyTransports() | Returns ['polling'] on mobile, ['websocket'] on desktop |
| loadOrCreateGuestPlayerId(key, storage?) | Stable guest id across reloads |
| RoomPersistence | localStorage room record (configurable key, TTL, storage adapter) |
| getRecoverTimeoutMs(budget?) | Adapt recover ack timeout to navigator.connection |
| armBrowserLifecycle(handlers) | Wire visibility/pageshow/online listeners |
| LocalStorageAdapter / MemoryKeyValueStorage | Storage adapters for the persistence layer |
Custom storage adapter
React Native / Capacitor / Electron apps that cannot use localStorage synchronously:
import type { KeyValueStorage } from '@phalanx-engine/client';
// Synchronous wrapper around any native key-value store:
class CapacitorStorageAdapter implements KeyValueStorage {
private cache = new Map<string, string>();
// Populate cache eagerly on startup with Preferences.get(…)
getItem(key: string): string | null {
return this.cache.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.cache.set(key, value);
void Preferences.set({ key, value }); // fire-and-forget
}
removeItem(key: string): void {
this.cache.delete(key);
void Preferences.remove({ key });
}
}
const client = new PhalanxClient({
serverUrl,
roomRecovery: {
enabled: true,
storage: new CapacitorStorageAdapter(),
},
});Desync Detection
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.
How It Works
- Game computes state hashes at regular intervals (e.g., every 20 ticks)
- Client submits hashes to the server via
submitStateHash() - Server compares hashes from all clients
- Server broadcasts results to all clients
- Client emits
desyncevent if hashes don't match
Submitting State Hashes
import { PhalanxClient, StateHasher } from '@phalanx-engine/client';
// In your tick handler
client.onTick((tick, commands) => {
// Run simulation
simulation.processTick(tick, commands);
// Submit state hash every 20 ticks (once per second at 20 TPS)
if (tick % 20 === 0) {
const hash = computeStateHash(tick);
client.submitStateHash(tick, hash);
}
});Using StateHasher
The StateHasher utility provides a deterministic FNV-1a hash implementation:
import { StateHasher } from '@phalanx-engine/client';
function computeStateHash(tick: number): string {
const hasher = new StateHasher();
// Add tick number
hasher.addInt(tick);
// Add entity data (sorted by ID for determinism)
const sortedEntities = [...entities].sort((a, b) => a.id.localeCompare(b.id));
hasher.addInt(sortedEntities.length);
for (const entity of sortedEntities) {
hasher.addString(entity.id);
hasher.addFloat(entity.x);
hasher.addFloat(entity.y);
hasher.addFloat(entity.z);
hasher.addInt(entity.health);
hasher.addString(entity.state);
}
return hasher.finalize();
}StateHasher API
const hasher = new StateHasher();
// Add primitive values
hasher.addInt(42); // Integer
hasher.addFloat(3.14159); // Float (converted to fixed-point)
hasher.addString('entity-123'); // String
hasher.addBool(true); // Boolean
// Add arrays
hasher.addIntArray([1, 2, 3]); // Array of integers
hasher.addFloatArray([1.5, 2.5]); // Array of floats
// Get final hash (8-char hex string)
const hash = hasher.finalize(); // e.g., "a1b2c3d4"
// Reset for reuse
hasher.reset();Handling Desync Events
// Listen for desync events
client.on('desync', (event) => {
console.error('Desync detected!');
console.error(`Tick: ${event.tick}`);
console.error(`Local hash: ${event.localHash}`);
console.error(`Remote hashes:`, event.remoteHashes);
// Options:
// 1. Log for debugging
// 2. Show error to players
// 3. Attempt recovery (rare)
});
// Listen for match end due to desync
client.on('matchEnd', (event) => {
if (event.reason === 'desync') {
console.error('Match ended due to desync');
console.error('Details:', event.details);
// event.details contains { tick, hashes }
}
});Configuring Desync Detection
// Enable/disable desync detection
client.configureDesyncDetection({ enabled: true });
// Limit stored hashes (for memory optimization)
client.configureDesyncDetection({ maxStoredHashes: 50 });Server Configuration
The server can be configured to take different actions on desync:
// phalanx-server configuration
const phalanx = new Phalanx({
enableStateHashing: true,
desync: {
enabled: true,
action: 'end-match', // 'log-only' | 'end-match'
gracePeriodTicks: 1, // Consecutive desyncs before action
},
});| Option | Description | Default |
| ------------------ | ------------------------------------------------- | ------------- |
| enabled | Enable desync detection | true |
| action | Action on confirmed desync | 'end-match' |
| gracePeriodTicks | Consecutive desyncs required before taking action | 1 |
Testing Desync Detection
To test desync detection during development:
// Intentionally cause a desync for testing
client.onTick((tick, commands) => {
simulation.processTick(tick, commands);
if (tick % 20 === 0) {
let hash = computeStateHash(tick);
// Force desync on a specific tick for testing
if (tick === 100 && client.getPlayerId() === 'player-1') {
hash = 'intentionally-wrong-hash';
}
client.submitStateHash(tick, hash);
}
});Pause / Resume
// Pause the game (notifies server, which broadcasts to all clients)
client.pauseGame();
// Resume the game
client.resumeGame();
// Listen for pause/resume events
client.on('gamePaused', (event) => {
console.log(`Game paused by ${event.requestedBy} at tick ${event.lastTick}`);
});
client.on('gameResumed', (event) => {
console.log(`Game resumed by ${event.requestedBy}`);
});Authentication
For games that require authentication (e.g., ranked matchmaking), PhalanxClient supports managed OAuth:
const client = await PhalanxClient.create({
serverUrl: 'http://localhost:3000',
auth: {
provider: 'google',
google: {
clientId: 'your-google-client-id',
tokenExchangeUrl: 'http://localhost:3000/auth/token',
},
},
});
// Trigger login flow
client.login();
// Check auth state
const authState = client.getAuthState();
console.log(authState.isAuthenticated, authState.user);
// Listen for auth changes
client.on('authStateChanged', (state) => {
console.log('Auth changed:', state.isAuthenticated);
});
// Logout
await client.logout();State Getters
const tick = client.getCurrentTick();
const matchId = client.getMatchId();
const playerId = client.getPlayerId();
const username = client.getUsername();
const clientState = client.getClientState();Client States
The client tracks its lifecycle state:
| State | Description |
| -------------- | ---------------------------------- |
| idle | Not in queue or match |
| in-queue | Waiting in matchmaking queue |
| match-found | Match found, waiting for countdown |
| countdown | Countdown in progress |
| playing | Game is active |
| paused | Game is paused |
| reconnecting | Attempting to reconnect to match |
| finished | Match has ended |
Example: Legacy Event-Based API
For more control or backward compatibility, you can use the event-based API:
import {
PhalanxClient,
TickSyncEvent,
PlayerCommand,
} from '@phalanx-engine/client';
class LegacyGameClient {
private client: PhalanxClient;
private pendingCommands: PlayerCommand[] = [];
constructor(serverUrl: string, playerId: string, username: string) {
this.client = new PhalanxClient({
serverUrl,
playerId,
username,
autoReconnect: true,
});
}
async start(): Promise<void> {
// Connect and find match
await this.client.connect();
await this.client.joinQueueAndWaitForMatch();
await this.client.waitForGameStart();
// Setup event handlers
this.client.on('tick', this.handleTick.bind(this));
this.client.on('commands', this.handleCommands.bind(this));
this.client.on('playerDisconnected', ({ playerId }) => {
console.log(`Player ${playerId} disconnected`);
});
this.client.on('matchEnd', ({ reason }) => {
console.log(`Match ended: ${reason}`);
});
// Start your own render loop
this.startRenderLoop();
}
private handleTick(event: TickSyncEvent): void {
// Submit any pending commands for next tick
if (this.pendingCommands.length > 0) {
this.client.submitCommandsAsync(event.tick + 1, this.pendingCommands);
this.pendingCommands = [];
}
}
private handleCommands(event: {
tick: number;
commands: PlayerCommand[];
}): void {
// Process commands from all players for deterministic simulation
for (const command of event.commands) {
this.processCommand(command);
}
}
private processCommand(command: PlayerCommand): void {
console.log('Processing command:', command);
}
private startRenderLoop(): void {
const loop = () => {
// Your rendering logic here
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
addCommand(type: string, data: unknown): void {
this.pendingCommands.push({ type, data });
}
stop(): void {
this.client.disconnect();
}
}
// Usage
const game = new LegacyGameClient('http://localhost:3000', 'player1', 'Alice');
await game.start();
// Add commands based on player input
game.addCommand('move', { x: 100, y: 200 });License
MIT
