Effect Replication
Proposed Replication Iris Planet VFX

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

Unit cull
1.5 km
Filter cap
1.5 km
Missile cull
600 m
Tiers
2

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 decision: an effect is addressed by its own world position and its own visual radius — never by what caused it, and never by who is watching. Those are the only two properties an effect intrinsically has.

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.

The channel is still worth building — but not as a bug fix. The argument for it never depended on the number: an arrival is addressed by place, a place has no proxy, and 1.5 km is a radius rather than a guarantee. A unit killed at 1.6 km, a blast between two units, a pod landing beyond a player's own units, and every future weapon that outranges the cull all still fall in the gap. What the correction changes is the urgency.

🩹 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.

C-array properties only ever mark element 0 dirty

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.

The effect readers have no bounds

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 Phase 0 is: convert every whole-property marking of a C-array to a per-element loop, settle the reader bounds, then re-measure the reported symptom on a pure client. If that restores the effects, the scope of everything below changes — the bulletin stays worth building, but as planned improvement rather than as a fix, and the phase that deletes two unfiltered multicasts becomes the most valuable part of it.

🔍 What Is on the Wire Today

EffectCarried byRelevant within
Sustained fireFiring-parts mask + start time on the unit proxyshooter, 150 000 cm
Hitscan shots, sparse launchesPer-unit shot ring, drawn at the render clockshooter, 150 000 cm
Missile flight, phase FX, detonationMissile proxy + a client-side re-simmissile, 60 000 cm
Part destruction, debris, husksAliveMask diff on the unitthe destroyed unit, 150 000 cm
Player bomb / beamMulticast on the characterthe character actor
Drop podsMulticast_DropPodsnothing — every connection, discarded client-side
Orbital channel aimMulticast_ChannelAim, 20 Hznothing — every connection
Missiles are culled at 600 m

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.

Two unfiltered multicasts remain

Drop pods and orbital channel aim both go to every connection and are filtered after arrival. They are the clearest wins available here.

The Spatial filter caps cull distance at 150 000 cm. Nothing spatially filtered can reach further, which shapes the tiering and forces two of its decisions.

🎯 The Model

HalfExamplesChannel
Originmuzzle flash, tracer leaving the barrel, wind-up, missile trail, wreckageDerived 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.
Terminalimpact, explosion, unit-death burst and ground mark, drop pod, orbital strikeThe 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.

One unguarded trait, not a Sim/Vis pair. A drain declared 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.

The drain's test is 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

TierCell sizeView radiusCull distanceFiltering
Area60 000 cm, flat XY100 000 cm≈ 142 000Spatial grid
Globalone per surfacemap-widen/aAlways relevant
There is no Local tier. The first draft sent impacts to a 20 000 cm cell reaching ~29 000 cm. Against a unit cull of 150 000 that is a fivefold narrowing of what viewers see today: everyone between 290 m and 1.5 km of a shooter would lose impacts they currently get, including the shooter's own client for anything outranging 290 m. A channel added to widen coverage must not narrow it.

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.

Mark the ring dirty per element, and register the C-array with 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.

Target present locally

Use that unit's render delay, and re-anchor the effect onto the pose being drawn.

Target absent

Fall back to the packet's absolute position and the global render delay.

The fallback is not an error path — it is the normal case for a distant unit, and the effect is fully presentable without ever resolving the link. Publish the raw shell point and let the drain run the impact-site resolve that every viewer already does; that satisfies the rule at zero cost on the wire.

⚙️ 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.

There is no initial-relevancy seed. The first draft had the first apply set the watermark to the newest ring entry so a joiner would not replay history. That is wrong, and the precedent it cited does not do it. The seed drops packets that are still due — most sharply the first packet in a cell that was created for that packet, which is exactly the long-range explosion this channel exists to deliver. Keep the lateness clamp; drop the seed.

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

EffectTodayAfter
Hitscan impact / explosionShooter's shot ring, at the endpointBulletin, Area
Projectile impact / explosionNothing — see belowBulletin, Area
Smoulder, scorch, wreckage impulseRide the explosion submitUnchanged — they follow the explosion wherever it comes from
Unit death: burst + ground markAliveMask diffBulletin, Area — one packet, one burst, one mark
Husks, per-part debris, wreckageAliveMask diffUnchanged — they need the design layout, which a distant viewer does not have
Drop podUnfiltered multicastBulletin, Global
Orbital strikeActor pathBulletin, Global
Orbital channel beam20 Hz multicastNot the bulletin — a replicated per-player array on the channel router
Muzzle flash, wind-up, missile trail, phase FXDerived from the emitterUnchanged
Hitscan beamShooter's shot ringUnchanged for viewers holding the shooter; an incoming tracer otherwise
Missile detonationClient-side re-simUnchanged — raise the missile cull distance instead
Player bomb / beamCharacter multicastUnchanged — 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.

Deleting the shot-ring block and gating the cosmetic round — the obvious change — would leave projectile weapons with no terminal effect on any machine. The authority publish and the cosmetic gate must land in the same changelist, never in sequence. The gate is an explicit defaulted parameter on the cosmetic spawn, deliberately not derived from the projectile role, because the spawn funnel is shared and a role rule would silently capture the orbital strike's rounds too.

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.

Raise the missile cull distance

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.

Put the orbital beam on the router, not the bulletin

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

The port fixes the wrong bug

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.

Something draws twice, or nowhere, and nothing detects it

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.

A silent failure on the one net mode nobody tests in

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.

A structural change in the wrong place asserts

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 4Measure. No code.
  6. 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.
  7. 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.
  8. 7Condition packets, quantisation, tuning. Conditional on Phase 4.

📐 What to Measure

MeasurementWhat it decides
Whether Iris delta-compresses a fixed POD C-array per element or resends it wholeRing 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 tableTier 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 structWhether the target anchoring works at all. Verify in Phase 1 that a non-zero id arrives non-zero.
Which channels actually have authored readers assignedWhich 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 secondCell size and ring depth.

❓ Open Decisions

Does the orbital beam go on the router or the bulletin?

The router recommendation removes a whole concept from this design. Worth ruling on before Phase 6 is scoped.

What does the drain do while the viewer is in Factory, Designer or System Map view?

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.

What does the drain do when the effects manager is null?

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.

Does the cursor ping stay unfiltered?

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

A hit ring on the victim's proxy

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.

A per-connection effect inbox

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.

One Mass entity per effect

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.

Keeping a global multicast for effects only

Rejected: it is what was just removed, and every player pays for every effect on the map.

Source of truth for this design is Docs/Design/EffectReplication.md in the Dropforge depot. This page is the readable copy.