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

Server / Standalone Only
Whole-slot save operations (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 and Replicated are independent flags
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.

The three scopes, where each one lives on disk, and who is allowed to write it.
ScopeCopiesStored underOwned byExample use
World (default)One per slotSlots/Slot0/Shared/ServerWorld progress, difficulty, shared containers
PlayerOne per player per slotSlots/Slot0/Players/<ProfileId>/ServerCharacter inventory, skill trees, GAS state
ProfileOne per profile, outside any slotProfiles/<ProfileId>/The local machineCharacter identity that travels between servers
Why scope is declared rather than inferred
An earlier design signalled "this is per-player" by returning a non-empty player key, and "this is shared" by returning an empty one. That overload could not tell "shared by everyone" apart from "this player's identity has not arrived yet" - so a system registering before its owner was known silently read and wrote the shared copy, and one player received another player's save. Scope is now declared up front, and an unresolved identity is simply not an address.

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:

PrioritySourceNotes
1Real platform accountA 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.
2This machine's local profile storeUCrimsonSaveLocalProfiles, keyed by local user index. Generated once and kept forever. Covers offline players, splitscreen guests, and every editor session.
The Null online subsystem is deliberately excluded
It mints a fresh throwaway net id on every launch. An id derived from that names a new save file each run, so nothing ever loads back - which is exactly how a packaged build can appear to save correctly and still start empty every time. When no real platform service is configured, resolution falls through to the local profile store instead.
PIE and packaged builds resolve the same way
The only difference is which index the local profile store is read at: the PIE instance in the editor, the local player's controller id in a build. Both produce a GUID from the same store, so a save made in PIE is addressed exactly as it would be in a shipping build.

Reporting identity to the server

The client resolves its own profile id and reports it; until it lands, the server holds that player's fragments rather than guessing.
Give your PlayerController an identity component
Only the owning client can answer "which human is this" - a remote player's controller lives in the SERVER's world, so resolving it there describes the server, not them. The component has the client report its own profile id once; the server stores it and addresses that player's fragments by it. Add it with the Add Crimson Save Profile Identity game feature action rather than as a native subobject, so which component answers stays a data decision - and so no experience ends up with two of them, each reporting and each triggering a load. The action works with any 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.
An unresolved profile id means "ask later", never "use the shared copy"
Between a remote player's controller spawning and their report arriving, the server genuinely does not know who they are, and 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.
The id is remembered, not re-derived at teardown
A system asked for its profile id while shutting down can no longer answer: the identity component is destroyed before the components that depend on it. The manager therefore reuses the last valid id each system reported, so a disconnecting client's data lands in its own file.
  1. On the player-owned system, override the Get Save Scope interface event and return Player.
  2. Override Get Profile Id, get the owning Player Controller, and feed it into Resolve Profile Id - a pure node on Crimson Save Profile Identity.
  3. 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.
  4. 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.

Reading a fragment and applying it are separate events, and the gap between them is where every ordering bug lives.
PrioritySystemWhy
100Skill treesRe-applies node effects; must precede the attribute snapshot
200Ability-system stateWrites attribute base values absolutely
300Inventory and containersEquipped items re-grant ability sets
1000 (default)Everything elseOrder does not matter

Dedicated server workflow

EventWhat happens
Server startupManager loads World fragments from DedicatedServerDefaultSlot (or the slot passed via -CrimsonSaveSlot=N). Player fragments are skipped - nobody has connected yet.
Client reports identityUCrimsonSaveProfileIdentityComponent reports the profile id; the manager calls LoadFragmentsForProfile and delivers that player's fragments in restore-priority order.
Player PostLoginCall NotifyPlayerLoggedIn(PC). A no-op until the client has reported, which is why the component drives the real load.
Player LogoutCall NotifyPlayerLoggedOut(PC) before Super::Logout. If bAutoSaveOnLogout is true, synchronously flushes that profile's fragments before their objects are destroyed.
Server shutdownSave 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.

Or use CrimsonCore
(CrimsonCore - requires all Crimson plugins.) 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.