Class ObserverStreamingPolicy
Tunables and pure decision functions for per-observer streaming: how far a character is visible from (scaled by local density), which observed characters a client receives at full rate, and what reduced rate the rest get.
public static class ObserverStreamingPolicy
- Inheritance
-
ObserverStreamingPolicy
- Inherited Members
Remarks
Two levers, both server-side. The first is the observer range: FishNet's
DistanceCondition is cloned per object, so its distance can be changed at runtime
per character. It is left at the authored value by default — a crowd is bounded by
VisibilityBudget, which cuts by relevance rather than by distance — but can
still be shrunk with local density; see RangeScaleAtHighDensity.
The second is the observer cap: of everything a client can see, only the
FullRateObserverCap most relevant characters send every unreliable update;
the rest are sent every Nth, with N chosen by distance. Relevance favours characters in
combat, then party and guild members, then proximity.
Everything here is pure and static so it can be unit-tested without a network, and so the
scene server can override the numbers from its configuration at startup
(ApplySetting(string, string)). Reliable sends are never rate limited — see
IObserverSendFilter.
Properties
CombatWeight
Relevance weight for a character currently in combat. Kept above PartyWeight + DistanceWeight so a fighter at the edge of range still outranks an idle party member standing next to the viewer.
public static float CombatWeight { get; set; }
Property Value
DensityRadius
Radius, in metres, within which other characters count towards local density.
public static float DensityRadius { get; set; }
Property Value
Remarks
The count is taken from a 3×3 block of cells this size — a deliberate superset of the disc, but roughly 2.9× its area, so LowDensity and HighDensity are reached by a crowd about a third as dense as their names suggest. That bias is part of why the shrink is off by default; anyone re-enabling it should calibrate against the box, not the radius.
DistanceWeight
Relevance weight for proximity: a character at distance 0 scores this, one at the viewer's full observer range scores 0.
public static float DistanceWeight { get; set; }
Property Value
EngagedFullRateBudget
How many characters inside the engagement radius receive every tick.
public static int EngagedFullRateBudget { get; set; }
Property Value
Remarks
The engagement exemption exists so lag compensation can rewind to a real tick sample, and without a budget it is unbounded: thirty players inside 40 m is thirty full-rate streams. The top entries by relevance keep every tick; the rest inside the radius fall to every second tick, which costs them a compensation accuracy of one tick — around 3 cm at walking speed, against the six ticks the distance band would otherwise have given them.
EngagedOverflowInterval
Interval applied to engaged characters beyond EngagedFullRateBudget. Clamped to MaxSendInterval.
public static byte EngagedOverflowInterval { get; set; }
Property Value
EngagementRange
Radius, in metres, inside which a character's transform is sent to an observer at FULL rate regardless of any distance or cap throttling.
public static float EngagementRange { get; set; }
Property Value
Remarks
This is what makes lag compensation honest. A throttled transform reaches the observer every 3, 6 or 8 ticks, and the client interpolates across that gap — so what it renders is a position that existed on no server tick, and no rewind can reproduce it. Inside this radius the observer receives every tick, so the pose it saw IS a tick sample and the rewind lands exactly on it.
It is a floor, not the whole rule: ResolveEngagementRange(float) widens it to cover a character's own longest ability, up to EngagementRangeCeiling. Everything beyond keeps the bandwidth saving, which is where most of it lives — exempting a 40 m disc out of a 100 m observer range leaves 84% of the observed area still throttled.
EngagementRangeCeiling
Hard ceiling on the engagement radius: no attack or ability in this project reaches further, so nothing beyond it can ever need tick-exact compensation.
public static float EngagementRangeCeiling { get; set; }
Property Value
EngagementRangeMargin
Metres added to a character's longest ability range when resolving its engagement radius, covering the ground both parties can close during the compensation window.
public static float EngagementRangeMargin { get; set; }
Property Value
Remarks
The rewind reaches up to LagCompensationTick.MaximumCompensationTicks into the past;
at a closing speed of roughly 12 m/s that is a few metres, and a target that is about to
come into range needs to already be at full rate when it does.
FullRateObserverCap
How many observed characters a single client receives at full rate. Everyone else it can see is rate limited by LodBands.
public static int FullRateObserverCap { get; set; }
Property Value
GuildWeight
Relevance weight for a character in the viewer's guild.
public static float GuildWeight { get; set; }
Property Value
HighDensity
Neighbour count at or above which a character's range is fully scaled down.
public static int HighDensity { get; set; }
Property Value
LodBands
Distance bands applied to characters beyond the cap, ascending by distance. The last band should have an infinite distance so every character matches one. Intervals are clamped to MaxSendInterval when read.
public static IReadOnlyList<ObserverStreamingPolicy.LodBand> LodBands { get; }
Property Value
Remarks
One band by default: with the ceiling at 2 there is no room for the table to get coarser
with distance, and bandwidth beyond the near field is VisibilityBudget's
job. The banding stays configurable (ObserverLodBands) for a deployment that
raises the interpolation buffer.
LowDensity
Neighbour count at or below which a character keeps its full configured range.
public static int LowDensity { get; set; }
Property Value
MaxSendInterval
Largest send interval, in ticks, any observer may be handed — by the cap bands here, by
the engaged overflow, or by NetworkTransformDistanceLod.
public static byte MaxSendInterval { get; set; }
Property Value
Remarks
Mirrors NetworkTransform._interpolation, which is 2 on every prefab. The client
buffers that many received goals before it starts moving and waits for that many again
whenever the buffer runs dry, so an observer fed every Nth tick stands still for up to
2N ticks at every restart; and when the buffer overflows the client drops to the newest
goals and snaps, a jump of everything it skipped. Both scale with the interval, which is
how the 4- and 8-tick bands this table used to carry rendered as characters "teleporting"
beyond the near field while the distance LOD's own table was being retuned to no effect.
NetworkTransformLodBufferTests pins this value against the prefabs.
Every interval read out of this class is clamped to it. Raise it only together with the
prefab interpolation (config key ObserverMaxSendInterval).
MinimumRange
Absolute floor on any scaled range, in metres, so combat never happens out of sight. Inert while RangeScaleAtHighDensity is 1.
public static float MinimumRange { get; set; }
Property Value
Remarks
Raised from 25 m with the shrink's retirement: 25 m was below every ability reach and below the engagement radius that lag compensation assumes, so a re-enabled shrink hitting its floor would have despawned characters from inside the range they were being shot at. The floor is now half the authored range rather than a quarter of it.
PartyWeight
Relevance weight for a character in the viewer's party.
public static float PartyWeight { get; set; }
Property Value
RangeChangeThreshold
Range changes smaller than this, in metres, are not applied — avoids churning the observer rebuild.
public static float RangeChangeThreshold { get; set; }
Property Value
RangeScaleAtHighDensity
Fraction of the configured range applied at HighDensity. 1 disables the density shrink, which is the default.
public static float RangeScaleAtHighDensity { get; set; }
Property Value
Remarks
This was a third existence cull, and the weakest of the three. It hides by DISTANCE and per OBJECT — a character in a crowd loses range symmetrically towards everyone, including the viewers who most needed to see it — whereas VisibilityBudget hides by RELEVANCE and per VIEWER, bounding exactly the same quantity (pairs = viewers × budget) while keeping the party members, targets and opponents the shrink was blind to.
With the budget in place the shrink bought no bandwidth a crowd could notice and cost the
authored 100 m range: at scale 0.5 a busy town quietly became a 50 m world, then a 25 m
one at the old floor, which is the culling the 2026-09-08 audit was asked to remove. The
machinery stays — set ObserverRangeScaleAtHighDensity below 1 to bring it back for
a deployment that measures a need for it.
RescheduleIntervalTicks
Ticks between scheduling passes. 15 is half a second at 30 Hz.
public static uint RescheduleIntervalTicks { get; set; }
Property Value
VisibilityBudget
Hard cap on how many CHARACTERS one viewer may observe at once.
public static int VisibilityBudget { get; set; }
Property Value
Remarks
The difference between this and FullRateObserverCap is existence versus fidelity. The full-rate cap slows the 25th character down; this one makes the 41st not exist on that client at all — no transform, no vitals, no buffs, no spawn. It is what bounds a client's cost in a town, where distance alone bounds nothing.
Applies only to registered characters. Interactables, world items and scene objects are governed by their own distance conditions and are not budgeted — they are cheap and their absence is far more confusing than a missing distant stranger.
VisibilityBudgetHysteresis
How far past VisibilityBudget an ALREADY VISIBLE character keeps its slot, as a fraction of the budget.
public static float VisibilityBudgetHysteresis { get; set; }
Property Value
Remarks
Rank hysteresis, not distance hysteresis. Two characters of near-identical score sitting either side of the boundary would otherwise swap every pass, and each swap is a spawn and a despawn — far more expensive than any rate change, and visible as flicker.
Methods
ApplySetting(string, string)
Applies one key=value server setting. Unknown keys are ignored; malformed
values are rejected. Returns true when a setting was applied.
public static bool ApplySetting(string key, string value)
Parameters
Returns
Remarks
Keys: ObserverFullRateCap, ObserverCombatWeight, ObserverPartyWeight,
ObserverGuildWeight, ObserverDistanceWeight, ObserverDensityRadius,
ObserverLowDensity, ObserverHighDensity, ObserverRangeScaleAtHighDensity,
ObserverMinimumRange, ObserverRescheduleTicks, ObserverEngagementRange,
ObserverVisibilityBudget, ObserverVisibilityBudgetHysteresis,
ObserverEngagedFullRateBudget, ObserverEngagementRangeCeiling,
ObserverEngagementRangeMargin, ObserverMaxSendInterval, and
ObserverLodBands as distance:interval,distance:interval,...
(e.g. 60:1,inf:2; intervals above MaxSendInterval are clamped).
ClampInterval(byte)
Clamps a configured interval into [1, MaxSendInterval].
public static byte ClampInterval(byte interval)
Parameters
intervalbyte
Returns
ClearVisibilityBudgetOverrides()
Drops every per-classification override, restoring the authored asset values.
public static void ClearVisibilityBudgetOverrides()
HasVisibilityBudgetOverride(ObserverClassification)
True when classification has a configured override.
public static bool HasVisibilityBudgetOverride(ObserverClassification classification)
Parameters
classificationObserverClassification
Returns
LodInterval(float)
Send interval, in ticks, for a character beyond the cap at the given distance. Returns 1 (full rate) when no band matches.
public static byte LodInterval(float distance)
Parameters
distancefloat
Returns
ResolveEngagementRange(float)
The full-rate radius for an observer whose longest usable ability reaches
longestAbilityRange.
public static float ResolveEngagementRange(float longestAbilityRange)
Parameters
longestAbilityRangefloatLongest range among the character's known abilities.
Returns
- float
The radius inside which throttling is suspended.
Remarks
Melee characters keep almost all of the LOD saving; a long-range caster pays for exactly
the reach it has. Note that every ability authored today resolves to a range of 0
(Ability.Range is Speed * LifeTime, and no template sets Speed), so the
floor is currently doing all the work — this widens automatically once ranges are authored.
ResolveVisibilityBudget(ObserverClassification, int)
The budget in force for classification: the server override when one
is configured, otherwise authored from the condition asset.
0 means unlimited.
public static int ResolveVisibilityBudget(ObserverClassification classification, int authored)
Parameters
classificationObserverClassificationThe classification being budgeted.
authoredintThe value authored on the classification's condition asset.
Returns
Remarks
Every read of a classification's budget goes through here, so an operator's
ObserverVisibilityBudgets entry and a designer's asset value can never disagree
about which one is in force.
ScaledRange(float, int)
Observer range for a character with neighbourCount other characters
within DensityRadius: the full baseRange at or below
LowDensity, scaled linearly to RangeScaleAtHighDensity at
HighDensity, never below MinimumRange (or the base range,
whichever is smaller).
public static float ScaledRange(float baseRange, int neighbourCount)
Parameters
Returns
Score(bool, bool, bool, float, float)
Relevance of an observed character to a viewer. Higher is more relevant.
public static float Score(bool inCombat, bool sameParty, bool sameGuild, float distance, float maxRange)
Parameters
inCombatboolObserved character is in combat.
samePartyboolObserved character shares the viewer's party.
sameGuildboolObserved character shares the viewer's guild.
distancefloatDistance from viewer to observed, in metres.
maxRangefloatDistance at which proximity contributes nothing.
Returns
SetLodBands(IEnumerable<LodBand>)
Replaces the LOD bands. Bands are sorted by distance; an empty list means "never limit".
public static void SetLodBands(IEnumerable<ObserverStreamingPolicy.LodBand> bands)
Parameters
SetVisibilityBudget(ObserverClassification, int)
Overrides one classification's budget. 0 is unlimited.
public static void SetVisibilityBudget(ObserverClassification classification, int budget)
Parameters
classificationObserverClassificationbudgetint
ShouldSendThisTick(uint, byte, int)
True when an update should be sent on tick to an observer whose
interval is interval. phase (typically the
connection id) spreads different observers' send ticks so a cap of limited observers
does not all fire on the same tick.
public static bool ShouldSendThisTick(uint tick, byte interval, int phase)
Parameters
Returns
ShouldSendToObserver(Channel, bool, bool, byte, uint, int)
The whole per-observer send decision, as a pure function of its inputs. Both send
filters — ObserverStreamingEntry and NetworkTransformDistanceLod — answer
IObserverSendFilter.ShouldSend with this, so there is one rule rather than two
copies of it that can drift.
public static bool ShouldSendToObserver(Channel channel, bool isOwner, bool firstSendToObserver, byte interval, uint tick, int clientId)
Parameters
channelChannelChannel the send is going out on.
isOwnerboolTrue when the recipient owns the object.
firstSendToObserverboolTrue when nothing has been sent to this connection since it became an observer.
intervalbyteComposed send interval for this observer; 1 is every tick.
tickuintServer tick the send is happening on.
clientIdintRecipient's client id, used as the modulo phase.
Returns
Remarks
Truth table, first matching row wins:
| reliable channel → send. The settle after a stop, a teleport, and the spawn baseline must reach every observer. |
| owner → send. A transform its owner would discard is excluded before the filter runs; anything else the owner is entitled to. |
first send since this connection became an observer → send. The
receiver's previous goal is the reliable spawn baseline, whose tick is 0, and
NetworkTransform.GetTickDifference reads a zero predecessor as exactly ONE tick of
motion — so a skipped first packet makes the next one carry N ticks of motion and play
them in one, an N× lurch on every spawn into observation, every range entry and every
budget re-admit. (FishNet's own latch, _observersRpcSettled, covers the
reliable→unreliable transition but is per behaviour and knows nothing about an observer
being ADDED.) |
| interval of 1 or 0 → send. Nothing is throttling this observer. |
otherwise → every intervalth tick, phase-shifted by
clientId. |