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/server

v0.1.4

Published

Phalanx Engine Server - A game-agnostic deterministic lockstep multiplayer server with authentication, matchmaking, and command synchronization

Downloads

650

Readme

Phalanx Server

Server component of Phalanx Engine - a game-agnostic deterministic lockstep multiplayer engine with authentication, matchmaking, and command synchronization.

For a high-level overview of engine features, see the root README.

Table of Contents

Installation

npm install @phalanx-engine/server

Requirements

  • Node.js >= 24.0.0

Quick Start

import { Phalanx } from '@phalanx-engine/server';

const app = new Phalanx({
  port: 3000,
  tickRate: 20,
  gameMode: '3v3',
});

app.start().then(() => {
  console.log('Phalanx server running on port 3000');
});

Minimal Configuration

A typical production-ready configuration looks like this:

import { Phalanx } from '@phalanx-engine/server';

const app = new Phalanx({
  port: 3000,
  tickRate: 20,
  gameMode: '3v3',
  countdownSeconds: 5,
  readyTimeoutMs: 30000,
  enableStateHashing: true,
  desync: {
    enabled: true,
    action: 'end-match',
  },
});

await app.start();

See the full reference below for every available option.

Configuration Reference

import { Phalanx, PhalanxConfig } from '@phalanx-engine/server';

const config: Partial<PhalanxConfig> = {
  // === Server ===
  port: 3000, // Server port (default: 3000)
  cors: { origin: '*' }, // CORS configuration
  extraRequestHandler: async (req, res) => {
    // Optional: mount trusted webhooks or custom HTTP endpoints on the
    // same HTTP server. Return true when the request was fully handled.
    if (req.method === 'POST' && req.url === '/telegram/webhook') {
      res.writeHead(204);
      res.end();
      return true;
    }
    return false;
  },

  // === TLS/SSL (optional) ===
  // See "TLS/WSS Configuration" section below for details
  tls: {
    enabled: true,
    keyPath: '/path/to/privkey.pem',
    certPath: '/path/to/fullchain.pem',
  },

  // === Authentication (optional) ===
  auth: {
    enabled: false, // Set true to require auth tokens
  },

  // === Tick System ===
  tickRate: 20, // Ticks per second (default: 20)
  tickDeadlineMs: 50, // Max wait for commands per tick

  // === Matchmaking ===
  gameMode: '3v3', // Preset: '1v1' | '2v2' | '3v3' | '4v4' | 'FFA4'
  // OR custom:
  // gameMode: { playersPerMatch: 6, teamsCount: 2 },
  matchmakingIntervalMs: 1000,
  countdownSeconds: 5,

  // === Timeouts ===
  timeoutTicks: 40, // Ticks before "lagging" warning
  disconnectTicks: 100, // Ticks before disconnect
  reconnectGracePeriodMs: 30000,

  // === Command Validation ===
  maxTickBehind: 10,
  maxTickAhead: 5,

  // === Command History (for reconnection) ===
  commandHistoryTicks: 200, // Ticks of command history to keep

  // === Ready Handshake ===
  readyTimeoutMs: 30000, // Max time for clients to send ready (default: 30000)

  // === Determinism / Desync Detection ===
  enableStateHashing: true, // Enable server-side hash comparison
  stateHashInterval: 60, // Hint interval for hash submission (default: 60)
  desync: {
    enabled: true,
    action: 'end-match', // 'log-only' | 'end-match'
    gracePeriodTicks: 1, // Consecutive desyncs before taking action
  },

  // === Pause/Resume ===
  pause: {
    maxPausesPerPlayer: 3,
    requireSamePlayerToResume: true,
  },

  // === Game Types (multi-game-type routing) ===
  tickMode: 'continuous', // Default tick mode: 'continuous' | 'event'
  turnTimeoutMs: 60000, // Turn timeout for event mode (default: 60000)
  gameTypes: [
    {
      gameType: 'moba',
      tickMode: 'continuous',
      tickRate: 20,
      gameMode: '1v1',
      countdownSeconds: 3,
    },
    {
      gameType: 'tactics',
      tickMode: 'event',
      gameMode: '1v1',
      countdownSeconds: 3,
      turnTimeoutMs: 90000,
    },
  ],
};

const app = new Phalanx(config);

Game Types

Phalanx supports running multiple game types on a single server instance. Each game type can override base config values like tickMode, tickRate, gameMode, countdownSeconds, and turnTimeoutMs.

Configuration

const app = new Phalanx({
  tickRate: 20,
  gameMode: '1v1',
  gameTypes: [
    {
      gameType: 'direct-strike',
      tickMode: 'continuous',
      tickRate: 20,
      gameMode: '1v1',
    },
    {
      gameType: 'chapayev',
      tickMode: 'event',
      gameMode: '1v1',
      turnTimeoutMs: 90000,
    },
  ],
});

Client: Joining a Game Type Queue

Clients declare which game type they want to join via the queue-join event:

socket.emit('queue-join', {
  playerId: 'player-123',
  username: 'Alice',
  gameType: 'chapayev', // optional — omit for default queue
});

Clients that omit gameType are placed in a 'default' queue and use the base config.

tickMode: 'event' (Turn-Based / Tickless)

When a game type uses tickMode: 'event', no server tick loop runs. Instead:

  • On receivePlayerCommands, the server immediately broadcasts commands-batch to all players in the room.
  • currentTick increments by 1 per broadcast.
  • A turn timeout (turnTimeoutMs, default 60000ms) starts on game start and resets on each received batch. If the timeout fires, the match ends with reason 'turn-timeout'.
  • timeoutTicks / disconnectTicks activity tracking is skipped in event mode.
  • Pause/resume works identically in both modes.

This is ideal for turn-based physics games (e.g. Chapayev checkers) where game state transitions are driven by player commands only.

turnTimeoutMs

Maximum time (in milliseconds) allowed between command batches in event mode. If no commands arrive within this window, the match ends automatically. Default: 60000 (1 minute).

Game Modes

import { GAME_MODES } from '@phalanx-engine/server';

// Available presets:
// '1v1'  - 2 players, 2 teams
// '2v2'  - 4 players, 2 teams
// '3v3'  - 6 players, 2 teams
// '4v4'  - 8 players, 2 teams
// 'FFA4' - 4 players, 4 teams (Free For All)

Event Hooks

app.on('match-created', (match) => {
  console.log(`Match ${match.id} created with ${match.players.length} players`);
});

app.on('match-started', (match) => {
  console.log(`Match ${match.id} started!`);
});

app.on('player-command', (playerId, command) => {
  // Custom command validation
  return true; // or false to reject
});

app.on('player-timeout', (playerId, matchId) => {
  console.log(`Player ${playerId} timed out`);
});

app.on('player-disconnected', (playerId, matchId) => {
  console.log(`Player ${playerId} disconnected from match ${matchId}`);
});

app.on('player-reconnected', (playerId, matchId) => {
  console.log(`Player ${playerId} reconnected to match ${matchId}`);
});

API

Phalanx Class

class Phalanx {
  constructor(config?: Partial<PhalanxConfig>);

  // Lifecycle
  start(): Promise<void>;
  stop(): Promise<void>;

  // Trusted server-side integrations
  createPrivateRoomForHost(params: {
    playerId: string;
    username?: string;
    gameType?: string;
  }): RoomCreatedEvent;

  // Events
  on(event: PhalanxEvent, handler: Function): this;
  off(event: PhalanxEvent, handler: Function): this;

  // Runtime info
  getActiveMatches(): MatchInfo[];
  getQueueSize(): number;
  getConfig(): PhalanxConfig;
}

Socket.IO Protocol Reference

| Event | Emit | Receive | Description | | --------------------------- | ---- | ------- | --------------------------------------------------------------- | | queue-join | ✅ | | Join matchmaking queue | | queue-leave | ✅ | | Leave matchmaking queue | | queue-status | | ✅ | Queue join/leave confirmation | | match-found | | ✅ | Match created, countdown starting | | match-waiting-for-players | | ✅ | Match is waiting for disconnected participants before countdown | | game-start | | ✅ | Countdown finished, load assets | | client-ready | ✅ | | Client finished loading, ready for ticks | | player-ready | | ✅ | A player reported ready | | match-end | | ✅ | Match has ended | | submit-commands | ✅ | | Send game commands | | submit-commands-ack | | ✅ | Command acknowledgment | | commands-batch | | ✅ | All commands for a tick | | tick-sync | | ✅ | Periodic tick synchronization | | countdown | | ✅ | Countdown before game starts | | submit-state-hash | ✅ | | Submit state hash for desync detection | | hash-comparison | | ✅ | Server hash comparison result | | pause-game | ✅ | | Request game pause | | resume-game | ✅ | | Request game resume | | game-paused | | ✅ | Game was paused | | game-resumed | | ✅ | Game was resumed | | reconnect-match | ✅ | | Attempt to rejoin a match | | reconnect-status | | ✅ | Reconnection result | | reconnect-state | | ✅ | Game state for reconnection | | player-disconnected | | ✅ | Another player disconnected | | player-reconnected | | ✅ | Another player reconnected | | room-create | ✅ | | Create a private room | | room-join | ✅ | | Join a private room by invite code | | room-cancel | ✅ | | Cancel a previously created room | | room-recover | ✅ | | Reclaim a room after socket drop (see Private Room Recovery) | | room-created | | ✅ | Room created; contains invite code | | room-error | | ✅ | Room operation failed; contains message | | room-expired | | ✅ | Room TTL exceeded; server evicted the room | | room-cancelled | | ✅ | Host cancelled the room | | room-recovered | | ✅ | Room successfully reclaimed after socket drop |

Game Start Synchronization

Phalanx uses a ready handshake protocol to ensure all clients are fully initialized before the simulation begins. This prevents desync caused by clients with different asset download speeds missing early ticks.

How it works

  1. Server emits game-start after the countdown completes and enters a waiting-for-ready state.
  2. Each client receives game-start, loads assets, sets up the game world, and initializes all systems.
  3. Each client calls client.sendReady() to emit client-ready to the server.
  4. Server receives client-ready from all connected players, then starts the tick loop.
  5. All clients are guaranteed to be subscribed to tick events before tick 0.

If any client fails to send client-ready within 30 seconds, the match ends with reason 'ready-timeout'.

Usage

Clients must call sendReady() after initialization:

client.on('gameStart', async () => {
  await game.initialize(); // Load assets, set up ECS, etc.
  client.sendReady(); // Tell the server we're ready
});

TLS / WSS Configuration

Phalanx supports secure WebSocket connections (WSS) for production environments.

Basic TLS Setup

import { Phalanx } from '@phalanx-engine/server';

const app = new Phalanx({
  port: 443,
  tls: {
    enabled: true,
    keyPath: '/etc/letsencrypt/live/game.example.com/privkey.pem',
    certPath: '/etc/letsencrypt/live/game.example.com/fullchain.pem',
  },
});

await app.start();
console.log('Phalanx server running with TLS on port 443');

TLS Configuration Options

interface TlsConfig {
  /** Enable TLS/SSL encryption */
  enabled: boolean;
  /** Path to the private key file (PEM format) */
  keyPath: string;
  /** Path to the certificate file (PEM format) */
  certPath: string;
  /** Optional path to CA certificate chain (for Let's Encrypt) */
  caPath?: string;
}

Development Mode (No TLS)

When TLS is not configured, the server runs in HTTP/WS mode:

const app = new Phalanx({
  port: 3000,
  // No tls config = development mode
});

Let's Encrypt Setup

  1. Install certbot:

    sudo apt install certbot
  2. Obtain certificates:

    sudo certbot certonly --standalone -d game.example.com
  3. Configure Phalanx:

    const app = new Phalanx({
      port: 443,
      tls: {
        enabled: true,
        keyPath: '/etc/letsencrypt/live/game.example.com/privkey.pem',
        certPath: '/etc/letsencrypt/live/game.example.com/fullchain.pem',
      },
    });
  4. Set up certificate auto-renewal:

    sudo certbot renew --dry-run

Note: You may need to run the server with elevated privileges for port 443, or use a reverse proxy like nginx.

Client Connection (WSS)

When connecting to a TLS-enabled server from the client:

import { PhalanxClient } from '@phalanx-engine/client';

const client = await PhalanxClient.create({
  serverUrl: 'https://game.example.com', // Use https:// for TLS
  playerId: 'player-123',
  username: 'MyPlayer',
});

Advanced Topics

HTTP Extension Hooks

Use extraRequestHandler when an embedding server needs a small HTTP surface on the same port as Phalanx, such as a Telegram webhook, an internal health probe, or a signed partner callback.

import { Phalanx } from '@phalanx-engine/server';

const app = new Phalanx({
  port: 3000,
  extraRequestHandler: async (req, res) => {
    if (req.method !== 'POST' || req.url !== '/telegram/webhook') {
      return false; // fall through to Phalanx built-in routes
    }

    if (
      req.headers['x-telegram-bot-api-secret-token'] !==
      process.env.WEBHOOK_SECRET
    ) {
      res.writeHead(401);
      res.end();
      return true;
    }

    // Parse and handle the request body here.
    res.writeHead(200);
    res.end();
    return true;
  },
});

Request routing order is:

  1. CORS preflight (OPTIONS) is answered by Phalanx.
  2. extraRequestHandler is called.
  3. Built-in routes run (/, /health, /auth/token).
  4. Unknown routes receive 404.

Handler contract:

  • Return true only after fully handling the request, including writing headers/body and ending the response.
  • Return false to let Phalanx continue with its built-in routing.
  • Set any route-specific CORS or content headers yourself for handled custom routes.
  • Parse and size-limit request bodies yourself; Phalanx only parses bodies for its own built-in routes.
  • If the handler throws, Phalanx logs the error and sends a generic 500 response when headers have not already been sent.

Treat custom HTTP routes as public internet-facing endpoints. Validate webhook secrets or signatures, avoid exposing trusted administrative operations to browsers, and apply rate limiting where appropriate.

Private Room Recovery

Private rooms support a room-recover handshake so a participant whose socket was dropped (e.g. mobile OS suspended the WebSocket while the user was sharing the invite link) can seamlessly reclaim either a waiting room or a private-room match without losing the pending join or countdown state.

room-recover requires both the deterministic playerId and the original room code. Treat the room code as a bearer recovery secret: possession of the code is what proves the caller is allowed to recover that waiting room or match.

Recovery protocol

  1. Client calls room-recover with { playerId, code } after reconnecting.
  2. If the code still maps to a waiting room, only the original host can recover it. The server rebinds the host socket and emits room-recovered.
  3. If the code has already been consumed into a match, any participant in that match can recover with the original code. The server emits room-recovered and delegates to the match reconnect flow.
  4. If the match is still waiting for disconnected participants, reconnecting everyone starts the countdown. If the match is already in progress, the normal reconnect state is delivered.
  5. Server responds with room-error: { message: 'Room expired' } on terminal failure. The same generic message is used for missing, expired, wrong-player, and already-finished cases so callers cannot probe room ownership.

Server-side room TTL

Waiting rooms are kept alive for ROOM_TTL_MS (default 5 minutes) from creation. If the host disconnects, the room remains recoverable until that original TTL expires; if no host socket is connected at expiry time, there is no room-expired recipient to notify. Clients should mirror this TTL in their local persistence layer so they do not attempt room-recover for a room the server has certainly already evicted.

The client-side RoomRecoveryController handles the full recovery lifecycle automatically when roomRecovery.enabled: true is passed to PhalanxClient.

Trusted Server-Side Private Room Creation

Trusted integrations can create a private room for a host that is not currently connected over Socket.IO. This is intended for server-side entry points such as bot commands: the bot creates a room, sends the invite link containing the room code, and the host later opens the game and recovers the room with the same deterministic playerId.

const room = app.createPrivateRoomForHost({
  playerId: `telegram:${telegramUserId}`,
  username: telegramUsername,
  gameType: 'chapayev',
});

const inviteUrl = `https://game.example.com/?room=${room.code}`;

createPrivateRoomForHost:

  • Requires playerId and accepts optional username and gameType.
  • Defaults username to playerId and gameType to the default game type when omitted.
  • Returns { code }. If the same host already has an active waiting room, the existing room code is reused rather than creating a duplicate room.
  • Throws if Phalanx is not running, if the host is already queued for matchmaking, or if the host is already in an active private-room match.
  • Does not emit room-created, because there is no host socket yet.

Typical bot/webhook flow:

  1. A trusted HTTP handler validates the webhook request.
  2. The handler derives a stable server-trusted playerId from the external identity, such as telegram:123456.
  3. The handler calls createPrivateRoomForHost and sends the invite URL/code back through the external channel.
  4. The host opens the game and emits room-recover with the same playerId and code.
  5. The guest opens the invite and emits room-join with the code.

Do not expose createPrivateRoomForHost as an unauthenticated browser endpoint. Browser clients should continue using the Socket.IO room-create flow, while trusted server integrations should authenticate the external request before creating rooms.

Related Packages

License

MIT