4.2 KiB
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 implicitDate.now). - Four load-balancing strategies:
least-players,random,region-based,round-robin. Therandomstrategy consults an injectedRng. - 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 typedGameServerErroron lookup miss. - One OTel counter (
game_server.registry.instance_lifecycle_total) tagged withop ∈ {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; emitsregistered.deregister(id)— remove; emitsderegistered. Throws on unknown id.heartbeat(id)— refreshlastHeartbeat.updateHealth(id, status)— transition health; emitshealth-changed.updateCapacity({ instanceId, playerCount, maxPlayers? })— emitscapacity-changed, plusfulloravailableat the boundary.discover(filter?)— filter healthy instances.selectInstance(filter?, strategy?)— pick one ornull.getInstance(id)— direct lookup, ornull.getStats()— aggregate snapshot.sweep()— force the TTL pass (also invoked bydiscover).on(listener)— subscribe to lifecycle events, returns an unsubscribe.dispose()— clear all internal state.
Implementation Notes
- Eviction runs in two phases: at
age > instanceTTLthe instance is markedunhealthy; atage > instanceTTL * 2it is removed. This preserves the upstream semantics where downstream consumers had one full TTL window to fail their own probes. randomprefers the optionalRng.nextIntInRangeand falls back toMath.floor(rng.nextFloat() * len)when onlynextFloatis provided.round-robinkeys 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.