Reference · Updated 2552.09.30.10.47
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.
| Field | Default | Clamp (enforced by the setter) |
|---|---|---|
LookSensitivityX | 1.0 | 0.05 – 10 |
LookSensitivityY | 1.0 | 0.05 – 10 |
ADSSensitivityMul | 0.6 | 0.1 – 2.0 |
bInvertLookY | false | — |
bInvertLookX | false | — |
FieldOfView | 95 | 70 – 120 |
DisplayName | empty | — |
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 —
USLScreenWidgetand 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:
| From | Call |
|---|---|
| A widget (UI) | USLPlayerProfileSubsystem::GetForWidget(this) — BP: Get Player Profile Subsystem (Widget), Context Widget defaults to self |
| A player controller | USLPlayerProfileSubsystem::GetForPlayerController(PC) |
| A pawn | GetForPlayerController(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 "Donut" -> slot "SLPlayerProfile_Donut"
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.
#4. Lifecycle
| When | What happens |
|---|---|
| Local player created | Initialize resolves a profile: resume, else the first unclaimed one, else generates one (§8.14) |
LoadProfile(Name) | flushes the outgoing profile if dirty, loads/creates the named one, broadcasts OnPlayerProfileChanged |
A Set* mutator | clamps, stores, marks dirty, broadcasts — does not touch disk |
SaveProfile() | writes the active profile to its slot, clears dirty |
| Local player removed | Deinitialize 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:
- Clamp (see the table in §1) — nothing else pins FOV to 70–120.
- Broadcast
OnPlayerProfileChanged— how the look handler and camera learn to re-read. Without it a
change appears to do nothing until a restart.
- Mark dirty — what drives the flush-on-
Deinitializebackstop.
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
| Consumer | Reads | Notes |
|---|---|---|
ASLPlayerController::Look | sensitivity, invert, ADS multiplier | reads fields per-input-event; no subscription needed |
ASLPlayerCharacter (spawn) | FieldOfView | applies the saved FOV over the authored camera default, local only |
ASLPlayerController::HandlePlayerProfileChanged | FOV | subscribed in BeginPlay, unsubscribed in EndPlay; pushes live FOV to the current pawn (the controller outlives the pawn across respawns) |
#7. Adding a new setting
- Add the field to
USLPlayerProfileSaveGamewith its default,BlueprintReadOnly.
- Add a
BlueprintPuregetter and a clampingBlueprintCallablesetter to the subsystem. The setter must
early-out when unchanged, mark dirty, and broadcast.
- Add a row to
DT_SL_SettingCopy(/Game/SystemLink/Data/) — row name is the setting id, withDisplayName Description. SeeSettingsMenuBuildout.md.
- Add a row widget to
WBP_SL_Settings, point itsSettingCopyhandle at that row, seed it from the getter
on Construct (with bBroadcast = false) and bind its change event to the setter.
- Have the consumer read it — via
OnPlayerProfileChangedif it needs to react live.
#8. Identity and the registry — DESIGNED, NOT BUILT
Design settled 2026-08-29. Everything in §1–§7 above is built and working; everything here is the layer that turns "a profile the code can load by name" into "a person who sits down and plays". Nothing below exists yet.
#8.1 Two scopes — the load-bearing distinction
§2 established that the active profile is per local player. The registry of which profiles exist is per machine — the list of people who play on this box is not player 1's property.
| Scope | Owner | |
|---|---|---|
| Which profile am I playing under, and its values | per local player | USLPlayerProfileSubsystem (ULocalPlayerSubsystem) — built |
| Which profiles exist on this machine, and who has claimed them | per machine | USLProfileRegistrySubsystem (UGameInstanceSubsystem) — to build |
Do not put the registry on the per-player subsystem. Two local players would hold two divergent copies of one machine-wide list, which is the same class of bug as the original game-instance-scoped profile (§2) with the scopes inverted. One list, N readers.
#8.2 The index, and why it must self-heal
UE has no "enumerate save slots" API. The portable answer is an index save game — USLProfileIndexSaveGame in a fixed slot (SLProfileIndex, user index 0) holding the profile names plus the last-used name.
Directory-scanning Saved/SaveGames for SLPlayerProfile_*.sav is the obvious alternative and is wrong: it assumes a real filesystem, which breaks on console and cloud saves.
⚠ An index is a cache, and caches lie — it can list a profile whose slot was deleted out from under it. On load, drop every entry that fails UGameplayStatics::DoesSaveGameExist. That bounds the lie to one frame rather than letting a phantom profile sit in the picker until someone selects it and gets a fresh empty save.
#8.3 Claiming — the split-screen requirement
Two local players must never hold the same profile at once. Each has its own subsystem and its own in-memory copy, so they will not corrupt each other live — they will both write the same slot on save, and last-writer-wins silently discards one player's evening.
The registry tracks which names are claimed by a live local player. The picker shows claimed profiles as disabled, with the claiming player indicated. Claims are runtime-only (never serialised), released when the local player is removed, and cleared wholesale on registry init so a crash cannot leave a profile permanently locked.
#8.4 Naming rules
MakeSlotName is currently Printf("SLPlayerProfile_%s") with no sanitization. That is safe only while names are code-supplied; it stops being safe the moment a player types one, because the name lands directly in a filename.
Before the picker ships, the registry must own validation:
- Trim surrounding whitespace; reject empty or whitespace-only.
- Reject path separators,
.., and any character outside a conservative allowlist (letters, digits, space,
hyphen, underscore, apostrophe).
- Cap the length (32 is generous for a nameplate).
- Reject collisions case-insensitively — "Caboose" and "caboose" are the same person to everyone except
the filesystem, and on a case-insensitive volume they are the same slot.
- Nothing is reserved.
Defaultwas removed in §8.14, so every profile is renamable and deletable.
Validation belongs on the registry, not the text box, so C++ callers cannot bypass it.
#8.5 Create / rename / delete
- Create — validate, write an empty profile to the new slot, add to the index.
LoadProfilealready
creates implicitly on first use; the registry path exists so a name can be rejected before a slot appears.
- Rename — the slot name derives from the profile name, so this is copy-to-new-slot, update index, delete
old slot. Validate the target first and abort the whole operation if it is taken; a half-applied rename leaves two slots and an index pointing at neither.
- Delete — remove the slot and the index entry. Refuse while the profile is claimed (§8.3). Deleting
someone's profile is the one destructive action in this system, so it confirms through USLModalWidget.
#8.6 Selection flow
Entry points only — the step-by-step calls for each are in §9.
| When | What happens |
|---|---|
| Boot, primary player | Auto-load the index's last-used profile; else the first unclaimed one; else generate. No prompt — the common case is the same person as last night. |
| Main menu → Profiles | Pick, create, rename, or delete. Selecting a profile calls LoadProfile and records it as last-used. |
| Split-screen player joins | They are given a generated profile immediately, so they can play at once. The picker is available to change it. |
#8.7 Guest profiles — SUPERSEDED by §8.14
Dropped 2026-09-02. A guest was to be a local player on a transient profile — class defaults, never written — so somebody's little brother could play without negotiating a name.
Generated names remove the reason for it. A joining player gets a real profile instantly, which costs them exactly as little and gives strictly more: the identity persists, so their settings survive and their history has somewhere to accrue. A transient profile deliberately throws both away.
#8.8 Menu shape
Decision 2026-08-29: PROFILES and OPTIONS are siblings on the main menu.
MAIN MENU
HOST GAME
JOIN GAME
PROFILES -> who is playing; their look feel, FOV, invert, rumble (per player)
OPTIONS -> video, scalability, audio (per MACHINE)
QUIT
The split follows §3.3's persistence split exactly: anything in USLPlayerProfileSaveGame is per player and lives under Profiles; anything in UGameUserSettings is machine-wide and lives under Options. A setting's menu location is therefore derivable from where it persists — if a new setting is hard to place, that means its persistence choice is wrong, not its menu placement.
⚠ The existing WBP_SL_Settings is the per-profile screen and belongs under Profiles. Audio is still undecided (§3.3 says "optionally audio"); it is machine-wide until someone argues otherwise, because one person's mute should not silence the other half of the couch.
#8.9 Build order
- ~~
USLProfileIndexSaveGame+USLProfileRegistrySubsystem: enumerate, validate, create, rename, delete,
last-used, self-heal on load.~~ Done 2026-08-29, covered by seven automation tests under SystemLink.Settings.ProfileRegistry. Nothing calls it yet — USLPlayerProfileSubsystem::Initialize still loads a profile unconditionally, which is step 2's job. ⚠ The registry reads and writes a fixed slot, so tests go through InitializeForTesting(Slot) to redirect the index. Without it an automation run would load and overwrite the player's real profile list.
Claim / release, wired to local-player add/remove.Done 2026-08-29.Initializenow resolves a
starting profile through the registry (last played, or the first unclaimed one if another local player holds it) and claims it; Deinitialize releases; LoadProfile returns bool and refuses a profile another local player is on. Only the primary local player updates last-used, so a guest cannot change what player one resumes into. ⚠ The wiring itself is not unit-tested — it needs a real UGameInstance for the registry to resolve through, and the headless harness builds a ULocalPlayer without one. Every registry call is therefore best-effort: with no registry the subsystem behaves exactly as it did before. Verify the resume and the two-player refusal in PIE/standalone — checklist: Docs/ProfilesPIEChecklist.md. Everything still open is on the kanban board (Docs/KanbanBoard/cards.json) — the picker's remaining actions, USLTextRow, the edit screen, GetEffectiveDisplayName, the menu-button repoint, and split-screen itself. Listing them here as well is how six lists drifted apart in the first place; the reasoning for each lives in §8.10, §8.11 and §8.14.
The ordering principle, which the board inherits: do the profile work before split-screen. It is testable single-player — create, rename, switch, delete, reboot and confirm last-used — and it makes the split-screen item smaller. USLTextRow pays for itself twice over, since Host and Join need the same widget.
#8.10 Picker UX — select is fast, one screen defines a profile
Decided 2026-09-01, after Halo CE's model: you pick a profile before a match, and you edit profiles from the front end.
PROFILES NEW PROFILE EDIT PROFILE — MIKE
Caboose Player 2 Profile Name ____ Profile Name Mike
> Mike Ⓐ Display Name ____ Display Name (same as profile name)
───────────────────── Settings — Settings >
[ New Profile ] ────────────────── Delete Profile >
Ⓐ Select Ⓧ Edit Ⓑ Back Ⓐ Create Ⓑ Cancel ─────────────────────────
Ⓑ Back
- Ⓐ Select — claim that profile and back out. The seat-swap path, one press. Reversible by picking
another row, so it needs no confirmation.
- Ⓧ Edit — claim it and open its screen.
Edit switches you to the profile, deliberately. USLPlayerProfileSubsystem holds one loaded profile per local player (§2), so editing Mike requires being Mike — there is no second load slot, and adding one would give a profile two in-memory sources of truth. It also reads correctly: you do not tune someone else's aim.
⚠ Create is a BUTTON beside the list, reversing the 2026-09-02 decision above. It was a row for two sessions, on the reasoning that a row needs no second focus region. What that actually bought was a sentinel threaded through every layer that touches a profile: bIsCreateNewEntry on the item, a bIncludeCreateEntry parameter on the builder, a branch in RequestSelectItem, a guard in the edit action, and a row Blueprint that had to hide its status and its per-profile affordances whenever the flag was set. Every one of those is a place to forget the sentinel case. Pinned first, it also put Ⓐ on Create for any screen that focused row 0 — a create-by-accident that had to be designed around rather than avoided.
A button costs one extra focus region and deletes all of that. The list is now uniformly real profiles, and the button pushes the edit screen in create mode itself — there is no C++ seam, because creating needs none: unlike Select and Edit it acts on no row, so there is no item to resolve and no rule to get wrong.
Order is fixed: the bootstrap profile, then the rest alphabetically. Positions matter more than insertion order — a row that moves when an unrelated profile is added is a row you have to re-find every time. Initial focus still lands on the active profile via NavigateToIndex, not on row 0: correct for its own sake now that row 0 is somebody's profile rather than a destructive action.
#One screen, two modes — and NO name-entry modal
Create opens the same screen in create mode: blank names, nothing written until Ⓐ Create. Create and Delete are separate buttons, shown by mode in OnEditedProfileChanged — two buttons rather than one relabelled, so each can be styled on its own. Everything else is identical, and the claim/switch happens on confirm rather than on entry.
Settings in create mode edit a draft (2026-09-23 — this replaced "Settings disabled in create mode"). BeginCreate has USLPlayerProfileSubsystem::BeginDraftProfile swap in a fresh in-memory profile, so the rows (which write through the subsystem) configure the profile being made. CommitDraftProfile writes the draft as the new slot via Registry->CreateProfileFrom and switches onto it; deactivating the screen still in create mode calls DiscardDraftProfile, which reloads the old profile from disk. ⚠ While drafting, ActiveProfileName still names the old profile and it stays claimed, so SaveProfile is a no-op — otherwise the draft would be written over it. ⚠ Anything pushed over create mode on the same stack deactivates the screen and loses the draft; use another layer.
A modal was the first design, and it was wrong for three reasons:
- Create needs the name before the profile exists. The registry writes the slot inside
CreateProfile
(§8.5), so a "create then rename" flow leaves an orphan New Profile in the list whenever someone backs out. Deferring the write until confirm removes the problem instead of cleaning up after it.
- On a pad the platform virtual keyboard is already a full-screen overlay, so "modal vs inline" only
changes anything for mouse and keyboard. A bespoke modal buys nothing on the device this menu is designed for, and costs KBM users an extra screen.
- Both names must be visible together (§8.11). A modal that asks for one name at a time hides exactly the
relationship that stops the two drifting apart.
Validation renders on the row that was typed into, via GetProfileNameResultText — a rejected name explains itself where you typed it rather than in a dialog to dismiss.
Delete lives on the edit screen, not the list, so the one destructive operation sits behind a deliberate step and its USLModalWidget confirm.
#8.11 Two names, and the rule that keeps them honest
Decision 2026-09-01: a profile carries both a profile name and a display name.
| What it is | Rules | |
|---|---|---|
| Profile name | Registry key and save slot; what the picker lists | Filename-safe: trimmed, allowlisted characters, length-capped, case-insensitively unique (§8.4) |
DisplayName | What other players see — scoreboard, server list | Free-form. Never becomes a filename, so it can be looser |
⚠ DisplayName is an OPTIONAL OVERRIDE, not a second required name. Leave it empty on create, and read it through an effective-name accessor that falls back to the profile name. Two names that must both be filled in is a drift factory: the picker and the scoreboard end up disagreeing about who someone is, and an empty required field renders a blank scoreboard entry. As an override, most players type one name and the two cannot disagree, while anyone who wants a different tag on the scoreboard can have one.
Show it in the edit screen as Display Name — (same as profile name) until it is deliberately set, so the relationship is visible rather than implied.
As of 2026-09-01 nothing reads DisplayName — it has a getter and setter and no consumer. It becomes live when the scoreboard or server list is built; until then the override rule is the contract to build against.
The edit screen's Display Name row is deferred (decided 2026-09-30). A row that saves a value nothing shows cannot be tested against anything, and profile names are already real, renamable names. Build the row alongside its first reader — SL-26 carries both the effective-name accessor and the row.
#8.12 What a profile will grow
Not built, and not to be built now — recorded so the container is shaped for it.
- Appearance. Armour colour and emblem. The obvious next edit-screen row, and the thing a LAN party
actually argues about; SystemLinkVision.md and the menu art both point straight at Red vs Blue.
- History. Per-person stats, rivalries, awards — the LAN Book. A profile is the natural anchor because it
is already the identity people pick.
Neither changes the design above; both are additional rows on the edit screen and additional fields on the save game. Adding a field follows §7 unchanged.
#8.15 The identity zone on the main menu — who is playing, and who wants to
Decided 2026-09-03, extended 2026-09-04. The main menu carries one region that answers "who is playing": the active profile, every other joined local player, and an invitation to anyone holding an unassigned pad.
Player 1 COFFEE CAKE ▸
Player 2 CABOOSE ▸
Controller 3 · press Ⓐ to join
HOST GAME
JOIN GAME
PROFILES
OPTIONS
QUIT
Modelled on Halo CE Anniversary, which puts the join prompt under the player name on the main menu; pressing Ⓐ opens a profile picker for that controller. One region, one job — which is a better argument for the indicator than the one that first justified it.
Every row reads "Player N", the viewer's included (revised 2026-09-04; earlier drafts gave the viewer "Playing as"). The same numbering is already the picker's status text, and a special case would make one row read differently from the other three. bIsClaimedByViewer / bIsViewer still mark your own row visually.
No confirm step before Host or Join. Halo CE asked at boot; resume exists here precisely so the usual case — the same person as last night — costs nothing, and a prompt would tax every session to catch a case that happens occasionally. Making the state visible answers "who am I?" before it is asked; if it is wrong, Profiles is one press away.
This matters more since §8.14. A name is now auto-assigned — a player boots as Walla Walla without ever choosing it — so without an indicator their identity is a thing they have to go looking for. It is also the natural home for the display name (§8.11) once the scoreboard exists and identity becomes public.
The same line belongs in the pause menu: not a step, just who the game thinks you are, so a player who suspects their sensitivity is wrong can check without leaving the match.
#Joining from here, and when the screen actually splits
A second player joins at the menu, not at a lobby and not after the match loads:
| Step | What happens |
|---|---|
| A pad with no local player is plugged in, or sends input | The zone grows a line: Controller 2 · press Ⓐ to join |
| Ⓐ | CreatePlayer → that player's subsystem generates a profile (§8.14) → their picker opens as an overlay |
| They pick, or back out | Either way they are in, with an identity, listed in the zone |
| Host Game | The match loads and the viewport splits there |
The front end never splits. UGameViewportClient::UpdateActiveSplitscreenType divides the viewport as soon as a second local player exists, so the front end holds SetForceDisableSplitscreen(true) and releases it as the match loads. Two people do not navigate menus at once; player two picks a profile in an overlay over an undivided screen.
That matters beyond taste: a split front end means two USLPrimaryGameLayouts and two menu stacks, which MenusAndOnline.md §9 item 3 already flags as the most likely source of surprises. Keeping the split to gameplay leaves that problem where it has to be solved anyway.
⚠ A quadrant player-select screen was considered and rejected (Halo CE's original model: after choosing a game the screen divides into four and each player picks a profile in their own region). It is a good design — symmetric, and the split becomes the interface rather than a thing to suppress — but it needs a screen between Host and the match, which MainMenuScreen.md §7 explicitly rules out, and it makes joining a ceremony before every match. Ambient joining suits a LAN party better: people drift in as they pick up pads.
⚠ Detecting "a controller wants in" is the hard half. Two signals, either of which raises the invitation (revised 2026-09-09):
- Connection.
IPlatformInputDeviceMapper::GetOnInputDeviceConnectionChange— plugging a pad in raises
its line straight away. This was rejected in the 2026-09-04 draft on the grounds that an idle plugged-in pad would prompt at nobody; that was the wrong call. Somebody who has just connected a controller and sees nothing has no way to know the menu is waiting for a button, and a line offering to add them is not a cost worth avoiding. The prompt is an invitation, not a demand.
- Input. A press from a pad with no local player, caught below the normal routing, which covers a pad the
platform mapper never announced. That is a Slate input preprocessor, and it must be registered as EInputPreProcessorType::PreGame: the default Game bucket loses to CommonUI's action router and would see nothing once any menu button carries a triggering action (USLIntroSkipWidget learned this the hard way — Footguns.md, UI).
⚠ The Ⓐ that accepts is caught in the same preprocessor, not by a focusable row. The zone is drawn in player one's viewport, so a button there would join player two on player one's press — the only thing that knows which pad accepted is the input event's user index. An accept from a pad that has no row yet raises its invitation instead of joining, and an accept outside the front-end map is ignored, because a pad accepting mid-match would add a player and divide the screen in the middle of a game.
#Reaching your profile (decided 2026-09-10, revised 2026-09-11)
A player reaches their profile from their own card, by pressing a button. The card is a display with a prompt on it, never something to navigate to.
PROFILES stays on the main menu (revised 2026-09-11). The first draft removed it, on the grounds that a global button cannot say whose profile it edits. That objection turns out not to bite: only player one has a mouse, and another player's pad cannot reach player one's menu at all — the routers are per local player. So the button is unambiguously player one's route, and every other player uses the prompt on their own card.
Keeping it also meant the card never had to become clickable, which would have put a focusable control in player one's viewport acting on a row that may not be theirs.
Nobody navigates to a card. The cards are display, not widgets to focus:
- A joined card shows a prompt and the pad answers it — X, caught in the same input preprocessor as the
join, keyed by the event's user index. Exactly the reasoning that put the Ⓐ there: the zone is drawn in player one's viewport, so a focusable row would act on player one's press no matter whose row it was.
- E does the same from a keyboard. ⚠ Joining stays gamepad-only: a keyboard belongs to the player already
sitting there, so offering them a second seat they cannot drive is meaningless. That is why an invitation card pins its prompt to the Ⓐ glyph instead of following the current input type — a keyboard player seeing "press Enter to join" is being promised something that cannot happen.
- This retires the open question of whether player one's cursor can reach player two's
▸. It cannot, and
neither can anyone else's — there is nothing to reach.
- First press wins. A second player pressing X while a screen is open gets nothing, silently. At a LAN
party the other person is sitting there and can see the screen is in use; a "busy" message would explain something already obvious.
⚠ The rule lives on the handler, which asks the screen it last pushed whether it is still up (IsValid and IsActivated). A flag mirrored in the subsystem would survive the screen closing by any route the flag did not hear about, and lock everyone out until the map changed. CommonUI pools widgets, so a popped screen is still a valid object — both checks are needed, not just the first.
Each local player has their own ASLPlayerHUD, USLPrimaryGameLayout and CommonUI action router (confirmed 2026-09-10), so the screen is pushed to that player's stack and driven by their pad, over player one's menu rather than through it.
Disconnection withdraws an invitation and nothing more. A pad that sleeps or dies never removes a player who has already joined — the original objection was right about that half, and it is answered by scoping the removal to candidates rather than by ignoring connection.
The candidate list is rescanned on any device change rather than patched one pad at a time: a disconnect broadcast carries the unpaired platform user, not the index the pad had, so there is nothing in the event to remove by. A rescan is self-correcting anyway.
#8.13 Deliberately not designed yet
A profile is the natural anchor for the shared history in SystemLinkVision.md — per-person stats, rivalries, awards, the LAN Book. Design the container now, build none of it. The only concession v1 makes to that future is treating a profile as an identity rather than a settings bag, which costs nothing today.
Identity mapping (EOS auto-selecting the primary player's profile, leaving the picker for guests) also stays out until Phase 6. → MenusAndOnline.md.
#8.14 No Default profile — generated names instead
Decided 2026-09-02. There is no bootstrap profile. The first thing that needs one creates a profile with a name from a pool — Donut, Caboose, Coffee Cake — so every profile is a person from the moment it exists.
Nobody at a LAN party is called "Default". It was a system artifact occupying a person's slot, and it earned its keep in special cases rather than in use: a reserved name, an exemption in the reconcile for a profile that legitimately had no file, a resume fallback, and a picker row that alone could not be renamed or deleted. All of that is gone rather than relocated — nothing is reserved now, so every profile is renamable and deletable.
#Where the names come from
| Row type | FSLProfileNameRow — ProfileName, or the row name when that is empty |
| Table | Project Settings ▸ System Link ▸ System Link Profiles ▸ ProfileNameTable |
| Fallback | FallbackProfileName ("Player"), numbered, when the table is unset or exhausted |
⚠ UE's DataTable JSON importer consumes the "Name" key as the row name, so a list exported as [{ "Name": "Donut" }, … ] arrives with the names as row names and ProfileName empty. The generator reads ProfileName when set and falls back to the row name, so either shape imports without editing the JSON.
Free names are picked at random, not first-available: a machine that hands "Donut" to every new player gets old fast. When none are free the name is numbered ("Donut 2"). It always produces something — leaving a player with no identity is worse than giving them a dull name.
#What this fixed beyond the cosmetics
Where a joining player lands. A second local player used to fall through to the first unclaimed profile, which could be somebody else's — they would play with a stranger's sensitivity and FOV, and write to that stranger's slot on save. No corruption, since claiming stops two players sharing one slot, but wearing someone else's clothes. Now they get their own name immediately.
Guest profiles. §8.7 existed so a newcomer could play without naming themselves. Generation costs exactly as little and gives strictly more: the identity persists, so their settings survive and their history has somewhere to accrue. §8.7 is superseded.
"Should Default be hidden from the picker?" Stops being a question.
#Consequences to hold
GetProfileToResume()returns EMPTY when no profiles exist. That is a real state — a fresh install — not
something to paper over. A caller that gets nothing back calls CreateGeneratedProfile, never invents a name.
- Generated profiles persist. Four friends visiting once leaves four profiles. Deliberate: at a LAN party
those are your regulars, and they are deletable now that nothing is reserved.
- The legacy
SLPlayerProfileslot adoption is deleted. It keyed off first load ofDefault, and no old
saves are in the wild.
#9. Flows — every journey, and the call behind each step
§8 is the design; this is the wiring. Each row is one thing the player does and the exact call it makes, so a screen can be built without re-deriving it from four sections.
Registry = USLProfileRegistrySubsystem (machine-wide, Get(WorldContext)). Profile = USLPlayerProfileSubsystem (per local player, GetForWidget / GetForPlayerController).
#9.1 Boot
| Step | Call |
|---|---|
| Local player created | Profile::Initialize runs ResolveStartingProfileName() → Registry->GetProfileToResume() |
| That profile is claimed by another local player | falls through to the first unclaimed name, else CreateGeneratedProfile |
| Load and claim | LoadProfileInternal then Registry->ClaimProfile(Name, LocalPlayer) |
No prompt. The common case is the same person as last night. Already built — steps 1–2 of §8.9.
#9.2 Switching profile — Ⓐ Select
| Step | Call |
|---|---|
| Row focused, Ⓐ pressed | read the row's USLProfileListItem via GetEntryItem (never a cached copy) |
| Refuse if unavailable | Item->bCanSelect is false → do nothing; the row is already disabled |
| Switch | Profile->LoadProfile(Item->ProfileName) — returns bool, false means another local player took it between render and press |
| On success | the subsystem claims it, releases the previous one, and (primary player only) records last-used |
| Refresh | OnProfileListChanged does not fire for a claim change — rebuild the list explicitly after a successful switch |
| Stay | the picker stays open (decided 2026-09-29): the Player N marker moving to the new row is the confirmation, and leaving would cost a player browsing profiles a re-open per try |
⚠ LoadProfile returning false is a real case, not a defensive check. The list is a snapshot (§8.10); a second player can claim a profile between the row rendering and the press.
⚠ Claiming does not broadcast. OnProfileListChanged covers create/rename/delete/reconcile only, so a switch has to trigger its own refresh or the Playing marker will lie.
#9.3 Creating — + New Profile
| Step | Call |
|---|---|
+ New Profile row pressed | push WBP_SL_ProfileEdit in create mode — nothing is written yet |
| Typing a name | Registry->ValidateProfileName(Text, OutName) for live feedback |
| Rejection | Registry->GetProfileNameResultText(Result) on the row that was typed into |
| Ⓐ Create | Registry->CreateProfile(Name, OutName) → writes the slot, adds to the index, broadcasts |
| Then | Profile->LoadProfile(OutName) to become the new person, and set the display name if one was entered |
| Ⓑ Cancel | pop. Nothing was written, so nothing to clean up |
⚠ Validate on the trimmed OutName, and create with it — not the raw text. Registry trims, and the stored name is what the list and the slot use.
#9.4 Editing — Ⓧ Edit
| Step | Call |
|---|---|
| Ⓧ on a row | Profile->LoadProfile(Item->ProfileName) first — editing requires being that profile (§8.10) |
| Then | push WBP_SL_ProfileEdit in edit mode |
| Profile Name row | Registry->RenameProfile(Old, New, OutName) — moves the slot, carries the claim and last-used across |
| Display Name row | Deferred (2026-09-30) until the scoreboard or server list reads it — see §8.11 |
| Settings row | push WBP_SL_Settings — it edits the active profile, which is now this one |
| Leaving the screen | Profile->SaveProfile(); mutators only mark dirty (§4) |
⚠ If the Ⓧ LoadProfile returns false, do not open the screen — another player holds that profile and the edit would silently target the wrong one.
#9.5 Deleting
| Step | Call |
|---|---|
| Delete row, edit mode only | CanDeleteEditedProfile(OutReason) first |
| Refused | push a USLModalWidget showing OutReason. No confirm — there is nothing to confirm |
| Allowed | push the USLModalWidget confirm |
| Confirmed | DeleteEditedProfile() — switches this player off the profile, then deletes |
| On success | pop to the picker; OnProfileListChanged refreshes the list |
⚠ You are deleting the profile you are currently playing as, because Ⓧ switched you to it. DeleteProfile refuses while claimed (§8.3), so the screen must switch away first — which is what DeleteEditedProfile does. Calling Registry->DeleteProfile straight from a graph always fails.
Deleting your last profile is REFUSED, with a modal (decided 2026-09-16). The alternative was generating a replacement automatically, and it was rejected: pressing Delete and becoming a stranger named Stompy is a surprising outcome for an action you just confirmed, and it makes a destructive press quietly constructive. Refusing leaves the player exactly where they were and tells them what to do instead.
⚠ Ask before confirming, not after. CanDeleteEditedProfile exists so the refusal replaces the confirmation. A flow that asks "are you sure?" and then answers "no" on the player's behalf reads as a bug.
⚠ Two different dead ends, and only one is actionable. "This is your only profile" told to someone whose three other profiles are merely claimed by other players is a lie they cannot act on, so the reason distinguishes them. The check and the delete share FindSwitchTarget, so the modal cannot promise a deletion that then fails — a refusal check that can disagree with the action it guards is worse than no check.
#9.6 A second player joining (split-screen)
Not built — MenusAndOnline.md §9 item 1. Nothing calls CreatePlayer yet, so a second pad does nothing.
| Step | Call |
|---|---|
| Second pad presses A | UGameplayStatics::CreatePlayer → a new ULocalPlayer |
| Its subsystem initialises | §9.1 runs for that player: resume is taken, every existing profile is claimed → Registry->CreateGeneratedProfile |
| They are playing immediately | as "Donut", with their own settings and their own save slot |
| Changing it | the picker, or the edit screen — never required before playing |
⚠ Get Owning Player, never Get Player Controller (0) — index 0 is always player one, so player two's picker would mark player one's profile as "yours".
⚠ A generated profile persists. Four friends visiting once leaves four profiles. That is deliberate — at a LAN party those are your regulars — and they are deletable, since nothing is reserved any more.