0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI LEVEL_FLOW_SYSTEM
UTC 00:00:00
◀ RETURN
LEVEL_FLOW_SYSTEM.md 4987 words ~23 min read Updated 2026-07-03

CRADL Level Flow System

Companion to PLAYFLOW_SYSTEM.md, ARCHITECTURE.md, CLAUDE.md, and THEME.md. Where PLAYFLOW is the session tier — Login, Play, Travel, Main Menu Return, Quit — this document is the level tier one step down: the per-peer presentation arc that runs once a peer is standing in a world. It is the contract that arc must satisfy — its trigger, the intro-sequence authoring + playback model, input suppression, customization injection, and the ambience/music manager. Implementation patterns and per-level content live in the assets; what's here does not change without a deliberate edit to this file.

North Star

A level, once entered, runs a deterministic local presentation arc: arrive → (optional) intro plays with input suppressed and ambience held at frame 0 → intro ends → input restored, ambience enters, gameplay begins. Everything in this layer is local-only, never replicated, cosmetic, and authored off ULevelDefinition. PLAYFLOW gets you into the level (travel, spawn-transform, possession, save); LEVELFLOW governs the per-peer experience once you're standing in it. The intro plays identically for every peer that joins and reflects no remote peer — each player sees their own ship, their own camera, their own silence. The two tiers share exactly one seam: the arrival moment.

Boundary test — a concern belongs in LEVELFLOW iff all four hold: (1) local-only / never replicated, (2) cosmetic (presentation, not authority state), (3) triggered by or after arrival, (4) authored on the level asset. Anything that touches authority, save, replication, or spawn-transform resolution is PLAYFLOW's (see PLAYFLOW Map-to-Map Travel).

Quick Reference

Topic Answer Section
What authors a level's intro? An optional TSoftObjectPtr<ULevelSequence> IntroSequence on ULevelDefinition. Null = no intro (dev maps, most levels). Authoring Surface
When does the intro play? Local arrival into a menu- or travel-originated session (UCradlGameFlowSubsystem::HasActiveLevel() true), after local possession and loadout visuals are applied. Never on direct-PIE. Arrival Trigger
How often does it play? Per-level ELevelIntroCadence (EveryEntry / OncePerLaunch), default OncePerLaunch. The visited-set lives on the persistent intro subsystem. Cadence
Who owns intro playback? UCradlLevelIntroSubsystem (ULocalPlayerSubsystem — survives OpenLevel, local-only). Intro Playback
How is the loading-screen seam closed? The intro subsystem registers as an ILoadingProcessInterface processor and holds the loading screen until the sequence is async-loaded and parked at frame 0 — so there is no "world peek" between load and intro. Arrival Trigger
How is gameplay input suppressed during the intro? ACradlPlayerController::BeginCinematicControl(Cinematic.LevelIntro) with an authored IMC_LevelIntro (skip-only). EndCinematicControl restores behind IsAliveForInput(). Never the sequence's legacy bDisableMovementInput/bHideHud. Input Suppression
How does the player's ship get into the sequence? Sequencer binding tags Player (the real local pawn, reference-only) and PlayerProxy (a locally-spawned, non-replicated rig built from the loadout's VisualsActor), filled via SetBindingByTag before play. Customization Injection
How are intro fades handled? Entry fade baked into the sequence (optional, author-owned — frame-0-black also closes the loading-screen reveal seam). Exit fade code-owned in teardown via StartCameraFade — skip-safe, because a baked tail never plays under skip. No new fade-manager class. Intro Playback
How is level ambience silenced during the intro? The intro pushes a refcounted Cinematic.LevelIntro suppression reason onto UCradlAmbienceSubsystem; matched sources defer-start (don't begin) until release. Not a volume duck, not a SoundClass override. Ambience & Music Manager
Why doesn't muting "Music" hide cutscene music? It does — sequence audio routes through the same user-facing Music/SFX SoundClasses, so user volumes and mutes apply for free. There is no cutscene-specific audio channel by design. Ambience & Music Manager
Is anything here replicated? No. Sequence player, proxy rig, pawn-hide, IMC swap, ambience suppression, HUD hide — all per-peer local. Replication Audit

Authoring Surface

Rule. Level-flow content is authored on the existing ULevelDefinition primary asset — the same asset PLAYFLOW already carries forward by id across travel (CradlLevelDefinition.h). Three new fields:

// Optional per-level intro cinematic. Null = no intro. Soft so the always-resolvable
// definition doesn't drag the sequence (and its possessables) into memory; the intro
// subsystem async-loads it on arrival.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Level Flow")
TSoftObjectPtr<ULevelSequence> IntroSequence;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Level Flow", meta=(EditCondition="!IntroSequence.IsNull()"))
ELevelIntroCadence IntroCadence = ELevelIntroCadence::OncePerLaunch;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Level Flow", meta=(EditCondition="!IntroSequence.IsNull()"))
bool bIntroSkippable = true;

ELevelIntroCadence: EveryEntry (plays on every qualifying arrival) / OncePerLaunch (plays the first qualifying arrival into this level id per app launch). Default OncePerLaunch — with map-to-map travel landing alongside this system, frequent portal hops would otherwise replay an intro every time even with skip available.

Why. ULevelDefinition is already the single authoring surface for per-map identity (world ref, map background, POI bake) and is already validator-gated. Hanging the intro off it keeps one asset per playable map, makes the intro validator-checkable, and rides the level-id → asset resolution that the GameFlow subsystem already carries forward — the intro subsystem learns "which intro" from the same GetActiveLevelId() the GameMode reads for its own identity. A placed ALevelSequenceActor in the world was rejected: it scatters the authoring surface, escapes validation, and re-introduces a reverse world→definition question PLAYFLOW deliberately has no answer to.

The sequence asset is authored in its level's editor context, so possessable bindings to level actors (cameras, set pieces) resolve at runtime normally. The Player / PlayerProxy bindings are filled programmatically (see Customization Injection) and must be left as spawnable/possessable placeholders the author tags, not hard references to a specific pawn.

Module dependency: LevelSequence and MovieScene must be added to CRADL.Build.cs PublicDependencyModuleNames — neither is present today. This is the only engine-module addition the system requires; EnhancedInput (the input seam) is already a dependency.

Implementation surface: - File: ULevelDefinition (new IntroSequence, IntroCadence, bIntroSkippable fields; new ELevelIntroCadence enum). - Build: CRADL.Build.cs — add LevelSequence, MovieScene. - Validator (same-change, per CLAUDE.md): UCradlLevelDefinitionValidator gains: if IntroSequence is set, it must resolve in the AssetRegistry (mirror the existing Level / MapBackgroundTexture "references missing X" checks). IntroCadence / bIntroSkippable need no check (enum + bool, always valid).

Footguns: - The intro sequence must not put transform/animation tracks on the Player binding. That binding resolves to the real, server-corrected pawn — see Customization Injection. Express motion on PlayerProxy. The validator cannot see inside a sequence asset, so this is a stated authoring rule, not an enforced one. - Don't promote intro authoring to a separate ULevelIntroDefinition. The one-asset-per-map invariant (validator-gated, id-carried) is load-bearing; a parallel asset re-opens the reverse-lookup problem.

Related: Map Selection on Play (the id-carry-forward pattern this reuses), Arrival Trigger.


Arrival Trigger

Rule. The intro fires for menu- or travel-originated arrivals only — exactly when UCradlGameFlowSubsystem::HasActiveLevel() is true (the same gate PLAYFLOW uses for location persistence). Direct-PIE / direct-launch never sets ActiveLevelId, so it never plays an intro. The trigger sequence on the local peer is:

  1. World comes up; UCradlAmbienceSubsystem (WorldSubsystem) initializes.
  2. Local possession completes and loadout visuals are applied — the intro waits for both so the Player binding (and any PlayerProxy source) is wearing the correct loadout. The wait hooks a new C++ delegate on ULoadoutVisualsComponent fired alongside its existing OnVisualsApplied BIE.
  3. The intro subsystem resolves IntroSequence for GetActiveLevelId(), consults cadence, and — if it should play — async-loads the sequence via FStreamableManager (never sync-load, per CLAUDE.md).
  4. Loading-screen hold: while resolving + loading + parking the player at frame 0, the subsystem holds the loading screen via ILoadingProcessInterface (see below). The screen hides only once the intro is parked on frame 0, so the reveal order is loading screen → intro frame 0 → gameplay, with no un-intro'd "world peek."

Loading-screen seam. This is the reserved ILoadingProcessInterface extension point PLAYFLOW already documents (the TODO(EOS) seam in Loading Screen) paying off for a second consumer. The intro subsystem calls ULoadingScreenManager::RegisterLoadingProcessor(this) on the arriving world and returns "still loading" from ShouldShowLoadingScreen until frame 0 is ready; it unregisters at teardown. The 2.5s HoldLoadingScreenAdditionalSecs floor still applies underneath — the intro hold composes with it, never shortens it (per feedback_loading_feel_min_hold.md).

Why. HasActiveLevel() is the precise, already-existing signal for "this is a real arrival, not a dev iteration." Reusing it means the intro inherits the direct-PIE exclusion for free and stays consistent with the location-persistence gate — one concept, one gate. Waiting for loadout-visuals-applied (not just possession) is what makes requirement #2 — "the player's customization must be available to the sequence" — true by construction: the binding the sequence frames is already wearing the ship. Holding the loading screen over the load+park closes the only visual seam a naive "play on BeginPlay" would leave.

Implementation surface: - File: UCradlLevelIntroSubsystem (new ULocalPlayerSubsystem). - Trigger hook: a new C++ multicast delegate on ULoadoutVisualsComponent fired where OnVisualsApplied (BIE) is fired today — the intro subsystem subscribes (mirroring how the component itself subscribes to ACradlCharacter::OnPlayerStateReplicated for late-arrival). - Gate: UCradlGameFlowSubsystem::HasActiveLevel / GetActiveLevelId. - Loading seam: ILoadingProcessInterface + ULoadingScreenManager::RegisterLoadingProcessor (Plugins/CommonLoadingScreen). - Async load: FStreamableManager / UAssetManager.

Footguns: - Don't trigger off OnPossess alone. Possession can complete before the loadout's VisualsActor async-resolves; framing the pawn then catches it mid-swap (or bare). The contracted trigger is loadout-visuals-applied, which is strictly after possession. - The intro subsystem is a ULocalPlayerSubsystem, not a UWorldSubsystem. It must survive OpenLevel so the cadence visited-set persists across travel; a WorldSubsystem would reset it every level load. (Contrast the ambience manager, which is correctly per-world.) - Register/unregister the loading processor in lockstep with the world. A processor that outlives its world holds the next world's loading screen forever. Unregister on intro teardown and on Deinitialize.

Related: Loading Screen, Intro Playback, Cadence.


Intro Playback

Rule. UCradlLevelIntroSubsystem owns one ULevelSequencePlayer (created via ULevelSequencePlayer::CreateLevelSequencePlayer) at a time. Lifecycle:

  1. Prepare: create the player paused at frame 0; fill the Player / PlayerProxy bindings (Customization Injection); push input suppression (Input Suppression) and ambience suppression (Ambience & Music Manager); hide the gameplay HUD via an explicit local toggle on UCradlHUDLayout.
  2. Reveal + play: release the loading-screen hold (frame 0 is ready), then Play().
  3. End: on OnFinished (fired by bPauseAtEnd at the natural end — the engine pauses on the composed final frame and still broadcasts), run one teardown path — restore input, release ambience suppression, unhide the HUD, destroy any PlayerProxy and unhide the real pawn, destroy the sequence player, mark the level visited.
  4. Skip: if bIntroSkippable, the IA_SkipCinematic action pauses the player and routes into the same teardown as natural completion, but enters it with a hard-cut to black instead of a fade-out. The two endings diverge only in how the screen reaches black — natural completion fades out gracefully from its composed final frame; skip has no such frame to fade from (it was pressed mid-shot), and neither seeking to the end nor fading from the mid-shot reads well: a seek teleports the cinematic camera to its final pose in full view before any fade-out could mask it, and a fade-from-mid-shot just lingers on a frame the player asked to dismiss. So skip snaps to black and tears down under cover. Never GoToEndAndStop() or any Stop() here — stopping releases the camera cut immediately; the player is stopped in teardown, under black, where restore-state also unwinds any sequence-driven state.

Fades — split at the gameplay boundary: - Entry (loading screen → intro): baked, optional, author-owned. A fade-from-black at the head of the sequence (Sequencer Fade Track) is the author's call. Authoring frame 0 as black also closes the loading-screen reveal seam for free — the widget hard-cuts to a black frame 0 and the baked fade brings the shot up. No code fade at this boundary. - Exit (intro → gameplay): code-owned, mandatory, NOT baked. The teardown gets the viewport to black, performs the swap-back under black (stop the player — the deliberate camera-cut release — destroy PlayerProxy, unhide the pawn, snap to the gameplay spring-arm, restore input, unhide HUD), then fades back in. Reaching black differs by ending: natural completion fades out from its held final frame (the player is paused there, so the cinematic camera stays live for the whole fade-out); skip hard-cuts (0-duration StartCameraFade to held black) so the swap is covered the same frame, with no fade-out ramp over a teleported camera. Both converge on the identical under-black swap + fade-in. This must be code-owned because a baked exit fade never plays under skip — it would be skipped over, dumping the player into gameplay on a hard cut (or stuck black). Code-owning the exit is the same invariant that routes both endings through one teardown. - Mid-sequence fades: baked. Fully internal to the cinematic, author-owned.

No new fade-manager class — reuse the StartCameraFade(0→1, …, bHoldWhenFinished=true) shape the death pipeline and the travel/loading departures already standardize on (see PLAYFLOW Loading Screen). The intro subsystem orchestrates; the ACradlPlayerController executes the fade (the camera manager lives there — the same "fades owned controller-side" rule PLAYFLOW states). The fade tints the 3D viewport only, so the HUD-hide stays a separate explicit widget toggle.

Why. A single teardown is the invariant that keeps input-restore, ambience-release, pawn-unhide, HUD-reveal, and the under-black swap + fade-in from drifting apart between the skipped and natural endings — the class of bug where skipping a cutscene strands the player with no input, a muted world, or a stuck-black screen. Both endings reach that one teardown; only the way the screen reaches black differs (graceful fade-out vs. hard-cut), because there's no artifact-free way to fade out from a skip's mid-shot. Pausing (not Stop) keeps the camera cut held until the swap runs under black. Parking at frame 0 before releasing the loading screen is what makes the entry reveal seamless.

Implementation surface: - File: UCradlLevelIntroSubsystem / .cpp. - Engine: ULevelSequencePlayer, ALevelSequenceActor (the player's companion actor), FMovieSceneSequencePlaybackSettings (bPauseAtEnd=true — pauses at the end and still broadcasts OnFinished), ULevelSequencePlayer::OnFinished, Pause for skip; StartCameraFade 0-duration cut for the skip hard-cut to black. - HUD toggle: UCradlHUDLayout (visibility/RenderOpacity — author concern, mirrors the Lyra arrival fade-in pattern PLAYFLOW notes).

Footguns: - FMovieSceneSequencePlaybackSettings::bDisableMovementInput / bDisableLookInput / bHideHud are forbidden. They route through legacy APlayerController::SetCinematicMode ignore-flags — the legacy input path, which collides with Enhanced Input and the documented CTM/WASD arbitration (feedback_wasd_ctm_arbitration.md). Input suppression goes through BeginCinematicControl; HUD hiding is an explicit widget toggle. - Only one intro player at a time. Re-arrival while an intro is mid-play (rapid travel) must tear the prior down first (same OnFinished path, forced) before preparing the next. - Don't Play() before releasing the loading-screen hold — the intro would advance under the loading widget and the player would reveal mid-sequence. - Don't bake an exit / gameplay-handoff fade into the sequence. A baked tail never plays under skip (skip hard-cuts to black and the code fade takes over), so skip would hard-cut into gameplay (or strand on a black frame). The author owns the entry fade; the subsystem owns the exit fade. Only one fade lives at the gameplay boundary, and it's the code's.

Related: Input Suppression, Customization Injection, Arrival Trigger.


Input Suppression

Rule. The intro suppresses gameplay input through the existing cinematic-control seam, not anything new: ACradlPlayerController::BeginCinematicControl(Cinematic.LevelIntro) on prepare, EndCinematicControl(Cinematic.LevelIntro) on teardown. The PC's CinematicInputContexts map must bind Cinematic.LevelIntroIMC_LevelIntro, an authored mapping context containing only IA_SkipCinematic (or empty, for an unskippable intro). BeginCinematicControl already: halts any in-flight CTM/WASD movement via IPawnCombatant::StopMovement, removes the default IMC, and adds the cinematic IMC (CradlPlayerController.cpp:1422). EndCinematicControl restores the default IMC only if IsAliveForInput() (CradlPlayerController.cpp:1505) — so an intro that somehow overlaps a death window doesn't shove gameplay input back mid-respawn.

Why. Requirement #3 ("limit input during the sequence, restore as soon as it ends") is already solved by a seam built for exactly this: imperative, sequence-owned suppression that composes with the GAS stun/death gates rather than fighting them. Reusing it means input restore is guarded by the same IsAliveForInput() check OnPossess uses, and the movement-halt prevents a held-WASD or running click-path from drifting the pawn into the intro framing. A skip-only IMC keeps the player able to skip without exposing any gameplay action.

Implementation surface: - Seam: ACradlPlayerController::BeginCinematicControl / EndCinematicControl / CinematicInputContexts. - Tag: Cinematic.LevelIntro (see Tag Taxonomy). - Author assets: IMC_LevelIntro, IA_SkipCinematic (bound in the IMC, dispatched to the intro subsystem's skip handler).

Footguns: - EndCinematicControl is mode-matched. It no-ops if ActiveCinematicMode != Cinematic.LevelIntro — so a stale End from a torn-down sequence can't yank input from whoever's currently driving. Always End with the same tag you Began with. - The skip action lives in the cinematic IMC, not the default IMC. Binding skip in the default mapping context would make it dead during the intro (the default IMC is removed) and live during gameplay (wrong).

Related: Intro Playback, feedback_wasd_ctm_arbitration.md.


Customization Injection

Rule. The sequence references the player through Sequencer binding tags, filled by the intro subsystem via ULevelSequencePlayer::SetBindingByTag (or SetBinding) before Play(). Two tags, two distinct contracts:

  • Player → the real local pawn, which already wears its loadout rig locally (ALoadoutVisualsActor, attached, never replicated). Reference-only: the camera may frame it, attach to it, or look at it — but the sequence must never drive transform/animation tracks on this binding. On a client, sequencer motion fights server position correction; on a listen-server host it would replicate your private cutscene motion to every peer, violating requirement #1.
  • PlayerProxy (the expressive path, v1) → a locally-spawned, non-replicated visuals rig built from the active loadout's VisualsActor class — the same spawn pattern ULoadoutPreviewSubsystem already uses for the PIP cockpit. The sequence may fly this freely (the ship swooping into the landing zone) while the real pawn is locally hidden. Teardown destroys the proxy and unhides the pawn; the author ends the sequence on a cut or fade so the hand-off pop is invisible.

The proxy is the v1 default for expressive intros: it makes "pump the player's ship into the sequence" literal while keeping the real pawn untouched and authority-correct.

Why. This is the only shape that satisfies requirement #2 (customization in the sequence) without violating requirement #1 (per-peer-local, no remote-peer reflection). The loadout id → visuals resolution is already local and async; the proxy spawn is a direct reuse of the preview-subsystem pattern (resolve ULoadoutDefinition for ActiveLoadoutId, async-load VisualsActor, spawn locally with a supersede guard). Driving the real pawn was rejected because it's either ineffective (client correction overrides it) or actively wrong (host replicates the cutscene).

Implementation surface: - Binding fill: ULevelSequencePlayer::SetBindingByTag against the author-tagged Player / PlayerProxy bindings. - Proxy spawn: new helper on UCradlLevelIntroSubsystem reusing the resolve-and-spawn pattern from ULoadoutPreviewSubsystem (the supersede guard — ActiveLoadoutIdLocal — and async two-stage load). - Loadout source: ULoadoutComponent::ActiveLoadoutIdULoadoutDefinition::VisualsActor. - Pawn hide/unhide: local SetActorHiddenInGame on the possessed pawn (and its ALoadoutVisualsActor) — never a replicated visibility change.

Footguns: - The Player binding is a camera target, not a puppet. Re-stated because it's the easy mistake: any transform/anim track on Player is the bug. If motion is wanted, it's a PlayerProxy job. - Proxy spawn must carry a supersede guard. A loadout change mid-prepare (unlikely, but the preview subsystem already handles it) must not spawn a stale rig. Reuse the existing guard rather than re-deriving it. - Hide is local-only. SetActorHiddenInGame on the real pawn must never be a replicated property change — a hidden-during-my-intro pawn must stay visible to every other peer.

Related: ULoadoutPreviewSubsystem (the spawn pattern), project_pocketworlds_vendoring.md (the PIP preview that established it).


Ambience & Music Manager

Rule. Level ambience and music are owned by UCradlAmbienceSubsystem (UWorldSubsystem — per-level, local-only, never replicated). Two orthogonal axes, owned by two systems that must not cross:

  • Playback policy (this manager): what is audible right now — start / stop / defer / fade, per registered source.
  • Volume routing (UCradlSettingsSubsystem): how loud per user prefs — the sole owner of SoundClass/SoundMix overrides.

The manager never touches SoundClasses or SoundMixes. Every source it plays sits on a user-facing channel (Music / SFX), so user volumes and mutes apply multiplicatively on top — which is exactly why there is no cutscene-specific audio channel: sequence music routes through the Music SoundClass like everything else, so a player who muted Music hears no cutscene music and is never surprised.

Registration + suppression model: - Ambience sources are placed via a new UCradlAmbientSourceComponent, each carrying a category tag (Ambience.Bed for world ambience, Ambience.Music for area music; extensible). The component registers with the manager on BeginPlay. - Suppression is a refcounted set of (CategoryFilter, ReasonTag) pairs. PushSuppression(CategoryFilter, ReasonTag) / ReleaseSuppression(ReasonTag). A source is audible iff no active suppression matches its category. - Defer-start: a source that registers (or would start) while a matching suppression is active simply does not begin until the suppression lifts — so the intro gets true silence from frame 0, and ambience enters when the intro ends rather than fading up from already-playing. - The intro pushes Cinematic.LevelIntro as its reason (mirroring the BeginCinematicControl tag) and releases it in teardown. A future dialogue or death suppressor pushes its own reason and composes with no knowledge of the intro.

Handoff ordering (why two subsystem scopes work): on arrival the world brings up the UCradlAmbienceSubsystem (WorldSubsystem) before BeginPlay; the persistent intro subsystem (ULocalPlayerSubsystem, already alive) then pushes its suppression onto the freshly-spawned manager, plays, and releases. Sources registering during the window defer. No push-before-audio-device-ready race, because the manager exists at world-init and the intro pushes after it.

Why. Two decisions drive this:

  1. A global manager over a SoundMix duck. The settings subsystem deliberately applies every user volume with bApplyToChildren=false and bakes Main as a per-child multiplier (CradlSettingsSubsystem.cpp:327,399) — the SoundClass hierarchy is deliberately not used for cascade. A class-taxonomy duck (route cutscene music to a Music.Cinematic child, override only Music.World) would force re-opening that apply-path and stacking a second active mix whose adjusters must multiply correctly against MasterMix — subtle machinery in a historically footgun-y spot. The manager instead mutes by policy (defer/stop the source), leaving all volume routing untouched. It can also do what a duck can't: prevent ambience from starting (true frame-0 silence), and it's the seam OSRS-style area music / login themes / combat stingers will want anyway. Ambience is greenfield — "all ambience is placed via the ambient component" is a day-one rule, not a migration.
  2. No cutscene channel. Routing sequence audio through the standard channels is what makes user mutes honest. The trade — the manager can silence ambience, not stray world/combat SFX (a remote peer fighting next to your spawn is still audible during your intro) — is accepted (decided 2026-06-10): leaking combat/world SFX under an intro is fine for v1. If it ever matters, a broad SFX policy-suppression (another category the manager already supports) complements the design with no redesign.

Implementation surface: - Files: UCradlAmbienceSubsystem (new UWorldSubsystem), UCradlAmbientSourceComponent (new, in the same module path). - Volume boundary (read-only here): UCradlSettingsSubsystem, UCradlAudioSettings (MusicSoundClass, SFXSoundClass, MasterMix). - Tags: Ambience.Bed, Ambience.Music, Cinematic.LevelIntro (see Tag Taxonomy).

Footguns: - The manager never calls SetSoundMixClassOverride / PushSoundMixModifier. Volume routing is the settings subsystem's exclusive job. The two axes stay orthogonal: policy here, volume there. Crossing them re-creates the two-systems-fighting trap this design exists to avoid. - Registration discipline is the failure mode. A vanilla AmbientSound actor or a raw UGameplayStatics::PlaySound2D escapes suppression entirely. Ambience plays only via UCradlAmbientSourceComponent + the manager — same discipline a SoundClass duck would require, but a component is more visible than a class dropdown. - Suppress by category, release by reason. Push takes (CategoryFilter, ReasonTag); release takes the ReasonTag and removes all pushes under it. Releasing by category would clobber an unrelated suppressor's hold on the same category. - The manager dies with the world. It's per-level by design (the audio device tears down on OpenLevel anyway, so cross-travel continuity is impossible without seamless travel). Don't cache a source list across travel — each world re-registers. Music restarting per transition is the accepted v1 behavior (decided 2026-06-10); if cross-level continuity is ever wanted, promote to a UGameInstanceSubsystem that manages per-world source lifetimes manually.

Related: project_audio_settings_resolution.md (the boot-apply saga that made the no-cascade decision), UCradlSettingsSubsystem, Intro Playback.


Cadence

Rule. ELevelIntroCadence on ULevelDefinition decides replay: - EveryEntry — plays on every qualifying arrival. - OncePerLaunch (default) — plays the first qualifying arrival into this level id per app launch; subsequent arrivals skip straight to gameplay (no intro, no input suppression, no ambience hold).

The visited-set is a TSet<FPrimaryAssetId> on UCradlLevelIntroSubsystem (the ULocalPlayerSubsystem), keyed by level id. Because the subsystem persists across OpenLevel (LocalPlayer outlives world travel), the set survives map-to-map hops and only resets on app teardown — exactly the "per launch" semantics.

Why. With map-to-map travel landing alongside this system, EveryEntry everywhere would replay intros on every portal hop — fatigue even with skip. OncePerLaunch as the default keeps the first-impression intro while making repeat traversal frictionless; EveryEntry stays available for levels where the intro is the arrival beat. Keeping the set on the persistent subsystem makes cadence entirely self-contained in LEVELFLOW — no coupling to PLAYFLOW's GameFlow carrier.

Implementation surface: - File: UCradlLevelIntroSubsystem (TSet<FPrimaryAssetId> PlayedThisLaunch). - Enum: ULevelDefinition::IntroCadence.

Footguns: - Mark visited in the single teardown path, not on Play(). Marking before the intro can fail/abort would suppress a re-attempt after a legitimate interruption. Mark when OnFinished teardown runs (covers both natural end and skip). - The visited-set is per-LocalPlayer, intentionally. It's a local cosmetic-cadence concern, never replicated, never persisted to the save profile (a new launch should re-show the intro — that's the "per launch" contract).

Related: Authoring Surface, Intro Playback.


Replication Audit

Per feedback_p2p_replication_audit.md — every piece of this layer is deliberately local-only:

State Scope Replicated?
ULevelSequencePlayer + bindings Per-peer, intro subsystem No
PlayerProxy rig actor Local spawn (like ALoadoutVisualsActor) No
Real-pawn hide during intro Local SetActorHiddenInGame No — pawn stays visible to other peers
Cinematic.LevelIntro IMC swap Local Enhanced Input subsystem No
Ambience suppression / source playback UCradlAmbienceSubsystem (WorldSubsystem) No
HUD hide Local widget toggle No
Cadence visited-set ULocalPlayerSubsystem No

Server-visible state during a peer's intro: the pawn stands idle at its spawn transform. Each peer's intro is independent and identical-in-kind (their own ship, their own camera) — requirement #1 holds by construction, because nothing about one peer's intro touches another's.

One flagged consequence: the player is attackable while watching their own intro — a local-only system cannot grant combat protection. See Open Questions #1.


Tag Taxonomy

LEVELFLOW introduces tags in two namespaces. Per feedback_gameplay_tag_decl_minimal.md, the authoritative list is Config/DefaultGameplayTags.ini; CradlGameplayTags.h holds only the subset referenced by name in C++.

Tag Source Used By
Cinematic.LevelIntro C++ symbolic ref + .ini The intro's BeginCinematicControl mode key and its ambience-suppression reason — one tag, both gates
Ambience.Bed .ini (content-side category) UCradlAmbientSourceComponent world-ambience sources; matched by intro suppression
Ambience.Music C++ symbolic ref + .ini UCradlAmbientSourceComponent music sources; matched by intro suppression. The music director (UCradlMusicSubsystem) stamps it programmatically on its code-spawned deck component, so it carries a C++ symbol

Cinematic.* already exists as the namespace ACradlPlayerController::CinematicInputContexts keys on; Cinematic.LevelIntro is a new leaf. The Ambience.* namespace is content-authored; Ambience.Music gained a C++ symbol when the music director landed (its deck source is code-spawned, not placed). With the director in place, world music plays through the director's deck — placed Ambience.Music sources remain for spatial/localized music only.


Forward Code References

Future PRs that build LEVELFLOW land at these paths:


Open Questions

  1. Spawn-grace during the intro (attackable-while-watching). A local-only intro cannot make the pawn invulnerable. If it matters (it likely doesn't for invite-only co-op v1 — and listen-server-self-play hits no one), the fix is a server-side spawn-grace UGameplayEffect applied at spawn for a fixed window, independent of the local intro. Per project_persistent_effect_allowlist.md it must not be Effect.Persistent (it's a transient grace, not identity — it should die on respawn like any combat GE). Out of scope for v1; flagged so the local/authority split is explicit.

  2. PlayerProxy hand-off polish. v1 hides the seam by ending intros on a cut or fade. A frame-accurate match (proxy final transform == pawn spawn transform, swap on a black frame) is a content-authoring nicety, not a contract requirement. Revisit if intros end on a live (un-cut) reveal of the pawn.

  3. P2P intro timing (when sessions land). Each peer plays its own intro locally on its own arrival — the per-peer-local design composes with host-driven ServerTravel unchanged (the join still produces a local "arrival" with ActiveLevelId populated from the travel URL, per the PLAYFLOW carry-forward note). The open piece is whether a joining peer's intro should hold the host's gameplay start (it should not — intros are independent) and how HasActiveLevel() is seeded on a client that arrived via session travel rather than local OpenLevel. Tracked jointly with PLAYFLOW Open Question #3.

Resolved (2026-06-10): Cross-level music continuity — accepted that music restarts per transition (rationale in the Ambience manager footgun). Stray-SFX during intro — accepted that combat/world SFX leaks under an intro (rationale in Ambience "Why" #2).