How-To: Pool a Replicated Actor

Goal: pool an actor that replicates, without clients losing it, missing a use, or acting on stale data.

Prerequisites
Quick Start finished, and Concept: Replication and Recycling read if you want the reasoning behind the rules below rather than just the rules.

1. Acquire and release on the server only

The pool refuses both calls on a client for any class that replicates, and logs a warning. Non-replicated cosmetic actors are unaffected, so a client can still pool its own effects. Nothing to configure - just do not write client code that acquires.

Verify
Play As Client with 2 clients. The warning AcquireActor refused on a client must never appear in the log.

2. Never use COND_InitialOnly

This is the single most likely mistake. COND_InitialOnly only sends in an actor's first bunch. A pooled actor's channel is already open on its second use, so that state is never resent and clients keep acting on the first use's data - silently, with no error.

cpp
void AMyPooledActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// WRONG for a pooled actor - only ever sent on the first use.
// DOREPLIFETIME_CONDITION(AMyPooledActor, ActivationState, COND_InitialOnly);
DOREPLIFETIME(AMyPooledActor, ActivationState);
}
COND_OwnerOnly has a related hole
Across a change of owner, a value that happens to be identical for the old and new owner is never dirty, so the new owner receives nothing. The pool calls ForcePropertyCompare on acquire to mitigate this, but do not build load-bearing logic on owner-conditional state in a pooled actor.

3. Put a counter in your activation state

If an actor is recycled with identical parameters, a byte-identical property is not seen as changed and the use is skipped entirely on clients. A counter that increments every use guarantees the property is always dirty.

cpp
USTRUCT()
struct FMyActivationState
{
GENERATED_BODY()
UPROPERTY() FVector_NetQuantize100 Location = FVector::ZeroVector;
UPROPERTY() bool bActive = false;
/** Incremented every activation. Wrapping is harmless - only inequality between two
* consecutive activations matters, and 256 uses cannot elapse between net updates. */
UPROPERTY() uint8 UseCounter = 0;
};
Verify
Play As Client with 2 clients. Fire the same pooled actor from the same spot twice in quick succession. Both clients must see both uses.

4. Leave net dormancy on

Project Settings > Crimson > Crimson Object Pool > Use Net Dormancy While Pooled is on by default. Leave it on. A released actor is hidden and non-colliding, which is exactly the condition the engine treats as not relevant - without dormancy the channel closes for relevancy a few seconds after each release and every client destroys its copy.

Do not park pooled actors far away
A 'graveyard' location is a common pooling trick and it is wrong for replicated actors: it puts them outside NetCullDistance, which loses relevancy and destroys the client copy just as surely. The pool leaves released actors exactly where they were, hidden and non-collidable. Do not move them in On Released To Pool either.

5. Check your class is poolable at all

An actor with bOnlyRelevantToOwner cannot be pooled safely: changing the owner on reuse makes it non-relevant to the previous owner, whose client destroys it. The pool warns once per class if you try.

Verify
Play As Client with 2 clients, run twenty acquire/release cycles, and confirm no client ever loses the actor and the log carries no CrimsonObjectPool warnings.

See also

  • Concept: Replication and Recycling
  • Concept: The Reuse Contract
  • How-To: Diagnose Pool Problems