Overview
Tested with: UE 5.4+
"I want to save player progress, but I don't know where to start..." — A save/load system is needed for almost every game, but with data persistence, slot management, and async processing to consider, it can be daunting to implement for the first time.
Using UE's USaveGame class, you can create a custom data container defining what to save, and implement slot management and async saving with a straightforward API. This article focuses on C++ implementation, covering fundamental concepts through best practices.
Core Concepts
Here are the key components of a save/load system.
| Component | Description |
|---|---|
| SaveGame class | Custom data container inheriting from USaveGame. Define the variables you want to save |
| Save Slot Name | Identifier for the save file. Used for multiple slot support |
| User Index | User profile identifier (for split-screen multiplayer) |
| Synchronous save | SaveGameToSlot — blocks until complete. Suited for menu screens |
| Asynchronous save | AsyncSaveGameToSlot — runs in background (recommended) |
| Asynchronous load | AsyncLoadGameFromSlot — background loading |
| Binary save | SaveGameToMemory / LoadGameFromMemory — processes in memory without disk I/O. Used for cloud saves and network transmission |
The basic save flow is: "Transfer game state to SaveGame object → save to slot." Loading is the reverse: "Restore SaveGame object from slot → transfer data to in-game objects."
Recommended: Use
AsyncSaveGameToSlotfor in-game saves. Synchronous saving blocks the main thread and causes frame rate drops.
In development, save files are stored in the project's Saved/SaveGames/ folder with a .sav extension. In packaged builds, they're stored in a platform-dependent location (on Windows: AppData/Local/[GameName]/Saved/SaveGames/).
Creating a SaveGame Class
The first step in building a save/load system is creating a custom SaveGame class that defines the data to save. This class acts as an intermediary container between game state and disk.
C++ Definition
// MySaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FString PlayerName;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
int32 PlayerScore = 0;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FVector PlayerLocation = FVector::ZeroVector;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
TArray<FName> InventoryItemIDs;
};
Keep saved variables to a minimum. Data that can be recalculated or re-fetched at load time (max HP, item display names, etc.) doesn't need to be saved.
Blueprint Creation
If you're not using C++, right-click in the Content Browser > Blueprint Class > select SaveGame as the parent class. Add variables and you'll have the exact same functionality in Blueprint.
Saving the Game (C++)
Use UGameplayStatics functions to save a SaveGame object to disk. Here's an async save implementation.
#include "Kismet/GameplayStatics.h"
#include "MySaveGame.h"
void AMyPlayerController::SaveGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
// Load existing data if available, otherwise create new
UMySaveGame* SaveInstance = nullptr;
if (UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
SaveInstance = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));
}
if (!SaveInstance)
{
SaveInstance = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
}
// Transfer current game state to SaveGame object
SaveInstance->PlayerName = TEXT("Hero");
SaveInstance->PlayerScore = CurrentScore;
SaveInstance->PlayerLocation = GetPawn()->GetActorLocation();
// Save asynchronously (doesn't block main thread)
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &AMyPlayerController::OnGameSaved);
UGameplayStatics::AsyncSaveGameToSlot(SaveInstance, SlotName, UserIndex, OnSaved);
}
void AMyPlayerController::OnGameSaved(const FString& SlotName, const int32 UserIndex, bool bSuccess)
{
if (bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Save succeeded: %s"), *SlotName);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Save failed: %s"), *SlotName);
}
}
DoesSaveGameExist checks for an existing save — overwrite if found, create new if not. This is the standard pattern. AsyncSaveGameToSlot fires a delegate on completion, allowing you to implement success/failure feedback (UI notifications, etc.).
Save Timing Guidelines
- Auto-save at checkpoint arrival or level completion
- Manual saves from menu screens can use synchronous processing
- Watch for disk I/O load with overly frequent auto-saves
Loading the Game (C++)
Loading is the reverse of saving. Restore a SaveGame object from save data and apply the data to in-game objects.
Synchronous Load
Loading typically occurs on menu screens, where synchronous processing is perfectly fine.
void AMyPlayerController::LoadGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning, TEXT("No save data in slot: %s"), *SlotName);
return;
}
UMySaveGame* Loaded = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));
if (!Loaded) return;
// Restore game state from SaveGame object
SetPlayerName(Loaded->PlayerName);
if (APawn* MyPawn = GetPawn())
{
MyPawn->SetActorLocation(Loaded->PlayerLocation);
}
}
Asynchronous Load
For large save data, AsyncLoadGameFromSlot enables background loading.
void AMyPlayerController::AsyncLoadGameData()
{
const FString SlotName = TEXT("MainSlot");
const int32 UserIndex = 0;
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &AMyPlayerController::OnGameLoaded);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIndex, OnLoaded);
}
void AMyPlayerController::OnGameLoaded(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedData)
{
if (UMySaveGame* Loaded = Cast<UMySaveGame>(LoadedData))
{
// Restore game state
SetPlayerName(Loaded->PlayerName);
}
}
Optimization and Troubleshooting
Optimization Tips
Save data design significantly impacts save/load times and file sizes.
- Save only essential data: Omit anything that can be regenerated at load time (derived values, default settings, etc.)
- Group with structs: Bundle related data into
USTRUCTfor better manageability - Reference by ID: Avoid direct
UObjectreferences. SaveFSoftObjectPathor RowName instead, and re-acquire withLoadObjectorFindObjectat load time - Saving level actors: Save actor names or tags as keys, and search with
GetAllActorsOfClassat load time to restore state - Data versioning: Include a version number in save data to maintain compatibility when the SaveGame class structure changes across game updates. A common pattern is to add a
SaveVersionfield and branch migration logic at load time
UPROPERTY(VisibleAnywhere, Category = "SaveData")
int32 SaveVersion = 1; // Increment with each structural change
- Encryption and tamper protection: For shipping builds, consider save data tamper prevention. Options include custom
FArchiveimplementations with AES encryption or checksum validation. Package encryption keys can also be configured in the Crypto section of Project Settings
Common Issues and Solutions
| Issue | Solution |
|---|---|
| Save not being written | Check SaveGameToSlot return value and logs. In C++, also check for missing UPROPERTY() specifiers |
| Corrupted state after load | Debug data transfer logic. Watch for level load order and actor spawn timing |
| Fails in packaged build | Verify write permissions to the save folder |
| Crash on load | Missing null checks. Ensure no access to non-existent actors or objects |
Summary
- Inherit from
USaveGameto define a custom data container with variables to save - Async save (
AsyncSaveGameToSlot) avoids frame rate impact - Async load (
AsyncLoadGameFromSlot) enables background loading for large data - Manage multiple save files with slot names and user indices
- Avoid direct
UObjectreferences — save by ID or path instead