CRADL Play Flow System
Companion to ARCHITECTURE.md, CLAUDE.md, (where the death/respawn boundary touches) COMBAT_SYSTEM.md, and (for the per-level presentation arc that runs after arrival) LEVEL_FLOW_SYSTEM.md. This document is the contract the session lifecycle must satisfy — its identity model, map selection, spawn calculation, map-to-map travel, save-write cadence, menu-return guarantees, and quit semantics. Implementation patterns, balance numbers, and per-feature design briefs live elsewhere; what's here does not change without a deliberate edit to this file.
North Star
A CRADL session has exactly five moves: Login (acquire identity), Play (resolve target map + spawn, travel from the menu), Travel (gameplay → gameplay, through the same level funnel), Main Menu Return (clean transition back), and Quit (terminate the app). Identity is established once, stamped onto ACradlPlayerState as a replicated field, and threaded explicitly through every save call site so future remote-peer support drops in without retrofitting. Saves are per-FPlatformUserId from day one — there is no shared default slot to migrate off of later. Direct-PIE iteration works without going through the menu, but never silently restores to or clobbers a real saved location. In-combat disconnect counts as death; quitting is not an exfiltration trick. Per the OSRS heritage in THEME.md, the menu uses generic verbs (Login, Play, Settings) — no theme-flavoured copy in code-side identifiers.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Who initialises CommonUser? | Menu's Auth screen (user-driven) only. Never auto on GameInstance::Init, never from PostLogin. Direct-PIE on a gameplay map runs unauthenticated and saves no-op. |
Identity Acquisition |
| What keys a player's save slot? | This machine's local user via UCradlGameInstance::GetLocalSaveUserId() — a machine-local concept, not carried on the PlayerState. Only the local player's save/load uses it; a remote peer's host-side PS passes PLATFORMUSERID_NONE. |
Identity Carry-Through |
| Where does the player's display name live? | Chosen at the menu, carried via UCradlGameFlowSubsystem::PendingDisplayName, persisted in UCradlPlayerProfile::PlayerDisplayName, stamped onto APlayerState::PlayerName (engine-replicated) at PS BeginPlay. |
Display Name |
| When is the username prompt shown? | First session for an FPlatformUserId (empty PlayerDisplayName), via ACradlMenuPlayerController::EnsureDisplayNameThen — it gates the play-screen push (auth → name → play, never name-over-play). Seeded from UCommonUserInfo::GetNickname() (online) / FPlatformProcess::UserName() (offline). Also re-openable on demand via the play screen's optional Btn_EditName → PromptDisplayNameChange (seeded from the current name, no gate). |
Display Name |
| How is the target map chosen on Play? | Saved LastLevelId from per-user profile wins; falls back to UCradlLevelSettings::DefaultStartingLevel. |
Map Selection on Play |
| Where does the menu carry the chosen level across travel? | UCradlGameFlowSubsystem::ActiveLevelId (GameInstance subsystem; survives OpenLevel). |
Map Selection on Play |
| How is the initial spawn transform calculated? | Saved LastTransform if the multi-gate restore check passes; otherwise ACradlRespawnPoint::ResolveRespawnTransform(). |
Spawn Transform |
Why doesn't ShouldSpawnAtStartSpot return its engine default? |
Engine default returns true after first spawn, which would skip RestartPlayerAtTransform. We always want transform-based. |
Spawn Transform |
| How does a player travel between two gameplay maps? | A gameplay-side initiator (portal/dock/map UI) funnels through the same UCradlGameFlowSubsystem::TravelToLevel(FPrimaryAssetId) the menu uses — no new travel verb. |
Map-to-Map Travel |
| What stops a teardown save mis-attributing the source-map transform to the destination? | ACradlGameMode::EnsureLevelContext() latches the level id once per world lifetime; the departing world's EndPlay save reads the latched (source) id, not the re-targeted ActiveLevelId. |
Map-to-Map Travel |
| How does a player land at a specific spot when a map has two entrances? | A consumed-once PendingEntryPointTag on the GameFlow subsystem, resolved by RestartPlayer ahead of the saved-transform check (entry tag → saved transform → respawn point). |
Map-to-Map Travel |
What happens if the player travels while Status.InCombat? |
Same as Main Menu Return / Quit — the authoritative EndPlay "in-combat = death" rule fires; the initiator carries an advisory UI gate. |
Map-to-Map Travel |
| Save slot name format? | PlayerProfile_<FPlatformUserId::GetInternalId()>. Invalid id → no slot, methods log + return safe nullary. |
Save Lifecycle |
| When does the autosave timer fire? | AutosaveIntervalSeconds (default 60s) while a PlayerState exists with bSuppressSaves=false. |
Save Lifecycle |
| How does a joining guest get their progression? | Guest serializes its own profile client-side and pushes it once via Server_SubmitJoinProfile; the host applies it authoritatively through ApplyProfile (the shared apply seam). No host-side slot, no replicated id. |
Remote-Peer Progression |
| When does a guest's progression persist back to disk? | Graceful exit only — a client-initiated, acked logout handshake (Server_RequestLogoutPersist → Client_PersistProfile). No per-autosave streaming. Hard-quit / crash / drop = session loss (revert to pre-join). |
Remote-Peer Progression |
| What stops a guest acting as a half-loaded character? | The establishment latch — BeginPlay enters Awaiting, defers pawn spawn + holds the loading screen until the payload applies (or ~15s timeout → defaults). One-shot per PS. |
Remote-Peer Progression |
What if the user quits while Status.InCombat is active? |
Death pipeline runs synchronously before save; profile records bDiedWhileDisconnected=true. |
Quit Semantics |
| How does gameplay return to the main menu? | Confirm dialog → UCradlGameFlowSubsystem::TravelToMainMenu() clears ActiveLevelId and opens UCradlLevelSettings::MainMenuMap. |
Main Menu Return |
| How does the player start a NEW game (wipe the save)? | Play screen's optional Btn_NewGame → UCradlNewGameModalWidget (true modal, own lifecycle): retype the current name (shown as hint; trimmed, case-insensitive) → explicit Yes/No → DeleteProfile, clear PendingDisplayName, ReturnToAuth(). |
New Game (Profile Reset) |
| Why two menu screens (Auth + Play) instead of one? | Auth establishes identity; the Play screen is where pre-play state (settings popout, future loadout/character selection) accumulates. | Two-Screen Menu UX |
| How does ESC pop the Play screen back to Auth? | UCradlPlayScreen is a bIsBackHandler; the menu PC loads an IMC binding IA_BackHandler to ESC; CommonUI routes the action through NativeOnHandleBackAction. |
Two-Screen Menu UX |
| What happens if the user clicks Login (or Play Offline) again after already signing in? | Fast-path: AuthScreen's handlers detect the current ECommonUserInitializationState and call NotifyAuthSucceeded immediately without re-issuing the subsystem call. NativeOnActivated resets visual state on re-entry. |
Two-Screen Menu UX |
| How do WBPs hook menu transition animations? | OnAdvancingToPlay / OnReturningToAuth / OnPlayTransitionStarted events on the Layout and screens, paired with PlayTransitionDuration for the travel delay. |
Menu Transition Animation Hooks |
| What covers the seam between menu outro and gameplay HUD? | ULoadingScreenManager (plugin GameInstanceSubsystem) auto-shows on PreLoadMap, holds ≥ HoldLoadingScreenAdditionalSecs (2.5s), hides on PostLoadMapWithWorld + hold expiry. |
Loading Screen |
Identity Acquisition
Rule. CommonUser is never initialised on GameInstance::Init. CommonUser is initialised from exactly one place: UCradlAuthScreen when the user clicks Login or Play Offline. The screen calls UCommonUserSubsystem::TryToInitializeForLocalPlay(0, FInputDeviceId(), bCanUseGuestLogin=false) — and, for the Login path only, chains TryToLoginForOnlinePlay. Success advances to UCradlPlayScreen.
The gameplay ACradlGameMode does not auto-init CommonUser, and (since the save-identity refactor) does not stamp any identity onto the PlayerState. If the player reaches the gameplay map without going through the menu (direct-PIE on a gameplay map, packaged direct-launch), UCradlGameInstance::GetLocalSaveUserId() returns PLATFORMUSERID_NONE and saves no-op (the save subsystem's invalid-id warning is the tell).
NULL OSS fires OnUserInitializeComplete synchronously inside the TryTo* call. EOS will be async; the contract requires the AuthScreen handler to remain valid under async delegate firing — see Footguns.
Why. Initialising on GameInstance::Init would defeat the "menu controls identity" guarantee — a packaged build would surprise-network on launch before the user ever saw a UI. Initialising in PostLogin would defeat the same guarantee from the other end and obscure the fact that direct-PIE on a gameplay map isn't a supported entry point for save persistence; the save subsystem's invalid-id warning (from GetLocalSaveUserId() returning none) makes that explicit.
Implementation surface:
- Files: UCradlGameInstance, UCradlAuthScreen, ACradlGameMode (no longer overrides PostLogin — there's no identity to stamp).
- Subsystem: UCommonUserSubsystem (vendored plugin under Plugins/CommonUser).
- Key types: UCommonUserInfo, ECommonUserInitializationState, ECommonUserOnlineContext, FPlatformUserId.
- UCradlGameInstance::Init binds OnUserInitializeComplete + OnHandleSystemMessage for log-only observation — no auth side-effects.
Footguns:
- Player 0 cannot use guest login. UCommonUserSubsystem::TryToInitializeUser hard-rejects LocalPlayerIndex==0 && bCanUseGuestLogin (returns false synchronously, never fires OnUserInitializeComplete). The guest flag is reserved for splitscreen secondary players (1+); player 0 always uses the platform identity. Both HandleClick_Login and HandleClick_PlayOffline pass bCanUseGuestLogin=false — the two paths share the same first call and diverge only in whether Login chains TryToLoginForOnlinePlay.
- NULL OSS sync-firing: OnUserInitializeComplete fires inside TryTo*. The Auth screen state machine flips to InProgress before dispatching; reversing the order causes the delegate to observe Idle and never advance (per the UCradlAuthScreen::HandleClick_Login ordering comment). EOS will fire async; the same order works either way.
- EOS landing is a future async retrofit: when the EOS path is enabled, the AuthScreen's HandleUserInitializeComplete will need to handle delayed callbacks — particularly the Login_Stage2_Login state, which currently re-checks GetLocalPlayerInitializationState synchronously after TryToLoginForOnlinePlay. EOS may leave the state at LoggedInLocalOnly until a later broadcast lands. The state machine already accommodates this (re-arms Login_Stage2_Login and waits for the next broadcast), but make sure any future refactor preserves that behaviour.
- Remote-peer persistence is speced but not yet built (names already work): there is no per-PS identity stamp. A remote peer's host-side PS gets defaults at BeginPlay (save/load is gated to the local player), and its display name arrives engine-natively via the connect URL's ?Name= (see Display Name). The guest progression path — the host can't read the peer's disk — is now committed in Remote-Peer Progression: a client→server join payload + graceful-exit write-back to the guest's own disk. Listen-server-self-play is the only path implemented today.
- CommonUser is forked, OSSv1-aligned: see project_commonuser_sourcing.md for the porting fixes that survive in-place under Plugins/CommonUser. The plugin's Build.cs defines COMMONUSER_OSSV1=1; do not switch to OSSv2 without auditing every plugin call site.
- Don't reach for an [/Script/CommonUser.CommonUserSubsystem] ini block to gate auto-login: the subsystem reads no config keys at all. Auto-login is purely an API question (who calls TryTo* and when), not a config question.
Related: CLAUDE.md replication rule (no startup-time network calls in dev), project_commonuser_sourcing.md memory entry, project_shipping_targets.md memory entry (EGS + Steam via EOS).
Identity Carry-Through
Rule. Save identity is a machine-local concern, not per-PlayerState state. The save key is "this machine's logged-in user," resolved on demand from CommonUser via UCradlGameInstance::GetLocalSaveUserId() — which takes no APlayerState, because the key is a property of the machine, not a player actor. It is deliberately not carried on the (replicated) PlayerState.
Save subsystem signatures all take FPlatformUserId User as a parameter: SavePlayer(PS, User), LoadPlayer(PS, User), PeekProfile(User), DeleteProfile(User), ProfileExists(User). Invalid id → log a LogTemp warning and return the safe nullary value (false / nullptr). No method falls back to a shared default slot. Only the local player's call sites pass a real id (from GetLocalSaveUserId()); a remote peer's host-side PS passes PLATFORMUSERID_NONE, which routes to defaults with no disk I/O.
Why. Three things drive this:
FPlatformUserIdis a machine-local handle, not a cross-machine identity. ItsGetInternalId()is a per-process local-user/device index (numeric under NULL/Steam, hex under EOS), meaningful only on the machine that minted it. Replicating it cross-machine is meaningless — a client receiving the host's0has nothing to compare it against. Cross-peer identification (block list, friend overlay), when it lands, needsFUniqueNetId/FUniqueNetIdRepl, not this. (This is why the oldReplicatedPlatformUserIdfield was removed: it conflated a local save key with networked state, and replicating it bought nothing — no client ever read it.)- The save use is authority-local and per-machine. The host reads/writes its own disk for its own player; a remote peer's save lives on the peer's disk, which the host can't read. So the host never keys a slot by a peer's id —
GetLocalSaveUserId()resolves this machine's user, full stop. - Per
feedback_carry_identity_forward.md, identity still threads through the save API signatures explicitly (Useris a parameter on every method), so the call sites don't get retrofitted when the source of the id evolves. What changed is the source: a local funnel (GetLocalSaveUserId) rather than a reverse-lookup off a PlayerState.
Save/load is gated to the local player: ACradlPlayerState caches bIsLocalPlayerState at BeginPlay (GetPlayerController()->IsLocalController()); ACradlGameMode::RestartPlayer's saved-location gate checks NewPlayer->IsLocalController(). A remote peer's host-side PS gets defaults only — its persisted state arrives via a client→server join payload (Remote-Peer Progression) and its display name via ?Name= (Display Name), never by the host loading a slot.
Implementation surface:
- Files: UCradlGameInstance::GetLocalSaveUserId, UCradlSaveSubsystem, the local-player gate on ACradlPlayerState (bIsLocalPlayerState) and ACradlGameMode::RestartPlayer.
- Call sites resolving the local id through GetLocalSaveUserId(): the host's own PS (BeginPlay load, EndPlay / autosave write — all gated on bIsLocalPlayerState), the menu PC (ProceedToGame level restore, EnsureDisplayNameThen / ResolveDisplayNameSeed name resolve — all PeekProfile), RestartPlayer's spawn gate, and the ACradlPlayerController debug execs (GetLocalSaveUser helper). All menu profile reads MUST route through GetLocalSaveUserId(), not GetLocalUserInfo()->GetPlatformUserId() directly — the latter bypasses the PIE per-instance harness, so the menu would key a different slot than gameplay and never resolve the existing save (re-prompts for a name, loses the saved level/intro).
P2P replication audit (per feedback_p2p_replication_audit.md):
- No save identity is replicated. GetLocalSaveUserId() resolves locally per machine. Removing ReplicatedPlatformUserId eliminated the only cross-machine identity replication this project had; nothing read it.
- The player display name does replicate — via the engine-canonical APlayerState::PlayerName (see Display Name), set on authority.
- UCommonUserSubsystem / UCommonUserInfo are GameInstance-scoped, never replicated. CommonUser is per-client by design.
- Save subsystem calls run on authority only (!PS->HasAuthority() guard) and local-player only (the bIsLocalPlayerState gate at the call sites).
Footguns:
- Don't reintroduce a per-PS save id or a "resolve identity from a PlayerState" API. A ResolveForPlayer(PS)-shaped helper encodes the false premise that any PS has a resolvable local save id — true only for the host's own PS. The save key belongs to the machine; reach for GetLocalSaveUserId().
- Don't reach for UCommonUserInfo* in save-subsystem signatures. FPlatformUserId is the foundational key; the wrapper carries nothing the slot keying needs. Per feedback_push_back_on_duplicate_identity.md, propose the leaner type.
- FPlatformUserId::GetInternalId() is filesystem-safe across NULL OSS (numeric), Steam (numeric), and EOS (hex). MakeSlotName does no sanitisation. If a future platform returns characters that need escaping, the helper is the single place to add it.
- UserIndex=0 in UGameplayStatics::SaveGameToSlot is unchanged by the per-user keying — it's a separate, orthogonal concept (platform save-sandbox index, not player identity). Revisit when console support lands.
Related: feedback_carry_identity_forward.md, feedback_push_back_on_duplicate_identity.md, feedback_p2p_replication_audit.md, Display Name.
Display Name
Rule. Every player has a chosen display name — distinct from the FPlatformUserId identity primitive. It is the P2P-facing label and the source of the per-session welcome toast. The lifecycle is:
- Source of truth on disk:
UCradlPlayerProfile::PlayerDisplayName(per-user slot, v14). Empty = "never chosen" → prompt. - Replicated runtime value:
APlayerState::PlayerName— the engine-canonical display-name slot, replicated to all peers. Not a new field; reusing it (perfeedback_check_5_4_best_practice+feedback_push_back_on_duplicate_identity) means scoreboards / overlays / chat read the value for free. - First-run capture (gates the play screen):
ACradlMenuPlayerController::AdvanceToPlayScreencallsEnsureDisplayNameThen([push play screen]). If a name is already known (chosen this session, or carried on the per-user profile) it pushes immediately. If not → seed =UCommonUserInfo::GetNickname()(online: Steam persona / EOS DisplayName)?:FPlatformProcess::UserName()(offline: OS user name, sinceGetNickname()is empty under NULL OSS local-only). The sanitized seed is floated throughUCradlMenuLayout::PromptForDisplayName, whichAddToViewports aUCradlTextInputWidgetover the auth screen (theUCradlCountInputWidgetlifecycle, NOT a CommonUI stack push). The play screen is pushed only when the prompt resolves — name capture happens before the play screen appears, never alongside it. (UCradlAuthScreen::PerformAdvanceToPlayScreen, which fires once theMinProgressDisplaySecondshold elapses, clears the typewriter "Connecting…" status + collapses the spinner panel viaUTypewriterTextBlock::Clear()so the auth screen is a neutral backdrop behind the prompt.) - Carry-forward: the resolved name is stashed on
UCradlGameFlowSubsystem::PendingDisplayName(sibling toActiveLevelId; survivesOpenLevel). The prompt always resolves to a usable name — the sanitized typed value on commit, or the seed on dismiss (ESC/Back) — so the gated continuation always has a name to carry. - Stamp + toast:
ACradlPlayerState::BeginPlayresolves the final name — the GameFlow-carriedPendingDisplayNamewins, elseLoadedDisplayName(staged from the profile byLoadPlayer) as the fallback for menu-less sessions (direct-PIE) — and callsSetPlayerName(Name), then posts aMessage.Reason.Welcometoast through the standardPostClientMessagepath. The carried name leads because it's either equal to the profile name (returning, no rename) or a deliberate newer choice (first run, or an on-demand rename); it can never be staler than the profile, since every profile write derives fromPlayerNamewhich derives from the carried name. Resolving profile-first would discard a rename — the renamedPendingDisplayNamewould lose to the stale on-disk name, which then gets re-saved. The in-game HUD echoes the name:UCradlPlayerHUDWidget::OnPlayerNameChanged(BIE) is seeded offAPlayerState::PlayerNameonce the VisualState binds (same frame as the HP/combat seed), so it resolves on both host and remote-client HUDs. - Persistence:
SavePlayercapturesPS->GetPlayerName()back intoProfile->PlayerDisplayName(sole writer). After the first save the profile owns the authoritative copy and the carry-forward is vestigial.
On-demand rename (menu only). The first-run capture is a one-shot gate; to let a player fix or change their name afterward, UCradlPlayScreen exposes an optional Btn_EditName (BindWidgetOptional), colocated with the Txt_PlayerName identity line it edits. Its click handler dispatches to ACradlMenuPlayerController::PromptDisplayNameChange() — the same GetOwningPlayer<…>()-and-call idiom as Btn_Play/Btn_Quit, so no delegate indirection: the play screen is already PC-aware, and it only exists post-name (the gate pushed it), so no visibility-gating is needed. PromptDisplayNameChange floats the same PromptForDisplayName prompt, but seeded from the current name (ResolveDisplayNameSeed: PendingDisplayName → profile → first-run platform fallback) with no skip-gate and no play-screen continuation. On resolve it writes straight back to PendingDisplayName (step 4's slot), so steps 5–6 are unchanged — the new name rides into gameplay, stamps onto PlayerName, and SavePlayer persists it exactly as a first-run name would. ESC/Back resolves to the seed (the current name), making a dismiss a no-op. Because UCradlPlayScreen only re-resolves the identity line on activation, the rename's commit also calls ActivePlayScreen->RefreshPlayerName() so the typed name updates live while the play screen stays up. An unbound button is a no-op — the WBP can omit it entirely.
Name rules: 12 chars, letters/digits/single spaces, trimmed. No profanity filter (invite-only). UCradlTextInputWidget::SanitizeDisplayName is the single enforcement point — callers run the seed through it too.
Why. Three decisions drive this shape:
APlayerState::PlayerName, not a parallelFString. It's the engine's replicated display-name primitive; a second field would duplicate it (counter to the no-duplicate-identity rule). The one subtlety: the engine auto-fillsPlayerNamewith a throwaway placeholder during login — so we stamp inBeginPlay(which runs after that auto-fill) to make our value the one that wins. "Use the engine field" and "override its default value" are orthogonal: which field vs. which write lands last.- Carry-forward via GameFlow, not a menu-side profile write. Per
feedback_carry_identity_forward, the name is pushed forward through the GameInstance subsystem rather than written to the profile from the menu (which has no PlayerState).SavePlayerstays the sole profile writer. Trade-off accepted: a name chosen then force-quit-at-menu (before the first autosave/EndPlay) is lost and re-prompts next launch — acceptable for invite-only. LoadedDisplayNameis staged, not stamped, byLoadPlayer. Mirrors thebPendingDisconnectDeathpattern —LoadPlayerrecords the value on the PS andBeginPlayconsumes it, so the stamp lands after the engine default rather than fighting it.
Implementation surface:
- Files: UCradlTextInputWidget (the floating prompt + SanitizeDisplayName), UCradlMenuLayout::PromptForDisplayName, ACradlMenuPlayerController::EnsureDisplayNameThen / PromptDisplayNameChange / ResolveDisplayNameSeed / PushPlayScreen, UCradlPlayScreen::Btn_EditName / RefreshPlayerName, UCradlGameFlowSubsystem::PendingDisplayName, UCradlPlayerProfile::PlayerDisplayName, ACradlPlayerState::LoadedDisplayName + BeginPlay stamp, UCradlSaveSubsystem (capture in SavePlayer, stage in LoadPlayer), UCradlLocalPlayer::GetNickname (the remote-peer ?Name= hook).
- WBP authoring (deferred user task): WBP_TextInput parents to UCradlTextInputWidget and binds TxtInput, Btn_Commit, Btn_Back; set DisplayNameInputClass on the WBP_MenuLayout CDO. WBP_PlayScreen may bind an optional UTypewriterTextBlock Txt_PlayerName — UCradlPlayScreen::NativeOnActivated (and RefreshPlayerName, also called after an on-demand rename) resolves the chosen name off UCradlGameFlowSubsystem::PendingDisplayName and types it via SetTargetText — and an optional UCommonButtonBase Btn_EditName next to it for the on-demand rename.
- Tag: Message.Reason.Welcome (see Tag Taxonomy).
P2P replication audit (per feedback_p2p_replication_audit.md):
- APlayerState::PlayerName — server-authoritative, engine-replicated to all. Set once in BeginPlay on authority; the engine's OnRep_PlayerName carries it to clients. No custom rep code.
- UCradlGameFlowSubsystem::PendingDisplayName / ACradlPlayerState::LoadedDisplayName / UCradlPlayerProfile::PlayerDisplayName — never replicated. GameInstance-scoped, transient, and on-disk respectively; the replicated value rides on PlayerName.
- Welcome toast routes server→owner through PostClientMessage (no separate replicated state).
Footguns:
- Don't stamp PlayerName in LoadPlayer. It runs before the engine's login-default assignment settles in some flows; stamping there can be clobbered. BeginPlay is the contracted stamp site.
- Remote-peer names ride the engine ?Name= handshake, not the save-id. UCradlLocalPlayer::GetNickname() returns the chosen name (from UCradlGameFlowSubsystem::PendingDisplayName); on join, UPendingNetGame appends ?Name= to the connect URL and the host's AGameModeBase::InitNewPlayer applies it via ChangeName → SetPlayerName (replicated) — no custom RPC. The local-player gate in BeginPlay is what stops the host's carried name from clobbering a peer's ?Name= value. This is decoupled from save-identity (the name is cosmetic and needs no profile on the host). The path is complete but dormant until the CommonSession join flow is wired — it can't be PIE-tested without two connected instances. LocalPlayerClassName=/Script/CRADL.CradlLocalPlayer in Config/DefaultEngine.ini is what installs the hook.
- SanitizeDisplayName is the single rule site. Both the live box filter and any seed must funnel through it; don't re-implement the charset/length rules at a call site.
- The prompt gates the play screen — don't reorder it to fire alongside the push. AdvanceToPlayScreen defers the push behind EnsureDisplayNameThen; the play screen is created only in the resolve continuation. The widget always resolves (commit = typed, dismiss = seed), so the continuation always runs with a name. If the front end tears down before the user resolves (Quit / travel), UCradlTextInputWidget::NativeDestruct drops the callback unfired — correctly not pushing the play screen into a dying menu.
- PendingDisplayName is deliberately NOT cleared on TravelToMainMenu (unlike ActiveLevelId). The chosen name, like the CommonUser identity, is established once per launch and should survive a menu round-trip.
Related: Identity Carry-Through, Save Lifecycle, Two-Screen Menu UX, feedback_carry_identity_forward.md, feedback_push_back_on_duplicate_identity.md, feedback_check_5_4_best_practice.md.
Map Selection on Play
Rule. When UCradlPlayScreen's Play button fires, the menu PC's ProceedToGame resolves the target level as follows:
- Resolve
FPlatformUserIdfromUCradlGameInstance::GetLocalUserInfo(0)->GetPlatformUserId(). Invalid user → fall through withPLATFORMUSERID_NONE. - Call
UCradlSaveSubsystem::PeekProfile(User). If a profile exists andProfile->LastLevelId.IsValid(), that's the target. - Otherwise, fall back to
GetDefault<UCradlLevelSettings>()->DefaultStartingLevel. - Call
UCradlGameFlowSubsystem::TravelToLevel(LevelId). The subsystem records the id before opening the world.
The gameplay GameMode reads the recorded id via UCradlGameFlowSubsystem::GetActiveLevelId(), exposed as ACradlGameMode::GetCurrentLevelId(). Direct-PIE / direct-launch sessions never call TravelToLevel, so the subsystem reports HasActiveLevel() == false, and the GameMode falls back to DefaultStartingLevel for its own identity reporting.
Why. Single-player OpenLevel travel destroys the menu world; only UGameInstanceSubsystems survive. UCradlGameFlowSubsystem is that carrier. Per feedback_carry_identity_forward.md, identity flows forward explicitly — gameplay code never reverse-maps a live UWorld back to its source asset (the PIE-prefix string-matching pitfall that motivated the original feedback entry).
ULevelDefinition is a UPrimaryDataAsset with TSoftObjectPtr<UWorld> Level, returning a stable FPrimaryAssetId via GetPrimaryAssetId(). Any future level-picker UI surfaces ULevelDefinition assets to the player and feeds their ids into TravelToLevel.
Implementation surface:
- Files: UCradlGameFlowSubsystem, UCradlLevelSettings, ULevelDefinition, ACradlMenuPlayerController::ProceedToGame, ACradlGameMode::GetCurrentLevelId.
- Validator: UCradlLevelDefinitionValidator — gates editor authoring of ULevelDefinition assets.
Footguns:
- MainMenuMap is a bare TSoftObjectPtr<UWorld>, not a ULevelDefinition. The menu is never persisted (it has no LastLevelId). TravelToMainMenu clears ActiveLevelId before opening so the menu's GameMode observes "no menu origin." Don't promote MainMenu to a ULevelDefinition — the persistence asymmetry is intentional.
- No reverse UWorld → ULevelDefinition lookup exists. If a future system needs to know "what definition is this world," carry the id forward via the GameFlow subsystem; don't add reverse metadata to the level.
- Level-selection UI lives outside the subsystem. Today ProceedToGame reads from save + settings directly. When a level-picker page lands on UCradlPlayScreen (or a child screen), it must funnel through TravelToLevel(id) — don't add a parallel "ProceedToSpecificLevel" path.
Related: STAT_PIPELINE.md (Primary Asset id pattern reuse), feedback_carry_identity_forward.md.
Spawn Transform on RestartPlayer
Rule. ACradlGameMode::RestartPlayer(NewPlayer) resolves the initial spawn transform with a single multi-gate check before falling back to a respawn-point:
if ShouldPersistLocation() AND
profile.bHasSavedLocation AND
!profile.bDiedWhileDisconnected AND
profile.SavedHealth > 0 AND
profile.LastLevelId == GetCurrentLevelId()
then SpawnTransform = profile.LastTransform
else SpawnTransform = ACradlRespawnPoint::ResolveRespawnTransform(World)
Then: RestartPlayerAtTransform(NewPlayer, SpawnTransform). ShouldSpawnAtStartSpot is overridden to return false unconditionally so RestartPlayerAtTransform is always the spawn path on both initial-spawn and respawn flows.
Why. Each gate exists for a specific failure mode:
- ShouldPersistLocation(): menu-originated only, prevents direct-PIE from restoring to a stale dev spot.
- bHasSavedLocation: distinguishes "first-time player" from "saved at a real location."
- !bDiedWhileDisconnected: a player who disconnected in combat funnels through Combat.Event.Death on ACradlPlayerState::BeginPlay, which runs the full death pipeline (cold storage, etc.). Restoring them to the saved transform would put them at the disconnect spot with full HP — wrong outcome.
- SavedHealth > 0: redundant sentinel for the dead-on-disconnect case; cheap defence-in-depth.
- LastLevelId == GetCurrentLevelId(): the user selected a different level than they last played; spawn fresh.
ShouldSpawnAtStartSpot returning false is non-obvious: the engine default returns true once PC->StartSpot is set (which RestartPlayerAtPlayerStart sets on first spawn). That would cause subsequent RestartPlayerAtTransform calls to skip spawning. Always overriding to false keeps both code paths (initial spawn and death respawn) on the transform path.
ACradlRespawnPoint::ResolveRespawnTransform is the fallback: first-found-actor-in-world wins, else UCradlCombatSettings::DefaultRespawnTransform. O(n) actor iteration is acceptable because spawn/respawn is rare.
Implementation surface:
- Files: ACradlGameMode::RestartPlayer, ACradlRespawnPoint, UCradlPlayerProfile (LastTransform, LastLevelId, bHasSavedLocation, bDiedWhileDisconnected, SavedHealth fields).
- The death pipeline's respawn path (CradlCombat::RestoreLiveness) calls GM->RestartPlayerAtTransform directly, bypassing the RestartPlayer override entirely. Initial-spawn and live-death respawn intentionally take different code paths into the same RestartPlayerAtTransform call.
Footguns:
- Don't add new gates that read mid-save state from UCradlPlayerProfile without considering the multi-load case. LoadPlayer is reachable mid-session via Debug_LoadProfile and structurally via future autoload paths. New gates must either be idempotent or be explicitly latched (see ReconciledSlotsThisSession for the canonical latching pattern).
- Don't move spawn-transform logic into the death pipeline or vice versa. RestartPlayer covers initial-spawn-only; the death pipeline covers live-death and disconnect-recovery. Mixing them introduces invariant gaps the current split avoids.
- Per feedback_replicated_loose_tag_ue54.md: Status.Dead and related death tags use AddReplicatedLooseGameplayTag paired with the local AddLooseGameplayTag so server-side reads (the spawn gate's SavedHealth > 0 check is HP-driven, but adjacent code may want to read the tag). The pair-write rule is non-negotiable for any new tag that the spawn gate reads on the server.
Related: COMBAT_SYSTEM.md "In-Combat State" and "Death & Cold Storage" sections (the death pipeline boundary), Map-to-Map Travel (the entry-point precedence that extends this gate).
Map-to-Map Travel
Rule. Gameplay → gameplay travel runs through the same funnel as menu Play: a gameplay-side initiator (portal volume, dock interactable, future world-map UI) resolves a destination FPrimaryAssetId and calls UCradlGameFlowSubsystem::TravelToLevel(LevelId) — the identical call the menu's ProceedToGame makes. There is no second travel verb. The initiator additionally owns three concerns:
- Entry point. It sets
UCradlGameFlowSubsystem::PendingEntryPointTag(a consumed-onceFGameplayTag) beforeTravelToLevel.RestartPlayerresolves it ahead of the saved-transform gate:if PendingEntryPointTag is set -> entry-point transform (then clear the tag) elif <existing saved-location multi-gate passes> -> profile.LastTransform else -> ACradlRespawnPoint::ResolveRespawnTransform()The entry point resolves to a taggedACradlRespawnPointvia a small entry-point registry world subsystem the points register into atBeginPlay— notGetAllActorsOfClass(per CLAUDE.md). No tag match → fall through to the untagged respawn point. - In-combat gate. An advisory UI gate on the initiator (same shape as Main Menu Return's confirm dialog), with the authoritative rule unchanged:
EndPlayfires the "in-combat = death" pipeline on any travel whileStatus.InCombat. - Departure fade + loading hold. Reuse the gameplay→menu shape — controller-owned
StartCameraFadethen a deferredTravelToLevel. The world stays alive through the fade window, so (like Main Menu Return) this half is robust by default. The loading-screen hold attaches automatically at the engine-delegate level — no call-site change.
Why.
- Single funnel. Open Question #4 already mandates TravelToLevel(FPrimaryAssetId) as the sole level-entry path; map-to-map is just a second caller. No parallel ProceedToSpecificLevel.
- The latch is the save-safety guarantee — promoted from accident to contract. ACradlGameMode::EnsureLevelContext() latches CurrentLevelId at first read (CradlGameMode.cpp:43), and RestartPlayer guarantees that read at spawn. TravelToLevel re-targets ActiveLevelId to the destination before opening the world; the departing world's EndPlay save reads GM->GetCurrentLevelId() (CradlSaveSubsystem.cpp:347) and must see the source id. The once-per-world latch guarantees it does. Without the latch, every gameplay-initiated travel would mis-attribute the source-map transform to the destination map's profile slot.
- Entry-tag precedence over saved transform. A portal arrival came through a specific door; the door wins. The tag is set only by gameplay initiators, never by menu Play, so menu-Play resume (the saved-transform path) is undisturbed. For a fresh destination the saved-location gate fails anyway (LastLevelId != GetCurrentLevelId()), so the entry point is simply the meaningful default; the precedence only bites when traveling back into a map you hold a save in — there the door still wins.
- In-combat = death. EndPlay runs on any world teardown; an ungated portal is therefore a logout-cheese death trap exactly like an ungated Main Menu button. The advisory initiator gate is courtesy; the server-side EndPlay rule is the authority. No new server logic.
Implementation surface:
- Funnel: UCradlGameFlowSubsystem::TravelToLevel (existing) + new PendingEntryPointTag field with a consumed-once setter (the PendingDisplayName carry-forward pattern, but cleared on consume unlike the name).
- Latch (contract note, no code change): ACradlGameMode::EnsureLevelContext / CurrentLevelId.
- Entry resolution: ACradlRespawnPoint gains an entry FGameplayTag; a new entry-point registry UWorldSubsystem; the new precedence branch in ACradlGameMode::RestartPlayer.
- Departure fade: reuse ACradlPlayerController::RequestReturnToMainMenu's controller-owned-fade-then-deferred-travel shape.
- Initiator (portal/dock/UI): out of this contract's scope — it must (a) call TravelToLevel, (b) set the entry tag, (c) honor the in-combat advisory gate.
P2P replication audit (per feedback_p2p_replication_audit.md):
- TravelToLevel / PendingEntryPointTag are GameInstance-scoped, never replicated — single-player OpenLevel today. A client calling OpenLevel would disconnect from a session; the P2P path is host-driven ServerTravel (the subsystem header already states this). When that lands, the joining client's ActiveLevelId and entry tag come from the host's travel URL, not this process-local subsystem.
- The spawn-transform resolution (including the entry-point branch) runs authority-side in RestartPlayer, consistent with the existing saved-location gate.
Footguns:
- NEVER add a RefreshLevelContext() / re-latch path. The once-per-world latch is precisely what makes gameplay-initiated TravelToLevel save-safe; re-latching mid-session leaks the destination id into the departing world's save.
- The entry tag is consumed-once — clear it in RestartPlayer after reading. A later in-session death respawn must route to the respawn point, not teleport the player back to the entry door.
- Don't restore the saved transform on a cross-map portal. The LastLevelId == GetCurrentLevelId() gate already blocks it for fresh maps; the entry-tag precedence covers the travel-into-a-saved-map case. Both must hold.
- Single-player only, today. Don't wire a client-side TravelToLevel into a networked session — that's the host-driven ServerTravel retrofit, not a v1 call site.
Related: Map Selection on Play (the shared funnel), Spawn Transform (the gate this extends), Main Menu Return (the in-combat advisory-gate pattern), Loading Screen (the departure-fade reuse), LEVEL_FLOW_SYSTEM.md (the intro that plays on arrival), Open Questions #4.
Save Lifecycle
Rule. Saves are per-FPlatformUserId and write at three moments:
ACradlPlayerState::BeginPlayresolves the profile, and the path forks by who's loading: - Local player (host-own / standalone): loads inline —LoadPlayer(GetLocalSaveUserId())followed byOnStateEstablished(), both in-frame. There is no identity-resolved deferral on this path: it assumesGetLocalSaveUserId()is valid in-frame. That assumption holds (even under async-identity EOS) because the menu gates travel on auth —UCradlAuthScreenblocks the Play screen untilOnUserInitializeCompleteresolves, so by the time this gameplayBeginPlayruns the id is already resolved. Direct-PIE / direct-launch straight onto a gameplay map is the one unguarded entry, and that's an unsupported-persistence path by design (invalid id → defaults, saves no-op). - Remote guest (!bIsLocalPlayerState): does not load inline. It entersAwaitingand establishes later via the once-per-PSOnStateEstablished()latch — reached when its join payload applies or the ~15s timeout fires — holding the guest behind the loading screen meanwhile (see Remote-Peer Progression). This is the path written to the asyncOnUserInitializeCompletecontract.
Establishment (both branches) applies the resolved profile via ApplyProfile; a missing slot or invalid id (e.g. CommonUser hadn't initialised) routes through the defaults-and-return-false path (loadout, spellbook, skills seeded).
2. ACradlPlayerState::HandleAutosave fires on a FTimerHandle AutosaveTimer with cadence AutosaveIntervalSeconds (default 60s, configurable on the PS). Gated by bSuppressSaves.
3. ACradlPlayerState::EndPlay writes one final save (gated by bSuppressSaves). If Status.InCombat is active at EndPlay, the death pipeline runs first (via CradlCombat::RunDeathPipeline) so the on-disk profile reflects post-death state.
The slot name is PlayerProfile_<FPlatformUserId::GetInternalId()>. Methods that take FPlatformUserId log a warning and return safe nullary values on invalid id; never falls back to a shared default slot.
Why. Three coupled decisions drive this lifecycle:
- No default-slot fallback because that would re-create the single-profile assumption the per-user keying exists to eliminate. Loud-fail is the correct mode.
LoadPlayerseeds defaults on every failure path (missing slot, invalid id, cast/deserialise failure). The three "EnsureDefault" lambdas (EnsureDefaultLoadout,EnsureDefaultSpellbook,EnsureDefaultSkills) run through a singleApplyDefaultsAndReturnFalsehelper so direct-PIE first-run sessions still get a working character. Skipping the seeding on the invalid-id path was a regression we explicitly fixed.- The death pipeline runs before the save on disconnect-in-combat so the on-disk state is consistent. If we saved first, the next load would see
Status.InCombatcleared but the player still at the disconnect spot with full inventory — the wrong outcome twice.
Implementation surface:
- Files: UCradlSaveSubsystem, UCradlPlayerProfile, ACradlPlayerState (BeginPlay, EndPlay, HandleAutosave, AutosaveTimer, bSuppressSaves, bPendingDisconnectDeath, ColdStorageCache).
- Helpers: UCradlSaveSubsystem::MakeSlotName(User), ReconciledSlotsThisSession (mutable set, once-per-startup quest reward reconciliation latch).
- Debug surfaces: ACradlPlayerController::Debug_SaveProfile, Debug_LoadProfile, Debug_DeleteProfile.
P2P replication audit (per feedback_p2p_replication_audit.md):
- All save methods assert PS->HasAuthority() and return false for non-authoritative callers.
- UCradlPlayerProfile is never replicated. It's an authority-only on-disk artefact; loaded state replicates through the components' existing replication paths (SkillsComponent, InventoryComponent, ASC, etc.).
- bPendingDisconnectDeath is UPROPERTY(Transient), never replicated. Set by LoadPlayer server-side, consumed by BeginPlay server-side, fires Combat.Event.Death which runs through the replicated event path.
- bSuppressSaves is UPROPERTY(Transient), never replicated. Per-PIE-session debug flag.
- ColdStorageCache is UPROPERTY(Transient), never replicated — clients are blind to cold storage by design.
Footguns:
- Status.InCombat is the disconnect-death pivot. Adding any future "graceful disconnect" mode (e.g. log-out timer that ends combat first) must update both SavePlayer's capture of bDiedWhileDisconnected and EndPlay's pre-save death-pipeline hook in lockstep.
- Don't bypass MakeSlotName. Every code path that derives a slot name must funnel through the helper so the validity check + warning happen exactly once per call.
- The location-capture carry-forward pattern is load-bearing. When ShouldPersistLocation() is false, SavePlayer reads the previous on-disk values for LastLevelId / LastTransform / bHasSavedLocation and writes them back unchanged. Removing the carry-forward would let a direct-PIE save overwrite a real menu session's location.
- The autosave timer is on ACradlPlayerState, not on the subsystem. PIE Stop + new PIE = new PlayerState = new timer. The subsystem's ReconciledSlotsThisSession latch survives map travel (subsystem persists) but resets on PIE Stop (GameInstance teardown).
- Per feedback_no_clean_up_later.md: changes to UCradlPlayerProfile fields that break the cold-storage / quest-reconciliation / location-capture paths are in-scope for the same change — don't ship a profile-field rename and defer the LoadPlayer fixup.
Related: COMBAT_SYSTEM.md "In-Combat State" (disconnect rule), QUEST_SYSTEM.md "Reconciliation" (the ReconciledSlotsThisSession latch's other consumer), feedback_no_clean_up_later.md.
Remote-Peer Progression
Rule. A joining guest's progression is theirs and theirs alone — it lives on the guest's disk, never the host's. The host is authoritative over the guest's live ACradlPlayerState for the duration of the session but is never the source of truth at rest. Three handshakes implement this; all bind the guest by the owning RPC connection (the host resolves the target PS from the RPC's owning PlayerController), so no save id is ever replicated:
-
Join (load-in). The guest client resolves its own identity, reads its own profile (a machine-local disk read — no authority required), serializes it with
UGameplayStatics::SaveGameToMemory, and pushes it once viaServer_SubmitJoinProfile(bytes). The host deserializes withLoadGameFromMemoryand applies it authoritatively throughApplyProfile(PS, Profile)— the same apply seam the host's own player uses. The host never loads a slot keyed by the peer's id; the peer's save is on the peer's disk, unreadable to the host. -
Establishment gate. A guest's
BeginPlaydoes not seed defaults and does not spawn a pawn. It entersAwaiting, holds the guest behind the loading screen via the reservedILoadingProcessInterfaceseam, and starts a timeout. A singleOnStateEstablished()latch — reached by payload-apply or by timeout (~15s → defaults) — runs every post-load hook exactly once (spawn the pawn at the host respawn/entry point, resolve Health/Mana after the MaxHealth recompute, disconnect-death / spawn-dead check,RunInitialUnlockSweep), then signals the client (Client_JoinStateEstablished) to drop the loading hold. The latch is once-per-PS: a payload arriving after it (post-timeout) is ignored and logged. -
Write-back (graceful exit only). During the session the guest's gains live in the host's live components (RAM); the host writes nothing to disk for the guest and runs no per-autosave RPC. Persistence happens once, on graceful exit, via a client-initiated, acked logout handshake: the guest's menu/quit path fires
Server_RequestLogoutPersist; the host — running the in-combat death pipeline first ifStatus.InCombat— captures the profile and sendsClient_PersistProfile(bytes); the guest writes its own slot and only then completes the original travel/quit. A graceful host shutdown sweeps every connected guest through the same handshake before tearing down. Anything that skips the handshake — hard-quit, crash, involuntary disconnect — leaves the guest's disk at its pre-join state: the session's gains are lost by design.
The host's own local player is unchanged: it loads/saves its own disk slot directly (authority == local == same process), keeps its 60s autosave-to-disk and EndPlay save, and never rides these handshakes.
Why.
- The host cannot read the peer's disk — and EOS does not change that.
FPlatformUserIdis machine-local; a guest's save is a file on the guest's machine. Even EOS Player Data Storage is per-user-scoped (a client reads only its own cloud namespace), so a P2P host — itself just another player's client — never gains privileged read of a guest's data. The join payload is therefore not throwaway scaffolding: it survives the EOS migration. Only a privileged shared backend (a real dedicated server / custom DB) would retire it, and P2P-listen-server is deliberately not that. - Client-local persistence is the ownership story. "Bring your character to a friend's game" means the at-rest truth is the guest's machine. Host-aggregated (the host holds everyone's saves) was rejected: it breaks the ownership model and would let a hard-quit guest recover host-side state, contradicting the loss rule.
- No per-autosave streaming. Autosave fires far too often to RPC a full profile each cycle. Holding the guest's progression in host RAM and persisting once on graceful exit is bandwidth-trivial and deterministic. The cost — hard-quit / crash / drop = session loss — is an accepted, legible rule, because "graceful exit" is defined precisely as "completed the logout handshake," the only boundary the host can actually distinguish (a completed ack vs a vanished connection). It dovetails with the existing in-combat-death anti-cheese: a graceful in-combat exit runs the death pipeline then persists; a hard in-combat quit loses the session, which subsumes the death penalty.
- Gated establishment, not seed-then-replace. Seeding defaults at
BeginPlaythen overwriting on payload would flash a wrong character and double-run the once-after-state hooks. Deferring the spawn behind the loading screen until a single establishment event means no wrong state ever exists where the guest (or a peer) can see it, and every once-after-state hook fires exactly once on both the payload and the timeout-defaults branch.
Async identity contract (adopted; EOS transport deferred). The establishment latch is the same mechanism that makes the local player's first load async-safe — BeginPlay never assumes UCradlGameInstance::GetLocalSaveUserId() is valid in-frame. The latch fires when identity resolves (synchronously under NULL OSS, possibly later under EOS) or on timeout, and the handshakes are written to the async OnUserInitializeComplete contract today even though NULL OSS fires it synchronously. Enabling EOS later is then a transport/config change, not a re-architecture. See Open Questions #1.
Implementation surface (Phases 0–5 built — Phase-4 establishment-gate core + Phase-5 validation chokepoint as a V1 pass-through; per-field guest clamping and cross-travel reward dedup are the remaining hardening, see Footguns / Open Questions #3):
- PIE harness (Phase 0 — BUILT): WITH_EDITOR synthetic per-instance save user (FPlatformUserId::CreateFromInternalId(1000 + PIEInstance)) in UCradlGameInstance::GetLocalSaveUserId so each PIE instance keys a distinct, non-default slot (PlayerProfile_1000, _1001, …). Remaps every PIE instance (gated on PIEInstance != INDEX_NONE); packaged builds unaffected. The listen-server PIE config (2 players) lives in per-user LevelEditorPlaySettings, not committed.
- Seam split (Phase 1 — BUILT): UCradlSaveSubsystem — LoadPlayer → ResolveProfile(User) + ApplyProfile(PS, Profile, ReconcileLatchKey); SavePlayer → CaptureProfile(PS) + WriteProfileToDisk(Profile, User). LoadPlayer/SavePlayer stayed thin compositions, so existing call sites are unchanged; PeekProfile now delegates to ResolveProfile. Authority split: CaptureProfile/ApplyProfile assert HasAuthority(); ResolveProfile/WriteProfileToDisk are machine-local and authority-independent. ApplyProfile carries a third ReconcileLatchKey arg (beyond the headline (PS, Profile)) because the slot-keyed quest-reward reconcile runs mid-restore and can't be lifted into the composition — the local player passes its slot name; guest reconcile re-keying hangs off this param and stays the deferred Phase-2 sub-task. These seams are also the EOS-cloud-save drop-in point.
- Join handshake + establishment gate (Phases 2 & 4-core — BUILT, merged): built together so the feature lands with no seed-then-replace flash.
- Transport: Server_SubmitJoinProfile(const TArray<uint8>&) (client→server) + Client_JoinStateEstablished() (server→owner) on ACradlPlayerController, which now implements ILoadingProcessInterface. A guest client (NM_Client + IsLocalController) reads its own profile via ResolveProfile, SaveGameToMemory → bytes (size logged for the compress/chunk decision), submits once, and holds its loading screen until Client_JoinStateEstablished. The host's own player / standalone skip this (load from disk in BeginPlay).
- Establishment latch (authority-only, never replicated): ACradlPlayerState — a guest's BeginPlay calls BeginAwaitingEstablishment (no defaults, no pawn) and establishes once via OnStateEstablished (latched by bStateEstablished), reached by ApplyJoinPayload (deserialize → ApplyProfile) or the ~15s EstablishmentTimeoutTimer → defaults. The latch spawns the deferred pawn (before the disconnect-death check), runs ResolveHealthAndDeath, and fires Client_JoinStateEstablished. The local player establishes synchronously inline. BindLevelUpRecompute/ResolveHealthAndDeath were extracted from BeginPlay with no behavior change.
- Deferred guest spawn: ACradlGameMode::RestartPlayer early-outs for !IsLocalController() (guest); RestartGuestPlayerNow (the latch callback) spawns the guest at the respawn/entry point (payload LastTransform/LastLevelId ignored, per the footgun).
- Guest reconcile key: ApplyProfile's ReconcileLatchKey for a guest is Guest_<GetPlayerId()> (link-safe; the online FUniqueNetId::ToString path isn't linked by CRADL). Open sub-task: robust cross-travel guest reward dedup (a guest re-submitting its unchanged disk profile after a host travel could re-drain pending rewards; PlayerId isn't travel-stable) — harden when guest map-travel is exercised. See Open Questions #3.
- Write-back (Phase 3 — BUILT): graceful-exit-only, client-initiated, acked persistence on ACradlPlayerController.
- Unified entry: both UCradlSettingsPageWidget exit handlers (HandleExitGameClicked, HandleMainMenuClicked) now funnel through ACradlPlayerController::RequestLogout(ECradlLogoutAction) instead of calling QuitGame / RequestReturnToMainMenu directly. RequestLogout branches on net mode: the host's own player / standalone (!= NM_Client) performs the exit immediately (its existing EndPlay disk save persists it); a guest (NM_Client) runs the handshake first.
- Transport: Server_RequestLogoutPersist() (client→server) + Client_PersistProfile(const TArray<uint8>&) (server→owner). The guest stashes the exit verb (PendingLogoutAction), fires the request, and starts a GuestLogoutPersistTimeoutSeconds (~5s) safety timeout so a silent host can't strand it (exit anyway → gains lost). The host's Host_PersistGuestProfile runs the in-combat death pipeline first (if Status.InCombat), CaptureProfiles the guest PS, SaveGameToMemory → bytes (size logged), and pushes them back. The guest deserializes, WriteProfileToDisks its own slot (machine-local), then CompleteLogout performs the deferred travel/quit.
- Host-shutdown guest sweep: when the host's own player logs out, PerformLogoutAction → SweepGuestsForLogoutPersist pushes Client_PersistProfile to every connected guest PC before the host tears down (Main-Menu rides the existing fade defer; Quit-App defers by GuestPersistFlushSeconds so the RPCs flush). A swept guest's Client_PersistProfile arrives with no pending logout — CompleteLogout is guarded by bAwaitingLogoutPersist, so it writes its slot without traveling.
- Location suppression: CaptureProfile skips last-known-location capture for a non-local PS (!bIsLocalPlayerState), so a guest's host-world position never clobbers the home spot on its own disk — WriteProfileToDisk's carry-forward preserves the guest's pre-join location, symmetric with the spawn-side rule that ignores a guest's payload LastTransform/LastLevelId.
- Validation chokepoint (Phase 5 — BUILT, V1 pass-through): UCradlSaveSubsystem::ApplyProfile calls ValidateProfileForApply(PS, Profile) on every non-null inbound profile before it lands on the authoritative components. The trust discriminator is PS->bIsLocalPlayerState — the host's own disk profile is trusted; a guest's RPC-delivered payload is the thing to scrutinize. Two outcomes are wired: in-place clamp (mutate the profile) and reject (return false → the same fresh-defaults path as a null profile, refactored into the shared ApplyFreshDefaults lambda, so a malformed / exploit payload never lands partial state). V1 is a pass-through: invite-only high-trust, so it clamps nothing and never rejects — the seam exists so host-side hardening (skill / Health / Mana clamps, unknown-item drops, ownership sanity) is a body edit, not a re-thread of the apply seam. Also the natural home for a server-authoritative EOS-era validation pass.
P2P replication audit (per feedback_p2p_replication_audit.md):
- The join payload and write-back are RPC byte buffers, not replicated state. Nothing is added to GetLifetimeReplicatedProps. Once ApplyProfile lands the guest's state on the authoritative components, the existing component replication (FastArray / OnRep / COND_OwnerOnly) carries the live state to the owning client and peers — the same paths the host's own player already uses.
- No save id is replicated (unchanged from Identity Carry-Through); binding is by the owning RPC connection.
- All apply/capture is authority-only (ApplyProfile / CaptureProfile assert HasAuthority()); disk reads/writes run on the owning client (machine-local, authority-independent).
- RPC size: a full profile may exceed the reliable-RPC bunch cap. Compress (FArchiveSaveCompressedProxy) first; chunk into sequenced RPCs reassembled host-side if still over; a transient replicated byte array is the last resort.
Footguns:
- Never RPC the write-back per autosave. The whole model rests on graceful-exit-only persistence; a per-tick stream re-introduces the bandwidth problem this design exists to avoid.
- The write-back must be client-initiated and complete before teardown. The host's OnLogout / guest-EndPlay is too late — the channel is already closing. The client requests, the host pushes, the client acks-by-writing, then the client travels/quits.
- The establishment latch is once-per-PS. A late payload (post-timeout) is ignored, not applied; applying it after the guest is already playing defaults re-creates the double-state the gate exists to prevent. Debug_LoadProfile is the separate, explicit mid-session re-apply and intentionally bypasses the gate.
- RunInitialUnlockSweep must run on both establishment branches (payload and timeout-defaults), per project_guest_progression_v1_deferred.md. ReconcileQuestRewardsForPlayer is slot-keyed and a guest has no host slot — re-keying reward reconciliation for guests is an explicit Phase-2 sub-task, not a silent drop.
- Spawn the pawn before the disconnect-death check so the death pipeline operates on a live pawn.
- A guest's payload LastTransform / LastLevelId are ignored for spawn. The Spawn Transform gate is already IsLocalController-gated (false for guests), so a guest always spawns at the host respawn/entry point regardless of where they last saved in their own world.
- In-combat hard-quit-to-revert is a known minor cheese (reverting to pre-join can beat dying-to-cold-storage). Accepted for invite-only high-trust V1; the only fix — a host-side post-death snapshot reconciled on the guest's next join — reintroduces a recoverable host-side guest copy and contradicts the loss rule.
Related: Save Lifecycle, Identity Carry-Through, Display Name (the ?Name= cosmetic-name handshake this sits beside), Spawn Transform, Loading Screen (the ILoadingProcessInterface hold), COMBAT_SYSTEM.md "In-Combat State", Open Questions #1, project_guest_progression_v1_deferred.md, project_commonuser_sourcing.md, feedback_p2p_replication_audit.md, feedback_carry_identity_forward.md.
Main Menu Return
Rule. Gameplay → menu transition is initiated by UCradlSettingsPageWidget's Main Menu button (and any future equivalent). The flow is:
- Button click →
UCradlHUDLayout::PushConfirmDialogwith an in-combat-blocking content payload. - On confirm →
UCradlGameFlowSubsystem::TravelToMainMenu(). TravelToMainMenuclearsActiveLevelId(so the menu world observes "not menu-originated") and opensUCradlLevelSettings::MainMenuMap.- Travel tears down the gameplay world.
ACradlPlayerState::EndPlayfires, running the save pipeline (including the in-combat death rule). - The menu world spawns
ACradlMenuPlayerController, which pushesUCradlAuthScreen(entry).
No per-PS identity needs to survive the travel — the menu spawns a new PlayerController + PlayerState pair (or no PS at all for the menu's GameMode setup, depending on PlayerStateClass), and the save key is resolved fresh from GetLocalSaveUserId() on the next entry. Identity persists in UCommonUserSubsystem because it's GameInstance-scoped; the AuthScreen on the next entry observes GetLocalPlayerInitializationState(0) already at LoggedInLocalOnly / LoggedInOnline and proceeds immediately if the user clicks Login or Play Offline again.
Why. Two guarantees the contract preserves:
- The user's last save is durable across the travel.
EndPlay's save runs before the world tears down; the next entry through Play sees the just-written profile. - The menu's auth state is observable, not re-acquired. The user doesn't have to re-Login after returning to the menu; the subsystem still has the
UCommonUserInfo. This matches the "Sign In once per session" UX expectation.
The in-combat confirm dialog gate exists because returning to the menu while Status.InCombat would otherwise be a logout-cheese path. The gate is UI-side advisory: it blocks the user from clicking through carelessly. The authoritative anti-cheese is EndPlay's "in-combat = death" rule — even if the user force-quits the app, the save records the disconnect-death.
Implementation surface:
- Files: UCradlSettingsPageWidget (Btn_MainMenu, HandleMainMenuClicked), UCradlHUDLayout::PushConfirmDialog, UCradlGameFlowSubsystem::TravelToMainMenu, UCradlLevelSettings::MainMenuMap.
- Confirm dialog: UCradlConfirmDialogWidget + FCradlConfirmDialogContent with InCombatTooltipText populated so the button gates while in combat.
Footguns:
- The menu's PlayerController is ACradlMenuPlayerController, not ACradlPlayerController. Setting the menu map's WorldSettings.GameModeOverride to the menu GM (which uses the menu PC) is non-optional — the gameplay GM's DefaultPawnClass = ACradlCharacter would spawn a character in the menu, breaking the UI-only input mode.
- TravelToMainMenu must clear ActiveLevelId before opening the world, not after. The new menu's GameMode evaluates HasActiveLevel() lazily on first read; if ActiveLevelId is still set when the gameplay-side EndPlay saves location, the save thinks it's still in a menu-originated session.
- Don't write a "return to menu without saving" debug path that bypasses EndPlay. Any such path must explicitly call SavePlayer with the current User first, or it desyncs the on-disk state from the runtime state.
- The Status.InCombat UI gate is not the authority. Per the COMBAT_SYSTEM contract, the authoritative rule is server-side at EndPlay. UI-side gating is a courtesy; never rely on it for anti-cheese.
Related: COMBAT_SYSTEM.md "In-Combat State" disconnect rule, UCradlSettingsPageWidget (the in-combat gate pattern).
Quit Semantics
Rule. There are exactly two Quit verbs:
- Quit to Main Menu (in-game): handled by the Main Menu Return flow above. Player returns to the menu world; the app keeps running; the AuthScreen reflects the persistent
UCommonUserSubsystemstate. - Quit App (in-game or menu): handled by
UKismetSystemLibrary::QuitGame(this, this, EQuitPreference::Quit, /*bIgnorePlatformRestrictions=*/false). Calls land at: - Menu:ACradlMenuPlayerController::HandleQuitClicked(called by bothUCradlAuthScreen::HandleClick_QuitandUCradlPlayScreen::HandleClick_Quit). - Gameplay:UCradlSettingsPageWidget::HandleExitGameClicked(in-combat confirm dialog →ACradlPlayerController::RequestLogout(ECradlLogoutAction::QuitApp)). For the host's own player / standalone this callsQuitGameimmediately (via the PC'sQuitAppNow); for a guest it first runs the write-back handshake (Remote-Peer Progression) and quits only after its profile is persisted to its own disk.
Quit App from gameplay must still run ACradlPlayerState::EndPlay, which means the in-combat-death rule applies — a player who Quits App while Status.InCombat is active is saved post-death-pipeline. A guest has no host-side disk slot, so its EndPlay does not write; its persistence is the write-back handshake's Client_PersistProfile → own-disk write, which the host runs through the same in-combat death pipeline before capturing.
Why. The distinction between "leave session" and "leave app" matters for the save invariant. Quit-to-Menu preserves the GameInstance (subsystem state survives, including CommonUser identity); Quit App tears it all down (next launch is cold). Both must honour the in-combat rule because the on-disk profile is the only thing that survives across both.
EQuitPreference::Quit is the cooperative shutdown — the engine runs EndPlay on all actors. EQuitPreference::Quit is the correct choice for both call sites; EQuitPreference::Background would not run EndPlay and would skip the save, which is the wrong outcome even on mobile.
Implementation surface:
- Files: ACradlMenuPlayerController::HandleQuitClicked, UCradlSettingsPageWidget::HandleExitGameClicked, UCradlAuthScreen::HandleClick_Quit, UCradlPlayScreen::HandleClick_Quit.
- QuitGame parameter: bIgnorePlatformRestrictions=false. On consoles, this respects the platform's "Quit App" policy (e.g. fade to OS).
Footguns:
- EQuitPreference::Background is not a substitute for Quit. Mobile-style suspend would skip EndPlay and skip the save. If a future mobile target wants suspend semantics, the save must move to a IApplicationLifetime hook (engine FCoreDelegates::ApplicationWillEnterBackgroundDelegate) — not relegate to QuitGame.
- Don't add a "Quit without save" debug path. Use Debug_DeleteProfile (which sets bSuppressSaves=true for the rest of the PIE session) as the existing surface; a parallel quit-without-save path would duplicate the suppression logic.
- The menu's Quit button is always enabled, even while the AuthScreen is InProgress. Per UCradlAuthScreen::ApplyState, the user can bail out of a stuck auth attempt. Disabling Quit during async would strand users on a hung login.
Related: Main Menu Return, COMBAT_SYSTEM.md "In-Combat State."
New Game (Profile Reset)
Rule. The play screen exposes an optional Btn_NewGame (BindWidgetOptional, same colocated-affordance idiom as Btn_EditName) whose click runs UCradlPlayScreen::PromptNewGame(). Presenting the modal is a play-screen concern — the modal is exclusively this screen's affordance, so its class ref lives here (unlike the shared display-name prompt, whose class lives on UCradlMenuLayout because the first-run gate over the auth screen and the play-screen rename both use it). The whole destructive flow lives on one widget — UCradlNewGameModalWidget, a true modal with its own self-contained lifecycle:
- Float above everything. The play screen
CreateWidgets the modal (class refNewGameModalClassonWBP_PlayScreen's Class Defaults; null = flow disabled) andAddToViewports it at ZOrder 200 — above the menu layout (0) and the floating name prompt (100). It is deliberately not a CommonUI activatable and never touchesRootStack, so no stack transition or focus restoration can displace it. The WBP's root must be a full-screen hit-testable scrim so pointer input can't reach the screens underneath. - Step 1 — retype the name. The current display name (
UCradlGameFlowSubsystem::PendingDisplayName— exactly what this screen just typed intoTxt_PlayerName) is installed as the entry box's hint text; the box starts empty — pre-filling would reduce the blocker to a click-through. The match is trimmed + case-insensitive: it's an accident blocker, not a test, and the answer is on screen.Btn_Continuestays disabled until the text matches; Enter in the box also advances on a match. - Step 2 — explicit Yes/No.
Btn_Confirmfires the modal'sOnConfirmedexactly once;Btn_Cancel(shared across both steps), ESC, or external teardown close without firing — a save deletion can never ride on an implicit teardown (NativeDestructdrops the callback unfired). - On confirm the modal's
OnConfirmedcalls back to the menu PC'sPerformNewGameReset()— the save/identity mutation stays PC-owned, alongside itsProceedToGamesave/travel authority:UCradlSaveSubsystem::DeleteProfile(GetLocalSaveUserId()), clearUCradlGameFlowSubsystem::PendingDisplayName(the chosen name was profile data — it dies with the profile), thenActivePlayScreen->ReturnToAuth()— the same hooks-then-deactivate route ESC takes (NativeOnHandleBackActionnow routes throughReturnToAuth, so the two paths can't drift). Back at auth, the next advance re-runs the first-run name gate (no pending name, no profile) exactly like a fresh install.
Entry gate: UCradlPlayScreen::PromptNewGame no-ops with a log warning when the class is unset or no profile exists on disk (invalid save ids land there too — ProfileExists rejects them).
Why.
- One widget, not a chained flow. Chaining the shared text-input prompt into a re-hosted confirm dialog through PC lambdas inherits each widget's quirks (the prompt's resolve-to-seed dismiss contract, CommonUI focus-restoration timing on the dialog pop). A single modal owns its steps, its cancel semantics, and its teardown — nothing external can half-advance it.
- Hint, not pre-fill; loose match, not exact. The step exists to force a deliberate act, not to gatekeep. Ghost text plus case-insensitive matching keeps it an accident blocker without ever stranding a player on their own capitalization.
- The name dies with the profile. Leaving PendingDisplayName set would silently stamp the dead character's name onto the new profile at the next SavePlayer; clearing it makes "new game" and "first game" the same code path from the name gate onward.
- Return via the existing flow. ReturnToAuth is the ESC path factored into a callable method — no second "go back" mechanism to keep in sync.
Implementation surface:
- Files: UCradlNewGameModalWidget (steps, match rule, focus, teardown), UCradlPlayScreen::PromptNewGame / Btn_NewGame / NewGameModalClass / ReturnToAuth, ACradlMenuPlayerController::PerformNewGameReset.
- WBP authoring (deferred user task): WBP_NewGameModal parents to UCradlNewGameModalWidget, binds TxtNameEntry, Btn_Continue, Btn_Confirm, Btn_Cancel, implements OnStepChanged to swap the two step panels (GetTargetName() is exposed for copy like "Type <name> to continue"), and gives the root a full-screen hit-testable scrim. Set NewGameModalClass on WBP_PlayScreen's Class Defaults; add Btn_NewGame to WBP_PlayScreen.
P2P replication audit (per feedback_p2p_replication_audit.md): nothing replicates. The flow is menu-local — no ACradlPlayerState exists — and mutates only this machine's disk slot plus GameInstance-scoped session state (PendingDisplayName).
Footguns:
- Don't pre-fill the entry box with the name. The hint carries the copy; the typing is the blocker. A pre-filled box turns the whole step into a click-through.
- ESC is eaten by CommonUI before Slate routes it to the modal (the same viewport-preprocessor behavior that drives the play screen's back handler — see Two-Screen Menu UX). Because the modal floats outside the RootStack, CommonUI routes the back action to the play screen (the top activatable), not the modal. So UCradlPlayScreen::NativeOnHandleBackAction is modal-aware: while the modal IsInViewport, ESC calls ActiveNewGameModal->Close() (cancel, no fire) and consumes the key; a second ESC then returns to auth. Btn_Cancel remains an equivalent bail-out. As a belt-and-suspenders backstop, PerformNewGameReset still null-checks ActivePlayScreen.
- Keep OnConfirmed single-outcome. Fired once from an explicit Yes, or never. Any new exit path on the modal must route through Close() (which drops the callback), never fire it.
Related: Display Name (the first-run gate this flow re-arms), Save Lifecycle (slot keying, DeleteProfile), Two-Screen Menu UX (the ReturnToAuth transition this reuses).
Two-Screen Menu UX
Rule. The front-end menu has exactly two screens, pushed in sequence onto UCradlMenuLayout's single RootStack:
UCradlAuthScreen(entry, pushed atBeginPlay):Btn_Login+Btn_PlayOffline+Btn_Quit. Owns the auth state machine (EAuthScreenState: Idle / InProgress / Error) and the pending-path tracker (EPendingAuthPath: None / Login_Stage1_Init / Login_Stage2_Login / Offline). Subscribes toUCommonUserSubsystem::OnUserInitializeCompleteviaUFUNCTION+AddDynamic. On terminal success callsPC->AdvanceToPlayScreen().UCradlPlayScreen(pushed by the PC'sAdvanceToPlayScreen):Btn_Play+Btn_Quit, plus the optional identity affordancesBtn_EditName(Display Name) andBtn_NewGame(New Game (Profile Reset)) (and, later,Btn_Settings). Stateless; dispatches clicks to the PC.
The PC (ACradlMenuPlayerController) is a thin dispatcher. It does not subscribe to OnUserInitializeComplete (the AuthScreen owns that subscription). Its public methods — AdvanceToPlayScreen(), HandlePlayClicked(), HandleQuitClicked(), NotifyReturningToAuth(), PromptDisplayNameChange(), PromptNewGame() — are the only auth-screen-to-PC and play-screen-to-PC seams. NotifyReturningToAuth is called by UCradlPlayScreen::ReturnToAuth (which both NativeOnHandleBackAction and the New Game reset route through) so the layout's OnReturningToAuth animation hook fires in lockstep with the screen's own back handling.
Idempotent re-entry. Each AuthScreen handler reads UCommonUserSubsystem::GetLocalPlayerInitializationState before issuing any subsystem call:
- Already
LoggedInOnline+ click Login → immediateNotifyAuthSucceeded, noTryTo*re-issued. - Already
LoggedInLocalOnly+ click Login →TryToLoginForOnlinePlay(upgrade path). - Already
LoggedInLocalOnly/LoggedInOnline+ click Play Offline → immediateNotifyAuthSucceeded.
UCradlAuthScreen::NativeOnActivated resets State = Idle and PendingPath = None on every activation — including the re-entry case where ESC popped UCradlPlayScreen back. This prevents the screen from rendering stale InProgress / Error UI after returning from a successful auth.
No logout flow is provided. The persistent UCommonUserSubsystem state across menu→play→menu cycles is intentional: identity, like the save slot it keys, is established once per app launch.
ESC contract (PlayScreen → AuthScreen). Three things must all be true for ESC to pop the play screen back to auth:
UCradlPlayScreenhasbIsBackHandler = true(set in its C++ constructor; the WBP inherits unless overridden).UCradlAuthScreenhasbIsBackHandler = false(default; the WBP should not opt in — there's nothing to back to from the entry screen).ACradlMenuPlayerController::DefaultInputMappingis set to an IMC whose action set includesIA_BackHandlermapped to ESC. The PC adds this IMC to the LocalPlayer's Enhanced Input subsystem onBeginPlay. Without it, the back-action binding registered bybIsBackHandlerresolves to an unmapped Enhanced Input action and ESC silently does nothing.
UCradlPlayScreen::NativeOnHandleBackAction overrides the engine default to (a) fire its own OnReturningToAuth animation hook, (b) call PC->NotifyReturningToAuth() for the layout-level hook, then (c) call Super::NativeOnHandleBackAction which deactivates the widget. Using NativeOnHandleBackAction instead of NativeOnDeactivated keeps these hooks ESC-specific — they won't fire if a future overlay pushes on top of PlayScreen.
Why. The two-screen split exists because pre-play state (settings popout, future loadout/character selection, future news feed) accumulates between auth and travel. A single-screen "Play Online does login + travel atomically" shape — the original design — left no room for that state. Moving auth to the entry screen and travel to the second screen makes the seam explicit.
State ownership is on the AuthScreen, not the PC, because:
1. The progress/error UI is screen-local — fading buttons to disabled, showing a spinner, rendering an error message all need the screen's widget tree.
2. The standalone "Sign In" use case (which previously had its own UCradlLoginShellScreen) was subsumed when auth moved to the entry screen — there's no longer a second auth surface to factor.
Implementation surface:
- Files: UCradlMenuLayout, UCradlAuthScreen, UCradlPlayScreen, ACradlMenuPlayerController.
- WBP authoring (deferred user task): WBP_AuthScreen parents to UCradlAuthScreen and binds Btn_Login, Btn_PlayOffline, Btn_Quit, optional ProgressPanel, optional StatusText. WBP_PlayScreen parents to UCradlPlayScreen and binds Btn_Play, Btn_Quit (plus optional Btn_EditName, Btn_NewGame, Txt_PlayerName).
- Class refs on BP_CradlMenuPlayerController CDO: AuthScreenClass, PlayScreenClass.
Footguns:
- State flip MUST precede subsystem call in UCradlAuthScreen::HandleClick_Login / HandleClick_PlayOffline. NULL OSS fires OnUserInitializeComplete synchronously inside the call; reversing the order causes the handler to observe Idle and never advance. The same ordering survives the async EOS case.
- Don't subscribe to OnUserInitializeComplete on both the PC and the AuthScreen. The previous design subscribed on both and tracked an EPendingMenuAction on the PC; that was deleted when auth moved entirely to the screen. Duplicating the subscription would re-introduce the race.
- ESC silently no-ops without the IMC. bIsBackHandler=true alone is necessary but not sufficient — the menu PC must AddMappingContext an IMC binding IA_BackHandler to ESC. Diagnostic: filter the Output Log on LogUIActionRouter; a working back-action shows the action firing, an unmapped one shows BlockGameInput consuming the key with no follow-up.
- Per feedback_umg_tooltip_delegate_init_timing.md: any delegate binding that needs to land before SynchronizeProperties snapshots the widget tree must happen in NativeOnInitialized, not NativeConstruct. The screen's CommonUser subscription is in NativeConstruct (which is correct because it doesn't drive a UMG-side property snapshot), but any future delegate that does drive UMG state should follow the timing rule.
- Btn_Quit stays enabled in every state. Don't disable it during InProgress — the user must be able to bail out of a stuck auth.
- UCradlPlayScreen carries no PS binding. The menu has no ACradlPlayerState. Settings widgets that bind to a PS (today's gameplay UCradlSettingsPageWidget) cannot be reused as-is on UCradlPlayScreen; the planned "full settings popout" is being designed PS/ASC-free for exactly this reason (see Open Questions).
Related: UCradlHUDLayout (gameplay-side layout for comparison), feedback_umg_tooltip_delegate_init_timing.md, feedback_slot_shadows_uwidget.md (general UMG hygiene).
Menu Transition Animation Hooks
Rule. The menu exposes three named transition events on the layout and one per matching screen, fired by the PC at the deterministic moments where a designer would want to paint an animation. All hooks are BlueprintImplementableEvents — WBPs override them and play animations; nothing in C++ waits for them except the Play → travel case (which uses a duration knob, not a callback).
| Transition | Layout hook | Screen hook | Fired by |
|---|---|---|---|
| Auth → Play (push) | UCradlMenuLayout::OnAdvancingToPlay |
UCradlAuthScreen::OnAdvancingToPlay |
ACradlMenuPlayerController::AdvanceToPlayScreen — before PushScreen |
| Play → Auth (ESC) | UCradlMenuLayout::OnReturningToAuth |
UCradlPlayScreen::OnReturningToAuth |
UCradlPlayScreen::NativeOnHandleBackAction → PC::NotifyReturningToAuth — before Super deactivation |
| Play → travel | UCradlMenuLayout::OnPlayTransitionStarted |
UCradlPlayScreen::OnPlayTransitionStarted |
ACradlMenuPlayerController::HandlePlayClicked — before the deferred ProceedToGame |
UMG's native BP_OnActivated / BP_OnDeactivated continue to fire on each screen as well — the explicit named events run before the stack push/pop so animations queued in them play in lockstep with the CommonUI transition rather than fighting it.
Why. Three reasons the named events exist alongside the native lifecycle events:
- The Layout doesn't get UMG lifecycle events when its child screens transition. It owns the stack, not the screens. Without explicit hooks fired by the PC, the Layout can't paint root-level transition art (vignettes, full-screen fades, audio cues).
- Direction is unambiguous.
BP_OnDeactivatedfires for any deactivation; the named events declare directionality (Auth→Play, Play→Auth, Play→travel) so future overlay-on-top-of-PlayScreen cases don't accidentally trigger the back-to-Auth animation. - Play → travel needs the duration knob. Travel tears down the world; without a deferred fire the outro animation gets cut.
PlayTransitionDuration(on the PC, default0.f) delaysProceedToGameby that many seconds after firing bothOnPlayTransitionStartedevents, giving the WBP time to play.
Implementation surface:
- Hooks: see table above.
- Delay knob: ACradlMenuPlayerController::PlayTransitionDuration (UPROPERTY(EditDefaultsOnly, Category="Cradl|UI", meta=(ClampMin="0.0")), default 0.f).
- Active widget tracking: PC stores ActiveAuthScreen / ActivePlayScreen TObjectPtrs after pushing each, so the hooks fire on the right instance.
- Timer: FTimerHandle PlayTransitionTimer on the PC; scheduled in HandlePlayClicked when PlayTransitionDuration > 0.f.
Footguns:
- Auth↔Play stack-transition timing is governed by UCommonActivatableWidgetStack's own TransitionDuration/TransitionType settings on the WBP, not by anything on the PC. If you want the screens to crossfade slowly during the push/pop, configure that on the stack widget in WBP_MenuLayout. The named events fire once when the transition starts; the actual animation length is the stack's.
- OnPlayTransitionStarted is the only hook with a deferred-action contract. The other two named events are fire-and-forget — the stack transition starts essentially immediately after. Don't author animations that need to outlast the stack push expecting C++ to wait.
- Don't rely on ActivePlayScreen outliving its stack popping. When ESC pops PlayScreen, the screen is released back to the pool (per UCommonActivatableWidgetContainerBase::HandleActiveIndexChanged in the engine). The TObjectPtr becomes null. The PC doesn't currently re-fire transition hooks against a dead screen; if you add one, null-check.
- NotifyReturningToAuth is a one-way notification, not a query. PlayScreen calls it on the PC, the PC fires the layout hook, that's it. Don't add return values or expect the PC to mutate state in response — the screen's NativeOnHandleBackAction::Super handles the actual deactivation right after.
Related: UCommonActivatableWidgetStack push/pop semantics (the stack keeps inactive widgets on its switcher, releases above-active on transition).
Loading Screen
Rule. Every map travel through UCradlGameFlowSubsystem is covered by ULoadingScreenManager's engine-delegate auto-hookup (FCoreUObjectDelegates::PreLoadMapWithContext + PostLoadMapWithWorld). The widget class is configured via [/Script/CommonLoadingScreen.CommonLoadingScreenSettings] in DefaultGame.ini. The displayed screen holds for ≥ HoldLoadingScreenAdditionalSecs (currently 2.5s) regardless of how fast the world actually loads. No call-site changes in UCradlGameFlowSubsystem::TravelToLevel / TravelToMainMenu — the manager attaches at the engine-delegate level, so every existing travel point inherits the hold.
Why. Per the OSRS heritage in THEME.md and user-side feel preference, "ready too fast" reads as amateur. The hold is a load-bearing feel mechanic, not a fallback. It compounds with ACradlMenuPlayerController::PlayTransitionDuration to give a two-layer transition: WBP menu-outro animation plays during PlayTransitionDuration → world tears down → loading widget covers the load → loading widget holds for the floor → gameplay HUD reveals. The same applies in reverse on Main Menu Return (OnReturningToAuth outro is layered before the load-screen hold).
Camera-fade layer (departure-side). The plugin shows/hides the loading widget via instant AddViewportWidgetContent / RemoveWidgetFromViewport — the 2.5s hold is a sustain between two hard cuts, with no fade of its own. To soften the departure edge, both travel paths kick a camera fade-to-black before the world tears down, reusing the same APlayerCameraManager::StartCameraFade(0→1, …, bHoldWhenFinished=true) shape as the death pipeline (CradlPlayerController.cpp, HandleDeathStateChanged):
- Menu → gameplay: ACradlMenuPlayerController::HandlePlayClicked kicks PlayFadeOutDuration (default 0.5s) alongside the outro hooks. Keep PlayFadeOutDuration <= PlayTransitionDuration — travel is deferred by PlayTransitionDuration and world teardown destroys the camera manager, so a longer fade is cut. While PlayTransitionDuration is 0 (its default) travel is synchronous and the fade gets no frame — bump PlayTransitionDuration when authoring the outro.
- Gameplay → menu: ACradlPlayerController::RequestReturnToMainMenu kicks ReturnToMenuFadeOutDuration (default 0.5s), then defers TravelToMainMenu by that duration. The world stays alive for the whole fade window (unlike the menu path's PlayTransitionDuration gate), so this half is robust by default. UCradlSettingsPageWidget::HandleMainMenuClicked routes its confirm callback here rather than calling TravelToMainMenu directly, so the fade is owned controller-side (where the camera manager lives).
- Gameplay → gameplay (map-to-map): reuses the gameplay→menu shape exactly — a controller-owned StartCameraFade then a deferred TravelToLevel. The world stays alive through the fade, so it inherits the same robustness. See Map-to-Map Travel. On the arrival side, LEVEL_FLOW_SYSTEM.md's intro subsystem extends the loading-screen hold past the load via the reserved ILoadingProcessInterface seam (Open Q #1), so an intro'd level reveals on intro frame 0 rather than the raw world.
The camera fade tints the 3D viewport only — UI widgets layered above (the menu layout, HUD, confirm dialog, and the loading widget itself) render on top, unfaded. So the departure fade dims the world background under the WBP outro and blacks the viewport before the loading widget pops (no "world peek" flash); the UI's own outro stays the WBP animation's job.
Arrival-side fade-in is a WBP author concern — no code. Matching the idiomatic Lyra pattern, the new world reveals via a root-canvas RenderOpacity 0→1 animation on WBP_CradlHUDLayout / WBP_MenuLayout (authored in BP_OnInitialized), not a camera-side hold. A symmetric camera-side "instant-black on BeginPlay, fade-from-black on first HUD construct" was considered and rejected as the more complex, less idiomatic option; the trade is that a WBP author who forgets the animation gets an instant pop-in on arrival rather than a fade.
Implementation surface:
- Plugin: Plugins/CommonLoadingScreen (vendored from Lyra, see project_commonuser_sourcing.md for the parallel rationale on why Epic-blessed-but-not-engine-bundled modules live in Plugins/).
- Two modules in the plugin: CommonLoadingScreen (Runtime, Default phase — owns ULoadingScreenManager which covers menu↔gameplay), and CommonStartupLoadingScreen (ClientOnly, PreLoadingScreen phase — FCommonPreLoadScreen runs before any UObject exists, covers boot before GameInstance is alive). Only the Runtime module is a CRADL.Build.cs dependency; the startup module loads on its own schedule and is not referenced from game code.
- Widget base class: UCradlLoadingScreenWidget — an empty UUserWidget subclass that exists as a stable parent for WBP_CradlLoadingScreen. Matches the UCradlAuthScreen / UCradlPlayScreen parenting shape.
- Config: ini block in DefaultGame.ini sets LoadingScreenWidget=/Game/UI/WBP_CradlLoadingScreen.WBP_CradlLoadingScreen_C, HoldLoadingScreenAdditionalSecs=2.5, HoldLoadingScreenAdditionalSecsEvenInEditor=True, LogLoadingScreenReasonEveryFrame=False. LoadingScreenZOrder stays at the plugin default (10000 — covers HUD).
- Reserved seam: ILoadingProcessInterface. Not implemented in v1. A TODO(EOS) comment above ACradlMenuPlayerController::HandlePlayClicked names the exact extension point — register the PC as a loading processor when EOS async lands so the screen holds across menu→gameplay until the online stage completes.
Footguns:
- HoldLoadingScreenAdditionalSecsEvenInEditor=True is non-default. Without it, PIE skips the hold and the feel cannot be verified during iteration. If a future engineer wonders why PIE adds 2.5s to every play, this is the deliberate trade.
- ULoadingScreenManager blocks all keyboard/mouse input via FLoadingScreenInputPreProcessor while visible. Don't author the loading widget to expect clicks — they won't land. The widget is presentational only.
- Diagnostic: flip LogLoadingScreenReasonEveryFrame=True (or the CVar CommonLoadingScreen.LogLoadingScreenReasonEveryFrame 1) to spam the per-frame held-reason to the log when the screen misbehaves. CommonLoadingScreen.AlwaysShow 1 forces the screen on indefinitely — useful for validating the widget loads at all.
- The seam for extending the hold (EOS async, P2P peer-identity wait, future shader precompile) is ILoadingProcessInterface — see Open Q #1. It never shortens the floor; per feedback_loading_feel_min_hold.md, the floor is the minimum, not a fallback to be eroded.
- UCradlAuthScreen::MinProgressDisplaySeconds is a separate floor — it gates how long the InProgress UI is held before the screen pushes its successor. The loading screen floor covers the next travel. The two compose, they don't replace each other.
Related: Menu Transition Animation Hooks (the WBP outro layer that fires before the load screen takes over), Open Questions #1 (EOS async retrofit reuses the reserved seam), feedback_loading_feel_min_hold.md, project_commonuser_sourcing.md.
Tag Taxonomy
PLAYFLOW introduces one tag (Message.Reason.Welcome, for the display-name welcome toast). It otherwise depends on existing tag namespaces:
| Tag | Source | Used By |
|---|---|---|
Message.Reason.Welcome |
CradlGameplayTags.h (C++ symbolic ref) + Config/DefaultGameplayTags.ini (authoritative declaration) |
The personalized welcome toast posted from ACradlPlayerState::BeginPlay once the display name resolves |
Status.InCombat |
CradlGameplayTags.h (C++ symbolic ref) + Config/DefaultGameplayTags.ini (authoritative declaration) |
EndPlay disconnect-death rule, Main Menu Return / Quit App in-combat gates |
Combat.Event.Death |
same | Fired by LoadPlayer's bPendingDisconnectDeath handshake and by BeginPlay's spawn-with-0-HP check |
Message.Reason.ColdStorage.Stashed / .Restored / .PartialRestore |
same | Player-facing toast messages from LoadPlayer's cold-storage triage + resurrection |
UI.Layer.GameMenu |
same | Confirm dialog stack (gameplay-side Quit-to-Menu confirmation) |
UI.Layer.Modal |
same | Confirm dialog stack |
Per feedback_gameplay_tag_decl_minimal.md, the C++ symbolic refs in CradlGameplayTags.h are a subset of the .ini's authoritative list, included only where code references them by name.
Forward Code References
Future PRs that touch PLAYFLOW will land at these paths. Listed for stable cross-doc anchoring:
- Source/CRADL/Player/CradlGameInstance.h / .cpp — machine-local save-id resolver (
GetLocalSaveUserId);WITH_EDITORsynthetic per-PIE-instance save user (Remote-Peer Progression Phase 0) - Source/CRADL/Player/CradlGameMode.h / .cpp — RestartPlayer spawn gate (local-player-gated); deferred guest
RestartPlayeruntil establishment (Remote-Peer Progression Phase 4) - Source/CRADL/Player/CradlPlayerState.h / .cpp — local-player save/autosave/load orchestration + display-name stamp;
Awaiting/OnStateEstablished()establishment latch +ApplyJoinPayload(deserialize →ApplyProfile) - Source/CRADL/Player/CradlPlayerController.h / .cpp —
ILoadingProcessInterfacejoin hold + the Remote-Peer handshake RPCs: join (Server_SubmitJoinProfile,Client_JoinStateEstablished) and graceful-exit write-back (RequestLogout,Server_RequestLogoutPersist,Client_PersistProfile,Host_PersistGuestProfile, host-shutdown guest sweep) - Source/CRADL/Player/CradlLocalPlayer.h / .cpp —
GetNicknameoverride feeding the?Name=client→server handshake - Source/CRADL/Player/CradlMenuPlayerController.h / .cpp — menu dispatcher, ProceedToGame, New Game reset (
PerformNewGameReset) - Source/CRADL/UI/CradlAuthScreen.h / .cpp — auth state machine
- Source/CRADL/UI/CradlPlayScreen.h / .cpp — post-auth landing screen
- Source/CRADL/UI/CradlMenuLayout.h — menu root layout
- Source/CRADL/UI/CradlLoadingScreenWidget.h — loading screen base class
- Source/CRADL/UI/CradlTextInputWidget.h / .cpp — floating display-name prompt + sanitize
- Source/CRADL/UI/CradlNewGameModalWidget.h / .cpp — self-contained New Game modal (retype → Yes/No)
- Plugins/CommonLoadingScreen — forked subsystem (auto-hooks map travel)
- Source/CRADL/UI/CradlSettingsPageWidget.h — gameplay-side Quit / Main Menu return
- Source/CRADL/SaveGame/CradlSaveSubsystem.h / .cpp — per-user save orchestrator;
ResolveProfile/ApplyProfile+CaptureProfile/WriteProfileToDiskseam split (Remote-Peer Progression Phase 1; EOS-cloud-save drop-in point) +ValidateProfileForApplytrust-boundary chokepoint (Phase 5, V1 pass-through) - Source/CRADL/SaveGame/CradlPlayerProfile.h — on-disk schema
- Source/CRADL/Levels/CradlGameFlowSubsystem.h — cross-travel level carrier
- Source/CRADL/Levels/CradlLevelSettings.h — DefaultStartingLevel + MainMenuMap
- Source/CRADL/Levels/CradlLevelDefinition.h — Primary asset wrapping a world reference
- Source/CRADL/Combat/CradlRespawnPoint.h / .cpp — spawn fallback (gains an entry
FGameplayTagfor map-to-map entry points) Source/CRADL/Levels/CradlEntryPointRegistry.h/ .cpp — registry world subsystem tagged respawn points register into (map-to-map entry resolution; replaces anyGetAllActorsOfClass)- Plugins/CommonUser — forked subsystem (OSSv1)
Open Questions
- Async identity contract — adopted; EOS transport deferred. The hazard this once tracked (an identity broadcast landing after
PS::BeginPlay, soGetLocalSaveUserId()returnsPLATFORMUSERID_NONEand the player loads defaults / never writes a real save) is closed by design — but by two different mechanisms depending on who's loading, and the precision matters so a future EOS retrofit doesn't misjudge what's already handled: - Local player (host-own / standalone): still loads inline inBeginPlay— there is no identity-resolved deferral here. Its async-safety comes entirely from the menu's auth-gates-travel ordering:UCradlAuthScreenblocks the Play screen untilOnUserInitializeCompleteresolves, so by the time the gameplayBeginPlayruns,GetLocalSaveUserId()is already valid even under EOS. Don't assume the establishment latch covers this path — it doesn't. - Remote guest: does defer — the establishment latch (Awaiting→OnStateEstablished()on payload-apply or ~15s timeout) holds the guest behind the loading screen via the reservedILoadingProcessInterfaceseam (see Loading Screen), so a slow/never join-payload never shows a wrong-state character. This is the path written to the asyncOnUserInitializeCompletecontract; NULL OSS just happens to fire it synchronously.
What remains deferred is only the EOS transport/config (SDK, sandbox, the EOSPlus shim for the Steam+EOS dual storefront — project_shipping_targets.md / project_commonuser_sourcing.md) and the dormant CommonSession join flow; enabling them is a config change, not a re-architecture. The one genuine retrofit this question still holds open: if EOS ever resolves the local identity asynchronously after the menu (it currently resolves at the AuthScreen, so it doesn't), the local player's inline load would need the same deferral the guest already has.
-
Full settings popout (PS/ASC-free).
UCradlPlayScreenis missing aBtn_Settingsuntil the project's planned "full settings" popout lands. That popout is being designed PS/ASC-free so both the menu and the gameplay HUD can spawn it; the existingUCradlSettingsPageWidget's ASC bindings (for in-combat gating of destructive actions) stay on the gameplay-side wrapper. The popout's authoring path is a separate design conversation — flagged here so PLAYFLOW knows where the settings seam lands. -
Remote-peer persistence + instantiation — resolved; promoted to Remote-Peer Progression. The committed model: a one-time client→server join payload applied authoritatively through the shared
ApplyProfileseam (load-in), a gated establishment latch (no wrong-state window), and graceful-exit-only write-back to the guest's own disk (hard-quit = session loss). Client-local persistence — the host is never the at-rest source of truth — chosen over host-aggregated. The phased build (Phase 0 PIE harness → Phase 5 validation seam) lives in that section; all of Phases 0–5 are built for V1 (PIE harness + theResolveProfile/ApplyProfile/CaptureProfile/WriteProfileToDiskseam split + the join handshake + the gated, no-flash establishment latch with deferred guest spawn + the graceful-exit, acked write-back handshake with host-shutdown guest sweep + theValidateProfileForApplytrust-boundary chokepoint). What remains is hardening within the shipped seams, not unbuilt phases: (a) per-field guest-profile clamping/rejection — Phase 5 is a deliberate pass-through for invite-only V1, soValidateProfileForApplycurrently trusts everything; (b) cross-travel guest reward dedup — the guest reconcile keys onGuest_<PlayerId>viaApplyProfile'sReconcileLatchKey, which runs the reconcile once per join but isn't travel-stable, so a guest re-submitting its unchanged disk profile after a host travel could re-drain pending rewards; (c) the RPC-size strategy — not deferred polish but a verification gate for the first two-instance test: a reliable RPC over the bunch cap fails outright (dropped call / disconnect), it doesn't degrade, so before the join / write-back can be relied on, read the logged payload sizes (Server_SubmitJoinProfileandHost_PersistGuestProfileboth log byte counts) and, if any approach the cap, compress (FArchiveSaveCompressedProxy) → chunk into sequenced RPCs reassembled host-side. -
Level-picker UI (if introduced). Today
ProceedToGamereads savedLastLevelIdor falls back toDefaultStartingLevel. A future "select a level" page onUCradlPlayScreenwould surfaceULevelDefinitionassets and feed the chosen id intoTravelToLevel. The contract requires that this UI funnel throughTravelToLevel(FPrimaryAssetId)— no parallel "ProceedToSpecificLevel" path. -
Console save sandboxing — not a current target; low priority. The shipping plan is EGS + Steam (PC) (
project_shipping_targets.md), so this is speculative.UGameplayStatics::SaveGameToSlot'sUserIndexis hardcoded to0; the Identity Carry-Through footgun already flags it as the single place to revisit, and a console per-user save sandbox would derive it fromFPlatformUserId::GetInternalId()(or the platform's mapping). Kept as a one-line pointer only — no work until a console target is actually committed.