Table of Contents

Interest Management (Delta Spatial Queries)

Per-observer "what changed near me since tick T" queries in O(dirty ร— observers), not O(everything in view ร— observers).

Status: ๐Ÿšง Partial ยท Visibility: Internal ยท Category: Spatial

๐ŸŽฏ What it solves

Multiplayer servers face the N-squared broadcast problem: naively re-sending every entity's state to every connected player each tick costs O(entities ร— players) bandwidth and CPU. What each observer actually needs is much smaller โ€” only the entities near them that changed since the observer last looked. Interest Management answers exactly that question per observer, without the caller re-running a full spatial query and diffing it by hand every tick.

โš™๏ธ How it works (in brief)

Instead of the traditional "per observer: spatial query, then filter by freshness" (O(entities in view ร— observers)), Typhon inverts the order: the engine archives each tick's DirtyBitmap into a 64-tick ring buffer, then for a delta request it ORs together the bitmaps for the ticks the observer missed and walks only the resulting (small) dirty set, testing each dirty entity's current position against the observer's interest AABB and category mask. Cost scales with how much changed, not with how much exists. An observer whose LastConsumedTick has fallen more than 64 ticks behind the ring (or who consumes before any tick has been archived) cannot be served a delta and instead gets a full-sync result โ€” every currently-matching entity, treated as "changed."

๐Ÿ’ป Usage

// Engine-internal entry point today โ€” see Guarantees & limits.
var table = dbe.GetComponentTable<Position>();
var interest = table.SpatialIndex.GetOrCreateInterestSystem(table);

// Register once per connected client, e.g. centered on their camera/view frustum AABB.
double[] bounds = { 0, 0, 0, 200, 200, 200 };   // [minX,minY,minZ, maxX,maxY,maxZ]
SpatialObserverHandle observer = interest.RegisterObserver(bounds, categoryMask: (uint)Faction.Enemy, initialTick: dbe.CurrentTick);

// Each tick, after dbe.WriteTickFence(tick) has archived that tick's dirty set:
SpatialChangeResult delta = interest.GetSpatialChanges(observer, currentTick: tick);
if (delta.IsFullSync)
{
    // Observer fell off the 64-tick ring (or first call) โ€” resync its whole interest region.
}
foreach (long entityId in delta.ChangedEntities)
{
    // Serialize this entity's current state into the observer's outgoing packet.
}

// On camera/view move:
interest.UpdateObserverBounds(observer, newBounds);

// On disconnect:
interest.UnregisterObserver(observer);
RegisterObserver arg Default Effect
bounds required Interest AABB, [minX,minY,(minZ,) maxX,maxY,(maxZ)]
categoryMask 0 0 = no filtering; non-zero = AND-conjunctive, same semantics as R-Tree category filtering
initialTick 0 Starting point for delta accumulation on the first GetSpatialChanges call

โš ๏ธ Guarantees & limits

  • Engine-internal only today โ€” SpatialInterestSystem, ComponentTable.SpatialIndex, and SpatialIndexState.GetOrCreateInterestSystem are all internal. There is no public DatabaseEngine method to reach an observer system; only SpatialObserverHandle and SpatialChangeResult are public types. Application code cannot call this without engine-internal access today (this is the "Partial" status).
  • SV-only (rule IM-03) โ€” dirty-bitmap ring archival only exists for SingleVersion/Transient ComponentTables and SV-backed cluster archetypes; Versioned tables don't participate and have no ring to query.
  • No missed changes (rule IM-01) โ€” any entity mutated at tick T, still matching an observer's region and category mask at query time, appears in that observer's ChangedEntities provided LastConsumedTick < T โ‰ค currentTick and T is still within the ring.
  • Ring depth is 64 ticks (~2.1s at 30Hz) โ€” an observer that doesn't call GetSpatialChanges for longer than that is flagged IsFullSync rather than served a (silently incomplete) delta (rule IM-02).
  • Only Tier 1 (GetSpatialChanges) is implemented โ€” spatial/bounds changes only. The design's Tier 2 (GetEntityChanges, any component on the entity changed, via cross-table dirty projection) is documented but not built.
  • Zero-allocation in steady state โ€” ChangedEntities is a span over a per-observer buffer reused across calls; valid only until the next GetSpatialChanges call for that same observer.
  • Handles are generation-checked โ€” calling any method with an unregistered or stale (reused-slot) handle throws ArgumentException.
  • Covers both spatial-index layers โ€” a single delta call fans out across the legacy per-component R-Tree path and the per-cluster archetype path, so observers don't need to know which storage backs a given archetype.

๐Ÿงช Tests

  • SpatialInterestTests โ€” observer lifecycle + generation-checked handle reuse, UpdateObserverBounds, dirty-entity delta reporting in/out of region