Skip to content

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 USaveGame or 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

  1. OnPlayerStateReady_Implementation() fires when the PlayerState is ready on the server.
  2. If Socket.IO is already connected, calls LoadPlayerData() immediately.
  3. If not connected yet, binds to SocketIOConnectedDelegate and 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:

  1. TryStartBatchLoadingItemsTimer() starts a 0.1s repeating timer.
  2. OnBatchLoadingItems() loads up to 32 items per tick from the cached JSON.
  3. Each item is reconstructed: LoadItem() or LoadEquippedItem(), then LoadPieces() for dynamic item pieces.
  4. When all storages are exhausted, PostLoadedItems() runs.

Completion

CheckAndHandleAllLoadingCompletes() checks whether all four streams have finished. When everything is loaded:

  1. OnPreAllLoadingCompletedDelegate broadcasts.
  2. OnPostAllLoadingCompletedDelegate broadcasts.
  3. 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 UMIPSaveOperationsHandler from the PlayerController and adds it to PlayerSaves.
  • Tick (~2 seconds) — calls IterateSaveList(), which iterates every player's handler and calls IterateOperations(false). This processes queued save operations into PerIDPerStorageMap (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 calls WriteToCloud() to emit WRITE_ITEMS to the backend. The player is then removed from PlayerSaves.
  • Deinitialize — when the Socket.IO subsystem shuts down, calls IterateSaveList(true) which triggers WriteToCloud() 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:

UMIPSaveOperationsHandler::Save(PC, Object, SaveID, SavableObjects, bInstant);
  • bInstant = true — serializes immediately and writes directly into PerIDPerStorageMap (used by MarkComponentForSave()).
  • bInstant = false — queues the operation in SaveOperationObjects for 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:

  1. Calls PreLogout() — notifies all saveable components via OnLoggingOut(), saves temporal duration objects, broadcasts PrePlayerLoggingOutDelegate. The bLoggingOut flag prevents this from running twice.
  2. For each entry in PerIDPerStorageMap, stringifies the JSON and emits WRITE_ITEMS to the backend with FMIPWriteItemsRequest (serialized JSON content, player info, death state, save tag, map type).
  3. Resets each JsonObject after 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

  1. On BeginPlay (if not dedicated server), or when PlayerState initializes.
  2. Derives a slot name from the player's ID (GetPlayerSaveSlotName()).
  3. Attempts UGameplayStatics::LoadGameFromSlot().
  4. If no save exists, creates a new UMIPClientSaveGame and saves it.
  5. 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:

  1. Loads all quick bar slots from UMIPClientSaveGame::QuickSlots using FMIPSavePropertyHelper to deserialize each slot's JSON.
  2. 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.