npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@phalanx-engine/client

v0.1.4

Published

Client library for Phalanx Engine - a game-agnostic deterministic lockstep multiplayer engine

Readme

Phalanx Client

Client library for Phalanx Engine - a game-agnostic deterministic lockstep multiplayer engine.

Table of Contents

Installation

npm install @phalanx-engine/client

Requirements

  • 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 frame

Interpolation 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 tick
  • alpha = 0.5: Render halfway between last tick and expected next tick
  • alpha = 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

  1. Game computes state hashes at regular intervals (e.g., every 20 ticks)
  2. Client submits hashes to the server via submitStateHash()
  3. Server compares hashes from all clients
  4. Server broadcasts results to all clients
  5. Client emits desync event 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