Effect Replication: Address an Effect by Where It Lands
One channel for the visual effects a viewer cannot derive from any entity it can see — impacts, explosions, unit deaths, drop pods. Carried on per-region Mass entities, filtered by position rather than by cause. Revised after implementation planning: Phase 0 comes first and may change the scope of everything after it.
🗺 Overview
Retiring the TickManager moved every visual effect off a global reliable multicast and onto state replicated per entity. That was the right move and it is not being reversed. It did, however, invert one thing without anyone deciding to: relevance became keyed on what caused an effect, rather than on where the effect happened. An arrival — an impact, a blast, a death — is a fact about a place, and a place has no proxy.
The first draft misdiagnosed the reported bug
The first version of this document asserted that NetCullDistance defaults to 150 m, so a shot fired from 400 m away produces nothing on a client. That is wrong, and the error is worth recording because it is easy to repeat: 15 000 cm is the field initialiser on UHorizonEntityProxy, and the planet-unit path overwrites it. UPlanetUnitRepSyncProcessor writes UPlanetUnitSettings::NetCullDistance, which is 150 000 cm — 1.5 km. Reading a declaration is not the same as finding out who assigns it.
So the sniper case as originally framed sits comfortably inside relevance today and cannot be what players are reporting. Two defects that can explain it were found instead, both verified in the code, and both far cheaper to fix than this port.
🩹 Phase 0: Fix First, Then Judge
Both of these are verified present, both are cheaper than the port, and either could be the reported regression. Neither is fixed by building the bulletin, and the bulletin would be built on top of both.
A C-array UPROPERTY replicates as one net field per element (RepIndex + ArrayIndex). MARK_PROPERTY_DIRTY marks a single index, so the plain macro marks element 0 and nothing else — with per-property dirty tracking on, which is the default, elements 1..N never update on a client.
UMachineEntityProxy::CopyFrom documents this trap in a comment and is the only site in the codebase handling it correctly, looping MARK_PROPERTY_DIRTY_FROM_NAME_STATIC_ARRAY_INDEX per element. UPlanetUnitEntityProxy carries six C-array properties — Shots, AliveMask, MoveSamples, PartHeat, PartDamageType, WindupProgress — and every one is marked the whole-property way. An AliveMask that only ever ships word 0 means part destruction beyond part index 32 never reaches a client, which is a complete explanation of missing destruction effects on its own.
UPlanetEffectsManager::ConfigureEffectBounds computes a full-landscape bound and the line that applies it, Reader->SetSystemFixedBounds(LocalBounds), is commented out — directly beneath a comment saying that without it the readers cull against a small box at the origin. Every Niagara Data Channel effect in the game goes through those readers, and so would every packet this design drains.
🔍 What Is on the Wire Today
| Effect | Carried by | Relevant within |
|---|---|---|
| Sustained fire | Firing-parts mask + start time on the unit proxy | shooter, 150 000 cm |
| Hitscan shots, sparse launches | Per-unit shot ring, drawn at the render clock | shooter, 150 000 cm |
| Missile flight, phase FX, detonation | Missile proxy + a client-side re-sim | missile, 60 000 cm |
| Part destruction, debris, husks | AliveMask diff on the unit | the destroyed unit, 150 000 cm |
| Player bomb / beam | Multicast on the character | the character actor |
| Drop pods | Multicast_DropPods | nothing — every connection, discarded client-side |
| Orbital channel aim | Multicast_ChannelAim, 20 Hz | nothing — every connection |
Against a unit cull of 1.5 km, a viewer can hold the target and not the missile that kills it. A real gap — closed by raising one constant, not by anything in this document.
Drop pods and orbital channel aim both go to every connection and are filtered after arrival. They are the clearest wins available here.
🎯 The Model
| Half | Examples | Channel |
|---|---|---|
| Origin | muzzle flash, tracer leaving the barrel, wind-up, missile trail, wreckage | Derived from the emitter's replicated state, as today. Worth nothing to a viewer who cannot see the emitter, and deriving it locally is what keeps the flash glued to the barrel that viewer actually drew. |
| Terminal | impact, explosion, unit-death burst and ground mark, drop pod, orbital strike | The region bulletin. An arrival at a world position, which must reach anyone near that position whether or not the cause is relevant. |
A region is a Mass entity
One long-lived replicated object per occupied world cell, carrying a bounded ring of effect packets. Registering it as an ordinary Horizon entity type — rather than a bespoke replicated object — is what makes it cheap: the spatial grid filter, the cull distance, the dirty-tag sync, the client mirror creation and the archetype pre-registration all already exist and need no plugin changes.
A cell entity models nothing in the game, and that is the honest objection. The defence is that it is not game state: it is a spatial index over the world, the same category of object as the factory nav grid's tiles, the planet spatial hash, and Iris's own filter grid.
Client | Standalone already runs on host, client and standalone and already excludes a headless server, because a listen server maps to Client|Server and the check is a bitwise AND. The events fragment must exist on every net mode anyway — the server publishes into it. (EProcessorExecutionFlags has no ListenServer member; the first draft's flags did not compile.)📡 The Region Bulletin
The packet
USTRUCT()
struct FFXEventPacket
{
// Effect position relative to the owning cell's centre. Relative, not absolute, so the
// value stays small and quantises well; the drain adds the cell centre back.
FVector3f LocalPosition = FVector3f::ZeroVector;
// For a point effect, the surface normal. For an arrival, the direction the thing that
// caused it came FROM, which is what lets a viewer draw an incoming tracer without
// knowing where the shooter is.
FVector3f Direction = FVector3f::ZeroVector;
// Authority world time the effect happened. Held until the render clock reaches it.
float ServerTime = 0.f;
// Monotonic per-cell counter starting at 1. The de-dup key: a drain tracks the highest
// it has drawn and compares wrap-safely, so a ring re-sent unchanged replays nothing.
uint16 Seq = 0;
// Impact, Explosion, Debris, DropPod — and the channel's visual variant id.
uint8 Channel = 0;
uint8 Visual = 0;
// Per-channel scalars, quantised against a per-channel range. Explosion uses both
// (blast radius, blast force); most channels use neither.
uint8 Value0 = 0;
uint8 Value1 = 0;
// The unit this landed on, or invalid when it landed on terrain. Used ONLY to pick the
// render clock and re-anchor onto the pose this viewer is drawing. Never dereferenced
// for anything the effect needs in order to exist.
FMassNetworkID TargetNetID;
};
SurfaceID is carried on the proxy, not in the packet, and is mandatory in three places for three different reasons. It belongs in the cell key, because surfaces are separated by a Z offset of 10 000 cm — far inside every tier's cull — so the spatial filter alone cannot tell a factory from a planet. It is stamped as a surface-membership fragment, because that is what the surface-clear path queries on. And it is replicated, because the client sync processor caches one shared-fragment value per entity type, so a per-surface const-shared value structurally cannot reach a client.
SurfaceID == GetActivePlanetSurfaceID, not "is the surface loaded". UPlanetEffectsManager::Get resolves the active manager, so a loaded-but-inactive surface's packets would otherwise draw through the wrong readers at the wrong coordinates.Tiers — two, not three, and both forced
| Tier | Cell size | View radius | Cull distance | Filtering |
|---|---|---|---|---|
| Area | 60 000 cm, flat XY | 100 000 cm | ≈ 142 000 | Spatial grid |
| Global | one per surface | map-wide | n/a | Always relevant |
Cells are flat in XY, and that is forced rather than preferred. Cull distance is view radius plus half the cell diagonal. A 60 000 cm cubic cell gives 100000 + 51962 = 151962 — over the cap on day one. Flat gives 142 426. Surfaces are separated in Z alone, and the SurfaceID key already provides that separation.
Global is not a convenience. No spatially filtered tier can ever match 150 000 cm of relevance, because its guaranteed reach is its view radius — an effect may sit a half-diagonal from the cell centre — and view + half-diagonal is hard-capped. Anything that must be as visible as the unit it belongs to has to be always-relevant.
The ring
Iris delivers only a property's newest value and coalesces updates, so a single slot would lose every event but the last between two sends. Depth must cover the peak events in one cell per replication interval, plus events that have arrived but are not yet due. Overflow overwrites oldest-first, costing a missing burst and nothing else.
DOREPLIFETIME_WITH_PARAMS, not _FAST. Building this channel on the whole-property macro would reproduce the exact defect Phase 0 fixes.Cell lifecycle
Created lazily on the first effect published into them, retired after an idle timeout comfortably longer than the drain's lateness window. Pooled and long-lived by design: the per-cell cost that matters is the Iris creation header, sent once per cell per client, so a cell must never be spun up per explosion. The find-or-create path must validate its cached handle and treat a retiring record as absent, or a publish landing between the destroy and the observer writes through a dying handle — the stale-handle class this project has hit twice.
👁 Seeing What You Cannot See
A viewer does not get the shooter's position — neither available nor desirable to leak. It gets the impact, and the packet's direction gives the bearing the shot arrived from, so the drain can draw a bounded-length incoming tracer that reads as a shot arriving out of the dark.
The one place a packet looks at another entity
An impact on a moving unit must land on the unit where this viewer is drawing it, at that unit's own interpolation delay — not at the authority position it was recorded at.
Use that unit's render delay, and re-anchor the effect onto the pose being drawn.
Fall back to the packet's absolute position and the global render delay.
⚙️ Publish and Drain
Publish (authority)
A server world subsystem owns the cell map and a plain queue of pending packets. Once per frame it drains the queue, buckets by tier and cell, creates any missing cell entity, appends into that cell's fragment and marks it dirty; a rep-sync processor copies fragment to proxy.
The queue is a plain array, not one entity per effect — these effects have no simulation to run, and the Niagara Data Channel batching already does the presentation batching.
Drain (every viewer, host included)
A Client | Standalone processor walks each cell's ring oldest-first, skips anything at or below its watermark, stops at the first packet not yet due, and submits the rest inside a client-presentation scope. The listen-server host drains the ring it just authored, through the same processor, at its own render clock. There is no host shortcut to diverge.
The drain reaches decals and smoulder through the explosion and destruction submits, never a lower-level spawn. That coupling is what makes "every explosion leaves a mark" true by construction, and it is why the channel enum has no Smoulder member: the explosion submit already presents the smoulder, the scorch and the wreckage impulse. A separate channel would double-draw the smoke, and one without an explosion has no producer anywhere.
📋 What Moves, and What Does Not
| Effect | Today | After |
|---|---|---|
| Hitscan impact / explosion | Shooter's shot ring, at the endpoint | Bulletin, Area |
| Projectile impact / explosion | Nothing — see below | Bulletin, Area |
| Smoulder, scorch, wreckage impulse | Ride the explosion submit | Unchanged — they follow the explosion wherever it comes from |
| Unit death: burst + ground mark | AliveMask diff | Bulletin, Area — one packet, one burst, one mark |
| Husks, per-part debris, wreckage | AliveMask diff | Unchanged — they need the design layout, which a distant viewer does not have |
| Drop pod | Unfiltered multicast | Bulletin, Global |
| Orbital strike | Actor path | Bulletin, Global |
| Orbital channel beam | 20 Hz multicast | Not the bulletin — a replicated per-player array on the channel router |
| Muzzle flash, wind-up, missile trail, phase FX | Derived from the emitter | Unchanged |
| Hitscan beam | Shooter's shot ring | Unchanged for viewers holding the shooter; an incoming tracer otherwise |
| Missile detonation | Client-side re-sim | Unchanged — raise the missile cull distance instead |
| Player bomb / beam | Character multicast | Unchanged — relevancy-filtered by the character |
Projectile weapons never drew terminal effects from the shot ring
This is the subtlest thing in the port and it inverts the obvious change. The projectile update gates all four terminal submit sites on bPresents = (P.Role != EProjectileRole::Authoritative), so the authority's damage round presents nothing. There is therefore no existing code that could publish a projectile arrival.
Unit death narrows to a burst and a mark
The first draft promised full debris from a unit dying outside your view. It cannot deliver that: husks, the death scorch and the pooled physics debris all ride the destruction submit off the AliveMask diff and need the design layout, which a viewer without the unit does not have. What the bulletin carries is one packet meaning a unit died here — one burst, one ground mark. Wreckage stays derived for viewers who hold the unit.
De-dup the two paths at tear-off, not at presentation. A presentation-time record loses the race whenever the packet is due before the derived death, and a net-id presence probe cannot work at all, because both the server and client id maps are cleared in the same call as the publish. The tear-off hook runs on every rendering net mode, before any presentation, with the net-id fragment still attached.
✂️ Two Fixes That Are Not This Design
Both surfaced during planning, both are cheaper and more valuable per line than anything in the phases, and neither needs the bulletin.
It is 60 000 cm against a unit cull of 150 000, so a viewer can hold the target and not the missile that kills it. One constant. This closes the missile-visibility gap far more cheaply than moving detonation onto the bulletin, which would also require reworking the client missile clock and leaves the mesh hanging beside its own fireball for the linger duration.
A replicated per-player array on the already-always-relevant channel router gives identical reach and identical send-on-change economics, delivers correctly to a mid-channel joiner for free, and carries a surface field to filter on — with no Global cell, no condition concept, no capacity ceiling and no generation handles. The entire win being sought is deleting the 20 Hz aim multicast, and the router array delivers that on its own. Only the orbital strike — a discrete arrival — belongs on the bulletin.
🔁 Sustained Streams Stay Conditions
The bulletin must not quietly become the old bus with better filtering. A weapon hosing a wall at 10 Hz would otherwise publish ten arrivals a second into one cell for as long as it fires.
The same move already made for sustained fire applies to the far end of it: publish one packet describing the condition — impact point, rate, and a valid-until horizon — and let each viewer generate the impacts from it. Cost becomes proportional to state changes rather than to rounds, and a viewer that misses the end stops on its own.
Until measurement says this is needed, the interim protection is simpler: gate the projectile publish on the same sustained-fire test the generator already uses.
⚠️ Hazards
Phase 0 is the whole answer: re-measure before Phase 1 begins, and scope down if it restores the effects. A third candidate exists too — the missile phase-FX channel has no authored data channel, so that submit is already a silent no-op.
Once the drain holds a presentation scope the parity guardrail cannot see a double-draw — a scope is an allow, not a check. Acceptance is a hand count with the shooter in view: exactly one impact per shot, tested separately for a hitscan and a projectile weapon. Fence the bypass while you are there: the static emit helpers are live BlueprintCallable functions that submit directly, outside any scope, and are the route by which this defect returns.
Four independent one-line omissions each kill the channel on a pure client only, with no log: a missing fragment-registration override; a missing ReplicatedUsing plus dirty-mask set, without which the client sync skips the apply entirely and the ring updates once at creation and never again; _FAST on the C-array registration; and a thin client config whose archetype never resolves. Sign-off is therefore not "a packet arrived" — every one of those failures passes that test. It is a pure client whose newest sequence number keeps advancing across a sustained engagement.
The surface-membership stamp is a synchronous const-shared-fragment add; the deferred spawn helper runs its initializer inside a command flush with a live creation context held. Cell creation needs both and nobody has established that they compose. Test a full surface unload and a PIE stop with cells live, with Mass validation on.
🚧 Phases
- 0Fix the C-array dirty marking and the reader bounds; re-measure. Function bodies only, so no full rebuild. Knows it worked: a pure client sees more than one shot per unit per ring wrap, and part destruction fires for parts outside the first mask word. If it does, re-scope everything below.
- 1The channel end to end, hitscan impacts only, publish behind a cvar. One tier, sized Area. Add a default fragment-registration override to the Horizon proxy base so that whole failure class dies. Knows it worked: with the cvar on, a listen host and a pure client each draw exactly one bulletin impact per shot in the same place; the client's newest sequence keeps advancing; cells are created and retired symmetrically; a planet unload leaves zero region entities.
- 2Take the terminal half — hitscan and projectile in one change. Delete the shot-ring terminal block and its two flags; add the authority projectile publish; add the cosmetic gate. Flip the cvar on. Knows it worked: exactly one impact per shot by hand count, for both weapon kinds; the Client Parity table stays empty.
- 3Unit death, narrowed to burst plus mark, including the tear-off claim. Knows it worked: one burst and one mark for a death you hold, one for a death you do not, never two. Test with the target held, torn off mid-linger, and never held.
- 4Measure. No code.
- 5The Global tier: drop pods and the orbital strike. One always-relevant cell per surface. Delete the pod multicast, its event struct and the now-callerless emit helper — and that path's presentation scope in the same edit. Coalesce pods to one packet per cell per frame with a count, since a batch shares a drop location. Knows it worked: a bulk deployment shows every pod; a client on another surface sees none; packet count scales with drop points, not units.
- 6The orbital beam off its 20 Hz multicast, via the router. Three fixes ride along or the change ships an invisible weapon: the beam prewarm needs calling from BeginPlay, since its only caller today is the binding this deletes; the valid-until horizon must be extendable, because an authored-unbounded channel computes a horizon of zero; and the disconnect path must close the slot, since a dropped channeller's beam is currently stopped through the multicast this removes.
- 7Condition packets, quantisation, tuning. Conditional on Phase 4.
📐 What to Measure
| Measurement | What it decides |
|---|---|
| Whether Iris delta-compresses a fixed POD C-array per element or resends it whole | Ring depth, more than any other number — every "unchanged slots cost nothing" claim rests on it. Entangled with Phase 0: if only element 0 is ever marked, the question is moot because nothing past it is polled. |
| The real max-range column in the weapon stats table | Tier sizing. Reasoning from the C++ member initialiser gives 10 m, which is plainly not the shipping value. |
| Whether the net-id type serialises through Iris inside a nested replicated struct | Whether the target anchoring works at all. Verify in Phase 1 that a non-zero id arrives non-zero. |
| Which channels actually have authored readers assigned | Which effects are live at all. The effects manager already logs this per channel, so it is one PIE run. |
| Cells subscribed per viewer at real camera zoom, and peak packets per cell per second | Cell size and ring depth. |
❓ Open Decisions
The router recommendation removes a whole concept from this design. Worth ruling on before Phase 6 is scoped.
Ungated it spawns Niagara for a surface nobody is watching; gated naively, switching back fires a volley. The rule that works is to gate the submit, never the watermark — a suppressed packet is still consumed.
It legitimately is, on a joining client whose cell mirrors arrive before the surface streams in. Advancing the watermark eats those packets; holding them fires a volley. Both are wrong differently.
After Phase 5 it is the last unfiltered world-effect bus. There is a good argument that it should stay — a ping is addressed to teammates, not to a place — but leaving it unstated invites the next reader to find a contradiction.
🔀 Alternatives Considered
Genuinely cheap, needs no new object, and covers being shot from beyond relevance, because your own unit is always relevant to you. Rejected as the architecture, not as an idea: it addresses an effect by whom it hit, so it says nothing about a shot that misses into the terrain beside you, an explosion between units, or a pod landing. A channel that handles some arrivals and not others means two channels forever.
Attractive at small player counts, and it makes relevance an explicit readable predicate. Rejected because it is a bespoke relevance system standing beside the one Iris already provides, and because an effect seen by n players is serialised n times.
Rejected: fire-and-forget particles have no simulation to run, an entity per burst buys archetype churn and the create/destroy-during-processing hazards this codebase has already been bitten by, and the Niagara Data Channel batching already provides the batching an entity would be pressed into justifying.
Rejected: it is what was just removed, and every player pays for every effect on the map.
Docs/Design/EffectReplication.md in the Dropforge depot. This page is the readable copy.