Interest management
Configure entity visibility, update rates, camera focus, and game-specific authorization rules.
Use interest management to choose which entities a client receives and how often it receives their transform updates. The entities a client receives form its interest set.
A gateway routes client input and replicated state. A worker is a headless Unity process that simulates entities in assigned box-shaped areas called containers. Together with clients, the orchestrator, and the control-plane service, they form a mesh. See Architecture for the roles and shared registry.
Interest management bounds who hears about an entity. It does not bound who simulates it. A container is still one worker's simulation budget, whatever a client is or is not told. See Scaling limits and when to partition before choosing a container size.
How it flows
- Client focus. Each client has one or more foci: points (or boxes) it cares about. The default policy uses the client's pawn position. Game code can add more through a policy, or nudge the pawn's focus with a client-sent hint.
- Regions. The world is divided into regions, a uniform grid over absolute world coordinates (
InterestCellSize, default 64 m). A focus covers the regions within its interest radius, plus a subscribe margin. - Gateway subscriptions. For every region a client's foci need, the gateway subscribes that region from the workers that own overlapping containers. A gateway receives only what its clients need.
- Worker filtering. Each worker buckets its authoritative entities by region and publishes each region only to the gateways that subscribed it. A worker with 4,000 entities across 40 containers sends a gateway near one corner only the handful of regions its clients are in.
- Per-client exact filtering. From the entities a gateway has cached, it decides per client which ones belong in that client's interest set, using the exact radius, hysteresis (different entry and exit distances), level of detail, and any policy
Authorizecheck. This set gates every kind of entity traffic: spawns, despawns, transforms, variables, synchronization state, and remote procedure calls (RPCs).
Nothing about this flow depends on how the game partitions its world. A single unpartitioned container works the same way as a baked grid of cells — see Scaling limits and when to partition for what changes and what does not.
Default behavior
With no interest-specific setup, a client:
- hears about every entity within 120 m of its pawn (
InterestRadius); - keeps hearing about an entity until it is more than 136 m away (
InterestRadius + InterestExitMargin) for a full second (InterestLingerSeconds), so entities near the boundary do not spawn and despawn repeatedly; - gets full-rate updates within 30 m, a quarter-rate update between 30 m and 80 m, and a twelfth-rate update beyond that (
InterestNearRadius,InterestFarRadius,InterestMidDivisor, andInterestFarDivisor); and - with no pawn (a spectator or a client still joining) hears only about
AlwaysRelevantentities, until a policy gives it a focus.
These defaults limit the spatial extent of replication. Entity density, always-relevant entities, and explicit subscriptions still affect bandwidth. Partition the world into more containers to distribute simulation across workers.
Configure interest management
Every field lives on NebulaConfig (Resources/NebulaConfig.asset), with a matching field in the exported service manifest that the standalone gateway and worker read.
| Field | Default | What it does |
|---|---|---|
InterestRadius | 120 m | How far a client hears about an entity. A prefab's RelevanceRadius overrides it per entity. Command line: -nebula-interest-radius. |
InterestExitMargin | 16 m | Extra distance past InterestRadius an entity must cross before it leaves a client's set. |
InterestLingerSeconds | 1 s | How long an entity must stay past the exit radius before the client is told to despawn it. |
InterestCellSize | 64 m | Edge length of one interest region. With a world definition, this is snapped to an integer divisor of the world's cell size so region edges land on cell edges. Regions of this size cover about ±2^20 cells (±67,108 km at 64 m) from a scope's origin; beyond that, regions are 32 times wider (2,048 m at 64 m), out to about ±2,147,000 km, so a whole star system is addressable. Command line: -nebula-interest-cell. |
InterestPlanar | true | Regions are infinite columns: height does not affect the region key. Turn it off for a volumetric or space game. |
InterestEvalHz | 4 | How often each client's interest set is re-evaluated per second. Routine evaluations are spread evenly across the ticks of one InterestEvalHz interval by a rotation (InterestSchedule), rather than every client landing on the same tick. A client marked dirty — its focus crosses a region edge, or its pawn, instance, carrier, focus mode, or hint changes — is evaluated on the next tick instead of waiting for its turn, through a second, separately bounded pass so a change that dirties every client at once is spread rather than stalling the loop. The realized rate is GatewayStats.InterestEvalsPerSecond. |
InterestSubscribeMargin | 32 m | Extra distance of regions a gateway subscribes beyond a focus, so entities are already arriving before a client's exact filter needs them. |
InterestRegionLingerSeconds | 3 s | How long a region stays subscribed after the last client needed it, to stop a player pacing a region edge from making a worker start and stop sending. |
InterestLinkLingerSeconds | 10 s | How long a gateway keeps a worker connection open after the last reason to hold it is gone. |
InterestResyncSeconds | 30 s | How often a gateway resends its whole subscription as an audit, on top of the incremental deltas. |
InterestMaxRadius | 1,024 m | Ceiling on a prefab's RelevanceRadius, and the farthest a wide entity (one whose radius exceeds InterestRadius) may reach. It is a cost and security boundary, so it is never uncapped: setting it to 0 or less is reported and falls back to InterestRadius. |
InterestMaxFoci | 8 | Most foci one client may have at once. Extra foci from a policy are dropped. |
InterestHintMaxDistance | 60 m | How far a client's focus hint may sit from its pawn before it is clamped. |
InterestHintMaxHz | 5 | Most focus hints accepted from one client per second. |
InterestMaxExplicitPerClient | 16 | Most entities one client's policy may subscribe to by ID (party members, quest targets). |
InterestFarMaxEntities | 64 | Most entities one client holds in the far tier at once, nearest first. 0 turns the far tier off. |
InterestMaxFocusSpeed | 12 m/s | The fastest a player is expected to travel. Used only to check that InterestSubscribeMargin covers the distance a focus can move between two evaluations. |
InterestMaxTravelPerUpdate | 4 m | The most an entity may travel between two updates a client receives. A fast entity is sent more often than its distance tier says. See Fast movers. 0 turns it off. |
InterestFocusLeadSeconds | 0.5 s | Seconds of travel ahead of a moving pawn at which the gateway adds a second focus. See Fast movers. 0 turns it off. |
PartitionWarnEntities | 2,000 | A worker warns when one container holds more entities than this. 0 disables the warning. |
PartitionWarnFilterMs | 2 ms | A worker warns when filtering entities for its gateways takes longer than this per tick. 0 disables the warning. |
InterestNearRadius | 30 m | Full update rate within this distance, for an entity of Normal priority. |
InterestFarRadius | 80 m | The far distance band starts here. |
InterestMidDivisor | 4 | Send at most one update every N ticks between the near and far radius. |
InterestFarDivisor | 12 | Send at most one update every N ticks beyond the far radius, and to a pawn-less client for anything not AlwaysRelevant. |
NebulaConfig.Validate (also OnValidate, the setup window, and role start-up logs) checks these against each other and against ClientLoadRadiusCells and GhostBandMargin. A value that would make behavior wrong (InterestRadius <= 0, InterestCellSize <= 0) is an error that blocks start-up; other invalid values are adjusted, with warnings for most corrections. For example: InterestFarRadius above InterestRadius is lowered to it; InterestSubscribeMargin below one evaluation of travel at InterestMaxFocusSpeed is raised to cover it; ClientLoadRadiusCells too small to cover InterestRadius + InterestExitMargin is raised until it does; and a GhostBandMargin as large as InterestCellSize is reported (a region would be narrower than the band a gateway must subscribe around it).
One notion of "near"
Client content streaming, the runtime chunk allocator's ring, and the containers a client is told about all derive from the same computed value, to size the content window consistently:
overshoot = InterestMaxFocusSpeed * (InterestLingerSeconds + 1 / InterestEvalHz)
NearCells(cellSize) = max(ClientLoadRadiusCells, ceil((InterestRadius + InterestExitMargin + overshoot) / cellSize))The overshoot term accounts for movement during removal delays. InterestRadius + InterestExitMargin is where an entity starts to leave a
set, not where it is gone: it has to stay outside for InterestLingerSeconds, and the set is only re-evaluated
InterestEvalHz times a second, so a client traveling at InterestMaxFocusSpeed legitimately still holds
entities that much further out. Sizing the content window to the exit radius alone leaves a client holding a
replica standing on a cell it has not loaded. (With no world definition — cellSize of 0 — the formula
short-circuits to ClientLoadRadiusCells.)
- World partition's client cell-streaming radius is
NearCells.ClientLoadRadiusCellssets its minimum value. - The runtime chunk allocator's ring defaults to
NearCells + 1(NearCells + ChunkedWorldServerRingMargin), so a chunk is leased before a client's interest radius can reach it. - A client's
ContainerOwnershipwindow is a box ofNearCells × cell sizearound each focus its evaluation authorized (the pawn by default; every focus with a free camera or a policy).
Level of detail inside the set
InterestNearRadius, InterestFarRadius, InterestMidDivisor, and InterestFarDivisor throttle how often an entity already in the client's interest set receives root-transform updates. They do not decide whether the entity is visible. A client without a pawn uses InterestFarDivisor for anything that is not AlwaysRelevant.
A divisor of N sends the client at most one update in each window of N ticks: the first update the worker sends for the entity in that window. If the worker already sends the entity less often than that, because the entity has an update interval, every update reaches the client.
An entity's NetworkIdentity.RelevancePriority shifts these tiers for every client:
| Priority | Within InterestNearRadius | Up to InterestFarRadius | Beyond |
|---|---|---|---|
High | every update | every update | InterestMidDivisor |
Normal (default) | every update | InterestMidDivisor | InterestFarDivisor |
Low | InterestMidDivisor | InterestFarDivisor | InterestFarDivisor |
Background | InterestMidDivisor | none | none |
"None" stops the client's updates without despawning the entity there. The client keeps the replica where it was last told and still receives the entity's reliable updates: the pose it comes to rest at, a teleport, and a change of container. A client's own pawn always receives every update. See Run crowds of server-owned entities.
To give High, Low or Background entities tiers of their own instead of the shift, set InterestHighTiers, InterestLowTiers or InterestBackgroundTiers with Override on: two radii and a divisor for each band, where 0 is none. Normal always uses the four settings above. This lets a crowd use slow tiers while players keep the default ones; see Set the distance tiers for a crowd. A behavior can also have its sync state follow these tiers, with NetworkBehaviour.SyncDistanceRating.
Fast movers
Distance tiers suit walking speeds. A vehicle at 400 m/s, 100 m from a client, would be sent every 12th tick and move 80 m between updates, and a pawn that fast crosses the whole interest radius in a fraction of a second. Two settings adjust interest for fast entities:
- Speed floor on the update rate. The gateway lowers an entity's divisor until the entity travels no more than
InterestMaxTravelPerUpdatemeters between two updates, using the velocity in its latest state. With the 4 m default, a walker at 5 m/s keeps its tiers, an entity at 60 m/s is sent at least every fourth tick, and one at 400 m/s every tick. A band whose divisor is0(none) stays none, and the floor never makes a tier slower. - Lead focus. When a client's pawn, or the ship it rides, moves fast enough that
InterestFocusLeadSecondsof travel is longer thanInterestExitMargin, the gateway adds a second focus that far ahead, in the pawn's own space. Entities ahead join the set, and their regions are subscribed, before the pawn reaches them. With the defaults, a pawn needs to move faster than 32 m/s to get one. The lead is capped atInterestMaxRadius, ignores height in a planar world, and counts towardInterestMaxFoci.
Both settings cost bandwidth only for entities and pawns that move fast. Raise InterestMaxFocusSpeed too if your pawns are that fast, so validation sizes InterestSubscribeMargin for them.
Per-prefab overrides
Five fields on NetworkIdentity adjust how a prefab participates in interest, independent of the client-side rate fields above:
| Field | Type | Default | Use it for |
|---|---|---|---|
RelevanceRadius | float | 0 (mesh default, InterestRadius) | An entity visible from farther than the default. A lighthouse visible from 2 km sets 2000; a coin that should only matter up close sets 20. The gateway clamps the effective value to InterestMaxRadius (1,024 m by default — raise it on NebulaConfig before relying on a RelevanceRadius above that). |
AlwaysRelevant | bool | false | An entity every client must always hear about: a match timer, a world boss, a weather driver. Skips distance testing entirely. |
InterestGroup | byte | 0 | A number your own IInterestPolicy.Authorize can filter on, such as a team marker. Nebula only carries the value; it assigns no meaning to it. |
FarRelevanceRadius | float | 0 (off) | How far the entity is seen as a marker, beyond its normal radius: a ship in orbit, an outpost. Not clamped by InterestMaxRadius. See See ships and outposts from far away. |
FarUpdateRate | float | 1 | Far-tier pose updates per second, clamped to 0.1 to 30. |
A coin set to 20 and a lighthouse set to 2000 (with InterestMaxRadius raised to cover it) behave correctly at the same time: the coin only enters a client's set within 20 m, while the lighthouse is found through a separate wide-entity list checked directly against each client's foci, so its long radius does not cost every other client a wider region scan.
These fields ride in EntitySpawn, so the standalone gateway (which has no prefabs of its own) still knows how to scope an entity without recompiling.
The policy API
Install a policy on the gateway to add foci beyond the default pawn-only one, subscribe specific entities by ID, or filter what a client is allowed to see:
public interface IInterestPolicy
{
// Add foci (points or boxes) and explicit always-relevant entity IDs for this client.
void Collect(in InterestClient client, InterestQuery query);
// Security filter, checked before an entity may enter or stay in a client's set.
bool Authorize(in InterestClient client, in InterestEntity entity);
}The policy examples below use the Nebula namespace. Their lookup methods are placeholders for your game data; replace them before using the policy. Installing a policy replaces the default, so call DefaultInterestPolicy.Instance.Collect when you want to retain pawn and hint focus.
Set it once, on every gateway of the mesh (gateway is your NebulaGateway instance):
gateway.InterestPolicy = new FogOfWarPolicy();The standalone gateway holds no game code
gateway.InterestPolicy is only reachable from whoever constructs the NebulaGateway object. nebula start and a deployed mesh run the standalone nebula-gateway executable, which is built from Nebula's own sources and has no game assembly to call this from. Install a policy there — and tag clients with a team, or set a focus mode — through a gateway extension instead.
InterestPolicies.Combine(a, b) composes two policies: Collect calls both (their foci and explicit entities are unioned), and Authorize is the logical AND of both. Its authorization check can only narrow access, although the combined foci can expand the spatial view.
What a policy is told
Both methods receive a snapshot the gateway fills once per evaluation, not a live reference:
InterestClient — ClientId, Identity, Name, PawnNetId, HasPawn, PawnX/PawnY/PawnZ (absolute, resolved through any carrier), PawnCarrierNetId (the outermost thing the pawn is riding, 0 if it stands in the world), InstanceId (the client's simulation scope, resolved through the pawn's carrier chain), Team (gateway.SetClientTag), Tags (64 more server-owned bits, gateway.SetClientTags), HasHint/HintX/HintY/HintZ (the validated hint), and FocusMode.
InterestEntity — NetId, PrefabId, OwnerClientId, Container, X/Y/Z (absolute, resolved through carriers), RelevanceRadius, AlwaysRelevant, InterestGroup (the entity's own, not its carrier's — a filter on "what kind of thing is this" is about the passenger, not the ship), InstanceId (resolved through the carrier chain), and CarrierNetId (the entity immediately carrying this one; the interest decision itself is made on the root carrier).
Strategy cameras
A focus is a camera, a selected formation, an observed front, or some other small spatial anchor — not a stand-in for every unit a player owns. InterestMaxFoci caps a client at 8 foci by default, so a strategy game with dozens or hundreds of owned units cannot give each one its own focus; even under the cap, doing that spends the budget describing "near every unit" instead of "near the camera," and still tells the client nothing about an off-screen unit's surroundings.
Give the client one or two foci for what it is actually looking at — its camera (through FocusHint and FocusMode.Free) and, optionally, its current selection:
public sealed class RtsInterestPolicy : IInterestPolicy
{
public void Collect(in InterestClient client, InterestQuery query)
{
// The camera hint the gateway already validated (FocusMode.Free lets it roam the map).
if (client.HasHint)
query.AddFocus(client.HintX, client.HintY, client.HintZ);
else if (client.HasPawn)
query.AddFocus(client.PawnX, client.PawnY, client.PawnZ);
// Optional: the center of the player's current selection, so a detail panel stays populated
// when the camera pans away from it.
if (TryGetSelectionCenter(client.ClientId, out var selection))
query.AddFocus(selection.X, selection.Y, selection.Z);
}
public bool Authorize(in InterestClient client, in InterestEntity entity) => true;
private bool TryGetSelectionCenter(ulong clientId, out (double X, double Y, double Z) center)
{
center = default;
return false; // your lookup
}
}For the hundreds of units a player owns but is not looking at, do not try to widen interest to reach them. Send an aggregate instead: one always-relevant or group-scoped summary entity per army or region (unit counts, a front line, a strength indicator) that a minimap or army panel reads through NetworkVariables or an RPC, independent of any client's foci. See Scaling limits and when to partition — interest management decides who hears about an entity, not how many entities one worker can simulate, so an army too large for one client's interest set to reach efficiently is a partitioning and container-sizing question, not one this feature solves by adding foci.
Spectator focus for a pawn-less client
A client with no pawn has no default focus and, without a policy, hears only about AlwaysRelevant entities. Give a spectator a focus at whatever it is watching:
public sealed class SpectatorInterestPolicy : IInterestPolicy
{
public void Collect(in InterestClient client, InterestQuery query)
{
if (!client.HasPawn && TryGetSpectateTarget(client.ClientId, out var target))
query.AddFocus(target.X, target.Y, target.Z);
}
public bool Authorize(in InterestClient client, in InterestEntity entity) => true;
private bool TryGetSpectateTarget(ulong clientId, out (double X, double Y, double Z) target)
{
target = default;
return false; // your lookup
}
}Party members always relevant
Subscribe specific entities by ID so a client keeps hearing about its party regardless of distance, up to InterestMaxExplicitPerClient (16 by default):
public sealed class PartyInterestPolicy : IInterestPolicy
{
public void Collect(in InterestClient client, InterestQuery query)
{
DefaultInterestPolicy.Instance.Collect(client, query);
foreach (ulong memberNetId in PartyMembersOf(client.ClientId))
query.AddEntity(memberNetId);
}
public bool Authorize(in InterestClient client, in InterestEntity entity) => true;
private System.Collections.Generic.IEnumerable<ulong> PartyMembersOf(ulong clientId) => System.Array.Empty<ulong>();
}Team and fog-of-war authorization
Authorize is a security filter, not a convenience: it runs before an entity is allowed to enter or remain in a client's set, and failing it removes the entity immediately, with no linger. Use InterestGroup or your own game state to hide the other team's units outside detection range:
public sealed class FogOfWarPolicy : IInterestPolicy
{
public void Collect(in InterestClient client, InterestQuery query) =>
DefaultInterestPolicy.Instance.Collect(client, query);
public bool Authorize(in InterestClient client, in InterestEntity entity)
{
if (entity.InterestGroup == 0 || entity.InterestGroup == client.Team)
return true; // neutral, or the client's own team
return IsDetectedBy(client.Team, entity.NetId);
}
private bool IsDetectedBy(byte team, ulong netId) => false; // your visibility check
}Assign a client's team with gateway.SetClientTag(clientId, team), read it back with gateway.GetClientTag(clientId), and read it inside Authorize from InterestClient.Team.
Revoke now, reveal soon
After you change state that Authorize reads, tell the gateway — it cannot see your game's variables. Which call you make depends on the direction of the change:
| Call | What it does | Use it when the change |
|---|---|---|
gateway.RevalidateInterest(clientId) | Re-runs instance scope and Authorize over every replica the client already holds, before the call returns, and despawns what is no longer allowed. It also marks the client dirty, so anything the same change reveals arrives at the next evaluation. | Tightens visibility: a detection roll that failed, a stealth cloak switched on, fog closing over a region, an access rule revoked. |
gateway.RevalidateAllInterest() | The same for every client. | A world-wide tightening: a fog sweep that can close as well as open, a war declared. |
gateway.MarkInterestDirty(clientId) | Puts the client at the front of the evaluation queue. Additive only: nothing is taken away by this call. | Reveals something: a unit given to the player, a party joined, a fog cell uncovered. |
gateway.MarkAllInterestDirty() | The same for every client. | A world-wide reveal. |
Dirty clients jump the routine rotation but are still bounded per tick, so on a busy gateway a client can wait a few ticks for its turn. That is the right trade for showing something more and the wrong one for taking something away, which is why a tightening change must not use MarkInterestDirty alone: until the client's turn came up it would keep the replica and keep receiving state, variables, sync state, and RPCs for it.
A revocation pass costs one Authorize call per replica the client currently holds — no grid query, no candidate scan, nothing allocated — so it is cheaper than the evaluation it replaces. RevalidateAllInterest concentrates about one round of evaluation into the call instead of spreading it over an evaluation interval. Use it for the events above, not once per tick. It is safe to call from inside a policy callback: a nested call falls back to marking the client dirty rather than re-entering the pass.
Three built-in paths already revoke synchronously, so you do not have to remember them:
- assigning
gateway.InterestPolicy(it runsRevalidateAllInterestinside the setter, so nothing the new policy refuses is relayed afterwards); gateway.SetClientTag(clientId, team);gateway.SetClientTags(clientId, tags).
gateway.SetClientFocusMode deliberately does not revoke. Dropping the client's hint only narrows its foci, and a focus is a distance rather than a boundary, so the entities it was holding leave through the usual exit margin and linger.
Client focus hints are inputs, never authority
A client can suggest where it is looking — a sniper scope, a strategy camera scrolled away from the pawn — with:
// client is your NebulaClient; camera is the Unity Camera whose view you want.
client.FocusHint = camera.transform.position;
// ...
client.ClearFocusHint();FocusHint is in the client's own Unity space; NebulaClient converts it to absolute world coordinates before sending, so a floating-origin shift under the camera does not move the point the gateway thinks it is watching, and a camera kilometers from the client's origin is still named to the meter. Setting a hint sends an unreliable ClientFocusHint message at up to InterestHintMaxHz. ClearFocusHint() (or FocusHint = null) sends a reliable clear carrying a generation counter, so interest returns to the pawn even if a hint already in flight arrives after it — the gateway drops any hint older than the newest generation it has seen and ignores a clear a newer hint has already superseded.
A hint is worth exactly what the server says it is worth, never what the client claims. Every connected client has a server-owned FocusMode, set with NebulaGateway.SetClientFocusMode (or IGatewayExtensionContext.SetClientFocusMode from a gateway extension) and read back with GetClientFocusMode:
| Mode | Default | What a hint does |
|---|---|---|
FocusMode.PawnClamped | Yes | The hint is clamped to InterestHintMaxDistance (60 m by default) from the client's pawn. A client with no pawn has nothing to clamp to, so its hint is refused outright — connecting and sending a hint before ever being given a pawn cannot be used to scrape the map. |
FocusMode.Free | No | The hint is taken as sent, anywhere in the world. Give this to a spectator, a replay viewer, or a strategy camera — a decision your game makes server-side, never one a client grants itself by omitting a pawn or by sending a large value. |
FocusMode.Disabled | No | Hints from this client are ignored, pawn or no pawn. |
Nothing the client sends can change its own mode, and a client cannot even tell which mode it has except by what the gateway ends up sending it.
What happens to one hint, in order
Every hint the gateway receives goes through these steps. The cheap, non-negotiable checks come first, so a client cannot reach your game code by sending garbage or by sending quickly:
- Generation fence. A hint older than the newest generation the gateway has seen is dropped, so a hint that overtook a clear cannot re-establish a focus the client gave up. A newer generation advances the counter even if later validation rejects the hint.
- Clear. A
ClearFocusHint()is applied here and is never rate-limited: narrowing interest is never an attack, and dropping a clear would leave the client paying for a view it no longer has. - Malformed check. A coordinate that is NaN, infinite, or larger in magnitude than
FocusHintFilter.MaxMagnitude(1e9 m) is rejected, not clamped. Clamping would invent a place the client never asked about and then stream it. This runs before the client snapshot and before any game code, so a NaN never reaches anIFocusHintPolicy. - Attempt rate limit. Every hint that gets this far spends the client's budget, whatever becomes of it afterwards. That is what bounds calls into your hint policy at
InterestHintMaxHz(5 per second by default) — a client with no pawn, or one your policy refuses every time, pays for the asking. A server-side authorization change (SetClientFocusMode,SetClientTag,SetClientTags) resets the budget, so a freshly granted commander camera answers immediately instead of waiting out a budget it spent while being refused. Only the server can reset it. - Your hint policy.
IFocusHintPolicy.AuthorizeFocusHintsees the client snapshot and the server'sFocusMode, and may reject the hint or move the point before it is accepted. - Authorization. The resulting mode decides whether the client may have a hint at all (see the table above). A refusal also revokes: the hint the gateway was holding is dropped rather than left standing.
- Accept. In
FocusMode.PawnClampedthe point is pulled back toInterestHintMaxDistanceof the pawn; inFocusMode.Freeit is taken as sent.
Instance isolation is checked separately, during evaluation, in every mode: a free focus is a bigger view of the instance the client is already in, never a way to see into another one.
Five counters on NebulaGateway report hints across its clients: FocusHintsAccepted, FocusHintsClamped, FocusHintsDroppedByRate, FocusHintsDroppedMalformed, and FocusHintsDroppedUnauthorized. DroppedByRate counts attempts, so a client whose hints are all refused for some other reason still shows up there rather than nowhere.
Implement the optional IFocusHintPolicy on the same object as your IInterestPolicy when the per-client mode is not enough on its own — for example, when whether a commander camera is allowed depends on where it is pointed:
public interface IFocusHintPolicy
{
// Decide what to do with one hint. request.Mode is the server's FocusMode for this client;
// return FocusHintDecision.Reject() to ignore the hint, or move/narrow the point before accepting it.
FocusHintDecision AuthorizeFocusHint(in InterestClient client, in FocusHintDecision request);
}A refusal also revokes: a policy that has just said no, or a pawn-clamped camera whose pawn is gone, drops the hint the gateway was holding rather than leaving the last accepted one in place.
A hint that only moves within the same region waits for the client's next scheduled evaluation (InterestEvalHz); one that crosses into a new region triggers an immediate re-evaluation.
Guarantees this preserves
Interest management preserves the security properties of instancing and dynamic containers:
- Private-instance isolation is checked first, before any spatial test: authorization for an unknown container is always denied, then
policy.Authorizeruns. A client never sees into an instance it has not entered. - A portal's windows are an additional focus box: a client that may look through an open link and stands in the window of its own side receives what stands in the other side's window, on top of its normal interest set (see Look through a portal). They are the only way a client is sent another scope's entities, and they are checked on link records, not on the entity's distance.
- Unknown containers are never public. An entity in a container a client has not been told about is never added to that client's set, whatever its distance.
- A client's own pawn is always in its set, at full update rate, regardless of distance rules — an owned entity is a standing reason for the gateway to hold its worker link.
- Spatial and targeted RPCs respect the set. A broadcast RPC declared with a radius reaches only clients whose pawn is within that distance. A targeted
ClientRpcorOwnerRpcreaches only the named client. - Private sync state stays private. Holding an entity does not mean receiving all of it: a behavior's sync audience can limit its sync state to the owner, to no client, or to clients a rule selects. The gateway applies it to every spawn, update, and cached keyframe it sends.
- A carried entity enters and leaves a client's set with its carrier. See Carried entities below.
Carried entities
An entity inside a moving dynamic container — a passenger on a ship, a crate in a lift — is bucketed for interest at its root carrier's position and inherits the root carrier's radius, always-relevance, and enter/exit decision, not its own. A ship and everyone riding it enter and leave every client's set together, at any speed, instead of a passenger flickering independently as it nears a region edge on its own. This also covers a carrier riding another carrier (a boat in a cargo hold): the whole nested chain is bucketed and moves as the outermost, or root, carrier's subtree.
When a carrier moves between regions, or an entity boards or disembarks, the gateway is sent spawns and despawns for the whole affected subtree in one consistent order: carriers before their contents when something newly enters a client's set, and contents before their carriers when something leaves. A passenger is never delivered before the carrier that positions it, and a carrier is never removed while a client still holds one of its passengers.
A passenger sits where its carrier sits, on the worker and on the gateway. A passenger's whole placement follows the root carrier, not only its region: an AlwaysRelevant crate riding in an ordinary ship is a region entity in the ship's region for as long as it is aboard. It is published to the workers' gateways and cached by those gateways exactly as the ship is, so a crate's own prefab settings never cost a gateway on the other side of the world the ingest, the cached record, and the per-client evaluation for it. A passenger's non-spatial metadata stays its own: InterestGroup, its owner's gateway, and any explicit per-ID subscription are never inherited from the carrier.
Getting off restores the passenger's own placement. A wide or always-relevant passenger returns to its own list the moment it disembarks; a region one stays in the bucket it was put down in until its own next move says otherwise. Losing a carrier to a despawn does the same thing.
A carrier can arrive after its passengers, or be taken away from under them. Both cases are published, not just recorded. A passenger whose container names a carrier that does not exist yet is placed on its own account until the carrier turns up; the moment it does, the whole waiting subtree moves into the carrier's placement, and any gateway that was holding a passenger only because of the passenger's own AlwaysRelevant or RelevanceRadius is told to forget it. When a carrier is despawned, every surviving passenger is republished on the placement it gets back: gateways that knew it only through the carrier are told to forget it, and gateways its own reach now covers are sent it. The carrier is always announced before a passenger that newly reaches a gateway, and forgets always go out contents-first.
A passenger outlives its carrier without any work from you. When a carrier leaves the world, Nebula moves each entity directly inside it into the container the carrier itself was in, keeping the passenger's world position and rotation. You do not have to notice that a ship was destroyed and re-place what was aboard it. Anything deeper in the chain keeps naming a carrier that still exists, so every spawn a client is sent names a container the client already has. Destroy a carrier's contents yourself if your game wants them to go down with the ship.
Moving a carrier to another worker is not the same as destroying it. A handover takes the carrier and everything riding in it to the new worker together, and the new worker publishes the subtree where the ship actually is. Passengers are never briefly treated as though the carrier had disappeared, so a gateway nowhere near the ship is never sent one. Contents that stay behind — an interior pinned to a worker of its own — are the exception and are treated as surviving passengers.
A carrier cycle is refused, not obeyed. If something tries to put an entity inside itself, directly or through any chain, the link is rejected (CarrierLink.Cycle), the entity keeps the carrier it had, and the refusal is logged once per entity rather than once per tick. Fix the parenting that produced it: a cycle makes "the whole subtree" meaningless.
There is no subtree size or depth limit. A carrier's whole subtree always moves, at any width and any depth — a half-walked tree would be a half-rebucketed ship, some of its contents addressed to the destination and the rest still to the origin, with nothing downstream able to tell. The same applies to everything downstream of the move: a subscription snapshot carries every entity in the chain, spawn and despawn ordering compares real depths, a passenger's world position resolves through every carrier above it, and a private instance is resolved through the whole chain. Each of those walks is bounded by the number of entities or containers that could be in a chain rather than by a fixed depth, which is safe because a carrier cycle is refused when it is created. A container that has outgrown one worker's budget is the partition warning, not something to express as a silently truncated spawn list.
A carrier whose physics frame uses FrameInterest = OwnRegions, such as a planet, is the exception to all of this: what is on it is bucketed in regions of the planet's own coordinates, not with the planet. A client in that frame looks at the frame's regions around its player and at the space around the frame from the same point. See Make a planet a frame.
A carrier prefab stays bucketed by region, whatever AlwaysRelevant or RelevanceRadius it is given: putting a carrier in the wide or global list would leave its passengers bucketed wherever it was boarded, popping in and out of a client's set independently of the ship it rides in. Its radius still counts: the worker treats it as a reach on top of the region, and sends the ship and everything aboard to every gateway with a focus within that radius, as well as to the gateways subscribing its region. A ship with RelevanceRadius 600 is seen, crew and all, by a player 500 m away at the default settings, and both leave a client's set together. For a ship to be seen further than InterestMaxRadius, give it a far radius (next section).
See ships and outposts from far away
The far tier lets a client see large or important entities far beyond InterestMaxRadius, as markers rather than replicas. Set FarRelevanceRadius on the prefab, for example 100000 for a ship that is visible 100 km away in orbit. Beyond the entity's normal radius and within its far radius of a client's focus, the client receives only the entity's pose, about once a second (FarUpdateRate), and the facts needed to draw it: its prefab, its owner and its interest group. It doesn't receive variables, RPCs or sync state, and it doesn't get a replica.
Read far entities from NebulaClient.FarEntities, which maps net IDs to FarEntity values, and listen for FarEntityAdded, FarEntityUpdated and FarEntityRemoved:
client.FarEntityAdded += far => markers.Show(far.NetId, far.PrefabId);
client.FarEntityRemoved += (far, why) => markers.Hide(far.NetId);
void LateUpdate()
{
foreach (var far in client.FarEntities.Values)
markers.Place(far.NetId, far.PredictedWorldPosition(), far.Rotation);
}FarEntity.Position is absolute in the entity's scope, in double precision. WorldPosition is the same point in the client's Unity space, and PredictedWorldPosition carries it forward by the entity's velocity between updates. When the entity comes within its normal radius, the client gets the replica through the usual EntitySpawned, and the far entity is removed with FarEntityRemoval.Replicated first, so you can swap the marker for the model without a gap. When it moves back out, the replica despawns and the far entity returns.
Far entities are measured in the scope's own space, outside every physics frame. A pilot in orbit sees a ship parked on a planet, and a player on the planet sees ships in orbit. Each client holds at most InterestFarMaxEntities far entities, the nearest ones. Your policy's Authorize decides about far entities exactly as it does about replicas.
The far tier needs a gateway and clients of protocol 28 or newer, the versions a gateway admits; a client built against an older release is refused. Each far entity costs every gateway it reaches about 60 bytes per update, so use it for the entities players must see across the map, not for everything.
Scoped container ownership
ContainerOwnership tells each client which containers exist and who owns them. A client learns about a container only when it overlaps a NearCells window around one of the client's authorized foci, its current instance, or a dynamic container carrying an entity already in its set. Removals are sent only after the entities inside have been despawned, and additions are sent before the spawns that need them, so an entity never arrives before its container.
The window follows every focus, not only the pawn. A FocusMode.Free strategy camera, a policy-added focus, or a box focus each open their own NearCells window, deduplicated where they overlap, so a commander camera panning empty ground is told the empty chunks exist under it — not just the chunks named by an entity spawn. The window is recomputed once per evaluation, because panning over empty terrain changes no entity and would otherwise never refresh it.
This is bounded, not unlimited: a box focus wider than InterestMaxRadius on any axis is clamped to it about its center (InterestQuery.BoxesClamped), and one evaluation collects at most NebulaGateway.ContainerRowCap rows in total — derived from InterestRadius, the world's cell size, and InterestMaxFoci, and reported as NebulaGateway.ContainerBudgetHits when a client's foci ask for more. Reaching the cap can only ever withhold empty terrain: the rows an entity already in the client's set stands in are always collected first and are never withheld by the cap.
A client focus never allocates a chunk. Which chunks exist is a server decision made by RuntimeGridAllocator around the pawns a worker simulates and any anchors the game added with NebulaChunks.EnsureAt; nothing a client's focus does can create one. A camera panned over never-allocated ground is correctly told there is nothing there — allocating a chunk costs a lease, memory, content generation, and simulation, and none of that should be driven by a rate-limited hint a client chooses to send.
Strategy camera recipe. Give a commander camera content and interest together:
// On the server: gateway is a NebulaGateway; clientId identifies an authorized client.
gateway.SetClientFocusMode(clientId, FocusMode.Free);
// On the client, each time the camera moves:
client.FocusHint = camera.transform.position;
client.SetContentAnchor(camera.transform);FocusHint (validated server-side, rate-limited) is what the gateway streams entities and container rows for; SetContentAnchor (a local decision, sampled every frame) is what the client keeps loaded and where its floating origin sits. Keep them pointed at the same place — null to SetContentAnchor gives the pawn its job back, and a minimap ping should move neither.
Observability
Gateway (NebulaGateway): InterestSetSize(clientId), CachedEntityCount, IsEntityCached(netId), TryGetCachedPlacement(netId, out placement, out region), SubscribedRegionCount, WorkerLinkCount, InterestSettingsInUse, InterestGridInUse, ContainerRowCap, ContainerBudgetHits, and the five focus-hint counters (FocusHintsAccepted, FocusHintsClamped, FocusHintsDroppedByRate, FocusHintsDroppedMalformed, FocusHintsDroppedUnauthorized). The hint counters are gateway properties rather than GatewayStats fields: they answer a per-gateway debugging question, and nothing aggregates them across a mesh. GatewayStats adds interest set size (average and max), cached entity count, subscribed region count, worker link count and the reason for each link (region, foci, global, explicit, a first spawn, or an owned entity), spawns and despawns per second, interest evaluation time (average and max), InterestEvalsPerSecond (the rate InterestSchedule's rotation is actually running clients through), and ExtensionErrors when a gateway extension is loaded.
Worker: telemetry adds, per gateway link, the number of subscribed regions and the entries and bytes sent versus the unfiltered total — the direct measure of how much filtering interest management is doing for that link.
Dashboard: the worker table's Interest column shows the fraction of entries surviving the per-gateway filter and the filter's cost in milliseconds; the gateway table adds Interest, Cache, Regions, Links, and Eval columns. A worker whose container is heading toward the partition limit shows an amber warning line under it.
Debug overlay: a worker line reads interest regions=<n> sent=<pct>% filter=<ms>ms; a gateway line reads interest set=<avg>/<max> regions=<n> links=<n> (<reasons>) +<spawns>/s -<despawns>/s eval=<avg>/<max>ms.
InterestProbe: attach InterestProbe.Attach(client) to a client to log a [nebula-probe] line once a second, checking the four properties worth failing a soak on: replicas beyond the exit radius, duplicate spawns without a matching despawn, a replica in a chunk that is not loaded, and whether the replica count stays bounded as the client explores. See Load testing for how the same properties are asserted automatically.
Scaling limits and when to partition
Interest management bounds who hears about an entity. It does not bound who simulates it. One container is still one worker's simulation budget, whatever a client sees.
| World shape | Client culling | Gateway scoping | Simulation scaling |
|---|---|---|---|
| Infinite grid / baked cells | yes | yes (regions ≈ cells) | yes |
| Several zone containers | yes | yes (container + region) | yes, as far as the zones allow |
| One big container | yes | partial (region filter, single worker source) | no — limited by the one worker |
An unpartitioned game — a single scene, a single container — still gets correct, bandwidth-bounded clients with zero setup: interest management works the same way regardless of how many containers exist, because a region is just "all of a container" when the container is small, or a subdivision of it when the container is large. What it cannot do is add a second worker's processing capacity to a container that is still one box. A single container's entity count and tick cost are exactly what one worker can do, no matter how few of those entities any one client hears about.
Reaching a larger simulated population means partitioning the world into more containers, so more workers each own a share of it. chunked worlds and baked world partition provide authored and runtime partitioning workflows.
A worker warns — in its log and in the dashboard, at most once a minute — when one container holds more than PartitionWarnEntities entities (2,000 by default) or when filtering its entities for its gateways takes longer than PartitionWarnFilterMs per tick (2 ms by default): "partition this world." Treat either warning as a sign that a container has outgrown one worker, not as something interest management can absorb by itself.
Build an infinite runtime world
Allocate runtime chunks around players and supply their content.
World partition
Divide an authored level into baked cells.
Runtime containers
Register containers while the mesh runs and balance them across workers.
Load testing a gateway fleet
Run the soak scenarios that assert bounded replicas and bytes per client.
Extend the gateway
Install a policy, tag clients, and feed fog of war in the standalone gateway.