4.3 KiB
interest-manager
(no submodules)
Purpose
InterestManager implements area-of-interest (AOI) culling for open-world
multiplayer games: it tracks zones, entities, and players in memory and
returns the bounded slice of entities each player can see, ranked by
priority. The class is the standard mechanism for shrinking broadcast
scope from O(world) to O(perceived) updates.
It is dependency-injectable: a Clock source provides timestamps and an
OpenTelemetry Meter produces visibility-calculation counters and
duration histograms. Configuration is validated with Zod; invalid input
raises GAME_SERVER.INVALID_INTEREST_CONFIG. Unknown players passed to
updatePlayerPosition raise
GAME_SERVER.INTEREST_PLAYER_NOT_REGISTERED.
Features
- Three zone shapes: circular, rectangular (optionally 3D), custom (predicate-based).
- Per-player
viewDistanceoverride (defaults toconfig.defaultViewDistance). - Priority-weighted visibility ranking via
maxPriority - distance × decay + sharedZones × 0.5with output clamped into[0, maxPriority]. getVisibleEntitiesslices tomaxEntitiesPerPlayer.calculateVisibilityChangesemits enter/exit deltas only when the set actually changes (returnsnullon no-op).- Typed event registry:
entity:added/removed,player:subscribed/unsubscribed,visibility:changed. - OTel:
game_server.interest.visibility_calc_total(counter),game_server.interest.visibility_calc_duration_ms(histogram).
Usage: Basic
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
const aoi = new InterestManager({ defaultViewDistance: 50 });
aoi.registerPlayer("p1", { x: 0, y: 0 });
aoi.updateEntity("npc", { x: 10, y: 0 }, { kind: "merchant" });
const visible = aoi.getVisibleEntities("p1");
Usage: Advanced
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
const aoi = new InterestManager<{ name: string }>(
{
defaultViewDistance: 80,
maxEntitiesPerPlayer: 100,
priorityDecayRate: 0.05,
maxPriority: 20,
},
{ clock: () => testNow }
);
aoi.registerZone({
id: "downtown",
shape: "rectangular",
center: { x: 0, y: 0, z: 0 },
width: 200,
height: 200,
depth: 30,
priority: 5,
});
aoi.registerPlayer("p1", { x: 1, y: 1, z: 0 });
aoi.subscribeToZone("p1", "downtown");
const unsub = aoi.on("visibility:changed", (change) => {
syncEnterExitToClient(change.playerId, change.entered, change.exited);
});
// In the server tick:
aoi.updateEntity("merchant", { x: 5, y: 5, z: 0 }, { name: "Sera" });
aoi.calculateVisibilityChanges("p1");
unsub();
API Reference
new InterestManager(config, deps?)— validatesconfigwithInterestManagerConfigSchema; throwsGAME_SERVER.INVALID_INTEREST_CONFIGon failure.registerZone(zone)/unregisterZone(zoneId); throwsGAME_SERVER.INTEREST_REGION_INVALIDon emptyzone.id.updateEntity(id, position, data?, type?)/removeEntity(id).registerPlayer(id, position, viewDistance?)/updatePlayerPosition(id, position)/unregisterPlayer(id).subscribeToZone(playerId, zoneId)/unsubscribeFromZone(playerId, zoneId).getVisibleEntities(playerId)/calculateVisibilityChanges(playerId).isInZone(zoneId, point)/getZonesAtPoint(point)/getZone/getAllZones/getEntity/getStats/dispose.on(event, listener) → unsubscribe;off(event, listener).
Implementation Notes
Geometry primitives (vectorDistance, zoneContains, calcPriority)
live in interest-manager.internal.ts so the main class is dominated by
lifecycle, dispatch, and metrics emission. getVisibleEntities is the
hot path; it sorts the candidate set in descending priority and slices to
maxEntitiesPerPlayer so the result is always bounded. The shared-zones
walk only checks zones the player explicitly subscribed to, so an entity
in an unsubscribed zone is treated as if the zone were absent. Visibility
deltas are derived against the previous result rather than a snapshot of
the world to support incremental synchronisation. dispose clears every
internal collection so the manager can be safely garbage-collected after
shutdown.