Rewind System (C++ UE5) - 2024

This is a Rewind System made for a prototype of a track-placing puzzle game I'm working on. It encourages retrying a level by rewinding back to the start. I designed this to be a mechanic that can easily be expanded and implemented to future objects that wish to make use of it.


Overview

Game Time Interface

My goal for this mechanic was making it expandable and maintainable, so that it will be easy to add rewind funtionality to future features. I achieved this by creating the "IGameTimeInterface", which can be implemented to allow data to be recorded and then retrieved when desired. I've intentionally omitted any use of the word Rewind as this Interface could be used for other purposes where we want data to be stored to retrieve again later.

Using an interface allowed me to add rewind functionality to systems that have nothing in common, such as Player Animations, placed Track Actors, and the TrainMovementComponent. As seen in the code below, the Interface class is very simple, as it is up to the classes that implement it to provide the data they wish to record during game time, to then retrieve again during rewind.

  • #pragma once
    
    #include "CoreMinimal.h"
    
    #include "UObject/Interface.h"
    
    #include "InstancedStruct.h"
    
    #include "GameTimeInterface.generated.h"
    
    /**
     * 
     */
    UINTERFACE()
    class TRAINGAME2_API UGameTimeInterface : public UInterface
    {
    	GENERATED_BODY()
    };
    
    class TRAINGAME2_API IGameTimeInterface
    {
    	GENERATED_BODY()
    
    public:
    	virtual FInstancedStruct& RecordData() = 0;
    	virtual void RetrieveData(const FInstancedStruct& DataToRetrieve) = 0;
    };

Game Time Subsystem

I then created the UGameTimeSubsystem, which manages and tracks all the Interfaces by storing all the data and then giving it back during rewind. The systems that implement the Interface can then use the data accordingly. For example, the train uses the data to set its Transform, speed and MovementMode.

As shown in the UI for the video above, there is a rolling window of 10 seconds where rewind can be used. The subsystem also ensures that any data captured outside of this range is removed, preventing the stored data from becoming too large.

  • #pragma once
    
    #include "CoreMinimal.h"
    
    #include "Subsystems/WorldSubsystem.h"
    
    #include "GameTime/GameTimeInterface.h"
    
    #include "GameTimeSubsystem.generated.h"
    
    DECLARE_DYNAMIC_MULTICAST_DELEGATE(FRewindStateChangedDelegate);
    
    USTRUCT()
    struct FGameTimeData
    {
    	GENERATED_BODY()
    
    	float TimeStamp = 0.f;
    
    	UPROPERTY(Transient)
    	TArray> Interfaces;
    
    	UPROPERTY(Transient)
    	TArray Data;
    
    	FGameTimeData(float InTimeStamp)
    	{
    		TimeStamp = InTimeStamp;
    	}
    
    	FGameTimeData() {}
    };
    
    UCLASS(Config = Game, defaultconfig, meta = (DisplayName = "Game Time Subsystem Settings"))
    class TRAINGAME2_API UGameTimeSubsystemSettings : public UDeveloperSettings
    {
    	GENERATED_BODY()
    
    public:
    	UPROPERTY(Config, EditAnywhere, BlueprintReadOnly)
    	float m_MaxElapsedTime;
    
    	UPROPERTY(Config, EditAnywhere)
    	FRuntimeFloatCurve StartRewindCurve;
    
    	UPROPERTY(Config, EditAnywhere)
    	FRuntimeFloatCurve EndRewindCurve;
    
    	UGameTimeSubsystemSettings() {}
    };
    
    /**
     *
     */
    UCLASS()
    class TRAINGAME2_API UGameTimeSubsystem : public UTickableWorldSubsystem
    {
    	GENERATED_BODY()
    
    public:
    	UGameTimeSubsystem();
    
    	UFUNCTION(BlueprintCallable)
    	void StartRewind();
    
    	UFUNCTION(BlueprintCallable)
    	void EndRewind();
    
    	UFUNCTION(BlueprintCallable, BlueprintPure)
    	float GetCurrentTime() const { return m_CurrentTime; }
    
    	UFUNCTION(BlueprintCallable, BlueprintPure)
    	float GetEarliestRecordedTime() const { return m_EarliestRecordedTime; }
    
    	UFUNCTION(BlueprintCallable, BlueprintPure)
    	bool IsTimeRewinding() const { return m_bIsRewinding; }
    
    	void RegisterGameTimeInterface(TScriptInterface Interface);
    	void DeregisterGameTimeInterface(TScriptInterface Interface);
    
    	// FTickableGameObject implementation Begin
    	virtual TStatId GetStatId() const override;
    	virtual void Tick(float DeltaTime) override;
    	// FTickableGameObject implementation End
    
    	// USubsystem implementation Begin
    	virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    	virtual void Deinitialize() override;
    	// USubsystem implementation End
    
    public:
    	UPROPERTY(BlueprintAssignable)
    	FRewindStateChangedDelegate OnRewindStartedDelegate;
    
    	UPROPERTY(BlueprintAssignable)
    	FRewindStateChangedDelegate OnRewindEndedDelegate;
    
    private:
    	void IncrementGameTime(float DeltaTime);
    	void Rewind(float DeltaTime);
    
    private:
    	float m_MaxElapsedTime = 10.f;
    	float m_CurrentTime = 0.f;
    	float m_EarliestRecordedTime = 0.f;
    
    	FRuntimeFloatCurve m_StartRewindCurve;
    	FRuntimeFloatCurve m_EndRewindCurve;
    
    	UPROPERTY(Transient)
    	TArray m_GameTimeData;
    
    	UPROPERTY(Transient)
    	TArray> m_GameTimeInterfaces;
    
    	bool m_bIsRewinding = false;
    };
  • #include "GameTime/GameTimeSubsystem.h"
    
    #include "Kismet/GameplayStatics.h"
    
    UGameTimeSubsystem::UGameTimeSubsystem()
    {
    
    }
    
    void UGameTimeSubsystem::Initialize(FSubsystemCollectionBase& Collection)
    {
    	Super::Initialize(Collection);
    
    	const UGameTimeSubsystemSettings* SubsystemSettings = GetDefault();
    	m_StartRewindCurve = SubsystemSettings->StartRewindCurve;
    	m_EndRewindCurve = SubsystemSettings->EndRewindCurve;
    	m_MaxElapsedTime = SubsystemSettings->m_MaxElapsedTime;
    }
    
    void UGameTimeSubsystem::Deinitialize()
    {
    	m_GameTimeData.Empty();
    	m_GameTimeInterfaces.Empty();
    
    	Super::Deinitialize();
    }
    
    void UGameTimeSubsystem::RegisterGameTimeInterface(TScriptInterface Interface)
    {
    	if (Interface && !m_GameTimeInterfaces.Contains(Interface))
    	{
    		m_GameTimeInterfaces.Add(Interface);
    	}
    }
    
    void UGameTimeSubsystem::DeregisterGameTimeInterface(TScriptInterface Interface)
    {
    	m_GameTimeInterfaces.Remove(Interface);
    }
    
    void UGameTimeSubsystem::StartRewind()
    {
    	m_bIsRewinding = true;
    
    	OnRewindStartedDelegate.Broadcast();
    }
    
    void UGameTimeSubsystem::EndRewind()
    {
    	// Any data after the current time should be removed
    	for (int Index = m_GameTimeData.Num() - 1; Index >= 0; Index--)
    	{
    		if (m_GameTimeData[Index].TimeStamp > m_CurrentTime)
    		{
    			m_GameTimeData.RemoveAt(Index);
    		}
    	}
    
    	m_bIsRewinding = false;
    
    	OnRewindEndedDelegate.Broadcast();
    }
    
    TStatId UGameTimeSubsystem::GetStatId() const
    {
    	RETURN_QUICK_DECLARE_CYCLE_STAT(UGameTimeSubsystem, STATGROUP_Tickables);
    }
    
    void UGameTimeSubsystem::Tick(float DeltaTime)
    {
    	Super::Tick(DeltaTime);
    
    	if (m_bIsRewinding)
    	{
    		Rewind(DeltaTime);
    	}
    	else
    	{
    		IncrementGameTime(DeltaTime);
    	}
    }
    
    void UGameTimeSubsystem::IncrementGameTime(float DeltaTime)
    {
    	if (UGameplayStatics::GetGlobalTimeDilation(this) < 1.f)
    	{
    		return;
    	}
    
    	m_CurrentTime += DeltaTime;
    
    	FGameTimeData NewGameTimeData(m_CurrentTime);
    
    	float StartTimeLost = 0.f;
    	float EndTimeLost = 0.f;
    
    	float NewEarliestRecordedTime = m_CurrentTime - m_MaxElapsedTime;
    	if (NewEarliestRecordedTime > m_EarliestRecordedTime)
    	{
    		StartTimeLost = m_EarliestRecordedTime;
    		EndTimeLost = NewEarliestRecordedTime;
    		m_EarliestRecordedTime = NewEarliestRecordedTime;
    	}
    
    	if (StartTimeLost != 0.f || EndTimeLost != 0.f)
    	{
    		for (int Index = m_GameTimeData.Num() - 1; Index >= 0; Index--)
    		{
    			if (m_GameTimeData[Index].TimeStamp >= StartTimeLost && m_GameTimeData[Index].TimeStamp < EndTimeLost)
    			{
    				m_GameTimeData.RemoveAt(Index);
    			}
    		}
    	}
    
    	for (TScriptInterface Interface : m_GameTimeInterfaces)
    	{
    		if (Interface)
    		{
    			FInstancedStruct InterfaceData = Interface->RecordData();
    			NewGameTimeData.Interfaces.Add(Interface);
    			NewGameTimeData.Data.Add(InterfaceData);
    		}
    		else
    		{
    			DeregisterGameTimeInterface(Interface);
    		}
    	}
    
    	m_GameTimeData.Add(NewGameTimeData);
    }
    
    void UGameTimeSubsystem::Rewind(float DeltaTime)
    {
    	m_CurrentTime -= DeltaTime;
    
    	if (m_CurrentTime <= m_EarliestRecordedTime)
    	{
    		EndRewind();
    	}
    
    	if (m_GameTimeData.IsEmpty())
    	{
    		return;
    	}
    
    	const FGameTimeData* DataToRetrieveFrom = &m_GameTimeData[0];
    
    	// Find data with a timestamp closest to the current time
    	float ClosestTimeStampOffsetFromCurrentTime = 100.f;
    	for (const FGameTimeData& Data : m_GameTimeData)
    	{	
    		float Offset = FMath::Abs(Data.TimeStamp - m_CurrentTime);
    		if (Offset < ClosestTimeStampOffsetFromCurrentTime)
    		{
    			ClosestTimeStampOffsetFromCurrentTime = Offset;
    			DataToRetrieveFrom = &Data;
    		}
    	}
    
    	const TArray>& InterfacesToUpdate = DataToRetrieveFrom->Interfaces;
    	const TArray& InterfaceData = DataToRetrieveFrom->Data;
    
    	for (int32 Index = 0; Index < InterfacesToUpdate.Num(); Index++)
    	{
    		if (DataToRetrieveFrom->Data.IsValidIndex(Index))
    		{
    			DataToRetrieveFrom->Interfaces[Index]->RetrieveData(InterfaceData[Index]);
    		}
    	}
    }

FInstancedStructs

The systems that implement the IGameTimeInterface require different data: for example, the Player's animations don't need to store a Transform, but the TrainMovementComponent does. For this I made use of Unreal's FInstancedStructs to abstract the data type when recording data for the Interface, so they all take the same type of FInstancedStruct. Once retrieved, the system can access the base struct from this, such as FTrainGameTimeData for the TrainMovementComponent.

  • void UTrainMovementComponent::RetrieveData(const FInstancedStruct& DataToRetrieve)
    {
    	if (const FTrainGameTimeData* GameTimeData = DataToRetrieve.GetPtr())
    	{
    		if (AActor* Owner = GetOwner())
    		{
    			Owner->SetActorLocationAndRotation(GameTimeData->TrainLocation, GameTimeData->TrainRotation);
    			SetDistanceAlongAttachedTrack(GameTimeData->DistanceAlongAttachedTrack);
    
    			m_CurrentSpeed = GameTimeData->CurrentSpeed;
    			m_CurrentMovementMode = GameTimeData->MovementMode;
    			m_VelocityToSetOnRewindEnd = GameTimeData->Velocity;
    		}
    	}
    }