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:
- Start and stop recording for one or more actors.
- Spawn a bloodstain (an interactive trigger actor).
- Interact with the bloodstain to start replaying the recorded session.
While this process is straightforward in single-player mode, multiplayer introduces some challenges:
- Both the server and clients must be able to interact with bloodstains.
- Recording may happen on the server or the client.
- Replays must be synchronized across all players.
In this section, we explain:
- How the plugin transfers replay data.
- How it synchronizes one or more replayed actors across the network.
For clarity, let’s walk through a representative use case:
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:
Replay data is transferred in the following sequence:
Each Replay data is transffered to the client from the Server(Host) divided into several Chunks.
- 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.
- After all callbacks are received, the server streams the replay data to each client in chunks (e.g., 1KB per chunk).
- Once data transfer is complete, each client initializes its PlayComponent using the received replay data.
- 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.
- If the total
NumOutRec
across all channels exceeds half of the maximum reliable buffer size, we start throttling the data transfer to prevent network congestion.NumOutRec
represents the number of reliable RPCs sent but not yet acknowledged (ACKed) by the receiver.
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:
- AReplayActor.cpp
Multicast_InitializeForPayload
Server_TickTransfer
-
Tick
void AReplayActor::Tick(float DeltaTime) { // ... // if (bIsOrchestrator) { if (HasAuthority()) { if (bIsTransferInProgress) { Server_TickTransfer(DeltaTime); return; } Server_TickPlayback(DeltaTime); } } // ... // }
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.
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
-
Server_ReportReplayFileCacheStatus
: The client informs the server whether it already has a specific replay file. Server_SendFileChunk
: The client sends a recorded replay file to the server, chunk by chunk.Client RPCs
Client_ReceiveReplayChunk
: The server sends replay chunks directly to a specific client — typically triggered byAReplayActor
.
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:
- A client records new replay data
- The client wants to play that data later (e.g., via a bloodstain)
Here’s how the plugin handles it:
-
The client immediately sends the recorded data to the server.
This is done asynchronously through a delegate-based callback.
- Either the client or the server spawns a bloodstain referencing that data.
- When any player interacts with the bloodstain, the server initiates a replay request.
- 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.