Files
skills/docs/gaming/games/game-server/src/enhanced/service-discovery/game-instance-registry/game-instance-registry.md
2026-05-29 08:20:56 +01:00

4.2 KiB

GameInstanceRegistry

(no submodules)

Purpose

GameInstanceRegistry is the in-memory primitive that tracks every live game-server instance in a multi-host deployment. It owns the canonical {id → instance} map, evicts stale entries whose last heartbeat is older than instanceTTL, and selects one healthy instance per discovery request using one of four load-balancing strategies. Transport façades (HTTP/gRPC) that surface the registry over the network live in sibling packages — this module is the pure data structure plus its lifecycle.

Features

  • TTL-based eviction driven by an injected Clock (no implicit Date.now).
  • Four load-balancing strategies: least-players, random, region-based, round-robin. The random strategy consults an injected Rng.
  • Filter-based discover(): by game type, region, mode, map, slot count, privacy, tags, and version.
  • Lifecycle events streamed through a single fan-in listener: registered, deregistered, health-changed, capacity-changed, full, available.
  • Zod-validated register() boundary; every public mutation routes to a typed GameServerError on lookup miss.
  • One OTel counter (game_server.registry.instance_lifecycle_total) tagged with op ∈ {register, deregister, expire, health, capacity}.

Usage: Basic

import { GameInstanceRegistry } from "@suaveplan/game-server/enhanced/service-discovery/game-instance-registry";

const registry = new GameInstanceRegistry({
    clock: Date.now,
    rng: { nextUint32: () => 0, nextFloat: Math.random },
});

registry.register({
    id: "i-001",
    name: "us-east-shard-1",
    host: "10.0.0.1",
    port: 4000,
    metadata: {
        gameType: "duel",
        region: "us-east",
        playerCount: 0,
        maxPlayers: 8,
    },
});

const selected = registry.selectInstance(
    { gameType: "duel", minAvailableSlots: 2 },
    "least-players"
);

Usage: Advanced

const off = registry.on((event) => {
    if (event.kind === "full") notifyMatchmaker(event.instance.id);
    if (event.kind === "health-changed" && event.newStatus === "unhealthy") {
        drainSessionsFrom(event.instance.id);
    }
});

// Region-weighted selection — pass a latency map for cold start.
const r = new GameInstanceRegistry(
    { clock, rng },
    {
        instanceTTL: 30_000,
        defaultStrategy: "region-based",
        regionLatencies: new Map([
            ["us-east", 25],
            ["eu-west", 95],
            ["ap-southeast", 180],
        ]),
        autoDeregisterEmpty: true,
    }
);

setInterval(() => r.sweep(), 5_000);
off();

API Reference

  • new GameInstanceRegistry(deps, config?) — deps: { clock, rng }, config: { instanceTTL?, defaultStrategy?, autoDeregisterEmpty?, regionLatencies? }.
  • register(registration) — validate + insert; emits registered.
  • deregister(id) — remove; emits deregistered. Throws on unknown id.
  • heartbeat(id) — refresh lastHeartbeat.
  • updateHealth(id, status) — transition health; emits health-changed.
  • updateCapacity({ instanceId, playerCount, maxPlayers? }) — emits capacity-changed, plus full or available at the boundary.
  • discover(filter?) — filter healthy instances.
  • selectInstance(filter?, strategy?) — pick one or null.
  • getInstance(id) — direct lookup, or null.
  • getStats() — aggregate snapshot.
  • sweep() — force the TTL pass (also invoked by discover).
  • on(listener) — subscribe to lifecycle events, returns an unsubscribe.
  • dispose() — clear all internal state.

Implementation Notes

  • Eviction runs in two phases: at age > instanceTTL the instance is marked unhealthy; at age > instanceTTL * 2 it is removed. This preserves the upstream semantics where downstream consumers had one full TTL window to fail their own probes.
  • random prefers the optional Rng.nextIntInRange and falls back to Math.floor(rng.nextFloat() * len) when only nextFloat is provided.
  • round-robin keys on a sorted join of instance IDs so adding / removing instances does not silently reset the rotation.
  • The lifecycle counter is registered through the global OTel meter so no SDK wiring is needed in tests.