Reference · Updated 2552.07.27.08.58

Player Profiles

How SystemLink stores per-player preferences, why the subsystem is scoped the way it is, and what to do when you add a setting. Companion docs: SettingsMenuBuildout.md (the UI), Me

How SystemLink stores per-player preferences, why the subsystem is scoped the way it is, and what to do when you add a setting. Companion docs: SettingsMenuBuildout.md (the UI), MenusAndOnline.md §3.3 (what persists where), UISystem.md (CommonUI plumbing).


#1. What a profile is

A profile is one player's saved preferences: look feel, FOV, display name. It is a USLPlayerProfileSaveGame (Public/Settings/SLPlayerProfileSaveGame.h) — a plain USaveGame holding fields and nothing else. It has no logic; the subsystem owns all behaviour.

FieldDefaultClamp (enforced by the setter)
LookSensitivityX1.00.05 – 10
LookSensitivityY1.00.05 – 10
ADSSensitivityMul0.60.1 – 2.0
bInvertLookYfalse
bInvertLookXfalse
FieldOfView9570 – 120
DisplayNameempty

What is NOT in a profile: video/scalability settings (UGameUserSettings) and key rebinds (UEnhancedInputUserSettings). Those have their own engine-side persistence — don't duplicate them here.


#2. Scope: one subsystem per LOCAL PLAYER

USLPlayerProfileSubsystem is a ULocalPlayerSubsystem. This is the load-bearing decision in the whole design.

It was originally a UGameInstanceSubsystem — one instance for the entire game. That is a split-screen bug: both players would read and write the same profile, so player 2 inverting their Y axis would flip player 1's too. Per-local-player scoping fixes it structurally rather than by convention:

  • Each local player gets their own subsystem instance, own loaded profile, own save slot.
  • A widget resolves its own player's profile automatically — USLScreenWidget and friends live under an
  • owning local player, so player 2's Settings screen edits player 2's profile with no extra plumbing.

  • Player-scoped state can never leak between players, because there is no shared object to leak through.

USLMenuFocusSubsystem is scoped the same way, for the same reason (one focused control per player).

#Getting the subsystem

Never call GetGameInstance()->GetSubsystem<>() for this — it will not compile and would be the wrong player anyway. Use the helpers:

FromCall
A widget (UI)USLPlayerProfileSubsystem::GetForWidget(this) — BP: Get Player Profile Subsystem (Widget), Context Widget defaults to self
A player controllerUSLPlayerProfileSubsystem::GetForPlayerController(PC)
A pawnGetForPlayerController(Cast<APlayerController>(GetController()))

Both return null when there is no local player yet (a remote or AI controller, or a widget before it has an owning player), so null-check rather than assume.


#3. Storage: one save slot per profile


Profile "Default"  ->  slot "SLPlayerProfile_Default"

Profile "Beepers"  ->  slot "SLPlayerProfile_Beepers"

MakeSlotName(ProfileName) builds the slot; SlotUserIndex stays 0 for every profile. Profiles are disambiguated by name, not by the engine's user index — that keeps it platform-agnostic and means a split-screen guest can pick any existing profile rather than being pinned to a controller slot.

Legacy adoption: settings saved before profiles existed live in the unsuffixed slot SLPlayerProfile. On first load of the Default profile, if its own slot is absent but the legacy slot exists, the legacy save is adopted and marked dirty so it is rewritten into the new slot. One-time and silent; delete the branch in LoadProfileInternal once no old saves are in the wild.


#4. Lifecycle

WhenWhat happens
Local player createdInitialize loads the Default profile (creating it if absent)
LoadProfile(Name)flushes the outgoing profile if dirty, loads/creates the named one, broadcasts OnPlayerProfileChanged
A Set* mutatorclamps, stores, marks dirty, broadcasts — does not touch disk
SaveProfile()writes the active profile to its slot, clears dirty
Local player removedDeinitialize flushes if dirty (backstop, not a substitute for SaveProfile)

Mutators broadcast immediately so changes preview live (the camera FOV moves as you scroll the row) while disk writes stay batched — call SaveProfile() when the settings screen closes.


#5. The read/write contract

Read through the subsystem's getters. Write through its setters. Never touch the save game's fields.

Every field on USLPlayerProfileSaveGame is BlueprintReadOnly, so Blueprints cannot write them. That is deliberate — a raw field write would skip all three things the setter does:

  1. Clamp (see the table in §1) — nothing else pins FOV to 70–120.
  1. Broadcast OnPlayerProfileChanged — how the look handler and camera learn to re-read. Without it a
  2. change appears to do nothing until a restart.

  1. Mark dirty — what drives the flush-on-Deinitialize backstop.

Reads have BlueprintPure getters (GetLookSensitivityX, GetFieldOfView, …) so graphs never mention the save game type at all. GetProfile() still exists for C++ that wants the whole object, but prefer the getters: they fall back to the class defaults when no profile has loaded, so there is exactly one place defaults are written (the save game's initialisers) and no null-check at every call site.


#6. Consumers

ConsumerReadsNotes
ASLPlayerController::Looksensitivity, invert, ADS multiplierreads fields per-input-event; no subscription needed
ASLPlayerCharacter (spawn)FieldOfViewapplies the saved FOV over the authored camera default, local only
ASLPlayerController::HandlePlayerProfileChangedFOVsubscribed in BeginPlay, unsubscribed in EndPlay; pushes live FOV to the current pawn (the controller outlives the pawn across respawns)

#7. Adding a new setting

  1. Add the field to USLPlayerProfileSaveGame with its default, BlueprintReadOnly.
  1. Add a BlueprintPure getter and a clamping BlueprintCallable setter to the subsystem. The setter must
  2. early-out when unchanged, mark dirty, and broadcast.

  1. Add a row to DT_SL_SettingCopy (/Game/SystemLink/Data/) — row name is the setting id, with DisplayName
    • Description. See SettingsMenuBuildout.md.
  1. Add a row widget to WBP_SL_Settings, point its SettingCopy handle at that row, seed it from the getter
  2. on Construct (with bBroadcast = false) and bind its change event to the setter.

  1. Have the consumer read it — via OnPlayerProfileChanged if it needs to react live.

#8. Not built yet

  • Profile index / registry. UE has no "enumerate save slots" API, so listing profiles requires a small
  • index save game in a known slot recording the profile names. Nothing creates or reads one today — LoadProfile works, but only if you already know the name.

  • Create / rename / delete profile. No API yet; LoadProfile implicitly creates on first use.
  • Profile picker UI. The intended flow is that a player picks a profile (including a split-screen guest
  • joining). Until it exists, every local player silently loads Default — which means split-screen players currently share the Default profile's contents, though not its in-memory state.

  • Identity mapping. Later, EOS identity can auto-select a profile for the primary player, leaving the
  • picker as the path for guests. See MenusAndOnline.md.

  • Split-screen itself. Profiles are per-local-player and therefore ready for split-screen, but nothing
  • creates a second local player, splits the viewport, or routes a second input device yet. Work item and checklist: MenusAndOnline.md §9.