CRADL Level Flow Implementation
Companion to LEVEL_FLOW_SYSTEM.md (the contract), PLAYFLOW_SYSTEM.md (the arrival gate + loading screen this builds on), and ARCHITECTURE.md. This doc tracks the build order for v1 level flow: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what level flow is; this doc says what we build first, what depends on what, and how we know each step works.
Greenfield: both runtime subsystems (UCradlLevelIntroSubsystem, UCradlAmbienceSubsystem) are new. The work extends ULevelDefinition and reuses existing seams rather than inventing — the cinematic-input arrest (ACradlPlayerController::BeginCinematicControl), the loadout preview spawn pattern (ULoadoutPreviewSubsystem), the travel/death camera fade (StartCameraFade), the reserved loading-screen processor seam (ILoadingProcessInterface), and the settings-owned audio routing (UCradlSettingsSubsystem). It adds an arrival presentation arc, not a new foundation.
Not a prerequisite: map-to-map travel. The contract's intro trigger fires on any UCradlGameFlowSubsystem::HasActiveLevel() arrival, which is true today for menu→level Play. Map-to-map travel (a PLAYFLOW build, not derived here) only adds more arrival occasions; it is a sibling, not a dependency. Every phase below verifies against a menu→level arrival.
Conventions
- Phase status legend:
[ ]not started ·[~]in progress ·[x]done ·[!]blocked / deferred. - Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
- Cheat commands: test fixtures land under
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. - Author tasks (★): some verifications need a WBP / IMC / Input Action / LevelSequence asset the user authors (Blueprints are data containers per CLAUDE.md — no Event Graph logic). These are marked ★ author task and are the user's to create; Claude builds the C++ they bind to.
- Per CLAUDE.md "validators in lockstep": the phase that touches
ULevelDefinitionupdates UCradlLevelDefinitionValidator in the same change. - Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented
Build.batcall (UE: Build Editor (Development)) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job. - Replication: every runtime object introduced here is local-only / non-replicated per the contract's Replication Audit. No phase adds a replicated property; each phase's tasks restate this where it would otherwise be tempting (pawn-hide, proxy spawn).
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag & data scaffolding | [x] |
All later phases |
| 1 | Ambience & music manager | [x] |
4 · parallel-able with 2, 3 |
| 2 | Intro playback core (trigger → play → teardown) | [x] |
3, 4, 5 · parallel-able with 1 |
| 3 | Customization injection (Player / PlayerProxy) |
[x] |
5 |
| 4 | Audio handoff (intro ⇄ ambience) + HUD hide | [x] |
5 |
| 5 | Exit fade + re-arrival polish | [x] |
— |
Phase 0 — Tag & Data Scaffolding
Goal. A compile-clean codebase carrying the level-flow authoring surface and tag/module dependencies, with zero behavior. After this phase a designer can set an intro sequence + cadence on a ULevelDefinition and the validator gates it; nothing plays yet.
Rationale. Every later phase resolves IntroSequence / IntroCadence off the definition and references Cinematic.LevelIntro / Ambience.* by tag; the LevelSequence + MovieScene modules must link before any sequence code compiles. Landing all of it first unblocks phases 1–5 without forward-reference churn.
Tasks.
- [x] Module deps — CRADL.Build.cs:
- [x] Add
"LevelSequence"and"MovieScene"toPublicDependencyModuleNames(confirmed absent today — this is the enabling link step). - [x] Tags — CradlGameplayTags.h + Config/DefaultGameplayTags.ini:
- [x]
Cinematic.LevelIntro(both C++ + .ini — referenced by name as theBeginCinematicControlmode key and the ambience suppression reason; new leaf under the existingCinematicroot.) - [x]
Ambience(.ini root only — no C++ symbol yet; Phase 4 adds it when the intro filters by the parent tag in code, perfeedback_gameplay_tag_decl_minimal.md.) - [x]
Ambience.Bed(.ini — world-ambience category.) - [x]
Ambience.Music(.ini — area-music category.) - [x] Data shape — CradlLevelDefinition.h:
- [x]
enum class ELevelIntroCadence : uint8 { EveryEntry, OncePerLaunch }(new.) - [x]
TSoftObjectPtr<ULevelSequence> IntroSequence(soft — async-loaded on arrival, per contract.) - [x]
ELevelIntroCadence IntroCadence = ELevelIntroCadence::OncePerLaunch(EditCondition on!IntroSequence.IsNull().) - [x]
bool bIntroSkippable = true(EditCondition on!IntroSequence.IsNull().) - [x] Validator update — CradlLevelDefinitionValidator / .cpp:
- [x] If
IntroSequenceis set, it must resolve in the AssetRegistry — mirror the existingLevel/MapBackgroundTexture"references missing X" checks.IntroCadence/bIntroSkippableneed no check (enum + bool).
Verification.
- Compile clean (Claude builds via
Build.bat; links exit-0). - ★ author task: open a
ULevelDefinitionasset, setIntroSequenceto a valid LevelSequence → validator passes; point it at a since-deleted asset → validator fails with the missing-reference message.IntroCadence+bIntroSkippableappear in the Details panel only whenIntroSequenceis set.
Exits. Phases 1–5 now have: the Ambience.* / Cinematic.LevelIntro tags, the linked sequence modules, and the per-level intro authoring fields + cadence enum.
Phase 1 — Ambience & Music Manager
Parallel-able with Phase 2 — no shared state until Phase 4 joins them.
Goal. A working UCradlAmbienceSubsystem (UWorldSubsystem) that placed UCradlAmbientSourceComponent sources register with, exposing refcounted suppression with defer-start. Verifiable in isolation via cheat commands — no intro involved yet.
Rationale. The audio spine before its consumer (the intro's audio handoff, Phase 4). Building it standalone with a test fixture (cheat-driven push/release) proves the suppression + defer-start semantics before the intro depends on them — the contract's "policy here, volume there" boundary is easiest to validate when nothing else is moving.
Tasks.
- [x] Subsystem — CradlAmbienceSubsystem.h / .cpp (new
UWorldSubsystem): - [x] Source registry:
RegisterSource/UnregisterSource(UCradlAmbientSourceComponent*). - [x] Refcounted suppression:
PushSuppression(FGameplayTag CategoryFilter, FGameplayTag ReasonTag)/ReleaseSuppression(FGameplayTag ReasonTag)— release removes all pushes under that reason (suppress by category, release by reason, per contract footgun). - [x]
IsSuppressed(FGameplayTag Category) const— a source is audible iff no active suppression matches its category (tag-inheritance viaMatchesTag). - [x] On push: stop/fade matching live sources. On release: start any matching sources that deferred. (Implemented as a blanket
RefreshAllSources()— each source no-ops when its desired state is unchanged, so the push/release delta is computed source-side and stays race-free.) - [x] Non-replicated — local per-peer; dies with the world (don't cache a source list across travel).
- [x] Source component —
Source/CRADL/Audio/CradlAmbientSourceComponent.h/ .cpp (new): - [x]
FGameplayTag Category(Ambience.Bed/Ambience.Music); the component subclassesUAudioComponentso it IS the routed source — author assigns a Sound + aSoundClassOverrideofMusic/SFX(per UCradlAudioSettings) in the Details panel so user volumes apply. - [x] On
BeginPlay: register with the subsystem; defer-start (bAutoActivate=false+ aRefreshPlayState()that plays only if!IsSuppressed(Category), else waits for the release callback). - [x] On
EndPlay: unregister. - [x] Cheat commands — CradlPlayerController (decl unconditional, body
#if !UE_BUILD_SHIPPING): - [x]
Debug_AmbiencePush <Category> <Reason>/Debug_AmbienceRelease <Reason>.
Verification.
- Compile clean.
- ★ author task: a test map with one
Ambience.Bedand oneAmbience.Musicsource placed. - PIE:
Debug_AmbiencePush Ambience Cinematic.LevelIntro→ both go silent;Debug_AmbienceRelease Cinematic.LevelIntro→ both resume. A source placed while suppressed defer-starts (no audio) and enters on release. - Lower the Music channel in settings while a source plays → it attenuates (proves routing stays the settings subsystem's job; the manager never touched volume).
Footguns.
- The manager never calls
SetSoundMixClassOverride/PushSoundMixModifier— volume routing is UCradlSettingsSubsystem's exclusively (the two-axes rule). Mute is policy (stop/defer the source), not a volume write. - Registration discipline: a vanilla
AmbientSoundor a rawPlaySound2Descapes suppression. Ambience plays only viaUCradlAmbientSourceComponent.
Exits. Phase 4 can push Cinematic.LevelIntro suppression from the intro's prepare step and release it in teardown.
Phase 2 — Intro Playback Core (trigger → play → teardown)
Parallel-able with Phase 1.
Goal. A camera-only intro that triggers on arrival, holds the loading screen until parked at frame 0, suppresses input while it plays, restores input on finish, and routes skip through the same single teardown. No customization injection, no ambience, no exit fade yet.
Rationale. The intro spine before its verbs (customization in 3, audio in 4, fade polish in 5). A camera-only test sequence is the "test fixture before content" — it exercises the full trigger→hold→play→teardown loop without needing the proxy/binding machinery, so each later phase bolts onto a proven core.
Tasks.
- [x] Trigger hook — LoadoutVisualsComponent.h / .cpp:
- [x] Add a C++ multicast delegate (
FOnLoadoutVisualsApplied) fired in the same spot the existingOnVisualsAppliedBIE fires, carrying(FPrimaryAssetId, ALoadoutVisualsActor*). Non-dynamic, C++ subscribers only (mirrorsACradlCharacter::OnPlayerStateReplicated). Added aHasAppliedVisuals()latch so a subscriber that binds after the first apply catches up. - [x] Subsystem skeleton — CradlLevelIntroSubsystem.h / .cpp (new
ULocalPlayerSubsystem): - [x] Per-arrival:
FCoreUObjectDelegates::PostLoadMapWithWorldpicks up each world; locate the local pawn'sULoadoutVisualsComponent(via the PC'sOnPossessedPawnChanged+ immediate-pawn check) and subscribe to the new delegate (re-bind each world; unbind on teardown/Deinitialize). - [x] Gate: only proceed when
UCradlGameFlowSubsystem::HasActiveLevel()is true (direct-PIE never plays). - [x] Cadence:
TSet<FPrimaryAssetId> PlayedThisLaunch; consultELevelIntroCadencefrom the resolvedULevelDefinition;OncePerLaunchskips if already in the set. Mark visited in teardown, not onPlay(per contract footgun). - [x] Resolve
IntroSequenceforGetActiveLevelId(); async-load viaFStreamableManager(never sync-load). The definition itself is resident-fast-path / async-LoadPrimaryAsset(never sync) before readingIntroSequence. - [x] Loading-screen hold:
ULoadingScreenManager::RegisterLoadingProcessor(this); implementILoadingProcessInterface::ShouldShowLoadingScreento return "still loading" until the player is parked at frame 0 (hold released at reveal); unregister at teardown/Deinitialize. - [x] Playback — same subsystem:
- [x] Create
ULevelSequencePlayer(CreateLevelSequencePlayer), park paused at frame 0 viaSetPlaybackPosition(FFrameTime(0), Jump)so the camera-cut evaluates before reveal. - [x] Input suppression:
Cast<ACradlPlayerController>(GetLocalPC())->BeginCinematicControl(Cinematic.LevelIntro). - [x] Release loading-screen hold, then
Play(). - [x] Single teardown on
ULevelSequencePlayer::OnFinished:EndCinematicControl(Cinematic.LevelIntro), destroy the player (+ itsALevelSequenceActor), mark the level visited. - [x] Skip:
RequestSkip()(gated onbIntroSkippablevia the stashedbPendingSkippable) pauses the player and routes intoBeginExitTeardown(bInstantBlack=true)→ hard-cut to black (StartIntroExitSnapToBlack), swap/teardown under cover, fade up on gameplay. Deliberately no seek-to-end and no fade-out: teleporting the cinematic camera to its final pose is visible before any fade-out could mask it, and fading from a mid-shot just lingers on a frame the player asked to dismiss. The composed final frame is only ever revealed by playing to it (natural completion). NeverGoToEndAndStop()/Stop()here — the player is stopped inTeardown, under black. - [x] One intro at a time: a re-arrival mid-play (next
PostLoadMapWithWorld) forces the prior teardown first. - [x] Skip input — CradlPlayerController:
- [x] Added
SkipCinematicAction(directUInputActionref, likeBackInputAction), bound inSetupInputComponent; the handler forwards to the localUCradlLevelIntroSubsystem::RequestSkip().CinematicInputContextscarryingCinematic.LevelIntro→IMC_LevelIntrostays the ★ author task on the BP CDO. - [x] Cheat —
Debug_ResetIntroCadenceclearsPlayedThisLaunchsoOncePerLaunchre-tests without a relaunch.
Verification.
- Compile clean.
- ★ author tasks:
IMC_LevelIntro(binds onlyIA_SkipCinematic),IA_SkipCinematic, and a camera-only testIntroSequence(a cine-camera flythrough) set on the test level'sULevelDefinition; add theCinematic.LevelIntro→IMC_LevelIntroentry on theBP_CradlPlayerControllerCDO. - PIE menu→level: the loading screen holds until the sequence is loaded, reveals on frame 0, the flythrough plays, movement/click input is dead during it, and input returns the instant it ends. Pressing the skip action jumps to the end and restores input identically. With
OncePerLaunch, a second arrival into the same level skips straight to gameplay;EveryEntryreplays. - Cheat
Debug_ResetIntroCadence(clearsPlayedThisLaunch) lets the user re-testOncePerLaunchwithout relaunching.
Footguns.
- Never use
FMovieSceneSequencePlaybackSettings::bDisableMovementInput/bHideHud— they route through legacySetCinematicMode, colliding with Enhanced Input + the CTM/WASD arbitration (feedback_wasd_ctm_arbitration.md). Input isBeginCinematicControl's job. - Subscribe to loadout-visuals-applied, not
OnPossess— possession can precede the loadout rig's async resolve, framing a bare/mid-swap pawn. - The subsystem is a
ULocalPlayerSubsystem(survivesOpenLevel→ the cadence set persists). AUWorldSubsystemwould reset cadence every load. - Register/unregister the loading processor in lockstep with the world — a processor outliving its world holds the next world's screen forever.
Exits. Phases 3–5 bolt onto the prepared/teardown bookends: binding fill + proxy (3), ambience push/release + HUD hide (4), exit fade (5).
Phase 3 — Customization Injection (Player / PlayerProxy)
Goal. The sequence frames the player's actual loadout: the Player binding resolves to the real pawn (reference-only), and the optional PlayerProxy binding resolves to a locally-spawned rig built from the active loadout's VisualsActor, with the real pawn hidden during and restored after.
Rationale. This is requirement #2 ("customization available to the sequence") made literal, layered onto the proven Phase 2 core. It reuses the ULoadoutPreviewSubsystem resolve-and-spawn pattern (with its supersede guard) rather than inventing a parallel one.
Tasks.
- [x] Binding fill — CradlLevelIntroSubsystem (in
StartPlayback, before the frame-0 park /Play): - [x]
Playertag → bind the real local pawn. (Filled viaALevelSequenceActor::SetBindingByTag— the binding-override API lives on the actor, not the player — guarded by a silentFindBindingsByTagprobe so an un-tagged sequence no-ops —FindNamedBindingwould raise a PIE warning for the absent optional tag.) Reference-only — never drive transform/anim tracks on it (contract rule; not validator-enforceable). - [x]
PlayerProxytag (optional) → spawn a local, non-replicated rig fromULoadoutComponent::ActiveLoadoutId→ULoadoutDefinition::VisualsActor(two-stage async-load, supersede guardActiveProxyLoadoutIdLocalmirroringULoadoutPreviewSubsystem), then bind it. (Only when the sequence authors thePlayerProxybinding; the loading-screen hold spans the proxy load —FinalizeStartPlaybackis the shared tail reached with or without a proxy. Loadout missing aVisualsActor→ author placeholder stands.) - [x] Pawn hide/unhide — same subsystem:
- [x] When a
PlayerProxyis in use,SetActorHiddenInGame(true)on the real pawn (+ itsALoadoutVisualsActor) inSpawnProxyRig. Local-only — never a replicated visibility change.bPawnHiddenlatch so teardown unhides exactly once. - [x] In teardown (the single
OnFinishedpath): destroy the proxy,SetActorHiddenInGame(false)on the pawn.
Verification.
- Compile clean.
- ★ author task: a test
IntroSequencewith aPlayerProxy-tagged binding that flies the rig through the shot (and aPlayer-tagged camera-target binding). - PIE: the rig shown matches the equipped loadout; switching loadout before arrival changes the rig; the real pawn is hidden for the duration and visible again after (verify from a second vantage that other peers — listen-server self-play stand-in — would still see it, i.e. the hide didn't replicate).
Footguns.
- The
Playerbinding is a camera target, not a puppet — any transform/anim track on it is the bug (client correction overrides it; host would replicate the cutscene). - Proxy spawn must carry the supersede guard — a loadout change mid-prepare must not bind a stale rig.
- Hide is
SetActorHiddenInGamelocal-only; never a replicated property.
Exits. Phase 5's exit fade now has a proxy→pawn swap to cover under black.
Phase 4 — Audio Handoff (intro ⇄ ambience) + HUD hide
Pre-work. Depends on Phase 1's UCradlAmbienceSubsystem (the suppression API) and Phase 2's prepare/teardown bookends.
Goal. Frame-0 silence: the intro holds level ambience/music for its duration and hands the world its audio on finish, and the gameplay HUD is hidden under the intro.
Rationale. Requirement #5 — ambience "not necessarily audible during the sequence" — realized by the contract's defer-start handshake rather than a volume duck. Lands after both halves exist so the handshake is wiring, not new mechanism.
Tasks.
- [x] Tag —
Ambienceparent C++ symbol (deferred from Phase 0 perfeedback_gameplay_tag_decl_minimal.md; added now that the intro filters by the parent in code): CradlGameplayTags.h/.cppUE_DECLARE/DEFINE_GAMEPLAY_TAG(Ambience, "Ambience"). Bed/Music leaves stay .ini-only. - [x] Suppression handshake — CradlLevelIntroSubsystem:
- [x] In
FinalizeStartPlayback(before reveal/Play, alongsideBeginCinematicControl): resolveArmedWorld'sUCradlAmbienceSubsystemandPushSuppression(CradlTags::Ambience, CradlTags::Cinematic_LevelIntro)(parent filter → bothBed+Musicdefer). Latched (bAmbienceSuppressed) so the push is once. - [x] In
Teardown(singleOnFinished/forced path):ReleaseSuppression(Cinematic.LevelIntro)→ deferred sources enter. Latch clears intent-first so a torn-down world can't strand it. - [x] HUD hide — same subsystem + UCradlHUDLayout:
- [x] New
UCradlHUDLayout::SetHiddenForCinematic(bool)— caches priorESlateVisibility, setsCollapsed, restores exactly on unhide. Idempotent. Not the sequence'sbHideHud. - [x] Intro calls
SetIntroHUDHidden(true)inFinalizeStartPlayback,falseinTeardown(via the PC'sGetHUDLayout()). Latch clears intent-first so a stale latch can't block the next arrival's hide.
Verification.
- Compile clean.
- PIE menu→level (test level has placed
Ambience.Bed+Ambience.Musicsources, anIntroSequence, and ★ optionally a music track on the sequence routed through theMusicSoundClass): ambience is silent at frame 0, the sequence's own audio plays, and ambience enters the moment the intro ends or is skipped. The HUD is hidden during, restored after. - Mute the Music channel in settings → the sequence's music is silent too (the no-cutscene-channel proof: cutscene audio honors user mutes).
Footguns.
- Push the suppression in prepare, before the ambience sources'
BeginPlaymay have run — the WorldSubsystem exists at world-init, and defer-start means a source registering during the window simply won't start. (Ordering is safe: manager up at world-init, intro pushes after, on the same local timeline.) - Release exactly once, in the single teardown — a stray release path would un-suppress mid-intro.
Exits. Only the boundary fade remains.
Phase 5 — Exit Fade + Re-arrival Polish
Goal. A seamless gameplay handoff: skip or natural-end fades to black, performs the proxy→pawn swap and camera re-anchor under black, then fades in — identical for both endings. Rapid re-travel tears down cleanly.
Rationale. Polish last, per the phasing heuristic. The exit fade is contract-mandated code-owned (a baked tail never plays under skip), so it's the final guarantee that the single-teardown invariant reads seamlessly.
Tasks.
- [x] Exit fade — CradlLevelIntroSubsystem teardown + CradlPlayerController:
- [x] PC owns the fade (camera manager lives there): new
StartIntroExitFadeOut()(0→1,bHoldWhenFinished=true) /StartIntroExitFadeIn()(1→0) + a sharedIntroExitFadeDuration(Cinematic category, default 0.5s) — sameStartCameraFadeshape as death/travel. - [x]
BeginExitTeardown(bInstantBlack)is the shared exit (entered byHandleIntroFinishedonOnFinishedfor natural completion, byRequestSkipfor skip). Natural completion (bInstantBlack=false): fade to black over the held final frame — the player is paused there (bPauseAtEnd=true), so the cinematic camera stays live for the whole fade-out — then armExitFadeTimer. Skip (bInstantBlack=true):StartIntroExitSnapToBlack(0-duration held black) thenCompleteFadedTeardownsynchronously. AbExitFadinglatch guards skip/re-entry during the window. - [x] Under black (
CompleteFadedTeardown→Teardown):Stop()+ destroy the paused sequence player (the deliberate camera-cut release — snaps to the gameplay spring-arm invisibly under black),EndCinematicControl, destroy proxy + unhide pawn, release ambience, unhide HUD, mark visited. ThenStartIntroExitFadeIn. - [x] Fade-in is code-owned (
StartIntroExitFadeIn) — the subsystem owns the exit; the author owns only the entry fade (below). - [x] Entry-fade path: unchanged-by-code — a Sequencer Fade Track at the sequence head reveals correctly off the loading-screen hard-cut to black frame 0 (★ author/PIE verification; the contract's "baked entry fade" needs no subsystem work).
- [x] Re-arrival robustness:
TeardowncancelsExitFadeTimerand clearsbExitFadingup front, so a forced mid-fade teardown (re-travel viaHandlePostLoadMap, orDeinitialize) runs the same synchronous cleanup without a stranded under-black completion or a leaked proxy.
Verification.
- Compile clean.
- PIE: skip the intro mid-play → screen fades to black, the swap-back happens unseen, gameplay fades in on the spring-arm camera with no hard cut and no camera-blend pop. Natural completion looks identical.
- ★ author task: add a fade-from-black Fade Track to the head of the test sequence → the loading-screen→intro reveal is a smooth fade-up rather than a cut.
- Rapid menu→level→(return)→level re-entry: each intro tears down cleanly; no stuck-black, no orphaned proxy, no doubled audio.
Footguns.
- Don't bake an exit/gameplay-handoff fade into the sequence — it never plays under skip (skip hard-cuts to black and the code fade takes over). The author owns the entry fade; the subsystem owns the exit.
- The swap-back must complete inside the black window — if the fade-back starts before the camera re-anchors, the blend shows. (Implementation timing, not contract; the place the seam shows if it's going to.)
- The fade tints the 3D viewport only — UI renders on top, so the HUD-unhide and the fade are independent and must both be sequenced in teardown.
Open Questions (carried from the contract)
These do not block any phase above; v1 ships without them.
- Spawn-grace during the intro (attackable-while-watching). A local-only intro can't grant invulnerability. If wanted, a server-side spawn-grace
UGameplayEffectapplied at spawn — independent of this build, and perproject_persistent_effect_allowlist.mdit must not beEffect.Persistent(transient grace, dies on respawn). Out of v1 scope. PlayerProxyhand-off polish. v1 hides the seam with a cut/fade (Phase 5). A frame-accurate proxy→pawn transform match is a content nicety, revisited only if intros end on a live (un-cut) pawn reveal.- P2P intro timing. Single-player today; the per-peer-local design composes with host-driven
ServerTravelunchanged. Open: whetherHasActiveLevel()is seeded from the travel URL on a session-joined client. Tracked jointly with PLAYFLOW Open Question #3.
Resolved in the contract (2026-06-10): cross-level music continuity (music restarts per transition — accepted) and stray-SFX-during-intro (combat/world SFX leaks — accepted). No phase work.