How to Save and Load Asynchronously in Unreal Engine 5 using C++
Let's go over the process of how to save and load actors asynchronously using C++. We'll use Async and AsyncTasks to offload the tasks to other threads.
Software Versions: Unreal Engine 5.5.1 | Rider 2024.3.2
Project Name: MyProject
Let's save and load data asynchronously in UE5. This post will skip over things mentioned in my previous "Save and Load Game Data" post that discusses save game objects, subsystems and SaveGame
specifiers. This post will try to focus primarily on Async
and AsyncTasks
to run save and load operations on different threads. Clone the GD Tactics Unreal Proejct for updates and/or a better understanding.
First, create a new USaveGame
object. I created a very simple one and called it MySaveGameAsync
. The .cpp
file will remain empty.
MySaveGameAsync.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGameAsync.generated.h"
USTRUCT()
struct FMyActorSaveAsyncData
{
GENERATED_BODY()
UPROPERTY()
FName Name;
UPROPERTY()
FTransform Transform;
UPROPERTY()
FDateTime DateTime;
UPROPERTY()
TArray<uint8> ByteData;
};
UCLASS()
class MYPROJECT_API UMySaveGameAsync : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY()
TArray<FMyActorSaveAsyncData> MySavedActors;
};
Next, create an interface so we can only target actors that inherit the save game async interface. I created a very basic interface called MySaveGameAsyncInterface
. The .cpp
file will remain empty for this example, but definitely adjust as needed for your project. I added two functions to be implemented by actors, currently I don't have any actors implementing them, but they're available.
MySaveGameAsyncInterface.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "MySaveGameAsyncInterface.generated.h"
UINTERFACE()
class UMySaveGameAsyncInterface : public UInterface
{
GENERATED_BODY()
};
class MYPROJECT_API IMySaveGameAsyncInterface
{
GENERATED_BODY()
implement this interface.
public:
UFUNCTION(BlueprintNativeEvent)
void OnLoadGameAsync();
UFUNCTION(BlueprintNativeEvent)
void OnSaveGameAsync();
};
Now we get to move into the subsystem where all the critical logic will live. Create a new UGameInstanceSubsystem
, I called mine MySaveGameAsyncSubsystem
. The important bits in the header file are the delegate and callbacks. At the top of the file I declare a four parameter delegate, FSaveCompleteDelegate
, that we'll use to broadcast a bSaving
state to the rest of the project. The two callbacks, SaveGameAsyncCallback
and LoadGameAsyncCallback
will be used to trigger actions after async operations have finished.
MySaveGameAsyncSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "MySaveGameAsyncSubsystem.generated.h"
class UMySaveGameAsync;
class USaveGame;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FSaveCompleteDelegate, const FString&, SlotName, int32, UserIndex, bool, bSuccess, bool, bSaving);
UCLASS()
class MYPROJECT_API UMySaveGameAsyncSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// Multicast delegate for save completion
UPROPERTY(BlueprintCallable, BlueprintAssignable, Category = "Save System")
FSaveCompleteDelegate OnSavingGameAsync;
UPROPERTY()
FString MySlotName;
UPROPERTY()
bool bLoading = false;
UPROPERTY()
bool bSaving = false;
UFUNCTION(BlueprintCallable)
void DeleteAllAsyncSaveFiles(FString File1, FString File2, FString File3);
UFUNCTION(BlueprintCallable)
void SaveGameAsync();
UFUNCTION(BlueprintCallable)
void LoadGameAsync(FString LoadSlotName);
private:
// Callback to call the broadcast event when saving completes
void SaveGameAsyncCallback(const FString& SlotName, int32 UserIndex, bool bSuccess);
// Load Game Async Callback
void LoadGameAsyncCallback(const FString& SlotName, int32 UserIndex, USaveGame* SaveGame);
};
Moving in the .cpp
file. The first function I wanted to have was selecting and loading the file from a UI widget. When user clicks one of the save slots, we'll set the SlotName
and either start the loading process or do nothing because it's a new save file.
void UMySaveGameAsyncSubsystem::LoadGameAsync(FString LoadSlotName)
{
if (LoadSlotName.IsEmpty()) return;
MySlotName = LoadSlotName;
if (UGameplayStatics::DoesSaveGameExist(LoadSlotName, 0))
{
FAsyncLoadGameFromSlotDelegate LoadGameCallbackDelegate;
LoadGameCallbackDelegate.BindUObject(this, &UMySaveGameAsyncSubsystem::LoadGameAsyncCallback);
UGameplayStatics::AsyncLoadGameFromSlot(LoadSlotName, 0, LoadGameCallbackDelegate);
}
}
Next, we can jump into the async save game file function. In the project's gym when the player overlaps the collision box we'll fire the save game event.
The save process is very similar to the regular synchronous saving process as discussed in the previous save post. At the beginning of the function there are some checks to prevent crashes and logic collisions, and then I move into creating localized variables that can be passed and captured by async lambdas. If you pass in this
as a captured argument you'll be able access class variables, but I wanted to reduce that if possible. I do, however, create a weak reference with TWeakObjectPtr
to this
to bind a function to the callback.
We start process with Async(EAsyncExecution::Thread, ...
to start the process on a separate thread outside of the main thread. The Async task will capture the following variables ... [WeakThis, LocalSaveObject, SaveSlotName, SaveGameActors]()
and launch the lambda function to kick off the saving process. We'll do as much as we can in the outside thread before hitting the TaskGraphMainThread
. After looping through all our SaveGameActors
and updating our LocalSaveObject
we'll then continue the saving process with Async(EAsyncExecution::TaskGraphMainThread, ...
. We have to return the main thread for the final saving event. We create a FAsyncSaveGameToSlotDelegate
variable and bind our SaveGameAsyncCallback
function that will fire once AsyncSaveGameToSlot
completes.
void UMySaveGameAsyncSubsystem::SaveGameAsync()
{
if (bSaving || bLoading) return;
TObjectPtr<UMySaveGameAsync> LocalSaveObject = CastChecked<UMySaveGameAsync>(UGameplayStatics::CreateSaveGameObject(UMySaveGameAsync::StaticClass()));
if (!IsValid(LocalSaveObject)) return;
bSaving = true;
OnSavingGameAsync.Broadcast(MySlotName, 0, false, bSaving);
TWeakObjectPtr<UMySaveGameAsyncSubsystem> WeakThis = this; // Alternatively: auto WeakThis = TWeakObjectPtr(this);
UWorld* World = GetWorld();
FString SaveSlotName = MySlotName;
TArray<AActor*> SaveGameActors;
UGameplayStatics::GetAllActorsWithInterface(World, UMySaveGameAsyncInterface::StaticClass(), SaveGameActors);
Async(EAsyncExecution::Thread, [WeakThis, LocalSaveObject, SaveSlotName, SaveGameActors]()
{
for (AActor* Actor : SaveGameActors)
{
FMyActorSaveAsyncData ActorData;
ActorData.Name = Actor->GetFName();
ActorData.Transform = Actor->GetActorTransform();
FMemoryWriter MyMemoryWriter(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive Ar(MyMemoryWriter, true);
Ar.ArIsSaveGame = true;
Actor->Serialize(Ar);
LocalSaveObject->MySavedActors.Add(ActorData);
}
Async(EAsyncExecution::TaskGraphMainThread, [WeakThis, LocalSaveObject, SaveSlotName]()
{
if (!WeakThis.IsValid()) return;
UMySaveGameAsyncSubsystem* ValidThis = WeakThis.Get();
FAsyncSaveGameToSlotDelegate SaveGameCallbackDelegate;
SaveGameCallbackDelegate.BindUObject(ValidThis, &UMySaveGameAsyncSubsystem::SaveGameAsyncCallback);
UGameplayStatics::AsyncSaveGameToSlot(LocalSaveObject, SaveSlotName, 0, SaveGameCallbackDelegate);
});
});
}
The LoadGameAsyncCallback
function will handle all of the loading logic. At the top of the file we bind this callback to a FAsyncLoadGameFromSlotDelegate
variable that is used in the AsyncLoadGameFromSlot
function. Similar to the save function load game callback starts with some safety conditionals then creates localized variables for the lambda to capture. LoadGameAsyncCallback
will utilize a FTimeHandle
to load the actors in chucks to not completely block the main thread allowing the player to still run around or have an uninterrupted loading screen.
We start the main loading process with Async(EAsyncExecution::Thread, [this, LocalSaveObject, SaveGameActors]()
to begin the event on a separate thread. We're capturing this
because I want to set the global bLoading
boolean
when loading completes. In the beginning of the Async thread we create a TPair
TArrray
to process all the saved actors for batched loading. We then proceed into in the game thread with AsyncTask(ENamedThreads::GameThread, [this, ProcessedActors]()
we setup our timer delegate event that will process and load the actors in batches. We'll process five actors at a time and stop when we reach the end of the ProcessedActors
TArray
. In the loop we find our actor via its index and then apply its Transform
data that was saved. The function continues to read and serialize the data, then it executes an interface function for any actors listening for the load event. Currently there aren't any actors in this example that listen for the event. After the for
loop we clear the timer and set bLoading
to false
.
With the timer delegate function created we can now create our timer. The timer will run every 0.1f
seconds to load the objects in batches by calling the timer delegate function we just created. The timed batches will help us not block the main game thread.
void UMySaveGameAsyncSubsystem::LoadGameAsyncCallback(const FString& SlotName, int32 UserIndex, USaveGame* SaveGame)
{
if (bSaving || bLoading) return;
bLoading = true;
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Orange, *FString::Printf(TEXT("Loading %s ..."), *SlotName));
}
TObjectPtr<UMySaveGameAsync> LocalSaveObject = Cast<UMySaveGameAsync>(SaveGame);
if (!IsValid(LocalSaveObject)) return;
static FTimerHandle TimerHandle;
TArray<AActor*> SaveGameActors;
UGameplayStatics::GetAllActorsWithInterface(GetWorld(), UMySaveGameAsyncInterface::StaticClass(), SaveGameActors);
Async(EAsyncExecution::Thread, [this, LocalSaveObject, SaveGameActors]()
{
TArray<TPair<AActor*, FMyActorSaveAsyncData>> ProcessedActors;
for (AActor* Actor : SaveGameActors)
{
if (!IsValid(Actor)) continue;
for (const FMyActorSaveAsyncData& ActorData : LocalSaveObject->MySavedActors)
{
if (ActorData.Name == Actor->GetFName())
{
ProcessedActors.Add(TPair<AActor*, FMyActorSaveAsyncData>(Actor, ActorData));
break;
}
}
}
AsyncTask(ENamedThreads::GameThread, [this, ProcessedActors]()
{
constexpr int32 BatchSize = 5;
int32 CurrentIndex = 0;
FTimerDelegate TimerDel;
TimerDel.BindLambda([this, ProcessedActors, CurrentIndex]() mutable
{
for (int32 i = 0; i < BatchSize && CurrentIndex < ProcessedActors.Num(); ++i, ++CurrentIndex)
{
const auto& Pair = ProcessedActors[CurrentIndex];
AActor* Actor = Pair.Key;
const FMyActorSaveAsyncData& ActorData = Pair.Value;
if (IsValid(Actor))
{
Actor->SetActorTransform(ActorData.Transform);
FMemoryReader MyMemoryReader(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive Ar(MyMemoryReader, true);
Ar.ArIsSaveGame = true;
Actor->Serialize(Ar);
IMySaveGameAsyncInterface::Execute_OnLoadGameAsync(Actor);
}
}
if (CurrentIndex >= ProcessedActors.Num())
{
UWorld* World = GEngine->GetWorldFromContextObjectChecked(ProcessedActors[0].Key);
if (IsValid(World))
{
World->GetTimerManager().ClearTimer(TimerHandle);
bLoading = false;
}
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("Loading game Successful!!"));
}
}
});
UWorld* World = GEngine->GetWorldFromContextObjectChecked(ProcessedActors[0].Key);
if (IsValid(World))
{
World->GetTimerManager().SetTimer(TimerHandle, TimerDel, 0.1f, true);
}
});
});
}
We just got past the hardest part. I added a delete function as well to reset our files to start fresh when actors move around the world.
void UMySaveGameAsyncSubsystem::DeleteAllAsyncSaveFiles(FString File1, FString File2, FString File3)
{
UGameplayStatics::DeleteGameInSlot(File1, 0);
UGameplayStatics::DeleteGameInSlot(File2, 0);
UGameplayStatics::DeleteGameInSlot(File3, 0);
}
Below is the final C++ save subsystem code.
MySaveGameAsyncSubsystem.cpp
#include "MySaveGameAsyncSubsystem.h"
#include "Async/Async.h"
#include "MySaveGameAsync.h"
#include "MySaveGameAsyncInterface.h"
#include "Kismet/GameplayStatics.h"
#include "Serialization/ObjectAndNameAsStringProxyArchive.h"
void UMySaveGameAsyncSubsystem::LoadGameAsync(FString LoadSlotName)
{
if (LoadSlotName.IsEmpty()) return;
MySlotName = LoadSlotName;
if (UGameplayStatics::DoesSaveGameExist(LoadSlotName, 0))
{
FAsyncLoadGameFromSlotDelegate LoadGameCallbackDelegate;
LoadGameCallbackDelegate.BindUObject(this, &UMySaveGameAsyncSubsystem::LoadGameAsyncCallback);
UGameplayStatics::AsyncLoadGameFromSlot(LoadSlotName, 0, LoadGameCallbackDelegate);
}
}
void UMySaveGameAsyncSubsystem::SaveGameAsync()
{
if (bSaving || bLoading) return;
TObjectPtr<UMySaveGameAsync> LocalSaveObject = CastChecked<UMySaveGameAsync>(UGameplayStatics::CreateSaveGameObject(UMySaveGameAsync::StaticClass()));
if (!IsValid(LocalSaveObject)) return;
bSaving = true;
OnSavingGameAsync.Broadcast(MySlotName, 0, false, bSaving);
TWeakObjectPtr<UMySaveGameAsyncSubsystem> WeakThis = this;
UWorld* World = GetWorld();
FString SaveSlotName = MySlotName;
TArray<AActor*> SaveGameActors;
UGameplayStatics::GetAllActorsWithInterface(World, UMySaveGameAsyncInterface::StaticClass(), SaveGameActors);
Async(EAsyncExecution::Thread, [WeakThis, LocalSaveObject, SaveSlotName, SaveGameActors]()
{
for (AActor* Actor : SaveGameActors)
{
FMyActorSaveAsyncData ActorData;
ActorData.Name = Actor->GetFName();
ActorData.Transform = Actor->GetActorTransform();
FMemoryWriter MyMemoryWriter(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive Ar(MyMemoryWriter, true);
Ar.ArIsSaveGame = true;
Actor->Serialize(Ar);
LocalSaveObject->MySavedActors.Add(ActorData);
FPlatformProcess::Sleep(0.05f);
}
Async(EAsyncExecution::TaskGraphMainThread, [WeakThis, LocalSaveObject, SaveSlotName]()
{
if (!WeakThis.IsValid()) return;
UMySaveGameAsyncSubsystem* ValidThis = WeakThis.Get();
FAsyncSaveGameToSlotDelegate SaveGameCallbackDelegate;
SaveGameCallbackDelegate.BindUObject(ValidThis, &UMySaveGameAsyncSubsystem::SaveGameAsyncCallback);
UGameplayStatics::AsyncSaveGameToSlot(LocalSaveObject, SaveSlotName, 0, SaveGameCallbackDelegate);
});
});
}
void UMySaveGameAsyncSubsystem::LoadGameAsyncCallback(const FString& SlotName, int32 UserIndex, USaveGame* SaveGame)
{
if (bSaving || bLoading) return;
bLoading = true;
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Orange, *FString::Printf(TEXT("Loading %s ..."), *SlotName));
}
TObjectPtr<UMySaveGameAsync> LocalSaveObject = Cast<UMySaveGameAsync>(SaveGame);
if (!IsValid(LocalSaveObject)) return;
static FTimerHandle TimerHandle;
TArray<AActor*> SaveGameActors;
UGameplayStatics::GetAllActorsWithInterface(GetWorld(), UMySaveGameAsyncInterface::StaticClass(), SaveGameActors);
Async(EAsyncExecution::Thread, [this, LocalSaveObject, SaveGameActors]()
{
TArray<TPair<AActor*, FMyActorSaveAsyncData>> ProcessedActors;
for (AActor* Actor : SaveGameActors)
{
if (!IsValid(Actor)) continue;
for (const FMyActorSaveAsyncData& ActorData : LocalSaveObject->MySavedActors)
{
if (ActorData.Name == Actor->GetFName())
{
ProcessedActors.Add(TPair<AActor*, FMyActorSaveAsyncData>(Actor, ActorData));
break;
}
}
}
AsyncTask(ENamedThreads::GameThread, [this, ProcessedActors]()
{
constexpr int32 BatchSize = 5;
int32 CurrentIndex = 0;
FTimerDelegate TimerDel;
TimerDel.BindLambda([this, ProcessedActors, CurrentIndex]() mutable
{
for (int32 i = 0; i < BatchSize && CurrentIndex < ProcessedActors.Num(); ++i, ++CurrentIndex)
{
const auto& Pair = ProcessedActors[CurrentIndex];
AActor* Actor = Pair.Key;
const FMyActorSaveAsyncData& ActorData = Pair.Value;
if (IsValid(Actor))
{
Actor->SetActorTransform(ActorData.Transform);
FMemoryReader MyMemoryReader(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive Ar(MyMemoryReader, true);
Ar.ArIsSaveGame = true;
Actor->Serialize(Ar);
IMySaveGameAsyncInterface::Execute_OnLoadGameAsync(Actor);
}
}
if (CurrentIndex >= ProcessedActors.Num())
{
UWorld* World = GEngine->GetWorldFromContextObjectChecked(ProcessedActors[0].Key);
if (IsValid(World))
{
World->GetTimerManager().ClearTimer(TimerHandle);
bLoading = false;
}
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("Loading game Successful!!"));
}
}
});
UWorld* World = GEngine->GetWorldFromContextObjectChecked(ProcessedActors[0].Key);
if (IsValid(World))
{
World->GetTimerManager().SetTimer(TimerHandle, TimerDel, 0.1f, true);
}
});
});
}
void UMySaveGameAsyncSubsystem::SaveGameAsyncCallback(const FString& SlotName, int32 UserIndex, bool bSuccess)
{
bSaving = false;
OnSavingGameAsync.Broadcast(SlotName, UserIndex, bSuccess, bSaving);
}
void UMySaveGameAsyncSubsystem::DeleteAllAsyncSaveFiles(FString File1, FString File2, FString File3)
{
UGameplayStatics::DeleteGameInSlot(File1, 0);
UGameplayStatics::DeleteGameInSlot(File2, 0);
UGameplayStatics::DeleteGameInSlot(File3, 0);
}
Now we can go into the Unreal editor and use what we made. We can likely write more C++ for some of Blueprint operations, but I thought it be a good idea to utilize Blueprints for fast iteration.
I created a new save async widget inside the Gym's SaveGameAsyncFolder called W_SaveGameAsync
that allows the player to select a save file. If the file exists we'll fire our subsystem's load game function. Furthermore, the widget will use a WidgetSwitcher
to display a loading indicator to the user when saving is in progress. The Blueprint logic for the widget is fairly simple, it'll fire the load events when the file is selected then switch views so it's ready to display the saving indicator when ready. We also return movement to the player when the save file widget is not visible.
Then, inside the Gym's SaveGameAsyncFolder, I created a simple collision trigger, BP_SaveGameAsync_TriggerWidgetBox
, to add the widget to the viewport, stop the players movement, and show the mouse cursor. Again, the logic is fairly simple to get us up and running.
Next, I created a simple platform when on overlap we call our subsystem's SaveGameAsync
function using the save slot that was previously selected from the widget.
In some of the Blueprints I'm casting to the character for safety. Casting to the character might be overkill, but in this example I have dozens of movable actors that I didn't want to accidentally trigger an overlap event.
I then created a basic physics actor mesh Blueprint that we can bump around in the level. When we move the actors we can then trigger our save function to save their transform data. Since we are saving this actor we need to ensure it inherits the MySaveGameAsyncInterface
interface. I called the BlueprintBP_SGA_PhysicsObject
and is inside the Gym's SaveGameAsyncFolder.
Finally, with everything created we can enjoy the fruits of our labor and build out our level. One thing I think is super cool is that since we're batching the actors when loaded, we can see the actors reposition in real time. Typically we would hide the actor's loading state or BeginPlay
loading change event or something with a loading screen, but it's fun to see it as it happens.
Watch the video at the top of the post to see the final result. Cloning the GD Tactics Unreal Proejct is likely a better way to get a full understanding of how this works. Regardless, I hope this helps in your game dev journey.
Harrison McGuire