CRADL Session System
Companion to ARCHITECTURE.md and PLAYFLOW_SYSTEM.md. This document is the contract the session layer must satisfy — who hosts and when, how invites are gated, how travel and level identity work under a session, how teardown and host loss resolve, and what each storefront build configures. Implementation patterns, EOS portal values, and per-screen UI briefs live elsewhere; what's here does not change without a deliberate edit to this file.
North Star
Online play is the existing playflow over a different transport, not a new mode. When a player who logged in online clicks Play, their world becomes a private, invite-only listen server that friends can drop into through the platform overlay — the OSRS-hearted "my world, friends welcome" model. There is no in-game session browser, no lobby screen, no matchmaking: the platform overlay is the entire social surface, and the main menu is the only doorway in. The session layer reuses every progression pattern the Remote-Peer Progression handshake established: it adds transport and gatekeeping, not foundation.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Who orchestrates sessions | UCradlSessionSubsystem (new), subclass of UCommonSessionSubsystem |
Ownership |
| When a session is hosted | LoggedInOnline + Play click; offline path unchanged (OpenLevel) |
Hosting |
| Lobby privacy | EOS lobby, not advertised, invites + friends-presence-join only — new request fields threaded through the vendored plugin | Hosting |
| How invites arrive | Platform overlay → OnUserRequestedSessionEvent; the game decides |
Join gate |
| Join gate | Menu world + LoggedInOnline + armed latch + no session in flight; refuse loudly |
Join gate |
| Host travel | HostSession → ServerTravel(map?listen); ActiveLevelId latched first |
Hosting |
| Guest travel | JoinSession → ClientTravel; existing handshake takes over unchanged |
Guest arrival |
| Guest level identity | Replicated SessionLevelId on ACradlGameState (new); guests never set ActiveLevelId |
Level identity |
| Mid-session map travel | Host ServerTravel + host-side session progression cache keyed by net id |
Mid-session travel |
| Leaving / teardown | Converges on RequestLogout; CleanUpSessions is the last step, after write-back flush |
Teardown |
| Host loss | OnNetworkFailure → one local recovery: message + RequestReturnToMainMenu |
Host loss |
| Refusal / error UX | In-game: message log; menu: info modal; strings on UCradlSessionSettings (new) |
Feedback |
| Storefronts | Steam = EOSPlus + Connect-only; EGS = EOS + EAS; EOS sessions + NetDriverEOS everywhere |
Storefronts |
| New replicated state | Exactly one property: ACradlGameState::SessionLevelId |
Replication audit |
Ownership & Orchestration
Rule: One GameInstance-scoped owner: UCradlSessionSubsystem (new), a subclass of UCommonSessionSubsystem. The plugin's ShouldCreateSubsystem already yields to a game-specific subclass (Plugins/CommonUser/Source/CommonUser/Private/CommonSessionSubsystem.cpp), so the subclass replaces the base cleanly. It owns: the host entry point, the invite gate, the failure funnel, the session progression cache, and teardown placement. No other CRADL class talks to the OSS session interface.
Why: The behaviors this contract needs that the stock plugin does not provide are reachable only by subclassing: HandleSessionFailure and TravelLocalSessionFailure are log-only virtual stubs with no broadcast (CommonSessionSubsystem.cpp), and the invite event needs exactly one gatekeeper. A separate listener subsystem could bind OnUserRequestedSessionEvent but could never see session failures. GameInstance scope also gives the progression cache travel-survival for free, per the identity-carry rule (feedback_carry_identity_forward.md).
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.h (new), Source/CRADL/Session/CradlSessionSubsystem.cpp (new)
- Classes: UCradlSessionSubsystem (new) : UCommonSessionSubsystem. Binds its own OnUserRequestedSessionEvent and GEngine->OnNetworkFailure in Initialize; overrides HandleSessionFailure / TravelLocalSessionFailure into the failure funnel.
- Access pattern: callers reach it via UGameInstance::GetSubsystem<UCradlSessionSubsystem>. An interface here would be pure ceremony for a single implementer (CLAUDE.md interface rule's stated exception).
Footguns:
- OnUserRequestedSessionEvent broadcasts failures too — RequestedSession can be null with bWasSuccessful=false (documented on FCommonSessionOnUserRequestedSession, CommonSessionSubsystem.h). The gate null-checks before any state checks.
- CleanUpSessions is re-entered from internal completion handlers (OnEndSessionComplete, OnStartSessionComplete) — a subclass override runs on those paths too, not just from menu code.
- Don't reach for a sibling standalone subsystem "to keep the plugin type out of game code" — the failure virtuals make the subclass load-bearing; see Why.
Related: PLAYFLOW_SYSTEM.md (identity lives in UCommonUserSubsystem; this subsystem never re-owns auth state), Host loss.
Hosting — Online Play Is a Private Lobby
Rule: In ACradlMenuPlayerController::ProceedToGame (Source/CRADL/Player/CradlMenuPlayerController.cpp), when UCommonUserSubsystem::GetLocalPlayerInitializationState(0) == ECommonUserInitializationState::LoggedInOnline, the resolved level id routes to UCradlSessionSubsystem::HostLevelSession (new) instead of UCradlGameFlowSubsystem::TravelToLevel. That path: latches ActiveLevelId via UCradlGameFlowSubsystem::LatchActiveLevel (new — extracted from TravelToLevel's existing latch step), then calls HostSession with a UCradlSessionHostRequest (new) configured as: OnlineMode=Online, bUseLobbies=true, not advertised, invites allowed, presence-join restricted to friends, join-in-progress allowed, MaxPlayerCount from UCradlSessionSettings. ConstructTravelURL already appends ?listen for Online mode and carries ExtraArgs (CommonSessionSubsystem.h); ExtraArgs carries CradlLevelId=<PrimaryAssetId>. The LoggedInLocalOnly path is byte-for-byte unchanged. Solo online play is hosting: a party of one whose friends can drop in.
The privacy flags require an in-plugin edit (decided fork: vendored copy, edited in place): FCommonSession_OnlineSessionSettings is .cpp-private and CreateOnlineSessionInternalOSSv1 is non-virtual, so new UPROPERTY fields on UCommonSession_HostSessionRequest (new fields: bShouldAdvertise default true, bAllowJoinViaPresenceFriendsOnly default false) are threaded into the settings ctor exactly the way MaxPlayerCount and bUseLobbies already flow (CommonSessionSubsystem.cpp).
UCradlSessionHostRequest (new) : UCommonSession_HostSessionRequest overrides the virtual GetMapName to resolve the world package from ULevelDefinition::Level (Source/CRADL/Levels/CradlLevelDefinition.h) instead of requiring maps to be registered as World-typed primary assets — the plugin's stock resolution goes through UAssetManager::GetPrimaryAssetData on a World MapID, which CRADL's LevelDefinition-keyed identity doesn't satisfy.
Why: TravelToLevel is OpenLevelBySoftObjectPtr and self-documents as single-player-only (Source/CRADL/Levels/CradlGameFlowSubsystem.h); PLAYFLOW commits the P2P path to host-driven ServerTravel, which is exactly what HostSession → FinishSessionCreation does. Latching before travel preserves the load-bearing level-id latch semantics (EnsureLevelContext reads the flow subsystem on the host; the latch order is unchanged from today's TravelToLevel). Invite-only-not-advertised is the user-facing promise: private and open to friends only, discoverable by nobody.
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.cpp (new), Source/CRADL/Session/CradlSessionHostRequest.h (new), Source/CRADL/Player/CradlMenuPlayerController.cpp (branch in ProceedToGame), Source/CRADL/Levels/CradlGameFlowSubsystem.h (LatchActiveLevel extraction), Plugins/CommonUser/Source/CommonUser/ (privacy-field threading)
- Classes: UCradlSessionSubsystem::HostLevelSession(APlayerController*, FPrimaryAssetId) (new); UCradlSessionHostRequest (new); UCommonSession_HostSessionRequest.bShouldAdvertise / .bAllowJoinViaPresenceFriendsOnly (new plugin fields)
- Replication: hosting state (PendingTravelURL, HostSettings) is plugin-internal and host-local — nothing replicated.
Footguns:
- OnCreateSessionCompleteEvent success fires before ServerTravel; travel failure afterward reaches only TravelLocalSessionFailure. Never treat create-success as "we are in the world."
- The plugin edits keep the new fields' defaults at stock behavior (bShouldAdvertise=true, friends-only off) so the DFR-style public-lobby path remains expressible and the diff against Lyra stays minimal.
- New plugin includes follow the existing // CRADL: 5.4 fixes (Online/OnlineSessionNames.h, not the deprecation shim — see the marker in CommonSessionSubsystem.cpp).
- HostSession with an unresolvable map routes to OnCreateSessionComplete(NAME_None, false) — surface it (see Feedback), don't strand the menu mid-fade. The PlayTransitionDuration fade must abort back to an interactive menu on host failure.
- Don't pre-create the session at the menu and travel later ("menu as lobby") — the DFR pattern and this contract both create-then-travel in one motion; the menu is never a joinable world.
Related: PLAYFLOW_SYSTEM.md Play path, Level identity, Storefronts.
Invites & the Main-Menu Join Gate
Rule: Invites exist only through platform overlays. UCradlSessionSubsystem binds OnUserRequestedSessionEvent (fed by the stock HandleSessionUserInviteAccepted, CommonSessionSubsystem.cpp) and honors an accepted invite only when all four hold:
1. The local world is the main menu — detected structurally: the local APlayerController is ACradlMenuPlayerController (Source/CRADL/Player/CradlMenuPlayerController.h); there is no string/map-path predicate and none is added.
2. UCommonUserSubsystem::GetLocalPlayerInitializationState(0) == LoggedInOnline.
3. The subsystem's own arm latch bOverlayInvitesArmed (new, subsystem-local bool) is set. It is armed when the menu reaches the play screen after login (ACradlMenuPlayerController::PushPlayScreen), and disarmed on any travel out of the menu and on logout. This is the discriminator that refuses cold-start invites: an invite accepted while the game was closed is queued by the platform layer and fires inside the login callstack — before the play screen exists — and is therefore refused. Joining is possible only from a game already sitting at the main menu, by design.
4. No host or join flow is already in flight (subsystem-internal state; it owns both paths).
A gate pass calls JoinSession with the delivered UCommonSession_SearchResult. Any gate failure refuses loudly (Feedback) — never silently, per the fail-at-intent rule (feedback_stop_short_physical_impediment_only.md).
Why: The plugin deliberately does nothing with an accepted invite beyond broadcasting — honoring it is 100% game policy, which is exactly where a main-menu-only rule belongs. Structural menu detection is the only predicate that exists (ACradlMenuGameMode is a World Settings override on the menu map alone, Source/CRADL/Player/CradlMenuGameMode.h); HasActiveLevel() == false is deliberately ambiguous with direct-PIE and is not a menu test.
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.cpp (new), Source/CRADL/Player/CradlMenuPlayerController.cpp (arm/disarm calls)
- Tags: Message.Reason.Session.JoinRefused (new) on refusal posts (Tag Taxonomy)
- Replication: the gate is entirely client-local; nothing replicated.
Footguns:
- Null-check RequestedSession first — the event fires on platform-layer failures too.
- The Steam-mirror bridge means an invite accepted on Steam arrives as an EOS search result (SETTING_CUSTOM_JOIN_INFO resolution in engine EOSPlus, verified) — the gate code is storefront-agnostic; do not special-case by platform.
- If EOS login hasn't completed when a platform invite is accepted, engine EOSPlus queues and re-fires it after login (PendingInviteResultsPerUser) — that re-fire is precisely the cold-start case the arm latch refuses. Don't "fix" a refused queued invite by arming earlier.
- The arm latch is a writer-owned bool tracking what the menu published, not a mirror of world state — never reseed it by inspecting the world (feedback_loose_tag_writer_bool_no_reseed.md discipline generalizes).
- An invite arriving mid-game must not touch gameplay: refusal is a message-log post only, no pause, no modal over gameplay.
Related: Guest arrival, Feedback, Open Question 1 (cross-storefront invites).
Join Travel & Guest Arrival
Rule: A gate pass calls JoinSession(MenuPC, Result); the stock plugin resolves the EOS P2P connect string and ClientTravel(URL, TRAVEL_Absolute) (CommonSessionSubsystem.cpp). From the moment the connection opens, the built Remote-Peer Progression handshake runs unchanged and unwrapped: ACradlPlayerController::TrySubmitJoinProfile (BeginPlay + OnRep_PlayerState) → Server_SubmitJoinProfile → ACradlPlayerState::ApplyJoinPayload buffer/latch → OnStateEstablished → ACradlGameMode::RestartGuestPlayerNow → Client_JoinStateEstablished drops the ILoadingProcessInterface hold (Source/CRADL/Player/CradlPlayerController.h, Source/CRADL/Player/CradlPlayerState.h, Source/CRADL/Player/CradlGameMode.cpp). The session layer adds nothing to this chain. Display names ride the existing engine ?Name= handshake via UCradlLocalPlayer::GetNickname (Source/CRADL/Player/CradlLocalPlayer.h) — no new plumbing.
Between JoinSession and travel, the menu enters a non-interactive "joining" hold on ACradlMenuPlayerController — the seam its own TODO(EOS) comment already reserves (ILoadingProcessInterface keyed off pending async work, Source/CRADL/Player/CradlMenuPlayerController.cpp). OnJoinSessionCompleteEvent failure (SessionIsFull, SessionDoesNotExist, generic) releases the hold and surfaces a menu modal.
Why: The handshake was designed for exactly this arrival and is already audited (pure-RPC, nothing replicated); wrapping it would violate the reuse-wholesale rule (feedback_reuse_proven_pipeline.md). The join hold honors the feel floor discipline — resolve on completion, never instantly (feedback_loading_feel_min_hold.md; MinProgressDisplaySeconds precedent on Source/CRADL/UI/CradlAuthScreen.h).
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.cpp (new) (bind OnJoinSessionCompleteEvent), Source/CRADL/Player/CradlMenuPlayerController.cpp (joining hold)
- Replication: none added; the handshake's existing posture (reliable RPCs, authority-local transients) is inherited as-is.
Footguns:
- OnJoinSessionCompleteEvent success fires before ClientTravel — a post-success connect failure arrives via the network-failure funnel (Host loss), not this event.
- InternalTravelToSession travels GetFirstLocalPlayerController (in-source @TODO acknowledges it ignores the requesting player) — harmless single-local-player today; do not build split-screen assumptions on it.
- RestartPlayer's remote-controller early-out is load-bearing (Source/CRADL/Player/CradlGameMode.cpp); any session-layer change to controller creation timing interacts with the submit guards and payload buffering. Change nothing there.
- The guest spawns at the host's entry point; payload transform is deliberately ignored (RestartGuestPlayerNow comment) — not a bug to fix.
- Join payload RPC size over real EOS P2P is an open verification gate (PLAYFLOW's own); the size telemetry log already exists in TrySubmitJoinProfile.
Related: PLAYFLOW_SYSTEM.md Remote-Peer Progression, Mid-session travel.
Level Identity Under a Session
Rule: Level identity has one authority: the host. Host side is unchanged — LatchActiveLevel before travel, ACradlGameMode::EnsureLevelContext per world (Source/CRADL/Player/CradlGameMode.cpp). For guests, the authoritative carrier is ACradlGameState::SessionLevelId (new) — a replicated FPrimaryAssetId on a (new) ACradlGameState : AGameStateBase, set once per world by the authority GameMode from GetCurrentLevelId(). UCradlGameFlowSubsystem::GetEffectiveLevelId (Source/CRADL/Levels/CradlGameFlowSubsystem.h) gains a middle fallback: ActiveLevelId → GameState SessionLevelId → DefaultStartingLevel. Guests never write ActiveLevelId — HasActiveLevel() stays false on a guest, keeping the location-persistence gate and the "entered through the menu" signal host-truths (guest persistence is exclusively the write-back handshake).
Why: PLAYFLOW specs "the joining client's ActiveLevelId … come[s] from the host's travel URL" — but under non-seamless ClientTravel, the host's URL options never reach the client world (the client receives the map via the welcome message; options are parsed server-side only). Replication is the carrier that actually exists; the host-side URL (ExtraArgs → CradlLevelId) remains the host's own record. This is a deliberate refinement of PLAYFLOW's wording, not a contradiction of its intent (host-owned truth, never process-local guesswork) — reconciliation edit tracked in Open Questions.
Implementation surface:
- Files: Source/CRADL/Player/CradlGameState.h (new), Source/CRADL/Player/CradlGameState.cpp (new), Source/CRADL/Player/CradlGameMode.cpp (set GameStateClass, stamp after EnsureLevelContext), Source/CRADL/Levels/CradlGameFlowSubsystem.cpp (GetEffectiveLevelId fallback)
- Replication (deliberate, per feedback_p2p_replication_audit.md): SessionLevelId — UPROPERTY(Replicated), no condition (every peer needs it), written once on authority per world, no RepNotify required (consumers are pull-based: full-map widget, save attribution never runs on guests).
Footguns:
- Do not reverse-map the loaded UWorld back to a ULevelDefinition on guests — that is the PIE-prefix string-matching trap feedback_carry_identity_forward.md exists to prevent.
- Do not add a second id field anywhere for this — FPrimaryAssetId on GameState is the whole mechanism (feedback_push_back_on_duplicate_identity.md).
- The one-shot EnsureLevelContext latch and the NEVER re-latch rule (PLAYFLOW) are untouched; the GameState stamp is a read of the latch, not a second latch.
- No validator work: no DataAsset shape changes (Source/CRADLEditor/Validators/ confirmed uninvolved). If a later phase touches ULevelDefinition, CradlLevelDefinitionValidator updates in the same change per CLAUDE.md.
Related: Hosting, Open Question 3 (PLAYFLOW reconciliation).
Mid-Session Map Travel — the Progression Cache
Rule: While hosting a session, UCradlGameFlowSubsystem::TravelToLevel switches its final step from OpenLevelBySoftObjectPtr to UWorld::ServerTravel (non-seamless), carrying the same URL shape as the initial host travel (?listen, CradlLevelId=). Because hard travel destroys and recreates every PlayerState, the host protects guest in-session progression with a session progression cache on UCradlSessionSubsystem: immediately before ServerTravel, the host runs UCradlSaveSubsystem::CaptureProfile (Source/CRADL/SaveGame/CradlSaveSubsystem.h) for every non-local ACradlPlayerState and stores the bytes keyed by the player's FUniqueNetId (engine-replicated APlayerState::GetUniqueId). After travel, when a re-connecting guest's payload arrives, ACradlPlayerState::ApplyJoinPayload consults the cache first — a cache hit applies the host-captured (current) profile and discards the client's re-submitted (stale, pre-join disk) bytes. Cache entries are cleared on graceful guest exit (Teardown) and on session destruction.
Why: Without this, every host map change silently resets every guest to their pre-join disk state — the guest's disk is only written at graceful exit, and the re-submitted payload is by definition stale. The grounded GuestReconcileKey TODO in Source/CRADL/Player/CradlPlayerState.cpp already names the sibling hazard (re-drained one-time rewards on re-apply); a net-id-keyed cache is the minimal mechanism that preserves the existing graceful-exit-only write-back philosophy — the host still never touches the guest's disk, and hard-quit loss semantics are unchanged (project_guest_progression_v1_deferred.md).
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.h (new) (cache storage + capture sweep), Source/CRADL/Levels/CradlGameFlowSubsystem.cpp (host-branch ServerTravel), Source/CRADL/Player/CradlPlayerState.cpp (cache-first branch in ApplyJoinPayload / ApplyJoinPayloadNow)
- Classes: cache is a plain TMap on the subsystem — GameInstance-scoped, survives travel, host-local.
- Replication: none. The cache is authority-only transient, mirroring the handshake's existing posture; nothing about it replicates.
Footguns:
- Seamless travel (bUseSeamlessTravel + CopyProperties across every progression component) was considered and rejected for v1 — it is a far larger audit surface than the cache and changes PlayerState lifecycle assumptions the establishment latch depends on. Don't reach for it; see Why.
- The write-back sweep (Host_PersistGuestProfile) and this cache both call CaptureProfile — they must share the in-combat-death-first rule already encoded in Host_PersistGuestProfile (Source/CRADL/Player/CradlPlayerController.cpp); a pre-travel capture of an in-combat guest follows the same disconnect-is-death pipeline order.
- GuestReconcileKey remains Guest_<PlayerId> for v1; its instability across travel is exactly why the cache keys by FUniqueNetId instead. Migrating the reconcile key itself is Open Question 2, not this section's scope.
- A guest that fails to reconnect after host travel (connection lost in transit) hits the guest-side failure funnel; its cache entry is retained until session destruction so a quick manual re-join (fresh invite) can still recover the in-session state.
Related: Guest arrival, Teardown, Open Question 2.
Leaving, Teardown & Write-Back Ordering
Rule: All voluntary exits converge on the existing verb: ACradlPlayerController::RequestLogout(ECradlLogoutAction) (Source/CRADL/Player/CradlPlayerController.h). The session layer adds exactly one step, in exactly one place: CleanUpSessions is the final act of PerformLogoutAction, after write-back flush, before travel/quit. Concretely:
- Guest leaving: unchanged acked write-back (Server_RequestLogoutPersist → Client_PersistProfile → disk → CompleteLogout) → PerformLogoutAction → CleanUpSessions → RequestReturnToMainMenu (fade → TravelToMainMenu, whose OpenLevel is also the disconnect). The subsystem clears the departing guest's progression-cache entry on the host when its write-back push completes.
- Host leaving/quitting: unchanged SweepGuestsForLogoutPersist → GuestPersistFlushSeconds flush window → CleanUpSessions (destroying the session closes guest connections; guests then ride their failure funnel, and their write-back was already pushed by the sweep — Client_PersistProfile with bAwaitingLogoutPersist=false is write-only by design) → travel/quit.
Why: The sweep and flush timers exist precisely so persist RPCs beat connection close (Source/CRADL/Player/CradlPlayerController.cpp); placing session destruction anywhere earlier reorders write-back against teardown and silently drops guest progression. Converging on RequestLogout means the settings-page confirms (Source/CRADL/UI/CradlSettingsPageWidget.cpp) get session teardown for free — no second exit path to keep in lockstep.
Implementation surface:
- Files: Source/CRADL/Player/CradlPlayerController.cpp (PerformLogoutAction gains the CleanUpSessions step), Source/CRADL/Session/CradlSessionSubsystem.cpp (cache clear hooks)
- Replication: no new state. The teardown step is local to each machine's own exit path.
Footguns:
- CleanUpSessions has no completion event in this plugin drop — teardown is fire-and-forget. Do not block travel on destroy completion; the state machine self-drives (OnEndSessionComplete re-enters). If a future need arises, surfacing OnDestroySessionComplete is a plugin edit, not a wait-loop.
- TravelToMainMenu clears ActiveLevelId before opening the menu (existing, load-bearing); PendingDisplayName is deliberately not cleared — once-per-launch by design, don't symmetrize (feedback_dont_symmetrize_speculatively.md).
- ACradlPlayerState::EndPlay disk-save gating (bIsLocalPlayerState + !bSuppressSaves) already makes teardown single-fire; the risk is RPC-flush racing, which the ordering rule owns — do not add new save calls to teardown.
- A guest's Client_ReturnToMainMenu (server-initiated recovery, Source/CRADL/Player/CradlPlayerController.cpp) must also pass through CleanUpSessions on its local half (RequestReturnToMainMenu) — one convergence point there too.
Related: Host loss, Mid-session travel.
Host Loss & Failure Routing
Rule: One funnel, one recovery, decisive exit — no retry ladders (feedback_no_log_and_strand_terminal_states.md). UCradlSessionSubsystem binds GEngine->OnNetworkFailure (new binding — zero handlers exist in Source/ today, grep-verified) and overrides the log-only virtuals HandleSessionFailure / TravelLocalSessionFailure. All three route into a single idempotent handler that, on the affected client: (1) releases the join loading hold if held (EndJoinLoadingHold — the hold currently has no failure release), (2) posts Message.Reason.Session.HostLost (new) via UCradlMessageLogSubsystem::Post (Source/CRADL/Player/CradlMessageLogSubsystem.h), (3) CleanUpSessions, (4) RequestReturnToMainMenu on the local controller. Guest in-session gains are lost — that is the committed graceful-exit-only design, restated here deliberately, not an oversight.
Why: Client_ReturnToMainMenu cannot cover host loss — it is an RPC from the very server that vanished. The local half of that same primitive (RequestReturnToMainMenu) is the established decisive exit; the funnel simply invokes it locally. The two plugin virtuals are the only session-layer failure signals and are reachable only by override (Ownership).
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSubsystem.cpp (new)
- Classes: idempotence latch is a subsystem-local bool (set on first funnel entry, cleared on reaching the menu); message copy from UCradlSessionSettings (new).
- Replication: none — failure handling is by definition local to the machine that observed the failure.
Footguns:
- The funnel must be a no-op on the host for guest-side connection failures (the host observes those as Logout/PS EndPlay, already handled: disconnect-in-combat = death pipeline, no write-back, by design).
- Mid-join host loss (connection dies while bHoldingForJoin) is the case the hold-release step exists for — without it the loading screen never drops (grounded absence #4).
- Do not add a host-side ACradlGameMode::Logout override for persistence — hard-drop = loss is a decision (project_guest_progression_v1_deferred.md), and "fixing" it here would fork the guest model.
- OnNetworkFailure can fire multiple times for one collapse (control channel + actor channels) — hence the idempotence latch.
Player Feedback & Copy
Rule: Two surfaces, chosen by context, both loud:
- In-game (invite refused while playing; host lost): UCradlMessageLogSubsystem::Post with Message.Source.System (existing tag) + a Message.Reason.Session.* leaf. Client-local post — no server round-trip exists or is needed for these events.
- Main menu (join failed, host-create failed, cold-start invite refused): a (new) UCradlSessionNoticeModal — a minimal title/body/OK page on UCradlMenuLayout::PushModal (Source/CRADL/UI/CradlMenuLayout.h), base UCradlMenuModalWidget, self-contained per the modal-flow rule (feedback_self_contained_modal_flows.md). The menu has no message-log renderer (grounded absence) and does not grow one for v1.
All player-facing strings are UPROPERTY(config, EditAnywhere) FText on UCradlSessionSettings (new) — a UDeveloperSettings (config=Game, defaultconfig, category CRADL, mirroring Source/CRADL/Interaction/CradlExamineSettings.h) — with each consumer restating the default as the empty-config guard (feedback_canonical_copy_in_settings.md; CDO read-back is circular). The same class holds the numeric knobs: MaxSessionPlayers. Additionally, UCradlGameInstance::HandleSystemMessage (Source/CRADL/Player/CradlGameInstance.cpp) stops swallowing error-tagged CommonUser system messages: at the menu it raises the notice modal; in-game it posts to the message log.
Why: Refusals are the product's visible edge of an invisible rule — they must fail loudly at intent time (feedback_stop_short_physical_impediment_only.md generalized). The message log needs only a LocalPlayer, so it covers every in-game case pawn-lessly; the auth screen's status machine and the modal stack are the only menu-context feedback patterns that exist, and a modal is the one that can't be missed.
Implementation surface:
- Files: Source/CRADL/Session/CradlSessionSettings.h (new), Source/CRADL/UI/CradlSessionNoticeModal.h (new), Source/CRADL/Player/CradlGameInstance.cpp
- Tags: see Tag Taxonomy. American English throughout (feedback_american_english.md).
- Replication: none — all feedback is cosmetic-client-only by definition (CLAUDE.md: never replicate cosmetic state).
Footguns:
- The modal widget follows pool-safe bind discipline: per-open state re-seeded in an Init call, child delegate binds per feedback_bind_child_delegate_in_nativeconstruct.md / reference_commonui_stack_pooling_bind_discipline.md.
- Message severity rides ECradlMessageLevel; any styling stays a client-resolved token (feedback_client_resolves_color_token.md) — no baked colors.
- Diagnostic logging for the session layer lands at Warning with an Error entry sentinel (feedback_log_level_warning_for_diagnostics.md); player-facing copy and diagnostics are separate channels.
Related: Join gate, Host loss.
Storefront & Crossplay Configuration
Rule: One session architecture on every storefront: EOS-primary sessions over EOS P2P transport, with the base platform layered by build. Engine-verified mechanics this contract relies on (UE 5.4 OnlineSubsystemEOSPlus module, inside the OnlineSubsystemEOS plugin): with bUseEOSSessions=true, CreateSession creates on EOS then mirrors a base-platform (Steam) session embedding the EOS session id as SETTING_CUSTOM_JOIN_INFO; a Steam-overlay invite is resolved back to the EOS session and fires the same invite delegate the EOS overlay fires. The mirror-injecting path is the FUniqueNetId& CreateSession overload — which the vendored plugin already calls.
Per-build config via UE custom config layers (Config/Custom/<Name>/, staged with -CustomConfig=):
- Base Config/DefaultEngine.ini: unchanged DefaultPlatformService=NULL — day-to-day PIE stays offline-fast, preserving PLAYFLOW's "direct-PIE runs unauthenticated" contract.
- Config/Custom/Steam/ (primary storefront): DefaultPlatformService=EOSPlus, NativePlatformService=Steam, [OnlineSubsystemSteam] enabled with the real app id (+ steam_appid.txt in dev), bInitServerOnClient=true. Connect-only (decided fork): bUseEAS=False, bUseEOSConnect=True — silent Steam-ticket auth, no Epic account ever, EOS social overlay off. bShouldEnforceBeingLaunchedByEGS=False.
- Config/Custom/EGS/: DefaultPlatformService=EOS, bUseEAS=True + BasicProfile/FriendsList/Presence scopes, EOS overlay + social overlay on, bShouldEnforceBeingLaunchedByEGS=True (guarantees exchange-code auth). Mirrors the shipped DFR configuration (reference: context/extern_reference/DFR/plugin-no-steam/DefaultEngine.ini).
- All online layers: exactly one GameNetDriver definition — OnlineSubsystemEOS.NetDriverEOS with bIsUsingP2PSockets=true (Steam sockets cannot reach EGS peers); CompatibleUniqueNetIdTypes=EOS,EOSPlus + MappedUniqueNetIdTypes=(("EOSPlus","EOS")); bUseEOSSessions=True; [OnlineServices] DefaultServices=Null (OSSv2 parked; plugin is COMMONUSER_OSSV1=1).
- Plugins enabled in CRADL.uproject: OnlineSubsystemEOS (carries the EOSPlus module), EOSShared, SocketSubsystemEOS, OnlineSubsystemSteam.
Why: This is the verified path to "both overlays, one code path": the invite gate never branches by storefront because EOSPlus normalizes both overlays into one delegate. Connect-only on Steam satisfies Epic's actual minimum for crossplay (a Connect PUID; no Epic account mandate exists for Game Services) with zero sign-in friction; EAS remains per-build in stock 5.4, so per-user optionality is out of scope (Open Question 1).
Implementation surface:
- Files: Config/DefaultEngine.ini, Config/Custom/Steam/DefaultEngine.ini (new), Config/Custom/EGS/DefaultEngine.ini (new), CRADL.uproject
- No game-code storefront branches anywhere. Ever.
Footguns:
- CRADL requires its own EOS product (portal setup incl. Steam identity provider is an external prerequisite, Open Question 6).
- The DFR Steam draft's duplicate GameNetDriver definition and SteamDevAppId=480 placeholder are known defects of that draft — don't import them.
- Deliberate asymmetry: the consequence of Connect-only is that EGS↔Steam mixed lobbies cannot form in v1 (no shared friends graph to invite across). Transport, sessions, and net identity are cross-play-ready regardless; lighting up cross-storefront invites later is config + a linking UX, not a re-architecture. Recorded in Open Questions, restate in a DevComment at the config layer.
- EOS overlay coexistence with the Steam overlay is moot in v1 (EOS overlay off on Steam) — if EAS-on-Steam ever ships, that coexistence becomes a first-class verification gate.
- No APIs or config newer than UE 5.4 (CLAUDE.md); the EOSPlus behaviors cited were verified against the installed 5.4 engine source, not docs.
Related: Ownership, Open Questions 1 & 6.
Replication Audit
Rule: The session layer introduces exactly one replicated property: ACradlGameState::SessionLevelId (Replicated, unconditional, authority-written once per world). Every other piece of session state has a deliberate non-replicated answer:
| State | Posture |
|---|---|
bOverlayInvitesArmed, failure-funnel latch, host/join in-flight state |
Subsystem-local, per-machine — never replicated |
| Session progression cache (net-id → profile bytes) | Authority-only transient on the host's subsystem — never replicated |
Plugin session state (HostSettings, PendingTravelURL) |
Plugin-internal, host-local |
| Join/write-back handshake | Unchanged: reliable RPCs + authority-local transients, per the existing audited posture in Source/CRADL/Player/CradlPlayerState.h |
| All feedback (modals, message-log posts) | Cosmetic-client-only |
| Level identity on host | UCradlGameFlowSubsystem::ActiveLevelId — GameInstance-scoped, never replicated (existing rule) |
Why: Per feedback_p2p_replication_audit.md, every field gets a stated answer, including the obvious ones. The session layer's job is transport; the only cross-peer fact it creates is "which level is this session in."
Footguns: New gate axes that read peer progression inherit the acknowledged-wrong COND_OwnerOnly fallback in lockstep — mirror, don't fix piecemeal (feedback_cond_owneronly_peer_fallback_wrong.md).
Tag Taxonomy
Per feedback_gameplay_tag_decl_minimal.md: ini is the authoritative list (Config/DefaultGameplayTags.ini); Source/CRADL/CradlGameplayTags.h declares only leaves referenced by C++ symbol. All three new leaves below are C++-referenced (posted from the subsystem), so both.
| Tag | New? | Declared | Use |
|---|---|---|---|
Message.Source.System |
existing | both (already) | Source tag for all session posts |
Message.Reason.Session.JoinRefused |
(new) | both | Gate refusals (not at menu / not online / not armed / in flight) |
Message.Reason.Session.JoinFailed |
(new) | both | OnJoinSessionCompleteEvent failures (full, gone, generic) |
Message.Reason.Session.HostLost |
(new) | both | Failure-funnel exit message |
No Session.* state-tag namespace is introduced — session state lives on the subsystem, not on ASCs.
Forward Code References
New files:
- Source/CRADL/Session/CradlSessionSubsystem.h / .cpp — subsystem, gate, funnel, cache
- Source/CRADL/Session/CradlSessionHostRequest.h — GetMapName override for LevelDefinition-keyed maps
- Source/CRADL/Session/CradlSessionSettings.h — strings + knobs
- Source/CRADL/Player/CradlGameState.h / .cpp — SessionLevelId
- Source/CRADL/UI/CradlSessionNoticeModal.h — menu notice modal
- Config/Custom/Steam/DefaultEngine.ini, Config/Custom/EGS/DefaultEngine.ini
Edited files:
- Plugins/CommonUser/Source/CommonUser/ — privacy-field threading (bShouldAdvertise, bAllowJoinViaPresenceFriendsOnly on the host request)
- Source/CRADL/Player/CradlMenuPlayerController.cpp — ProceedToGame branch, arm/disarm, joining hold
- Source/CRADL/Levels/CradlGameFlowSubsystem.cpp — LatchActiveLevel extraction, host-branch ServerTravel, GetEffectiveLevelId fallback
- Source/CRADL/Player/CradlGameMode.cpp — GameStateClass, SessionLevelId stamp
- Source/CRADL/Player/CradlPlayerController.cpp — PerformLogoutAction teardown step, RequestReturnToMainMenu cleanup convergence
- Source/CRADL/Player/CradlPlayerState.cpp — cache-first ApplyJoinPayload
- Source/CRADL/Player/CradlGameInstance.cpp — HandleSystemMessage routing
- Source/CRADL/Player/CradlDebugComponent.h — session cheats (dump session state, simulate network failure)
- Config/DefaultGameplayTags.ini, Source/CRADL/CradlGameplayTags.h, CRADL.uproject
Open Questions
- Cross-storefront invites (deferred light-up). Connect-only on Steam means no EGS↔Steam invite path in v1. Options when revisited: EAS-required-online on Steam (config flip + linking UX + dual-overlay verification gate), or a custom optional-EAS layer (out of stock OSSv1 scope). Also the fallback idea of a join-by-code verb if a graph-less bridge is ever wanted.
GuestReconcileKeymigration to net-id. The existingGuest_<PlayerId>TODO (Source/CRADL/Player/CradlPlayerState.cpp) predates sessions; withFUniqueNetIdnow genuinely available, should the reconcile key follow the progression cache onto net-id? Interacts withApplyProfilereconcile semantics — needs its own look before changing.- PLAYFLOW reconciliation edits on adoption. (a) Guest level identity wording ("from the host's travel URL" → GameState replication, per Level identity); (b) Remote-Peer Progression gains the progression-cache addendum; (c)
TravelToLevel's single-player caveat updates to the host-branch rule. Run/reconcile-docs PLAYFLOWafter the build. - Join payload size over real EOS P2P. Existing PLAYFLOW verification gate; first cross-machine test measures against the reliable-RPC bunch cap (telemetry already logs bytes).
GuestEstablishmentTimeoutSeconds(15s) vs real join latency. ClientTravel + EOS connect + payload round-trip may eat the margin; revisit the default after the first cross-machine test rather than pre-tuning.- EOS portal + Steamworks setup (external prerequisite). CRADL's own product/sandbox/deployment ids, client credentials, Steam identity provider (app id + encrypted app ticket key), real Steam app id. Config-only; no code dependency, but blocks any non-NULL-OSS testing.
MaxSessionPlayersvalue. The knob lands onUCradlSessionSettings; the number (and whether it ever varies per level) is a design call, not a contract one.