Quick answer: Use Unreal’s built-in USaveGame system with UPROPERTY(SaveGame) fields for automatic serialization. Use async save operations for large files, add a version field for migration, and keep settings separate from game progress saves.

Unreal Engine provides a robust built-in save system that handles serialization, platform-specific storage paths, and slot management. Most developers should use it instead of rolling their own. This guide covers how to use the USaveGame system correctly and the best practices that keep your save data reliable across platforms and updates.

The USaveGame System

Unreal’s save system is built around the USaveGame class. You create a subclass, add UPROPERTY fields for everything you want to persist, and use UGameplayStatics to save and load by slot name.

// MySaveGame.h
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"

UCLASS()
class UMySaveGame : public USaveGame
{
    GENERATED_BODY()

public:
    UPROPERTY(SaveGame)
    int32 SaveVersion = 1;

    UPROPERTY(SaveGame)
    FString Timestamp;

    UPROPERTY(SaveGame)
    FVector PlayerLocation;

    UPROPERTY(SaveGame)
    int32 PlayerHealth = 100;

    UPROPERTY(SaveGame)
    int32 Gold = 0;

    UPROPERTY(SaveGame)
    TArray<FString> CompletedQuests;

    UPROPERTY(SaveGame)
    TMap<FString, int32> InventoryItems;
};

Every UPROPERTY marked with the SaveGame specifier is automatically serialized when you call SaveGameToSlot. Properties without this specifier are skipped. This gives you explicit control over what gets persisted.

Saving and Loading

The basic synchronous save and load pattern uses UGameplayStatics:

// Saving
UMySaveGame* SaveGame = Cast<UMySaveGame>(
    UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
SaveGame->PlayerLocation = PlayerCharacter->GetActorLocation();
SaveGame->PlayerHealth = PlayerCharacter->Health;
SaveGame->Gold = PlayerCharacter->Gold;
SaveGame->Timestamp = FDateTime::UtcNow().ToString();

bool bSuccess = UGameplayStatics::SaveGameToSlot(
    SaveGame, "Slot_0", 0
);

// Loading
if (UGameplayStatics::DoesSaveGameExist("Slot_0", 0))
{
    UMySaveGame* Loaded = Cast<UMySaveGame>(
        UGameplayStatics::LoadGameFromSlot("Slot_0", 0)
    );
    if (Loaded)
    {
        Loaded = MigrateSave(Loaded);
        PlayerCharacter->SetActorLocation(Loaded->PlayerLocation);
        PlayerCharacter->Health = Loaded->PlayerHealth;
    }
}

Async Save and Load

Synchronous saves block the game thread. For small saves this is unnoticeable, but large save files with hundreds of objects can cause visible frame hitches. Use async operations for anything beyond trivial save sizes.

// Async save
FAsyncSaveGameToSlotDelegate SaveDelegate;
SaveDelegate.BindLambda([](
    const FString& SlotName,
    const int32 UserIndex,
    bool bSuccess)
{
    if (bSuccess)
        UE_LOG(LogTemp, Log, TEXT("Async save complete: %s"), *SlotName);
    else
        UE_LOG(LogTemp, Error, TEXT("Async save failed: %s"), *SlotName);
});

UGameplayStatics::AsyncSaveGameToSlot(SaveGame, "Slot_0", 0, SaveDelegate);

// Async load
FAsyncLoadGameFromSlotDelegate LoadDelegate;
LoadDelegate.BindLambda([this](
    const FString& SlotName,
    const int32 UserIndex,
    USaveGame* LoadedSave)
{
    UMySaveGame* MySave = Cast<UMySaveGame>(LoadedSave);
    if (MySave)
        ApplySaveData(MySave);
});

UGameplayStatics::AsyncLoadGameFromSlot("Slot_0", 0, LoadDelegate);

During an async save, prevent the player from triggering another save or quitting the game. Show a save indicator and block the quit action until the delegate fires.

Save Versioning and Migration

Add an integer version field to your save class. After loading, compare it to the current version and run migration logic for each version step.

UMySaveGame* MigrateSave(UMySaveGame* Save)
{
    if (Save->SaveVersion < 1)
    {
        // v0 -> v1: CompletedQuests was added
        // TArray defaults to empty, nothing to do
    }

    if (Save->SaveVersion < 2)
    {
        // v1 -> v2: Gold was moved from a separate struct
        // Apply any data transformations here
    }

    Save->SaveVersion = 2; // Current version
    return Save;
}

Unreal’s serialization system handles new fields gracefully—they get their default values when loading an old save. But renamed or removed fields need explicit migration logic. Avoid renaming UPROPERTY fields unless absolutely necessary.

Separate Settings from Progress

Create two separate save classes: one for player settings (audio, graphics, controls) and one for game progress (player state, quests, inventory). Settings should be a single global slot loaded at startup. Progress saves use named slots that the player manages.

UCLASS()
class USettingsSaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY(SaveGame) float MasterVolume = 1.0f;
    UPROPERTY(SaveGame) float MusicVolume = 0.8f;
    UPROPERTY(SaveGame) int32 ResolutionX = 1920;
    UPROPERTY(SaveGame) int32 ResolutionY = 1080;
    UPROPERTY(SaveGame) bool bFullscreen = true;
};

// Load settings at startup, save on change
USettingsSaveGame* Settings = Cast<USettingsSaveGame>(
    UGameplayStatics::LoadGameFromSlot("Settings", 0)
);

This separation means changing graphics settings never risks corrupting game progress, and loading a different save slot does not reset the player’s audio preferences.

Save Slot Management

Use a consistent naming convention for slots. Store slot metadata (timestamp, playtime, level name, thumbnail) either in the save file itself or in a separate index file so you can populate the load screen without deserializing every save.

// Check which slots have saves
TArray<FString> SlotNames = { "Slot_0", "Slot_1", "Slot_2", "Autosave" };

for (const FString& Slot : SlotNames)
{
    if (UGameplayStatics::DoesSaveGameExist(Slot, 0))
    {
        // Load and display metadata
        UMySaveGame* Save = Cast<UMySaveGame>(
            UGameplayStatics::LoadGameFromSlot(Slot, 0)
        );
        // Show Save->Timestamp, level info, etc. in UI
    }
}

// Delete a save slot
UGameplayStatics::DeleteGameInSlot("Slot_1", 0);

Preventing Save Corruption

Unreal’s built-in save system already handles atomic writes internally on most platforms. However, you should still implement backup saves for additional safety:

Before every save, copy the existing slot file to a backup slot. If the primary save fails to load, fall back to the backup.

Never save during level transitions unless you have confirmed all actors and data are in a stable state. Saving while streaming levels can capture incomplete state.

Validate after loading: Check that critical fields are within expected ranges. A save that loads successfully but contains garbage data is worse than one that fails to load, because the player will not know something is wrong until much later.

Platform Storage

Unreal abstracts platform-specific storage paths through FPaths::ProjectSavedDir() and the save system’s internal path resolution. On PC, saves go to the project’s Saved/SaveGames directory. On consoles, they go through platform-specific APIs.

Consoles: PlayStation, Xbox, and Switch each have save data size limits and mandatory save indicators. Use the platform’s save API through Unreal’s ISaveGameSystem interface. Follow the certification requirements for save notifications.

Mobile: Save data persists in the app’s writable directory but can be lost if the player clears app data. Consider cloud saves for mobile games.

Related Issues

If save data is not persisting between sessions, see Fix: Unreal Save Game Data Not Persisting. For tracking save-related bugs reported by players, read Bug Reporting Tools for Unreal Engine.

Use Unreal's built-in save system instead of rolling your own. It handles platform differences, serialization, and file management. Your job is to design the data structure, add versioning, and test on every platform you ship.