Quick answer: SaveGameToSlot returns false when the serialization or file write fails. Common causes include the save game object containing non-serializable UObject pointers, the save directory not being writable due to permissions, the slot name containing invalid filesystem characters, or the save data exceedi...
Here is how to fix Unreal save game data not persisting. You have set up a USaveGame subclass, stored your player data, called SaveGameToSlot, and confirmed it returned true. Next session you load the slot and get back default values — or the load fails entirely. Unreal's save system is straightforward on the surface but has several serialization pitfalls that silently discard your data. Here is a systematic guide to diagnosing and fixing every common failure mode.
The Symptom
Your save game data does not persist between play sessions. This manifests in several ways: SaveGameToSlot returns false and no file is written, the file is written but LoadGameFromSlot returns null, the load succeeds but all properties contain default values, or the save works in PIE but fails in a packaged build. In some cases, the save works initially but breaks after you add new properties to the save game class or rename it.
A particularly frustrating variant is when saves work on one platform but not another. A Windows build saves fine but the same code on a console platform fails silently. Or the save works for small amounts of data but starts failing once the player has progressed far enough to accumulate a large save file.
What Causes This
1. Non-UPROPERTY fields are not serialized. Unreal's save system uses the UPROPERTY reflection system to discover which fields to serialize. If you declare a member variable in your USaveGame subclass without the UPROPERTY() macro, it will be silently skipped during serialization. The save will appear to succeed but the field will have its default value when loaded.
2. Raw UObject pointers cannot be serialized across sessions. If your save game class contains a UObject*, AActor*, or any pointer to a runtime object, the serializer either skips it or writes an invalid reference. These pointers refer to memory addresses that are only valid during the current session. On load, the pointer is null or garbage because the original object no longer exists at that address.
3. The save game class was renamed or moved. Unreal's serialization system stores the class path in the save file. If you rename your USaveGame subclass, move it to a different module, or change its package path, existing save files will fail to deserialize because the stored class path no longer matches any loaded class. The load returns null with no obvious error.
4. SaveGameToSlot fails due to filesystem issues. On some platforms, the save directory may not exist or may not be writable. The slot name might contain characters that are invalid for the target filesystem. The save data might exceed platform-specific size limits. These failures cause SaveGameToSlot to return false but provide minimal diagnostic information by default.
The Fix
Step 1: Define your save game class with proper UPROPERTY declarations. Every field you want persisted must be marked with UPROPERTY(). Use value types and serializable containers instead of raw pointers:
// MyGameSave.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyGameSave.generated.h"
USTRUCT(BlueprintType)
struct FInventoryItemData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FName ItemID;
UPROPERTY(SaveGame)
int32 Quantity = 0;
UPROPERTY(SaveGame)
int32 SlotIndex = -1;
};
USTRUCT(BlueprintType)
struct FPlayerProgressData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FString CurrentLevelName;
UPROPERTY(SaveGame)
FVector LastCheckpointLocation = FVector::ZeroVector;
UPROPERTY(SaveGame)
FRotator LastCheckpointRotation = FRotator::ZeroRotator;
UPROPERTY(SaveGame)
int32 ExperiencePoints = 0;
UPROPERTY(SaveGame)
TArray<FName> CompletedQuests;
};
UCLASS()
class UMyGameSave : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(SaveGame)
FPlayerProgressData PlayerProgress;
UPROPERTY(SaveGame)
TArray<FInventoryItemData> InventoryItems;
UPROPERTY(SaveGame)
TMap<FName, bool> UnlockedAbilities;
UPROPERTY(SaveGame)
FDateTime LastSaveTime;
UPROPERTY(SaveGame)
int32 SaveVersion = 1;
};
Notice that we store FName ItemID instead of a pointer to an item object. We store FString CurrentLevelName instead of a level reference. All containers use value types or other USTRUCTs that themselves contain only serializable types. The SaveGame metadata specifier is optional but serves as documentation that the field is intended for save data.
Step 2: Implement a robust save/load manager with error handling and async support. Wrapping the save and load calls in a manager class gives you centralized error handling, versioning, and the ability to switch between sync and async operations:
// SaveManager.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "MyGameSave.h"
#include "SaveManager.generated.h"
UCLASS()
class USaveManager : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(
FSubsystemCollectionBase& Collection) override;
UFUNCTION(BlueprintCallable, Category = "Save")
bool SaveGame(const FString& SlotName = "Default");
UFUNCTION(BlueprintCallable, Category = "Save")
bool LoadGame(const FString& SlotName = "Default");
UFUNCTION(BlueprintCallable, Category = "Save")
void AsyncSaveGame(const FString& SlotName = "Default");
UFUNCTION(BlueprintCallable, Category = "Save")
void AsyncLoadGame(const FString& SlotName = "Default");
UFUNCTION(BlueprintPure, Category = "Save")
UMyGameSave* GetCurrentSave() const;
private:
UPROPERTY()
UMyGameSave* CurrentSave = nullptr;
void OnAsyncSaveComplete(
const FString& SlotName, int32 UserIndex,
bool bSuccess);
void OnAsyncLoadComplete(
const FString& SlotName, int32 UserIndex,
USaveGame* LoadedSave);
static constexpr int32 UserIndex = 0;
};
// SaveManager.cpp
#include "SaveManager.h"
#include "Kismet/GameplayStatics.h"
void USaveManager::Initialize(
FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
CurrentSave = NewObject<UMyGameSave>(this);
UE_LOG(LogTemp, Log,
TEXT("SaveManager initialized with empty save"));
}
bool USaveManager::SaveGame(const FString& SlotName)
{
if (!CurrentSave)
{
UE_LOG(LogTemp, Error,
TEXT("SaveGame: No save object exists"));
return false;
}
CurrentSave->LastSaveTime = FDateTime::Now();
bool bSuccess = UGameplayStatics::SaveGameToSlot(
CurrentSave, SlotName, UserIndex);
if (!bSuccess)
{
UE_LOG(LogTemp, Error,
TEXT("SaveGame: SaveGameToSlot failed for '%s'"),
*SlotName);
}
else
{
UE_LOG(LogTemp, Log,
TEXT("SaveGame: Saved to slot '%s'"),
*SlotName);
}
return bSuccess;
}
bool USaveManager::LoadGame(const FString& SlotName)
{
if (!UGameplayStatics::DoesSaveGameExist(
SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning,
TEXT("LoadGame: No save in slot '%s'"),
*SlotName);
return false;
}
USaveGame* Loaded = UGameplayStatics::LoadGameFromSlot(
SlotName, UserIndex);
UMyGameSave* TypedSave = Cast<UMyGameSave>(Loaded);
if (!TypedSave)
{
UE_LOG(LogTemp, Error,
TEXT("LoadGame: Cast to UMyGameSave failed"));
return false;
}
CurrentSave = TypedSave;
UE_LOG(LogTemp, Log,
TEXT("LoadGame: Loaded from slot '%s' (v%d)"),
*SlotName, CurrentSave->SaveVersion);
return true;
}
void USaveManager::AsyncSaveGame(const FString& SlotName)
{
if (!CurrentSave) return;
CurrentSave->LastSaveTime = FDateTime::Now();
FAsyncSaveGameToSlotDelegate Delegate;
Delegate.BindUObject(
this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(
CurrentSave, SlotName, UserIndex, Delegate);
}
void USaveManager::OnAsyncSaveComplete(
const FString& SlotName, int32 InUserIndex,
bool bSuccess)
{
UE_LOG(LogTemp, Log,
TEXT("AsyncSave '%s': %s"),
*SlotName,
bSuccess ? TEXT("OK") : TEXT("FAILED"));
}
void USaveManager::AsyncLoadGame(const FString& SlotName)
{
FAsyncLoadGameFromSlotDelegate Delegate;
Delegate.BindUObject(
this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(
SlotName, UserIndex, Delegate);
}
void USaveManager::OnAsyncLoadComplete(
const FString& SlotName, int32 InUserIndex,
USaveGame* LoadedSave)
{
UMyGameSave* TypedSave = Cast<UMyGameSave>(LoadedSave);
if (TypedSave)
{
CurrentSave = TypedSave;
UE_LOG(LogTemp, Log,
TEXT("AsyncLoad '%s': OK (v%d)"),
*SlotName, CurrentSave->SaveVersion);
}
else
{
UE_LOG(LogTemp, Error,
TEXT("AsyncLoad '%s': Failed or cast error"),
*SlotName);
}
}
UMyGameSave* USaveManager::GetCurrentSave() const
{
return CurrentSave;
}
The async variants are critical for large save files. A synchronous SaveGameToSlot call blocks the game thread during serialization and file I/O. For save files over a few hundred kilobytes, this can cause visible frame hitches. The async delegates let you show a save indicator and continue gameplay while the operation completes in the background.
Step 3: Handle save versioning for forward compatibility. When you add new fields to your save game class, old save files will not contain those fields. The serializer will leave them at their default values, which is usually fine. But if you rename or remove fields, you need a version number to handle migration. Check SaveVersion after loading and apply any necessary data transformations.
Related Issues
If your save works in PIE but fails in packaged builds, check that the save directory is writable on the target platform. On consoles, you may need to use platform-specific save APIs instead of the generic SaveGameToSlot. If saves suddenly break after a code change, check whether you moved your USaveGame class to a different module or renamed it — existing save files store the old class path and cannot find the renamed class.
If you are saving TSoftObjectPtr references to assets and they resolve to null after loading, verify that the referenced assets are included in your packaged build. Assets that are only referenced through soft pointers must be explicitly added to the asset packaging list or they will be stripped during cooking.