Replay System Tutorial

Overview This tutorial is intended to show you how to easily create a basic replay system, enabling you to record game data to a hard drive and play it back at a later time. I spent quite some time...

Updated about 5 years ago

Overview

This tutorial is intended to show you how to easily create a basic replay system, enabling you to record game data to a hard drive and play it back at a later time. I spent quite some time reading through the engine code to be able to implement this in my own projects and was surprised that no one covered it in a tutorial yet, so here goes... My method might not be the optimal one, but it works and I'd like to share it with those who want to work with replays as well. While the tutorial does include some c++ code, I will show you how to expose the necessary methods, so that they can be called from blueprints.

(NOTE: Replays can't be recorded or played back in PIE mode. Use Standalone instead!)

  1. In the Initial Setup I'll show you how a new project is initially configured to be able to deal with Replays.
  2. The section "Replication" sets up the First Person Example to replicate its Projectiles, Cube Meshes and to let Clients call Server_Fire
  3. In "Adding our C++ code" I introduce some functions to a new GameInstance class that will start/stop recording and Find, Rename, Watch and Delete Replays from Hard Disk
  4. In "Blueprint (UI) Implementation you will find a minimalistic Set-Up to start/stop Recording from Blueprints
  5. In "MainMenuUI" I show you how to make a simple Replay Browser to manipulate previous records
  6. The section "Testing it" is a good spot to make a break from this tutorial and an invitation to play around.
  7. The last section "Adding the ReplaySpectator Controller" introduces a PlayerController to handle a Replay Playback and a Widget to interface between User and PlayBack.
  8. Finally, there is still bug that needs to be fixed, this is described in "Conclusion, Bugs"

Initial Setup

To begin with, create a project of your liking. I chose the "First Person Example" as a base, however any Example should do. You can also include this tutorial in your own project, but for the sake of simplicity I will display it for a clean one. After the project is created, open the project folder on your hard drive and navigate to ReplayTutorial/Config/DefaultEngine.ini, open it, and add the following statement at the end of this file:

  • [/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="DemoNetDriver",DriverClassName="/Script/Engine.DemoNetDriver",DriverClassNameFallback="/Script/Engine.DemoNetDriver")

This step will enable and load the DemoNetDriver for you, which is the actual recorder.

Replication

In order to let the engine record gameplay, you need to make sure that our actors are properly replicated. The engine treats the replay recorder like a networked client, even in a single player game, so that replicated data is automatically recorded. You can safely skip to the next section if your project is already set up for multiplayer games.

In the level's "World Outliner", select all of the EditorCubeXX-actors and set their property "Static Mesh Replicate Movement" to true. The movement of these cubes will be recorded this way.

File:ReplayTutorial ReplicateMeshes.png

After that, open the Blueprint "FirstPersonProjectile" and set the properties of "Replicates" and "Replicate Movement" to true. This will make sure that the projectile balls will be seen on clients and in the records. Now, when the server shoots, others will see it. Additionally, clients might shoot, but they can't replicate data to the other clients OR the recorder. To ammend that, open the Blueprint "FirstPersonCharacter" and verify that "Replicate Movement" and "Replicates" is set. Then, locate the event called "InputAction Fire" and add a custom event called "Server_Fire" near it. Set this event to "Run on Server" and "Reliable", since this is important gameplay input. We need to restructure (see Images) the "InputAction Fire" event in order to make it network-enabled. Put the nodes "Montage Play" and "Play Sound At Location" directly after the "InputAction Fire" event. Then drag a node called "Switch Has Authority" out of the sound node. From the "Remote" Execute-Pin, call the previously created method "Server_Fire" and from the "Authority" Pin, spawn the projectile. This concludes our preparations.

File:ReplayTutorial ReplicateCharacter1.png

File:ReplayTutorial ReplicateCharacter2.png

Adding our C++ code

All of the recording and playback methods can be found within the engine. What we have to do in order to use them is to correctly expose these methods to blueprint. To achieve this, right click in the "Content Browser" and add a new c++ class. Click on "Show All Classes" in the upper right corner and search for GameInstance. Since this will be our parent class, select it. You can choose a name when you select Next, then create the new class.

This will open Visual Studio (if you installed it – I will skip this step here ;)), where you will see the newly created .h (definition) and .cpp (code) files. In my case, they are named MyGameInstance.x

ReplayTutorial.Build.cs

Before creating any code, make sure that you include "Json" in the PublicDependencyModules, a definition that can be found in the Solution Explorer under Solution/Games/ReplayTutorial/Source/ReplayTutorial.Build.cs. Open it and add "Json", like so:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Json" });

MyGameInstance.h

The .h (definition file) will contain our definitions of the necessary methods and properties. You will need to add an include at the beginning of this file, so that these three are available:

#include "Engine/GameInstance.h"
#include "NetworkReplayStreaming.h"
#include "MyGameInstance.generated.h"

Afterwards, in the class body, add the following function definitions. Their names will already tell you what the functions are intended for:


public:
    /** Start recording a replay from blueprint. ReplayName = Name of file on disk, FriendlyName = Name of replay in UI */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void StartRecordingReplayFromBP(FString ReplayName, FString FriendlyName);

    /** Start recording a running replay and save it, from blueprint. */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void StopRecordingReplayFromBP();

    /** Start playback for a previously recorded Replay, from blueprint */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void PlayReplayFromBP(FString ReplayName);

    /** Start looking for/finding replays on the hard drive */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void FindReplays();

    /** Apply a new custom name to the replay (for UI only) */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void RenameReplay(const FString &ReplayName, const FString &NewFriendlyReplayName);

    /** Delete a previously recorded replay */
    UFUNCTION(BlueprintCallable, Category = "Replays")
        void DeleteReplay(const FString &ReplayName);

We also need additional functions for our FindReplays() and DeleteReplay(..) methods, since they rely on async callbacks. To support these, add the following:


    virtual void Init() override;

private:

    // for FindReplays() 
    TSharedPtr EnumerateStreamsPtr;
    FOnEnumerateStreamsComplete OnEnumerateStreamsCompleteDelegate;

    void OnEnumerateStreamsComplete(const TArray& StreamInfos);

    // for DeleteReplays(..)
    FOnDeleteFinishedStreamComplete OnDeleteFinishedStreamCompleteDelegate;

    void OnDeleteFinishedStreamComplete(const bool bDeleteSucceeded);

Further, it will be necessary to display Information about replays to the user interface. Since this is in blueprint, let us create a struct to hold these data. Add the following definition at the beginning of the file (or before our UMyGameInstance class definition):


USTRUCT(BlueprintType)
struct FS_ReplayInfo
{
    GENERATED_USTRUCT_BODY()

    UPROPERTY(BlueprintReadOnly)
        FString ReplayName;

    UPROPERTY(BlueprintReadOnly)
        FString FriendlyName;

    UPROPERTY(BlueprintReadOnly)
        FDateTime Timestamp;

    UPROPERTY(BlueprintReadOnly)
        int32 LengthInMS;

    UPROPERTY(BlueprintReadOnly)
        bool bIsValid;

    FS_ReplayInfo(FString NewName, FString NewFriendlyName, FDateTime NewTimestamp, int32 NewLengthInMS)
    {
        ReplayName = NewName;
        FriendlyName = NewFriendlyName;
        Timestamp = NewTimestamp;
        LengthInMS = NewLengthInMS;
        bIsValid = true;
    }

    FS_ReplayInfo()
    {
        ReplayName = "Replay";
        FriendlyName = "Replay";
        Timestamp = FDateTime::MinValue();
        LengthInMS = 0;
        bIsValid = false;
    }
};

And lastly, add another function to our UMyGameInstance class, that we call when finding replays has completed:


protected:
    UFUNCTION(BlueprintImplementableEvent, Category = "Replays")
        void BP_OnFindReplaysComplete(const TArray &AllReplays);

MyGameInstance.cpp

This file will contain our actual code which carries out the previously defined methods. To begin with, lets include the following two definitions:


#include "ReplayTutorial.h"
#include "Runtime/NetworkReplayStreaming/NullNetworkReplayStreaming/Public/NullNetworkReplayStreaming.h"
#include "NetworkVersion.h"
#include "MyGameInstance.h"

Then, lets create our Init() function first, since this is the place were we can safely link the OnDelete...- and OnEnumerate...-Delegates before a user will call any of our functions:


void UMyGameInstance::Init()
{
    Super::Init();
    
    // create a ReplayStreamer for FindReplays() and DeleteReplay(..)
    EnumerateStreamsPtr = FNetworkReplayStreaming::Get().GetFactory().CreateReplayStreamer();
    // Link FindReplays() delegate to function
    OnEnumerateStreamsCompleteDelegate = FOnEnumerateStreamsComplete::CreateUObject(this, &UMyGameInstance::OnEnumerateStreamsComplete);
    // Link DeleteReplay() delegate to function
    OnDeleteFinishedStreamCompleteDelegate = FOnDeleteFinishedStreamComplete::CreateUObject(this, &UMyGameInstance::OnDeleteFinishedStreamComplete);
}

Some of our functions are actually only calling functions that are already present in the GameInstance, like the following:

void UMyGameInstance::StartRecordingReplayFromBP(FString ReplayName, FString FriendlyName)
{
    StartRecordingReplay(ReplayName, FriendlyName);
}

void UMyGameInstance::StopRecordingReplayFromBP()
{
    StopRecordingReplay();
}

void UMyGameInstance::PlayReplayFromBP(FString ReplayName)
{
    PlayReplay(ReplayName);
}

FindReplays() on the other hand needs an additional (our previously created) ReplayStreamer, called "EnumerateStreamsPtr". It starts looking for replays on your hard disc and asynchronously calls " OnEnumerateStreamsComplete" when ready:

void UMyGameInstance::FindReplays()
{
    if (EnumerateStreamsPtr.Get())
    {
        EnumerateStreamsPtr.Get()->EnumerateStreams(FNetworkReplayVersion(), FString(), FString(), OnEnumerateStreamsCompleteDelegate);
    }
}

void UMyGameInstance::OnEnumerateStreamsComplete(const TArray& StreamInfos)
{
    TArray AllReplays;

    for (FNetworkReplayStreamInfo StreamInfo : StreamInfos)
    {
        if (!StreamInfo.bIsLive)
        {
            AllReplays.Add(FS_ReplayInfo(StreamInfo.Name, StreamInfo.FriendlyName, StreamInfo.Timestamp, StreamInfo.LengthInMS));
        }
    }

    BP_OnFindReplaysComplete(AllReplays);
}

In order to rename replays I have stumbled upon the Engine-functions that create, write and read data to the actual files on our hard disk. I've put together a solution to setting a "Friendly-Name" (for UI) in a previously recorded replay, so that users can put their own nametags on their replays. However, I think this is a bit of a hack because the filestrings are merely put together like in the engine. If the methods in the engine ever change then this will obviously not work anymore:

void UMyGameInstance::RenameReplay(const FString &ReplayName, const FString &NewFriendlyReplayName)
{   
    // Get File Info
    FNullReplayInfo Info;

    const FString DemoPath = FPaths::Combine(*FPaths::GameSavedDir(), TEXT("Demos/"));
    const FString StreamDirectory = FPaths::Combine(*DemoPath, *ReplayName);
    const FString StreamFullBaseFilename = FPaths::Combine(*StreamDirectory, *ReplayName);
    const FString InfoFilename = StreamFullBaseFilename + TEXT(".replayinfo");

    TUniquePtr InfoFileArchive(IFileManager::Get().CreateFileReader(*InfoFilename));

    if (InfoFileArchive.IsValid() && InfoFileArchive->TotalSize() != 0)
    {
        FString JsonString;
`        *InfoFileArchive Pauser PlayerState to our current one. In order to quit the Pause State we will then Nullify this Setting. Additionally you can see that we will store our AntiAliasing and MotionBlur Console Variables

bool APC_ReplaySpectator::SetCurrentReplayPausedState(bool bDoPause)
{
    AWorldSettings* WorldSettings = GetWorldSettings();

    // Set MotionBlur off and Anti Aliasing to FXAA in order to bypass the pause-bug of both
    static const auto CVarAA = IConsoleManager::Get().FindConsoleVariable(TEXT("r.DefaultFeature.AntiAliasing"));

    static const auto CVarMB = IConsoleManager::Get().FindConsoleVariable(TEXT("r.DefaultFeature.MotionBlur"));

    if (bDoPause)
    {
        PreviousAASetting = CVarAA->GetInt();
        PreviousMBSetting = CVarMB->GetInt();

        // Set MotionBlur to OFF, Anti-Aliasing to FXAA
        CVarAA->Set(1);
        CVarMB->Set(0);

        WorldSettings->Pauser = PlayerState;
        return true;
    }
    // Rest MotionBlur and AA
    CVarAA->Set(PreviousAASetting);
    CVarMB->Set(PreviousMBSetting);

    WorldSettings->Pauser = NULL;
    return false;
}

Lastly, we will implement the "Interface" between User and DemoNetDriver. These functions simply forward requests that will be called from our UI to the DemoNetDriver. The actual work was already done In-Engine. After copying the following, hit compile and switch to the Unreal Editor:

int32 APC_ReplaySpectator::GetCurrentReplayTotalTimeInSeconds() const
{
    if (GetWorld())
    {
        if (GetWorld()->DemoNetDriver)
        {
            return GetWorld()->DemoNetDriver->DemoTotalTime;
        }
    }

    return 0.f;
}

int32 APC_ReplaySpectator::GetCurrentReplayCurrentTimeInSeconds() const
{
    if (GetWorld())
    {
        if (GetWorld()->DemoNetDriver)
        {
            return GetWorld()->DemoNetDriver->DemoCurrentTime;
        }
    }

    return 0.f;
}

void APC_ReplaySpectator::SetCurrentReplayTimeToSeconds(int32 Seconds)
{
    if (GetWorld())
    {
        if (GetWorld()->DemoNetDriver)
        {
            GetWorld()->DemoNetDriver->GotoTimeInSeconds(Seconds);
        }
    }
}


void APC_ReplaySpectator::SetCurrentReplayPlayRate(float PlayRate)
{
    if (GetWorld())
    {
        if (GetWorld()->DemoNetDriver)
        {
            GetWorld()->GetWorldSettings()->DemoPlayTimeDilation = PlayRate;
        }
    }
}

WID_ReplaySpectator

Now we need to call the previously created functions from somewhere in the UI. To do that, simply create another widget called WID_ReplaySpectator or similar. To correctly use the functions you'll need two Text Fields to display Current and Max Game Time, a Slider to Change it (and display the Current Progress as a Fraction), a Pause-Button and a ComboBox to select the PlayRate. I've certainly set this up to be minimalistic but you will likely design it your own way anyways:

File:ReplayTutorial ReplaySpectatorWID.png

Now switch to the Event Graph and build the following Events/Functions. Starting from the Event Construct, you'll save a Reference to the Replay PC and obtain the total Game Time In Seconds as a new Integer Variable. Then, from Event Tick, obtain the Current Game Time In Seconds. To display them, switch to the Designer and click on the CurrentTime Text. In its properties press the DropDown labelled as "Bind" and create a new Binding. Rename it so that the functions called "CurrentGameTimeToText" and fill in the functionality like in the picture. Then, do the same for the TotalGameTime-Text field.

File:ReplayTutorial ReplaySpectatorGameTimes.png

You might also want to display the current Progress as a fraction. To this end open the designer, select the Slider and under Value, create a new Binding. This will need two more variables that are created now, a Released At value as a float (we will change this later) and a Boolean to decide whether the user has picked up the Slider. Make sure that this bool is set to False as a Standard Value:

File:ReplayTutorial ReplaySpectatorSlider.png

To enable and disable Pause, implement the Button Clicked Function and Bind the Text on this Button according to the following:

File:ReplayTutorial ReplaySpectatorPause.png

To manipulate the current Playback Rate, simply do the following:

File:ReplayTutorial ReplaySpectatorPlayRate.png

The last thing to do for this widget is to let the users directly manipulate the Slider we provided. We need three events that are linked to the Slider to do this, CaptureBegin, ValueChanged and CaptureEnd. When the Capture Begins, we'll set the PickedUp Boolean to true. On Value Changed, we will store the ReleaseAt Value and on CaptureEnd we will tell the PlayerController->DemoNetDriver and reset the Boolean.

File:ReplayTutorial ReplaySpectatorSlider2.png

BP_PC_ReplaySpectator

We are slowly coming to an end of this tutorial. What is still left missing has to do with our ReplaySpectator-Player Controller. You will need to create a child Blueprint of the C++ class that we created. Right click in the Content Browser and create a Blueprint Class. From here, search for PC_ReplaySpectator and select it as a parent. Call the new Blueprint "BP_PC_ReplaySpectator" or similar, then open it.

From Event BeginPlay, drag out the Execution Pin and create a "Create Widget" node, with Class set to WID_ReplaySpectator and Owner to Self. Then drag out the Return Value and create a "Add to Viewport" node. Close the Blueprint.

Navigate to the "FirstPersonGameMode" Blueprint from the First Person Example and open it. In here, set the Replay Spectator Player Controller class to the new BP_PC_ReplaySpectator.

Conclusion, Bugs

I hope that this tutorial can be of any help to someone, especially since nothing comparable was in the wiki at this point. Of course the UI is very minimalistic, but this was a design choice for the tutorial, since the actual UI you'd use would depend on your own projects.

You will notice that when you switch to a different time, the Cube Meshes and the Player are not initially in the correct position. This is especially apparent if you have your replay set to paused. I'm not quite sure why this happens, yet, but I will update the tutorial when I find it out.