Save / Load¶
Overview¶
The save/load system has two completely separate paths:
- Server — all persistent game data (inventory, saveable components, currencies, temporal duration objects) is serialized to JSON and sent to the backend via Socket.IO. The server never uses UE's
USaveGameor local save slots. - Client — lightweight local data (quick bar layout, minimap state, input bindings, quest journal tab) is saved to UE save slots using
UGameplayStatics::SaveGameToSlot.
Server Loading¶
flowchart LR
A[PlayerState Ready] --> B{Socket.IO\nConnected?}
B -- Yes --> C[emit LOAD_PLAYER_SERVER]
B -- No --> W[Wait for\nSocketIOConnectedDelegate] --> C
C --> D[Backend]
D --> E[READ_SAVEABLE_OBJECTS\n×2: character + family]
D --> F[READ_ITEMS\n×3: char + family + dynamic]
D --> G[READ_CURRENCIES]
D --> H[READ_TEMP_DURATION_OBJECTS]
E --> I[Load Saveable\nComponents]
I --> J{All saveable +\nall item events\nreceived?}
F --> J
J -- Yes --> K[Batch Load Items\n32 per tick]
G --> L[Load Currencies]
H --> M[Load Temp Objects]
K --> N[OnPostAllLoadingCompletedDelegate]
L --> N
M --> N
N --> O[Client_OnServerLoaded RPC]
Server Saving¶
flowchart LR
A[Data Changes] --> B[MarkComponentForSave]
B --> C[SerializeObject\nUPROPERTY SaveGame → JSON]
C --> D[PerIDPerStorageMap\nin memory]
E[Queued Ops] --> F[GameMode Tick\nevery ~2s]
F --> G[DoOperationOnSaveItems]
G --> D
D -- "Player Logout\nor Server Shutdown" --> H[WriteToCloud]
H --> I[emit WRITE_ITEMS\nvia Socket.IO]
I --> J[(Backend\nMongoDB)]
style D fill:#fff3cd,stroke:#856404
style H fill:#f8d7da,stroke:#721c24
Client Save / Load¶
flowchart LR
A[PlayerState\nInitialized] --> B[LoadGameFromSlot]
B --> C[UMIPClientSaveGame]
C --> D[Load Quick Slots\nMinimap · Input Bindings]
E[UI State Changes] --> F[SaveToSlot]
F --> G[Local Save File]
style G fill:#d4edda,stroke:#155724
Server Loading — UMIPBaseServerLoadComponent¶
Lives on the PlayerController (server-side only). Responsible for loading all of a player's persistent data from the backend when they join.
How it starts¶
OnPlayerStateReady_Implementation()fires when the PlayerState is ready on the server.- If Socket.IO is already connected, calls
LoadPlayerData()immediately. - If not connected yet, binds to
SocketIOConnectedDelegateand waits.
Loading player data¶
LoadPlayerData() registers callbacks for four Socket.IO event types, then emits LOAD_PLAYER_SERVER to the backend with:
FMIPLoadPlayerInfo {
FString FamilyId;
FString FamilyName;
FString CharacterId;
FString CharacterName;
TArray<FString> Storages;
};
The backend responds with multiple events that can arrive in any order:
| Event | Count | What it loads |
|---|---|---|
READ_SAVEABLE_OBJECTS |
2 (character + family) | All UMIPBaseSaveableComponent data — JSON is deserialized back into each component's UPROPERTY(SaveGame) properties |
READ_ITEMS |
3 (character items + family storages + dynamic storages) | Inventory items with all their item pieces |
READ_CURRENCIES |
1 | Player currencies |
READ_TEMP_DURATION_OBJECTS |
1 | Time-limited objects (buffs, temporary items) |
Loading order¶
Saveable components load first. Items only begin batch loading after both saveable object events and all three item events have arrived. This ensures that storage components (which are saveable) are ready before items are placed into them.
Batch loading items¶
Items don't load all at once — they're processed in batches to avoid server hitches:
TryStartBatchLoadingItemsTimer()starts a 0.1s repeating timer.OnBatchLoadingItems()loads up to 32 items per tick from the cached JSON.- Each item is reconstructed:
LoadItem()orLoadEquippedItem(), thenLoadPieces()for dynamic item pieces. - When all storages are exhausted,
PostLoadedItems()runs.
Completion¶
CheckAndHandleAllLoadingCompletes() checks whether all four streams have finished. When everything is loaded:
OnPreAllLoadingCompletedDelegatebroadcasts.OnPostAllLoadingCompletedDelegatebroadcasts.Client_OnServerLoaded()— client RPC notifying the client that server-side loading is done.
Other systems use CallOrRegister_OnPreAllLoadedCompleted() or CheckAllLoadingCompleted() to wait for or verify that loading is finished before accessing player data.
Server Saving — UMIPBaseServerSaveComponent + UMIPSaveOperationsHandler¶
UMIPBaseServerSaveComponent¶
Lives on the GameMode. A single component that manages save handlers for all connected players via PlayerSaves (a TMap<const APlayerController*, UMIPSaveOperationsHandler*>).
- PostLogin — retrieves the player's
UMIPSaveOperationsHandlerfrom the PlayerController and adds it toPlayerSaves. - Tick (~2 seconds) — calls
IterateSaveList(), which iterates every player's handler and callsIterateOperations(false). This processes queued save operations intoPerIDPerStorageMap(accumulating and deduplicating entries by identifier), but does not write to the backend. The tick only moves data from the queue into the accumulation map. - Logout — calls the handler's
OnLogout(), which processes remaining queued ops and then callsWriteToCloud()to emitWRITE_ITEMSto the backend. The player is then removed fromPlayerSaves. - Deinitialize — when the Socket.IO subsystem shuts down, calls
IterateSaveList(true)which triggersWriteToCloud()for every remaining player.
Data is only written to the backend on logout or server shutdown
The 2-second tick does not send data to the backend. It only processes the operation queue into an in-memory accumulation map. The actual WRITE_ITEMS emit only happens when a player logs out or the server shuts down gracefully. If the game server crashes or is killed unexpectedly (OOM, Agones pod eviction, hardware failure), all changes accumulated since the player connected are lost.
UMIPSaveOperationsHandler¶
Lives on the PlayerController (server-side). Each player has their own handler. This is where save operations are queued, accumulated, and eventually written to the backend.
Queueing a save:
When a saveable component or item changes, it calls:
bInstant = true— serializes immediately and writes directly intoPerIDPerStorageMap(used byMarkComponentForSave()).bInstant = false— queues the operation inSaveOperationObjectsfor the next tick cycle.
Both paths end up in the same place: SavePerIDPerStorage(), which accumulates serialized JSON into PerIDPerStorageMap. Entries are keyed by identifier, so repeated saves of the same object overwrite the previous entry rather than duplicating it.
Serialization uses FMIPSavePropertyHelper::SerializeObject(), which iterates all properties marked with UPROPERTY(SaveGame) and converts them to a JSON object.
Writing to the backend:
WriteToCloud() is called only on logout or server shutdown. It:
- Calls
PreLogout()— notifies all saveable components viaOnLoggingOut(), saves temporal duration objects, broadcastsPrePlayerLoggingOutDelegate. ThebLoggingOutflag prevents this from running twice. - For each entry in
PerIDPerStorageMap, stringifies the JSON and emitsWRITE_ITEMSto the backend withFMIPWriteItemsRequest(serialized JSON content, player info, death state, save tag, map type). - Resets each
JsonObjectafter emitting, so a second call would have nothing to send.
Thread safety: All operations run on the game thread. DoOperationOnSaveItems() copies the queue and empties it before processing, so new operations added during iteration go into a fresh array. PostLogin/Logout events are processed in a different tick phase than component ticks, so PlayerSaves is never modified during iteration.
Saveable Components — UMIPBaseSaveableComponent¶
Any component that extends UMIPBaseSaveableComponent is automatically discovered, serialized, and persisted by the server save/load system.
How to make data persist¶
Mark properties with UPROPERTY(SaveGame):
UPROPERTY(BlueprintReadOnly, SaveGame, Category = MyFeature)
TMap<FGameplayTag, int32> MyCustomDataMap;
UPROPERTY(BlueprintReadOnly, SaveGame, Category = MyFeature)
bool bSomeFlag = false;
The system scans all CPF_SaveGame-flagged properties at save time and serializes them to JSON via FMIPSavePropertyHelper. On load, the JSON is deserialized back into the properties via LoadThisUObjectFromJsonObject(). No manual serialization code needed.
Accessibility scopes¶
Each saveable component declares an AccessibilityTag that determines its data scope:
| Tag | Scope | SaveID |
|---|---|---|
Tag_Save_Accessibility_Character |
Per-character data (inventory, quests, abilities) | CharacterId |
Tag_Save_Accessibility_Family |
Account-wide data (shared storage, account settings) | FamilyId |
The system routes save/load data to the correct scope automatically based on this tag.
Triggering a save¶
When data changes, call MarkComponentForSave() on the component. This serializes the component immediately (bInstant = true) and writes it into the handler's PerIDPerStorageMap. The data accumulates in memory and is emitted to the backend when the player logs out or the server shuts down.
Delegates¶
OnSaveableComponentLoadedDelegate— fires when the component's data has been deserialized from JSON.OnSaveableComponentPostLoadedDelegate— fires after post-load processing is complete.
Version migration¶
Each saveable component has a DatabaseVersion and FileVersion. When they differ during loading, OnVersionChanged() fires so you can migrate old save data to the new format.
Client Save — UMIPClientBaseSaveComponent¶
Lives on the PlayerController (client-only, not replicated). Manages a local UMIPClientSaveGame using UE save slots.
Initialization¶
- On
BeginPlay(if not dedicated server), or whenPlayerStateinitializes. - Derives a slot name from the player's ID (
GetPlayerSaveSlotName()). - Attempts
UGameplayStatics::LoadGameFromSlot(). - If no save exists, creates a new
UMIPClientSaveGameand saves it. - If a save exists, passes it to the client load component.
What's saved client-side¶
UMIPClientSaveGame (extends USaveGame) stores lightweight UI state:
| Data | Type |
|---|---|
| Quick bar slots | TMap<FString, FString> |
| Minimap zoom level | float |
| Minimap view center | FVector2D |
| Minimap follow player | bool |
| Input bindings | Via MIPSettingsSubsystem |
| Quest journal tab | Active tab index |
When it saves¶
SaveToSlot() is called whenever client-side state changes:
- Quick bar slot added/removed/moved
- Minimap zoom or position changed
- Input bindings modified
- Quest journal tab switched
Client Loading — UMIPBaseClientLoadComponent¶
Lives on the PlayerController. Loads client-side data from the local save slot.
LoadGame() is called during initialization with the loaded UMIPClientSaveGame. It:
- Loads all quick bar slots from
UMIPClientSaveGame::QuickSlotsusingFMIPSavePropertyHelperto deserialize each slot's JSON. - Loads input bindings via the
MIPSettingsSubsystem.
Architecture¶
Component Ownership¶
flowchart TB
subgraph GM["GameMode (Server)"]
SSC[UMIPBaseServerSaveComponent]
end
subgraph PC["PlayerController (per player)"]
SLC[UMIPBaseServerLoadComponent]
SOH[UMIPSaveOperationsHandler]
CSC[UMIPClientBaseSaveComponent\nclient-only]
CLC[UMIPBaseClientLoadComponent]
end
subgraph PCPS["PlayerController / PlayerState"]
SAV1[UMIPBaseSaveableComponent\nCharacter scope]
SAV2[UMIPBaseSaveableComponent\nFamily scope]
end
SSC -- "PlayerSaves map\nPC → Handler" --> SOH
SSC -- "Tick ~2s\nIterateSaveList" --> SOH
SLC -- "Discovers &\nloads JSON into" --> SAV1
SLC -- "Discovers &\nloads JSON into" --> SAV2
SAV1 -- "MarkComponentForSave" --> SOH
SAV2 -- "MarkComponentForSave" --> SOH
CSC -- "Manages" --> CSG[UMIPClientSaveGame\nUSaveGame]
CLC -- "Reads from" --> CSG
style GM fill:#4a6785,color:#fff
style PC fill:#2d4a22,color:#fff
style PCPS fill:#5a3d6b,color:#fff
Data Flow¶
flowchart TB
subgraph LOAD["Loading (Player Joins)"]
direction LR
BE1[(Backend\nMongoDB)] -- "Socket.IO\nREAD_* events" --> SLC2[ServerLoadComponent]
SLC2 -- "JSON → UPROPERTY" --> SAV3[Saveable Components]
SLC2 -- "Batch 32/tick" --> INV[Items & Storages]
SLC2 -- "Direct" --> CUR[Currencies]
SLC2 -- "Direct" --> TMP[Temp Duration Objects]
end
subgraph SAVE["Saving (During Gameplay)"]
direction LR
GP[Gameplay Changes] -- "MarkComponentForSave\nbInstant=true" --> MEM[PerIDPerStorageMap\nin memory]
GP2[Item Changes] -- "Save · bInstant=false" --> QUE[Operation Queue]
QUE -- "Tick ~2s\nDoOperationOnSaveItems" --> MEM
end
subgraph WRITE["Writing (Logout / Shutdown Only)"]
direction LR
MEM2[PerIDPerStorageMap] -- "WriteToCloud" --> SIO[Socket.IO\nWRITE_ITEMS] --> BE2[(Backend\nMongoDB)]
end
subgraph CLIENT["Client (Local Only)"]
direction LR
UI[Quick Bar · Minimap\nInput · Quest Tab] <-- "SaveToSlot\nLoadGameFromSlot" --> SLOT[Local Save File]
end
style LOAD fill:#1a3a1a
style SAVE fill:#3a2a0a
style WRITE fill:#3a0a0a
style CLIENT fill:#0a1a3a
Key Delegates¶
| Delegate | Component | Purpose |
|---|---|---|
OnPreAllLoadingCompletedDelegate |
UMIPBaseServerLoadComponent |
All server load streams finished (before post-processing) |
OnPostAllLoadingCompletedDelegate |
UMIPBaseServerLoadComponent |
All server loading fully complete |
OnSaveableComponentLoadedDelegate |
UMIPBaseSaveableComponent |
This component's data deserialized from JSON |
OnSaveableComponentPostLoadedDelegate |
UMIPBaseSaveableComponent |
Post-load processing complete |
PrePlayerLoggingOutDelegate |
UMIPSaveOperationsHandler |
About to do final save before logout |
Use CallOrRegister_OnPreAllLoadedCompleted() to safely run code that depends on player data being loaded — it calls immediately if loading is already done, or registers for later if still in progress.