Table of Contents

Class ObserverStreamingPolicy

Namespace
FishMMO.Shared
Assembly
FishMMO.Shared.dll

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

float

DensityRadius

Radius, in metres, within which other characters count towards local density.

public static float DensityRadius { get; set; }

Property Value

float

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

float

EngagedFullRateBudget

How many characters inside the engagement radius receive every tick.

public static int EngagedFullRateBudget { get; set; }

Property Value

int

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

byte

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

float

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

float

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

float

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

int

GuildWeight

Relevance weight for a character in the viewer's guild.

public static float GuildWeight { get; set; }

Property Value

float

HighDensity

Neighbour count at or above which a character's range is fully scaled down.

public static int HighDensity { get; set; }

Property Value

int

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

IReadOnlyList<ObserverStreamingPolicy.LodBand>

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

int

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

byte

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

float

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

float

RangeChangeThreshold

Range changes smaller than this, in metres, are not applied — avoids churning the observer rebuild.

public static float RangeChangeThreshold { get; set; }

Property Value

float

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

float

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

uint

VisibilityBudget

Hard cap on how many CHARACTERS one viewer may observe at once.

public static int VisibilityBudget { get; set; }

Property Value

int

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

float

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

key string
value string

Returns

bool

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

interval byte

Returns

byte

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

classification ObserverClassification

Returns

bool

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

distance float

Returns

byte

ResolveEngagementRange(float)

The full-rate radius for an observer whose longest usable ability reaches longestAbilityRange.

public static float ResolveEngagementRange(float longestAbilityRange)

Parameters

longestAbilityRange float

Longest 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

classification ObserverClassification

The classification being budgeted.

authored int

The value authored on the classification's condition asset.

Returns

int

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

baseRange float
neighbourCount int

Returns

float

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

inCombat bool

Observed character is in combat.

sameParty bool

Observed character shares the viewer's party.

sameGuild bool

Observed character shares the viewer's guild.

distance float

Distance from viewer to observed, in metres.

maxRange float

Distance at which proximity contributes nothing.

Returns

float

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

bands IEnumerable<ObserverStreamingPolicy.LodBand>

SetVisibilityBudget(ObserverClassification, int)

Overrides one classification's budget. 0 is unlimited.

public static void SetVisibilityBudget(ObserverClassification classification, int budget)

Parameters

classification ObserverClassification
budget int

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

tick uint
interval byte
phase int

Returns

bool

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

channel Channel

Channel the send is going out on.

isOwner bool

True when the recipient owns the object.

firstSendToObserver bool

True when nothing has been sent to this connection since it became an observer.

interval byte

Composed send interval for this observer; 1 is every tick.

tick uint

Server tick the send is happening on.

clientId int

Recipient's client id, used as the modulo phase.

Returns

bool

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.