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.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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.
// MyGameSaveGame.h#pragma once#include"CoreMinimal.h"#include"GameFramework/SaveGame.h"#include"MyGameSaveGame.generated.h"USTRUCT(BlueprintType)
structFInventoryItemData
{
GENERATED_BODY() // Required — missing GENERATED_BODY() breaks struct serialization silentlyUPROPERTY(SaveGame) FName ItemID;
UPROPERTY(SaveGame) int32 Quantity = 0;
UPROPERTY(SaveGame) bool bIsEquipped = false;
};
UCLASS(BlueprintType)
classMYGAME_API UMyGameSaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(SaveGame) int32 SaveVersion = 0; // Always include a version fieldUPROPERTY(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;
// TSet<FName> is also supported in UPROPERTY(SaveGame) fields and serializes/deserializes automatically.// Asset references: FSoftObjectPath stores a string path — safe across saves// Never use raw UObject* or hard TObjectPtr<> to content assets in save dataUPROPERTY(SaveGame) FSoftObjectPath LastEquippedWeaponPath;
};
Saving and Loading
#include"Kismet/GameplayStatics.h"staticconst FString SlotName = TEXT("MainSave");
staticconstexpr int32 UserIdx = 0; // Always 0 on PC; use GetPlatformUserIndex() on console// Create the object first, populate its fields, then save
UMySaveGame* SaveGame = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
SaveGame->PlayerHealth = 75.f;
// Then pass SaveGame to SaveGameToSlot / AsyncSaveGameToSlot below// Sync save (blocks game thread — avoid in gameplay)bool bSaved = UGameplayStatics::SaveGameToSlot(SaveData, SlotName, UserIdx);
// Async save (preferred — does not block)
FAsyncSaveGameToSlotDelegate OnSaved;
OnSaved.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(SaveData, SlotName, UserIdx, OnSaved);
// Loadif (UGameplayStatics::DoesSaveGameExist(SlotName, UserIdx))
{
UMyGameSaveGame* Save = Cast<UMyGameSaveGame>(
UGameplayStatics::LoadGameFromSlot(SlotName, UserIdx));
}
// Async load
FAsyncLoadGameFromSlotDelegate OnLoaded;
OnLoaded.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(SlotName, UserIdx, OnLoaded);
// Delete
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()
classMYGAME_API UMyLocalPlayerSave : public ULocalPlayerSaveGame
{
GENERATED_BODY()
public:
virtual int32 GetLatestDataVersion()constoverride{ return3; }
virtualvoidHandlePostLoad()override;
UPROPERTY(SaveGame) TMap<FName, int32> UnlockedAbilities;
};
voidUMyLocalPlayerSave::HandlePostLoad(){
Super::HandlePostLoad();
const int32 Ver = GetSavedDataVersion(); // version when last savedif (Ver < 2) { UnlockedAbilities.Add(TEXT("Dash"), 1); }
// Ver < 3 migrations go here
}
// Load or create (sync)
UMyLocalPlayerSave* Save = ULocalPlayerSaveGame::LoadOrCreateSaveGameForLocalPlayer(
UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"));
// Load or create (async)
ULocalPlayerSaveGame::AsyncLoadOrCreateSaveGameForLocalPlayer(
UMyLocalPlayerSave::StaticClass(), PlayerController, TEXT("PlayerSlot0"),
FOnLocalPlayerSaveGameLoadedNative::CreateUObject(this, &AMyPC::OnSaveLoaded));
// Save back
Save->AsyncSaveGameToSlotForLocalPlayer(); // async (preferred)
Save->SaveGameToSlotForLocalPlayer(); // sync
Step 5: FArchive and Custom Serialization
FArchive (from Serialization/Archive.h) is the base for all UE serialization. Key API:
Ar.IsLoading() // true when deserializing — same operator<< handles both directions
Ar.IsSaving() // true when serializing to output
Ar.IsError() // true after any read/write failure — always check before continuing
Ar.Tell() // current position (int64); -1 if not seekable
Ar.CustomVer(Key) // returns the registered version number for a FGuid key
Override Serialize(FArchive& Ar) for precise binary layout control:
voidUMyObject::Serialize(FArchive& Ar){
Super::Serialize(Ar); // always call Super first
Ar << BinaryField;
Ar << UniqueRunID;
if (Ar.IsLoading() && Ar.IsError()) { /* handle corruption */ }
}
Step 6: Versioning
Integer Versioning in USaveGame
namespace ESaveVersion
{
enumType : int32
{
Initial = 0,
AddedInventory = 1,
SoftRefForWeapon = 2,
VersionPlusOne,
Latest = VersionPlusOne - 1
};
}
voidUSaveManager::RunMigrations(UMyGameSaveGame* Save){
if (Save->SaveVersion == ESaveVersion::Latest) { return; }
if (Save->SaveVersion < ESaveVersion::AddedInventory)
Save->InventoryItems.Reset();
if (Save->SaveVersion < ESaveVersion::SoftRefForWeapon)
{ /* convert old FName field to FSoftObjectPath */ }
Save->SaveVersion = ESaveVersion::Latest; // stamp after migration
}
FCustomVersionRegistration (FArchive-based saves)
// Declare version enum + GUID (generate once with FGuid::NewGuid(), then hardcode)structFMySaveVersion
{
enumType { Initial = 0, AddedQuestData = 1, VersionPlusOne, Latest = VersionPlusOne - 1 };
staticconst FGuid GUID;
};
const FGuid FMySaveVersion::GUID(0xA1B2C3D4, 0xE5F60718, 0x293A4B5C, 0x6D7E8F90);
// Register globally (module startup or static):FCustomVersionRegistration GReg(FMySaveVersion::GUID, FMySaveVersion::Latest, TEXT("MySave"));
// In Serialize():
Ar.UsingCustomVersion(FMySaveVersion::GUID);
const int32 Ver = Ar.CustomVer(FMySaveVersion::GUID);
Ar << CoreData;
if (Ver >= FMySaveVersion::AddedQuestData)
Ar << QuestData;
elseif (Ar.IsLoading())
QuestData.Reset(); // Initialize missing data on old saves
Struct Field Migration
When a struct field is renamed or its type changes, override Serialize() on the struct to migrate old data:
voidFMyStruct::Serialize(FArchive& Ar){
Ar.UsingCustomVersion(FMySaveVersion::GUID);
if (Ar.CustomVer(FMySaveVersion::GUID) < FMySaveVersion::RenamedHealthToHP)
{
float OldHealth;
Ar << OldHealth;
HP = OldHealth; // Migrate old field name to new
}
else
{
Ar << HP;
}
}
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
// Platform save systems (Steam, EOS, console) provide ISaveGameSystem// Access via IPlatformFeaturesModule:
ISaveGameSystem* SaveSystem = IPlatformFeaturesModule::Get().GetSaveGameSystem();
if (SaveSystem && SaveSystem->DoesSaveSystemSupportMultipleUsers())
{
// Platform handles cloud sync — use UGameplayStatics normally// Steam: auto-syncs Saved/SaveGames/ via Steam Cloud if configured in Steamworks// EOS: use IOnlineSubsystem → IOnlineTitleFileInterface for explicit cloud read/write
}
// Cross-platform pattern: serialize to TArray<uint8>, then write via platform API
TArray<uint8> SaveData;
FMemoryWriter Ar(SaveData);
SaveObject->Serialize(Ar);
// Upload SaveData via platform SDK// Steam Cloud — write save slot directly via Steamworks API
ISteamRemoteStorage* SteamStorage = SteamRemoteStorage();
if (SteamStorage && SteamStorage->IsCloudEnabledForApp())
{
SteamStorage->FileWrite("SaveSlot1.sav", SaveData.GetData(), SaveData.Num());
}
// Read back: SteamStorage->FileRead("SaveSlot1.sav", Buffer, Size)
Save Data Encryption
// Use FAES for symmetric encryption of save data#include"Misc/AES.h"// Build a zero-padded 32-byte FAESKey from a string.// Do NOT use Key.Left(32): if the string is shorter than 32 chars it silently// produces a truncated key, corrupting every encrypt/decrypt call.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;
}
voidEncryptSaveData(TArray<uint8>& Data, const FString& KeyString){
int32 PaddedSize = Align(Data.Num(), FAES::AESBlockSize);
Data.SetNumZeroed(PaddedSize);
FAES::EncryptData(Data.GetData(), PaddedSize, MakeAESKey(KeyString));
}
voidDecryptSaveData(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(newstring[] { "Core", "CoreUObject", "Engine" });
// For UDeveloperSettings:
PublicDependencyModuleNames.Add("DeveloperSettings");