Concept: Multiplayer & Save Scopes
The save system is built for a dedicated server plus multiple clients. Saving world and player data is a server responsibility, every fragment declares who owns it, and restored state reaches clients only through normal replication. This page is the canonical reference for all three rules.
Authority: server / standalone only
RequestSaveProgress, RequestAutoSave, RequestQuickSave, RequestNewGameSave, SaveGameToActiveSlotSynchronous) are guarded by authority checks. Calls received on NM_Client are rejected, because they write World and Player data the server owns. Profile-scoped writes are the exception: a profile belongs to the machine it is on, so a client saving its own character writes nothing the host owns.SaveGame is not Replicated
SaveGame controls persistence; Replicated controls networking. Restored values live on the server only. A client sees a restored value only if the property is also marked `Replicated` (Details -> Replication -> Replicated, or RepNotify if you apply it in an OnRep). Restore runs server-side and forces a net update, but a SaveGame-only property never reaches clients. Symptom: correct on the server / listen-host, stale on a client. Mark any restored gameplay state Replicated.The three save scopes
Every saveable system returns an ECrimsonSaveScope from GetSaveScope(). The scope decides how many copies of its fragment exist, where they live, and who is allowed to write them.
| Scope | Copies | Stored under | Owned by | Example use |
|---|---|---|---|---|
World (default) | One per slot | Slots/Slot0/Shared/ | Server | World progress, difficulty, shared containers |
Player | One per player per slot | Slots/Slot0/Players/<ProfileId>/ | Server | Character inventory, skill trees, GAS state |
Profile | One per profile, outside any slot | Profiles/<ProfileId>/ | The local machine | Character identity that travels between servers |
Profile identity
A profile id is a stable FGuid that belongs to the player. It is never derived from connection order, PIE window number, or slot index - address a player's data by where they happen to be in a list and it moves the moment the list does. Shipping games key per-player data the same way: Minecraft writes playerdata/<uuid>.dat, Palworld writes Players/<guid>.sav.
UCrimsonSaveProfileIdentity::ResolveProfileId(PC) resolves it in two steps and nowhere else:
| Priority | Source | Notes |
|---|---|---|
| 1 | Real platform account | A non-Null online subsystem with a valid net id. The profile id is derived deterministically from it, so the same account is the same profile on any machine. |
| 2 | This machine's local profile store | UCrimsonSaveLocalProfiles, keyed by local user index. Generated once and kept forever. Covers offline players, splitscreen guests, and every editor session. |
Reporting identity to the server
APlayerController subclass: a controller that is a ModularGameplay component receiver gets the component through the component manager on every machine; any other controller gets it from the server at PostLogin (and when the feature activates mid-session), and the component replicates to its owning client. Without one there is no authoritative answer for a remote player, so resolution returns an invalid GUID rather than guessing.GetProfileId() correctly returns an invalid GUID. The manager holds every read and write for that player until LoadFragmentsForProfile runs. Registering a system before its owner is known is expected and safe. Never substitute a placeholder id to make the gap go away - that is what puts one player's progression in another player's file.- On the player-owned system, override the Get Save Scope interface event and return Player.
- Override Get Profile Id, get the owning Player Controller, and feed it into Resolve Profile Id - a pure node on
Crimson Save Profile Identity. - Give your Player Controller an identity component with the Add Crimson Save Profile Identity game feature action (any Player Controller class works), or the server has nothing to address remote players by.
- Leaving Get Save Scope at its default (World) makes the fragment shared by the whole session.
Restore order between systems
Fragments are delivered in GetRestorePriority() order, lowest first. Only override it when one system's restore genuinely depends on another having landed. The live example: restoring a skill tree re-runs each node's level-changed events, which re-applies that node's gameplay effects, while the ability-system snapshot writes attribute base values absolutely with those contributions already included. Trees first lands on the saved value; trees second lands on it twice.
| Priority | System | Why |
|---|---|---|
| 100 | Skill trees | Re-applies node effects; must precede the attribute snapshot |
| 200 | Ability-system state | Writes attribute base values absolutely |
| 300 | Inventory and containers | Equipped items re-grant ability sets |
| 1000 (default) | Everything else | Order does not matter |
Dedicated server workflow
| Event | What happens |
|---|---|
| Server startup | Manager loads World fragments from DedicatedServerDefaultSlot (or the slot passed via -CrimsonSaveSlot=N). Player fragments are skipped - nobody has connected yet. |
| Client reports identity | UCrimsonSaveProfileIdentityComponent reports the profile id; the manager calls LoadFragmentsForProfile and delivers that player's fragments in restore-priority order. |
| Player PostLogin | Call NotifyPlayerLoggedIn(PC). A no-op until the client has reported, which is why the component drives the real load. |
| Player Logout | Call NotifyPlayerLoggedOut(PC) before Super::Logout. If bAutoSaveOnLogout is true, synchronously flushes that profile's fragments before their objects are destroyed. |
| Server shutdown | Save the active slot from AGameMode::EndPlay (before Super::EndPlay) to flush World fragments. |
GameMode wiring (canonical example)
Forward login, logout, and shutdown to the manager. Ordering rule: notify / save before the matching Super:: call, so the controller and player objects are still valid.
Three chains in the Game Mode Blueprint. Event On Post Login -> Get Crimson Save Game Manager -> Notify Player Logged In (Player Controller = the event's New Player pin). Event On Logout -> Cast To PlayerController (Object = the event's Exiting Controller) -> Get Crimson Save Game Manager -> Notify Player Logged Out (Player Controller = the cast's As Player Controller pin). And Event End Play -> Get Crimson Save Game Manager -> Save Game To Active Slot Synchronous for shutdown - How-To: Configure Auto-Saving shows how to gate that one on the end-play reason.
The logout half of the ordering rule is handled for you in Blueprint: Event On Logout fires while the exiting controller is still valid, so there is nothing to sequence. Wire each node's Player Controller pin explicitly, though - it defaults to Self, and a Game Mode is not a controller.
ACrimsonGameMode already implements all three hooks (login, logout, save-on-exit) in the correct order, and CrimsonCore's experience setup already adds the identity component to the player controller with Add Crimson Save Profile Identity. Use them and you write none of this - see How-To: Turnkey Save Setup with CrimsonCore.