Ghost Replay Plugin logo Ghost Replay Plugin

Overview

In single-player mode, all replay files are stored locally. However, in multiplayer scenarios—such as Listen server or Dedicated server—replay files are saved independently by the Server (or Host) and each client.

The overall flow of the Ghost Replay System is consistent across both single-player and multiplayer modes:

  1. Start and stop recording for one or more actors.
  2. Spawn a bloodstain (an interactive trigger actor).
  3. Interact with the bloodstain to start replaying the recorded session.

While this process is straightforward in single-player mode, multiplayer introduces some challenges:

In this section, we explain:

For clarity, let’s walk through a representative use case:

Multiplayer Example Scene

Here, the gray Manny on the left is the Server (Host) player, the orange character is a Client, and the purple actor above the obstacle is the Ghost actor currently playing back a replay.

Replay Data Transfer from Server (Host)

In multiplayer environments like listen servers (P2P) or dedicated servers, replay data needs to be transferred across the network. While technically we could replicate the entire per-frame transform array, this would be highly inefficient. (as they are too heavy to NetSerialize)

Instead, our plugin sends replay data when the corresponding Replay Actor is spawned. However, not every Replay Actor is responsible for data transmission.

For each replay, only one Replay Actor on the server and client handles data transmission and PlaybackTime replication. Lets say this as “Orchestrator”.

Simplified diagram of that logic is below:

Simplified Data Transfer diagram

Replay data is transferred in the following sequence:

Each Replay data is transffered to the client from the Server(Host) divided into several Chunks.

  1. The server first sends the file header to all clients via RPC.
    • Each client checks whether it already owns the file and reports back using a callback RPC.
    • At this point, the client also sets its preferred chunk size.
  2. After all callbacks are received, the server streams the replay data to each client in chunks (e.g., 1KB per chunk).
  3. Once data transfer is complete, each client initializes its PlayComponent using the received replay data.
  4. Finally, the server also begins playing back the replay, replicating PlaybackTime.
    • As soon as a client receives the server’s PlaybackTime, it begins playing back the Ghost using that synchronized time.

If all clients already have a local copy of the replay file, no transfer takes place.

Throttling and Reliable Buffer Limits

Replay data is transmitted via Reliable RPCs.

To avoid Reliable Buffer Overflow, the number of chunks sent per frame is conservatively throttled based on the NumOutRec count of the current NetConnection channel.

This precaution is necessary due to Unreal Engine’s limits on the number of reliable RPCs (or the total Bunch size) that can be queued.

If the cumulative NumOutRec across channels exceeds half the Reliable Buffer limit, we throttle the transfer to avoid network congestion.

Sending oversized chunks or too many RPCs per frame can cause the following error:

Although our plugin applies quantization to compress replay data, a large file or rapid bursts of data across many frames can still overwhelm the buffer. If you increase the chunk size too aggressively or remove throttling altogether, you may encounter a fatal error like this:

LogNetPartialBunch: Error: Attempted to send bunch exceeding max allowed size. BunchSize=1210307, MaximumSize=65536 Channel: [UActorChannel] Actor: ReplayActor /Game/ThirdPerson/Maps/UEDPIE_0_ThirdPersonMap.ThirdPersonMap:PersistentLevel.ReplayActor_0, Role: 3, RemoteRole: 1 [UChannel] ChIndex: 18, Closing: 0 [UNetConnection] RemoteAddr: Your Address:port, Name: IpConnection_6, Driver: Name:GameNetDriver Def:GameNetDriver IpNetDriver_3, IsServer: YES, PC: PlayerController_1, Owner: PlayerController_1, UniqueId: NULL: ...
LogNetPlayerMovement: Hit limit of 96 saved moves (timing out or very bad ping?)

If you’d like to explore this transfer mechanism in detail, refer to the following core functions:

Replay Actor: Orchestrator vs. Visual

The orchestration and data transfer are handled by a single Orchestrator Replay Actor per replay. But what about Visual Replay Actors?

We separate responsibilities between orchestrating logic and visual playback to minimize network load, especially by reducing redundant PlaybackTime replication.

Inside AReplayActor::Client_FinalizeAndSpawnVisuals, clients spawn visual-only ghost actors after receiving replay data:

for (const FRecordActorSaveData& Data : AllReplayData.RecordActorDataArray)
    {
        AReplayActor* VisualActor = GetWorld()->SpawnActor<AReplayActor>(AReplayActor::StaticClass(), GetActorTransform());
        if (VisualActor)
        {
            VisualActor->SetReplicates(false); 
            VisualActor->InitializeReplayLocal(Client_PlaybackKey, AllReplayData.Header, Data, Client_PlaybackOptions);
            VisualActor->SetActorHiddenInGame(true);
            Client_SpawnedVisualActors.Add(VisualActor);
        }
    }

Each client’s Orchestrator manages all the visual ghost actors locally. So even if there are 100 visual ghosts, only one actor replicates PlaybackTime, and it distributes that timing to the rest.

This approach avoids unnecessary replication and improves performance.

That is, the server’s Orchestrator sends replay data to the client’s Orchestrator, which then spawns and manages the visual Ghost Actors on its side — as shown in the diagram below.

Ghost Replay Actor Replication

Remember the ghost you saw in the early demonstration image? What you were actually looking at was a Visual AReplayActor, not the Orchestrator.

Why did we structure it this way?

We didn’t wanted PlaybackTime to be replicated multiple times. Even if there are 100 ghost actors playing the same animation, only one of them — the client-side Orchestrator — handles the actual PlaybackTime replication. It then distributes that time to all associated visual ghost actors.

This avoids unnecessary replication, reduces network traffic, and simplifies management. Visual actors don’t perform any checks related to data transfer — their sole purpose is playback.

Only the Orchestrator has the authority to update PlaybackTime, and that’s why roles are separated clearly in our system.

GhostPlayerController

All RPCs involved in the Ghost Replay workflow are routed through PlayerController to ensure reliable delivery and clean client-server routing.

In our plugin, most directional RPCs — both Server and Client — are implemented through a custom subclass, AGhostPlayerController.

Here are some of its RPC examples:

Server RPCs

Why does this work? Because the server can access the exact client’s connection through: PlayerController->GetNetConnection()

That’s why we define a custom GhostPlayerController. It’s the only actor guaranteed to persist across a networked session and maintain a secure NetConnection.

In Unreal Engine, PlayerController is the only actor that the client owns and the only actor through which RPCs can be reliably sent from client to server.

Sending Client’s Replay Data to the Server(Host)

In cases where a replay is recorded locally on a client, the server won’t have it. This section explains how we synchronize that data.

Previously, we assumed that the server already had all replay data. But what if:

Here’s how the plugin handles it:

  1. The client immediately sends the recorded data to the server.

    This is done asynchronously through a delegate-based callback.

  2. Either the client or the server spawns a bloodstain referencing that data.
  3. When any player interacts with the bloodstain, the server initiates a replay request.
  4. The server sends replay data to all clients — except the one that originally recorded and uploaded it.

The client already owns that data, so no need to resend.


This process is implemented in AGhostPlayerController.cpp.

Replay data is split into multiple small chunks and sent one by one per tick, just like Server-to-Client replay transfer. We default to conservative values to avoid buffer overflow, but you can tweak these variables:

Variable Description
MaxChunksPerFrame Max chunks sent per frame
ChunkSize Size of each chunk

Refer to AGhostPlayerController.cpp for the full implementation.