Files
skills/docs/gaming/games/game-server/src/engine/player-manager/player-manager.md
2026-05-29 08:20:56 +01:00

4.9 KiB

PlayerManager

(no submodules)

Purpose

PlayerManager is the IPlayerManager implementation backing the player lifecycle in @suaveplan/game-server. It tracks every player's connection state (connected, disconnected, reconnecting, timeout), the session each player is bound to, the heartbeat-driven liveness signal, and the reconnection-grace window that distinguishes a transient disconnect from a permanent departure. Every wall-clock observation routes through the injected Clock, and the optional heartbeat-monitor loop schedules itself via the injected TimersLike — there are no Date.now() or setInterval calls.

The manager emits a typed PlayerManagerEventMap onto the injected EventBus so other subsystems (matchmaking, presence, audit) can react to join/leave/timeout signals without polling. Every state mutation is also mirrored to OTel via recordPlayerLifecycle so dashboards see the full counter set.

Features

  • Implements Wave A's canonical IPlayerManager contract.
  • Injectable Clock and TimersLike — fully deterministic under fakes.
  • Zod-validated options boundary (timeoutThresholdMs, reconnectionGraceMs) with stable INVALID_PLAYER_MANAGER_OPTIONS failure code.
  • INVALID_HEARTBEAT_INTERVAL for non-positive integer interval inputs.
  • Reconnection path: a connect against an existing disconnected / timeout entry transitions to reconnecting/connected rather than building a fresh record (preserves connectedAt).
  • Grace-window cleanup: in checkTimeouts, disconnected entries older than reconnectionGraceMs are evicted so a future connect builds a fresh record.
  • Defensive copies — public API returns frozen plain-object snapshots rather than mutable internals.

Usage: Basic

import { PlayerManager } from "../player-manager/index.js";

const mgr = new PlayerManager({
    clock: () => Date.now(),
    timers,
    logger,
    events,
});

mgr.connect("alice", "table-1", { tier: "gold" });
mgr.heartbeat("alice");
console.log(mgr.getConnection("alice")?.status); // "connected"

Usage: Advanced

// Custom timeout + grace window, with the heartbeat monitor running
// via the injected timers source.
const mgr = new PlayerManager(deps, {
    timeoutThresholdMs: 60_000,
    reconnectionGraceMs: 5_000,
});

mgr.startHeartbeatMonitor(1_000); // sweep every second
mgr.connect("alice", "table-1");
// ...network drop...
mgr.disconnect("alice", "network_lost");
// 5s pass without reconnect — checkTimeouts evicts the entry.
// A later connect("alice", "table-1") builds a fresh record.
mgr.stopHeartbeatMonitor();

API Reference

Constructor

new PlayerManager(deps, options?) where deps is { clock, timers, logger, events }. Options:

  • timeoutThresholdMs (default 30000) — ms without heartbeat before a connected player is marked timeout.
  • reconnectionGraceMs (default 10000) — ms after a disconnect during which a connect() is treated as a reconnection.

IPlayerManager surface (11 methods)

connect, disconnect, getConnection, getSessionPlayers, getAllConnections, heartbeat, checkTimeouts, updateMetadata, getPlayer, startHeartbeatMonitor, stopHeartbeatMonitor.

Extras

  • getStats() — connection-status histogram + average duration.
  • dispose() — stop monitor + drop every connection.

Errors

  • INVALID_PLAYER_MANAGER_OPTIONS — Zod failure on constructor.
  • INVALID_HEARTBEAT_INTERVAL — non-positive integer to startHeartbeatMonitor.

Events

player:connected, player:disconnected, player:reconnected, player:timeout, player:heartbeat, player:joined, player:left.

Implementation Notes

The heartbeat monitor is implemented via TimersLike.setTimer chained in a self-rescheduling tick rather than via setInterval. That makes the behaviour deterministic under FakeTimers — the test suite explicitly fires due timers rather than depending on real wall-clock progression. The chain self-cancels by checking heartbeatHandle !== undefined before scheduling the next tick — so a stopHeartbeatMonitor between ticks prevents the next tick from being scheduled.

Connection records are mutable internally for hot-path efficiency (heartbeat is called every frame in worst case) but every public accessor returns a freeze(...)-built defensive copy. The metadata bag in particular is shallow-copied per accessor call — callers can mutate the returned object without affecting the internal state.

The grace-window logic in checkTimeouts is what distinguishes a "reconnection" from a "fresh connection". A disconnected entry within the grace window is still resolvable via getConnection, so a later connect() call routes through the reconnection path; once the grace expires, the entry is evicted entirely and a future connect() builds a fresh record (with a new connectedAt).