| name | ue-serialization-savegames |
| description | Use when implementing save/load systems, player progress persistence, or data serialization in Unreal Engine. Triggers on: save game, USaveGame, FArchive, serialization, SaveGameToSlot, config, persist data, save file, load game. See references/save-system-architecture.md for full slot management and multi-user patterns. |
| metadata | {"version":"1.0.0"} |
UE Serialization & Save Games
You are an expert in Unreal Engine's serialization and save game systems. You implement save/load pipelines using USaveGame, FArchive, config files, and versioning so player progress persists correctly across sessions and game updates.
Step 1: Read Project Context
Read .agents/ue-project-context.md before giving any recommendations. You need:
- Engine version (UE 5.0+ has
ULocalPlayerSaveGame; earlier versions differ)
- Module names (the save system lives in a specific module)
- Target platforms (console vs. PC save paths and user indices differ)
- Whether multiplayer is in scope (server-authoritative vs. client-local saves)
If the file does not exist, ask the user to run /ue-project-context first.
Step 2: Gather Requirements
Ask before writing code:
- Save complexity: Simple key/value data, or complex world state with hundreds of objects?
- Data types: Primitives, nested structs, asset references (soft vs. hard)?
- Versioning needs: Live game with future patches? Old saves must keep working?
- Multiple save slots: How many? Does each player/user get their own?
- Async requirement: Can save/load stall the game thread, or must it be background?
Step 3: USaveGame Subclass
USaveGame is an abstract UObject from GameFramework/SaveGame.h. Subclass it and mark fields with UPROPERTY(SaveGame) for automatic tagged serialization by UGameplayStatics.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyGameSaveGame.generated.h"
USTRUCT(BlueprintType)
struct FInventoryItemData
{
GENERATED_BODY()
UPROPERTY(SaveGame) FName ItemID;
UPROPERTY(SaveGame) int32 Quantity = 0;
UPROPERTY(SaveGame) bool bIsEquipped = false;
};
UCLASS(BlueprintType)
class MYGAME_API UMyGameSaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(SaveGame) int32 SaveVersion = 0;
UPROPERTY(SaveGame) float PlayerHealth = 100.f;
UPROPERTY(SaveGame) int32 PlayerLevel = 1;
UPROPERTY(SaveGame) FVector LastCheckpointLocation = FVector::ZeroVector;
UPROPERTY(SaveGame) FString PlayerDisplayName;
UPROPERTY(SaveGame) float TotalPlayTimeSeconds = 0.f;
UPROPERTY(SaveGame) TArray<FInventoryItemData> InventoryItems;
UPROPERTY(SaveGame) TMap<FName, int32> AbilityLevels;
UPROPERTY(SaveGame) FSoftObjectPath LastEquippedWeaponPath;
};
Saving and Loading
#include "Kismet/GameplayStatics.h"
static const FString SlotName = TEXT("MainSave");
static constexpr int32 UserIdx = 0;
UMySaveGame* SaveGame = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
SaveGame->PlayerHealth = 75.f;
bool bSaved = UGameplayStatics::SaveGameToSlot(SaveData, SlotName, UserIdx);
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(SaveData, SlotName, UserIdx, OnSaved);
if (UGameplayStatics::DoesSaveGameExist(SlotName, UserIdx))
{
UMyGameSaveGame* Save = Cast<UMyGameSaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIdx));
}
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIdx, OnLoaded);
UGameplayStatics::DeleteGameInSlot(SlotName, UserIdx);
Step 4: ULocalPlayerSaveGame (UE 5.0+)
ULocalPlayerSaveGame ties a save to a specific local player, tracks versioning via GetLatestDataVersion(), and provides HandlePostLoad() for migrations.
UCLASS()
class MYGAME_API UMyLocalPlayerSave : public ULocalPlayerSaveGame
{
GENERATED_BODY()
public:
virtual int32 GetLatestDataVersion() const override { return 3; }
virtual void HandlePostLoad() override;
UPROPERTY(SaveGame) TMap<FName, int32> UnlockedAbilities;
};
void UMyLocalPlayerSave::HandlePostLoad()
{
Super::HandlePostLoad();
const int32 Ver = GetSavedDataVersion();
if (Ver < 2) { UnlockedAbilities.Add(TEXT("Dash"), 1); }
}
UMyLocalPlayerSave* Save = ULocalPlayerSaveGame::LoadOrCreateSaveGameForLocalPlayer(
UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"));
ULocalPlayerSaveGame::AsyncLoadOrCreateSaveGameForLocalPlayer(
UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"),
FOnLocalPlayerSaveGameLoadedNative::CreateUObject(this, &AMyPC::OnSaveLoaded));
Save->AsyncSaveGameToSlotForLocalPlayer();
Save->SaveGameToSlotForLocalPlayer();
Step 5: FArchive and Custom Serialization
FArchive (from Serialization/Archive.h) is the base for all UE serialization. Key API:
Ar.IsLoading()
Ar.IsSaving()
Ar.IsError()
Ar.Tell()
Ar.CustomVer(Key)
FMemoryWriter and FMemoryReader
FMemoryWriter/FMemoryReader (from Serialization/MemoryWriter.h / MemoryReader.h) serialize to/from TArray<uint8>:
TArray<uint8> OutBytes;
FMemoryWriter Writer(OutBytes, true);
int32 Version = 2;
Writer << Version;
Writer << SomeData;
checkf(!Writer.IsError(), TEXT("Serialization failed"));
FMemoryReader Reader(OutBytes, true);
int32 LoadedVersion = 0;
Reader << LoadedVersion;
if (LoadedVersion < 1 || Reader.IsError()) { return; }
Reader << SomeData;
FBufferArchive
FBufferArchive (from Serialization/BufferArchive.h) combines FMemoryWriter + TArray<uint8> — the object is the output buffer:
FBufferArchive Buffer(true);
int32 Magic = 0x53415645;
Buffer << Magic;
Buffer << MyStruct;
TArray<uint8> Bytes = MoveTemp(Buffer);
Custom operator<< for Structs
Define operator<< to make a struct serializable via any FArchive (required when passing it to FBufferArchive, FMemoryWriter, etc.):
FArchive& operator<<(FArchive& Ar, FMyCustomData& Data)
{
Ar << Data.Name << Data.Value << Data.Timestamp;
return Ar;
}
Compressed Archives
For large saves, use FArchiveSaveCompressedProxy / FArchiveLoadCompressedProxy (from Serialization/ArchiveSaveCompressedProxy.h):
TArray<uint8> Compressed;
FArchiveSaveCompressedProxy Comp(Compressed, NAME_Zlib);
Comp.Serialize(RawData.GetData(), RawData.Num());
Comp.Flush();
FArchiveLoadCompressedProxy Decomp(Compressed, NAME_Zlib);
TArray<uint8> Raw;
Raw.SetNum(KnownUncompressedSize);
Decomp.Serialize(Raw.GetData(), Raw.Num());
Custom Serialize() on UObject
Override Serialize(FArchive& Ar) for precise binary layout control:
void UMyObject::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
Ar << BinaryField;
Ar << UniqueRunID;
if (Ar.IsLoading() && Ar.IsError()) { }
}
Step 6: Versioning
Integer Versioning in USaveGame
namespace ESaveVersion
{
enum Type : int32
{
Initial = 0,
AddedInventory = 1,
SoftRefForWeapon = 2,
VersionPlusOne,
Latest = VersionPlusOne - 1
};
}
void USaveManager::RunMigrations(UMyGameSaveGame* Save)
{
if (Save->SaveVersion == ESaveVersion::Latest) { return; }
if (Save->SaveVersion < ESaveVersion::AddedInventory)
Save->InventoryItems.Reset();
if (Save->SaveVersion < ESaveVersion::SoftRefForWeapon)
{ }
Save->SaveVersion = ESaveVersion::Latest;
}
FCustomVersionRegistration (FArchive-based saves)
struct FMySaveVersion
{
enum Type { Initial = 0, AddedQuestData = 1, VersionPlusOne, Latest = VersionPlusOne - 1 };
static const FGuid GUID;
};
const FGuid FMySaveVersion::GUID(0xA1B2C3D4, 0xE5F60718, 0x293A4B5C, 0x6D7E8F90);
FCustomVersionRegistration GReg(FMySaveVersion::GUID, FMySaveVersion::Latest, TEXT("MySave"));
Ar.UsingCustomVersion(FMySaveVersion::GUID);
const int32 Ver = Ar.CustomVer(FMySaveVersion::GUID);
Ar << CoreData;
if (Ver >= FMySaveVersion::AddedQuestData)
Ar << QuestData;
else if (Ar.IsLoading())
QuestData.Reset();
Struct Field Migration
When a struct field is renamed or its type changes, override Serialize() on the struct to migrate old data:
void FMyStruct::Serialize(FArchive& Ar)
{
Ar.UsingCustomVersion(FMySaveVersion::GUID);
if (Ar.CustomVer(FMySaveVersion::GUID) < FMySaveVersion::RenamedHealthToHP)
{
float OldHealth;
Ar << OldHealth;
HP = OldHealth;
}
else
{
Ar << HP;
}
}
Step 7: Config Files
UGameUserSettings (user preferences)
UCLASS()
class MYGAME_API UMyGameUserSettings : public UGameUserSettings
{
GENERATED_BODY()
public:
UPROPERTY(Config, BlueprintReadWrite, Category="Game")
float MasterVolume = 1.0f;
UPROPERTY(Config, BlueprintReadWrite, Category="Game")
bool bSubtitlesEnabled = true;
void ApplyAndSave() { ApplySettings(false); SaveSettings(); }
};
UDeveloperSettings (project settings)
UCLASS(Config=Game, DefaultConfig, meta=(DisplayName="My Game Settings"))
class MYGAME_API UMyProjectSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UPROPERTY(Config, EditAnywhere, Category="Save") int32 MaxSaveSlots = 5;
UPROPERTY(Config, EditAnywhere, Category="Save") bool bEnableAutoSave = true;
UPROPERTY(Config, EditAnywhere, Category="Save") float AutoSaveIntervalSeconds = 300.f;
static const UMyProjectSettings* Get() { return GetDefault<UMyProjectSettings>(); }
};
GConfig Direct Access
#include "Misc/ConfigCacheIni.h"
FString Value;
GConfig->GetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), Value, GGameIni);
GConfig->SetString(TEXT("/Script/MyGame.MyConfig"), TEXT("Key"), TEXT("Val"), GGameIni);
GConfig->Flush(false, GGameIni);
MyObject->SaveConfig();
MyObject->LoadConfig();
INI section naming: Section [/Script/ModuleName.ClassName] maps to the CDO. SaveConfig() writes from the object to INI; LoadConfig() reads INI into the object and is called automatically for the CDO at startup. Custom section names require overriding OverrideConfigSection(FString& SectionName).
Cloud Save Integration
ISaveGameSystem* SaveSystem = IPlatformFeaturesModule::Get().GetSaveGameSystem();
if (SaveSystem && SaveSystem->DoesSaveSystemSupportMultipleUsers())
{
}
TArray<uint8> SaveData;
FMemoryWriter Ar(SaveData);
SaveObject->Serialize(Ar);
ISteamRemoteStorage* SteamStorage = SteamRemoteStorage();
if (SteamStorage && SteamStorage->IsCloudEnabledForApp())
{
SteamStorage->FileWrite("SaveSlot1.sav", SaveData.GetData(), SaveData.Num());
}
Save Data Encryption
#include "Misc/AES.h"
static FAESKey MakeAESKey(const FString& KeyString)
{
FAESKey AESKey;
FMemory::Memzero(AESKey.Key, FAESKey::KeySize);
const FTCHARToUTF8 Utf8(*KeyString);
FMemory::Memcpy(AESKey.Key, Utf8.Get(), FMath::Min(Utf8.Length(), FAESKey::KeySize));
return AESKey;
}
void EncryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
int32 PaddedSize = Align(Data.Num(), FAES::AESBlockSize);
Data.SetNumZeroed(PaddedSize);
FAES::EncryptData(Data.GetData(), PaddedSize, MakeAESKey(KeyString));
}
void DecryptSaveData(TArray<uint8>& Data, const FString& KeyString)
{
FAES::DecryptData(Data.GetData(), Data.Num(), MakeAESKey(KeyString));
}
Why encrypt: Prevents casual save editing for competitive/economy-sensitive games. Not foolproof — determined players can still extract keys from the binary. Combine with server-side validation for authoritative saves.
Step 8: Common Mistakes
| Anti-Pattern | Problem | Fix |
|---|
Saving raw UObject* or AActor* | Pointers invalid between sessions | Save FSoftObjectPath or a stable unique ID |
| No version field | Adding/removing fields corrupts old saves silently | Always include int32 SaveVersion; run migrations on load |
SaveGameToSlot on game thread per frame | Blocks rendering, causes hitches | Use AsyncSaveGameToSlot |
USTRUCT without GENERATED_BODY() in a saved field | Silent serialization failure | Add GENERATED_BODY() to all saved structs |
Ignoring Ar.IsError() | Reads past corrupted data, applies garbage | Check after every block; abort immediately if set |
| Overlapping async saves | Second save starts before first completes | Guard with bSaveInProgress flag or IsSaveInProgress() |
| Hardcoded save file paths | Breaks on consoles and different platforms | Use UGameplayStatics APIs; FPaths::ProjectSavedDir() only for debug |
PIE vs. Packaged / platform paths: In PIE, saves go to <Project>/Saved/SaveGames/. Packaged Windows builds write to %LocalAppData%/<ProjectName>/Saved/SaveGames/. Console platforms use title storage APIs. UGameplayStatics::SaveGameToSlot abstracts all of this through the platform's ISaveGameSystem — never hardcode OS paths; use FPaths::ProjectSavedDir() only for debug logging.
Advanced Edge Cases
Corruption recovery: When Ar.IsError() returns true mid-read or magic/version checks fail, discard the corrupt data and fall back to a fresh save. Optionally maintain a backup slot (write to Slot_Backup before overwriting Slot_Primary) so players never lose all progress:
USaveGame* LoadedSave = UGameplayStatics::LoadGameFromSlot(PrimarySlot, 0);
if (!LoadedSave)
LoadedSave = UGameplayStatics::LoadGameFromSlot(BackupSlot, 0);
if (!LoadedSave)
LoadedSave = UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass());
Large saves — chunked approach: Split world state across multiple slots by subsystem (e.g., Save_World_00, Save_Inventory, Save_Quests). Load each with AsyncLoadGameFromSlot in parallel. This prevents single-file bottlenecks and lets you load only what's needed for the current level.
Multiplayer save ownership: Shared world state (quests, economy, enemy state) belongs to server-authoritative saves — the server's AGameMode writes these; clients send state changes via RPCs, never write shared saves directly. Per-player preferences (keybinds, UI layout) remain client-local via ULocalPlayerSaveGame. This split prevents desync and cheating.
Module Dependencies (Build.cs)
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" });
PublicDependencyModuleNames.Add("DeveloperSettings");
Related Skills
ue-cpp-foundations — UPROPERTY, USTRUCT, UObject lifetime
ue-data-assets-tables — FSoftObjectPath patterns for asset references in saves
ue-gameplay-framework — GameInstance as save manager host; GameMode auto-save integration
Reference Files
references/save-system-architecture.md — Full slot manager subsystem, metadata bank, multi-user patterns, and migration pipeline