Every developer working on a vibrant, populated game world eventually faces the same performance bottleneck: the dreaded frame hitch when spawning too many characters at once.
When you're first getting systems off the ground, the natural
instinct is to rely on standard engine functions calling
SpawnActor when an entity enters the map, and calling
Destroy when they leave. But if you open up the
profile counters during dense gameplay, you quickly realize this
is a massive drain on your frame budget. Instantiating a full
character actor on the fly forces the engine to handle heavy heap
allocations, register components, and trigger immediate garbage
collection strain.
To bypass this overhead entirely in my project, I engineered a
data-driven Object Pooling system using a custom
UWorldSubsystem. Here’s how the architecture shifts
the performance footprint to a completely flat runtime profile.
The Architectural Breakdown
The system relies on an intentional division of labor between three elements: the manager, the shell, and the data configuration.
Instead of letting characters hold their own settings, I isolated
all raw variableswalk animations, speed modifiers, and sprite
colors into an immutable UDataAsset. The character
actor itself (APedestrian) acts as an empty visual
shell with no tick logic of its own. It simply sits waiting for a
data package to tell it what to look like.
The brains of the operation live inside a
UWorldSubsystem. By choosing a subsystem over a
standard level manager actor, the lifecycle is completely handled
by the engine. It kicks off automatically when the world loads,
giving us a clean, decoupled global layer to handle memory
allocation.
Pushing Allocations to the Initial Frame
The core philosophy of this pool is simple: pay the performance tax upfront so you never have to pay it during gameplay.
During the subsystem's initialization phase, it runs a
pre-allocation pass. It spawns the maximum number of required
characters all at once, disables their native ticks, turns off
their collision, and hides them from the renderer. They are tucked
away into a simple TArray acting as our
InactivePool. Because they are completely stripped of
their active systems, they sit cold in memory with zero CPU cost.
// Instantiating the cold memory footprint upfront
for (int32 i = 0; i < Size; i++)
{
APedestrian* NewPed = World->SpawnActor<APedestrian>(PedestrianClass, FVector::ZeroVector, FRotator::ZeroRotator);
if (NewPed)
{
NewPed->SetActorHiddenInGame(true);
NewPed->SetActorEnableCollision(false);
InactivePool.Add(NewPed);
}
}
The Visual Handshake
When the game needs a character to appear on a pathway, the
subsystem doesn't create anything new. It pops a cold actor out of
the InactivePool and passes it the necessary
UDataAsset data parameters.
Inside the character, I had to figure out a clean way to handle the visual update dynamically without breaking the animation state machine. Since these are 2D characters utilizing the PaperZD plugin, simply swapping an asset mid-game can throw internal engine warnings if the components aren't ready.
The fix was setting up an explicit handshake inside the character initialization code. The actor manually grabs its animation component and explicitly links the animation brain directly to the visual sprite body right as it wakes up:
// Direct engine linkage to swap animations on a recycled actor
UPaperZDAnimationComponent* AnimComp = FindComponentByClass<UPaperZDAnimationComponent>();
if (AnimComp && MyFlipbook)
{
AnimComp->InitRenderComponent(MyFlipbook);
if (DataAsset->WalkAnimation)
{
AnimComp->PlayAnimationSource(DataAsset->WalkAnimation);
}
}
Once the visuals match the data asset, the subsystem snaps the actor to its designated spawn vector, toggles visibility and collision back on, and pushes it into the active tracking sequence.
Zero-Allocation Recycling
The real magic happens when a character finishes its path. Instead of letting the actor reach a boundary and letting the engine delete it which would force a garbage collection pass later the subsystem intercepts it.
We perform the exact inverse of the spawn pass. The subsystem
pulls the actor out of the active tracking sequence, zeroes out
its location, cuts the rendering visibility, drops its collision
matrix, and pushes the pointer right back into the
InactivePool.
// Resetting the entity state without destroying the pointer
TargetActiveList.Remove(Ped);
Ped->SetActorHiddenInGame(true);
Ped->SetActorEnableCollision(false);
Ped->SetActorLocation(FVector::ZeroVector);
TargetPool.Add(Ped);
The actor never actually leaves memory; it just shifts states.
The Performance Payoff
By taking control of the entity lifecycles and utilizing a decoupled, data-driven architecture, the project's runtime memory footprint remains completely flat. Dozens of characters can cycle across screen spaces continuously without causing a single dynamic allocation spike or cyclic garbage collection stutter.
It keeps the execution smooth, keeps the frame budget locked, and results in a highly scalable systems foundation that makes expanding gameplay features incredibly clean.