Skip to content

Persistence

Summary: Persistence saves and restores a player's complete character — inventory, equipment, glyphs, quests, compendium, and world-map progress — through a single component that delegates to a swappable provider. A UPersistenceComponent on the player controller selects one of three providers (local file, remote backend, or read-only curated preset) based on net mode and dev settings, then drives auto-save and an initial auto-load. The provider abstraction lets the same save-data shape and restore pipeline serve a solo file, a dedicated-server REST call, or a shipped demo "play-as" build.

Table of Contents


Architecture Overview

+--------------------------------------------------------------+
|  AEternalPlayer (PlayerController)                            |
|                                                              |
|   +------------------------------------------------------+   |
|   |  UPersistenceComponent                               |   |
|   |  - CharacterId / AccountId (FGuid identity)          |   |
|   |  - Auto-save timer + save/load guards               |   |
|   |  - Selects ONE provider in InitializeProvider()     |   |
|   +-----------------------+------------------------------+   |
|                           |  IPersistenceProvider           |
+---------------------------|----------------------------------+
                            |
        +-------------------+-------------------+
        v                   v                   v
+----------------+  +----------------+  +-------------------------+
| Local provider |  | Remote provider|  | Preset provider         |
| JSON file      |  | REST API       |  | (READ-ONLY)             |
+-------+--------+  +-------+--------+  +-----------+-------------+
        |                   |                       |
        v                   v                       v
  Saved/SaveGames/    eternal-server         UPresetCharacterAsset
  {CharacterId}.json  (PersistenceApi)       .Payload (cooked asset)

         All three speak the SAME FCharacterSaveData struct,
         and load runs the SAME restore pipeline:
         UPersistenceHelpers::RestoreCharacterFromSaveData()

Key Design Principles

Principle Rationale
One component, swappable provider Same save/load lifecycle whether solo, networked, or demo
Provider is an interface, not a branch IPersistenceProvider keeps storage mechanism out of the component
One save-data struct for all backends Local file, REST body, and preset payload are identical FCharacterSaveData JSON
Component lives on the controller Persistence must outlive pawn death (see System Ownership)
Server authority on save/load Clients never write; only the authority builds and restores state

Core Concepts

Why a Provider Abstraction?

The character-save shape and the restore logic are stable, but where the data lives is not: a solo player saves to a local file, a multiplayer session round-trips through the backend, and a shipped demo loads a curated character that is never written back. Rather than branch on net mode throughout the component, the storage mechanism is hidden behind IPersistenceProvider. The component asks the provider to SaveCharacter / LoadCharacter and reacts to a completion delegate — async for remote, immediate for local — without knowing which backing store it is talking to.

Why the Component Lives on the Player Controller

Inventory and equipment must survive pawn death, so the save/load owner sits on AEternalPlayer (the player controller) rather than the pawn. During teardown the pawn — and the equipment/glyph data it transitively reaches through PlayerState — is destroyed before the component's EndPlay. The component therefore caches the equipment and quest component pointers in BeginPlay, and the authoritative save is the auto-save timer, supplemented by an explicit RequestSave() from AEternalGameMode::Logout while the pawn is still intact. EndPlay deliberately does not save.


Provider Selection

UPersistenceComponent::InitializeProvider() (called from BeginPlay) picks exactly one provider. A configured preset always wins; otherwise the choice is local vs. remote based on forced mode or net mode.

Provider Modes

EPersistenceProviderMode (Project Settings > Game > Persistence (Dev)):

Mode Behaviour
Auto Local in Standalone; Remote in DedicatedServer / ListenServer
Local Always the local JSON-file provider
Remote Always the remote backend provider
Preset Always load the configured read-only DefaultPreset (demo / meta build)

Precedence Chain

Preset selection is resolved first, by ResolveActivePreset(), and short-circuits local/remote selection entirely:

  1. GameInstance PendingPreset  ──►  menu selection survives OpenLevel()  ──► PRESET (read-only)
        | (null)
        v
  2. ProviderMode == Preset      ──►  forced demo mode                     ──► PRESET (read-only)
        | (no)
        v
  3. bAutoLoadPresetInPackaged-  ──►  packaged build only (#if !WITH_EDITOR),
     Build  &&  DefaultPreset         no explicit selection                ──► PRESET (read-only)
        | (no / editor)
        v
  4. ProviderMode == Remote  OR  (Auto && (DedicatedServer || ListenServer))  ──► REMOTE
        | (no)
        v
  5. otherwise                                                              ──► LOCAL
Precedence Source Resolves To
Highest UEternalGameInstance::GetPendingPreset() (menu selection) Preset provider
ProviderMode == Preset Preset provider (loads DefaultPreset)
bAutoLoadPresetInPackagedBuild (packaged builds only) Preset provider (loads DefaultPreset)
Remote mode, or Auto on a server net mode Remote provider
Lowest Everything else Local provider

The menu-selected preset is carried on the GameInstance because it is the only object that survives the OpenLevel() from the menu into the gameplay map (see Game Framework → Pending Preset Carrier).


The Read-Only Preset Provider

UPresetPersistenceProvider serves a curated character that loads instead of a real save and is never written back. It is the in-window addition to the persistence system, supporting shipped demo / "play-as" builds.

Read-Only Contract

Method Behaviour
IsReadOnly() Returns true (the base interface default is false)
LoadCharacter() Returns the cached FCharacterSaveData snapshot every time
SaveCharacter() No-op (callback reports success so callers don't error)
DeleteSaveData() No-op
HasSaveData() Always reports the preset is available

The provider is initialized from a UPresetCharacterAsset::Payload via Initialize(const FCharacterSaveData&), which caches the snapshot in PresetData.

How Read-Only Changes Component Behaviour

UPersistenceComponent checks Provider->IsReadOnly() and adjusts its lifecycle:

Aspect Normal provider Read-only preset
Auto-save timer Started if AutoSaveInterval > 0 Never started
IsUsingPreset() false true
Auto-load on BeginPlay Only if bLoadOnPIEStart Always auto-loads
GameMode::Logout save Saves Save is a no-op (writes ignored)

UPresetCharacterAsset — The Curated Source

A UPresetCharacterAsset is a cooked UPrimaryDataAsset (primary asset type "CharacterPreset") shipped with the build. Its Payload is the same FCharacterSaveData a real save file holds, so a preset loads through the exact same restore pipeline as a normal save. It also carries menu metadata (DisplayName, Description, Icon, ClassTag) for a character-select screen, plus an Availability gate controlling whether it is listed.

UPresetCharacterAsset
+-- DisplayName / Description / Icon / ClassTag   (menu metadata)
+-- Availability : EPresetAvailability            (menu listing gate)
+-- Payload : FCharacterSaveData                  (curated snapshot, loaded read-only)

EPresetAvailability — Listing Gate

Value Behavior
Available Listed on the character-select War Table in every build
DevOnly Hidden in packaged builds, listed in editor/dev builds so WIP presets stay testable
Hidden Never listed; still reachable by name for regression testing

This gates listing only — a hidden preset still cooks into the build and stays loadable by name. The menu paths (CharacterSelectController, MainMenuController) filter on it via IsListedInMenu(); Eternal.PlayPreset deliberately ignores it, which is what keeps hidden presets usable as regression fixtures.

Why it lives on the asset rather than a config deny-list: a deny-list keyed by asset name silently stops matching the moment a preset is renamed, and the failure mode is a WIP build appearing in a shipped menu.

Authoring a Preset: Eternal.ExportPreset

Presets are authored by capturing a live in-game character. With a possessed character in play (editor/PIE), run in the console:

Eternal.ExportPreset <AssetName>

This editor-only console command snapshots the live local AEternalPlayer through the same UPersistenceHelpers::BuildCharacterSaveData path the auto-save uses, strips the per-machine identity (CharacterId / AccountId) and timestamp so the preset is identity-agnostic, then writes /Game/DataAssets/Presets/Preset_<AssetName>. Re-running overwrites in place: only the gameplay Payload is refreshed, so hand-edited display metadata is preserved.


Save Data Shape

FCharacterSaveData is the top-level aggregate serialized to JSON for every provider. Fields below are exactly what the struct persists — items are stored as a flat array referenced by slot assignments, and item template data (icon, fragments, weapon stats) is not saved; it is reconstructed from the item registry by ItemID.

Group Fields Notes
Identity CharacterId, AccountId, CharacterName, PlayerClassTemplateId GUID identity + class template reference
Currency CraftingResource
Play time PlayTimeSeconds
Items Items: TArray<FItemSaveState> Flat instance list; slots reference these by InstanceId
Inventory InventorySlots: TArray<FInventorySlotSaveState> Item instance → grid top-left index
Equipment EquipmentSlots: TArray<FEquipmentSlotSaveState> Item instance → slot FGameplayTag
Glyphs StonePlates: TArray<FStonePlateSaveState> Plates + per-socket glyph placements
Quests QuestProgress: FQuestSaveState Received / active / completed quest tag containers
Compendium CompendiumEntries: TArray<FCompendiumEntry>
World map WorldMapProgress: FWorldMapSaveState Per-node + per-domain state, plus CurrentNodeID
Versioning SaveVersion, SaveTimestamp

Per-Item Instance State

FItemSaveState carries only instance-specific fields, not the template:

Field Purpose
InstanceId Matches UItemObject::InstanceId
ItemID Template reference resolved via the item registry
StackCount / ItemLevel / ConsumableUsagesLeft Per-instance counters
Modifiers : FEquipmentModifiers Rolled affixes, reused directly from the equipment fragment
StateModules : TArray<FItemStateModuleSaveEntry> Polymorphic per-instance runtime state (e.g. Remnant)

Each FItemStateModuleSaveEntry records a StructName plus a nested FJsonObjectWrapper so per-instance state serializes as introspectable JSON the backend can query — see Item State Modules.

⚠️ FJsonObjectWrapper persists JsonString, not JsonObject. JsonObject is a transient TSharedPtr; only the JsonString UPROPERTY serializes (PostSerialize re-parses it on load). Setting Wrapper.JsonObject = X without Wrapper.JsonObjectToString(Wrapper.JsonString) saves an empty payload — and on reload the empty string parses into a valid-but-empty object, so IsValid() guards pass and JsonObjectToUStruct "succeeds" into a default-constructed struct. Nothing errors. Always flush JsonString before save; guard readers with JsonObject->Values.IsEmpty() ("no state") not just IsValid(). (This silently dropped all Remnant state from preset exports — see Learnings/fjsonobjectwrapper-persists-jsonstring-only.md.)


Build & Restore Pipeline

Both save and load are orchestrated by static helpers in UPersistenceHelpers, keeping the conversion between live game objects and persistence structs out of the component.

SAVE                                         LOAD
----                                         ----
RequestSave()                                provider->LoadCharacter() -> callback
  |                                            |
  v                                            v
BuildCharacterSaveData(PlayerController)      RestoreCharacterFromSaveData(PlayerController, SaveData)
  + CollectAllItems (inv/equip/glyph)          + CreateItemFromSaveState (registry lookup)
  + CollectInventorySlots                      + RepopulateModifierCache / RepopulateEchoModCache
  + CollectEquipmentSlots                      + RestoreInventory / RestoreEquipment / RestoreGlyphs
  + CollectGlyphState                          + RestoreQuestProgress
  + CollectQuestProgress                       + RestoreCompendium
  + CollectCompendiumEntries                   + RestoreWorldMapProgress
  + CollectWorldMapProgress                    |
  |                                            v
  v                                          OnLoadComplete -> OnLoadCompleted.Broadcast(bSuccess)
provider->SaveCharacter() -> OnSaveComplete

On restore, items are rebuilt from their template by ItemID (via the registry), instance fields are overwritten, and CachedDefinition pointers on rolled modifiers — including Echo Mods inside Remnant state modules — are repopulated so live modifier logic works after a fresh load. A successful load seeds LastSaveTime so the auto-save timer does not immediately fire over freshly loaded state.


Auto-Load & Travel Gate

On BeginPlay, the component schedules an initial load deferred to the next tick (so PostLogin can disable persistence first), when either the provider is a read-only preset (always) or bLoadOnPIEStart is set — and only on authority. WillAutoLoad() exposes whether that load was scheduled, and HasCompletedInitialLoad() whether it has finished.

AEternalGameMode keys its world-map travel on these flags: an auto-load must restore the saved/preset world-map node before the game reads the travel target, so the GameMode waits for the load to complete before traveling.

GameMode::HandleStartingNewPlayer
   |
   +-- Persistence->WillAutoLoad() && HasAuthority() ?
        |                              |
        | yes                          | no
        v                              v
   HasCompletedInitialLoad() ?     TravelToCurrentNode()  (start node, immediately)
        |             |
        | yes         | no
        v             v
   TravelTo...   bind OnLoadCompleted -> OnPlayerPersistenceLoaded -> TravelTo...

If a scheduled auto-load cannot proceed (disabled, no CharacterId, or no provider), DeferredAutoLoad still marks the load complete and broadcasts failure so the travel gate is released and startup does not stall.

See Game Framework for the GameMode/GameInstance lifecycle this plugs into.


Identity & Multiplayer

Per-Player Identity

When no login system has set an identity, the component auto-assigns one in InitializeLocalIdentity() (authority only). It first tries to load a previously saved AccountId / CharacterId from Saved/Config/PersistenceLocal.ini; failing that it generates deterministic GUIDs seeded by the player's LocalPlayer controller id (ULocalPlayer::GetControllerId()), so split-screen / multiple local players get distinct, stable identities, and persists them for future sessions. It then ensures the backend account exists (a 409 Conflict is treated as success).

The controller id is the per-player seed deliberately — it is stable across map transitions, unlike a PlayerState player id, which can change.

Server Authority

Persistence is server-authoritative. Identity assignment, auto-save scheduling, and the deferred auto-load all gate on GetOwner()->HasAuthority(); clients never save or load directly. In multiplayer the authority uses the remote provider to round-trip the backend; in solo / standalone it uses the local file. See Server Authority and Backend Server.


Public Contracts

Component Methods (UPersistenceComponent)

Method Parameters Purpose
RequestSave () Trigger a manual save; no-op if a save is in progress
RequestLoad () Trigger a load; no-op if a load is in progress
SetCharacterId / GetCharacterId (FGuid) Session character identity (must be set before save/load)
SetAccountId / GetAccountId (FGuid) Session account identity
SetDisabled (bool) Disable all persistence (call before BeginPlay to prevent auto-load)
IsOperationInProgress () True while a save or load is running
IsUsingRemoteProvider () True when the remote provider is active
IsUsingPreset () True when the active provider is a read-only preset
WillAutoLoad () True if BeginPlay scheduled an initial auto-load
HasCompletedInitialLoad () True once the initial auto-load has finished
GetProviderLocationString () Human-readable backing-store location (file path or URL)

Provider Interface (IPersistenceProvider)

Method Parameters Purpose
SaveCharacter (CharacterId, SaveData, Callback) Persist a snapshot (async remote / immediate local)
LoadCharacter (CharacterId, Callback) Load a snapshot, returned via the load delegate
HasSaveData (CharacterId) Whether a save exists
DeleteSaveData (CharacterId, Callback) Remove a save
IsReadOnly () Skip auto-save / save-on-exit when true (default false)

Events

Delegate Payload When Fired
OnSaveCompleted bool bSuccess After a save attempt resolves
OnLoadCompleted bool bSuccess After a load + restore resolves (also released when auto-load can't proceed)

Authoring Command

Command Form Purpose
Eternal.ExportPreset Eternal.ExportPreset <AssetName> (editor only) Capture the live character into /Game/DataAssets/Presets/Preset_<AssetName>

Source References

Component / Concept Path
UPersistenceComponent Source/ProjectEternal/Public/Persistence/Components/PersistenceComponent.h
Component implementation (provider selection, auto-load) Source/ProjectEternal/Private/Persistence/Components/PersistenceComponent.cpp
IPersistenceProvider (interface, IsReadOnly) Source/ProjectEternal/Public/Persistence/Providers/PersistenceProvider.h
ULocalPersistenceProvider Source/ProjectEternal/Public/Persistence/Providers/LocalPersistenceProvider.h
URemotePersistenceProvider Source/ProjectEternal/Public/Persistence/Providers/RemotePersistenceProvider.h
UPresetPersistenceProvider (read-only) Source/ProjectEternal/Public/Persistence/Providers/PresetPersistenceProvider.h
UPresetCharacterAsset Source/ProjectEternal/Public/Persistence/Presets/PresetCharacterAsset.h
Eternal.ExportPreset command Source/ProjectEternal/Private/Persistence/Presets/PresetExportCommand.cpp
FCharacterSaveData + save structs Source/ProjectEternal/Public/Persistence/Types/PersistenceTypes.h
UPersistenceHelpers (build/restore) Source/ProjectEternal/Public/Persistence/PersistenceHelpers.h
UPersistenceDevSettings / EPersistenceProviderMode Source/ProjectEternal/Public/Persistence/PersistenceDevSettings.h
PendingPreset carrier Source/ProjectEternal/Public/GameMode/EternalGameInstance.h
Travel-gate consumer Source/ProjectEternal/Private/GameMode/EternalGameMode.cpp


Recent Changes

Date Change Impact
2026-08-06 EPresetAvailability on UPresetCharacterAsset Presets carry their own menu-listing gate (Available / DevOnly / Hidden) instead of a rename-fragile config deny-list; menu paths filter on it, Eternal.PlayPreset ignores it so hidden presets stay usable as regression fixtures
2026-06-17 Initial documentation Persistence system documented end-to-end; read-only Preset provider (UPresetPersistenceProvider / UPresetCharacterAsset / Eternal.ExportPreset) covered for demo "play-as" characters