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.