CRADL Teleport Implementation
Companion to TELEPORT_SYSTEM.md (the contract), COMBAT_IMPLEMENTATION.md (the respawn streaming-source flow this build generalizes), and ARCHITECTURE.md. This doc tracks the build order for v1 teleport: phased delivery, per-phase rationale, task checklists, and verification gates. The contract says what teleport is; this doc says what we build first, what depends on what, and how we know each step works.
This is a greenfield verb on existing foundations: teleport adds no new subsystem archetype. Every phase either introduces a (new) symbol under an existing pattern or generalizes one existing symbol (the respawn streaming-source provider) to a second caller. No phase invents a new foundation.
Open Questions — resolved at derive time
The contract's four Open Questions were resolved before phasing; the resolutions are baked into the phases below:
- Map identity → teleport stations get a distinct world-map landmark + minimap icon, not the shared
Map.Icon.Station. ExactMap.*leaf names are grounded against MAP_SYSTEM.md in Phase 3 (the only tags deferred out of Phase 0, because they depend on the map bake rules). - Combat gate + cooldown → teleport is combat-blocked (
InCombatTooltipTexton the confirm +ActivationBlockedTags = Status.InCombaton the execute ability); no cooldown in v1 (free/repeatable like OSRS spirit trees). NoCooldown.Teleporttag is introduced. - Enumeration mechanism → mirror
UCradlRecipeRegistry's tag-keyed primary-asset index (CradlRecipeRegistry.h) — but async-load the definitions (LoadPrimaryAssets/FStreamableManager), honoring the contract's async Rule rather than the registry's current synchronousTryLoad(whose own// Switch to LoadPrimaryAssets if recipe count growscomment sanctions the async path). Landed in Phase 1. - Non-owner peer requirement pre-check → moot for v1. Teleport is self-service: only the owning player opens its own modal, and the server re-validates the owner's own state on execute. No peer ever computes another player's teleport eligibility, so the
COND_OwnerOnlypeer fallback never triggers here. No phase. (This is distinct from server-authority-on-requirements, which the contract already settles in Teleport Ability & Authority Split.)
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 on
UCradlDebugComponent(the controller's dev-only console host) as exec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. Cheats are per-phase fixtures, not a trailing polish phase. - Per CLAUDE.md "validators in lockstep": any phase that touches
UTeleportDestinationDefinitionupdatesUCradlTeleportDestinationValidatorunder Source/CRADLEditor/Validators/ in the same change. - Per CLAUDE.md "Building": after the C++ edits for a phase land, Claude compiles with the documented
Build.batcall (theUE: Build Editor (Development)task) to verify the phase links clean before reporting it done. Runtime/PIE verification remains the user's job. - Native tag rule: CRADLEditor validator code resolves
Teleport.Network.*viaFGameplayTag::RequestGameplayTag(FName, /*ErrorIfNotFound*/false), neverCradlTags::symbols (LNK2001 across modules — perreference_native_tag_no_cross_module_export.md).
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag & data scaffolding | [x] |
All later phases |
| 1 | Destination catalogue: async registry + validator | [x] |
4, 5 |
| 2 | Shared point-warm provider refactor | [x] |
6 · parallel-able with 3, 4 |
| 3 | Station + modal open path (+ distinct map identity) | [~] |
4 |
| 4 | Destination list UI, selection & confirmation | [~] |
5 |
| 5 | Execute ability, authority & client-ready handshake | [x] |
6 |
| 6 | Warm-then-move transition | [~] |
— (closes the kill-loop-equivalent; C++ done, Cinematic.Teleport IMC entry on PC_CRADL outstanding) |
Phase 0 — Tag & Data Scaffolding
Goal. A compile-clean codebase carrying every teleport tag, the destination DataAsset class shell, its primary-asset registration, and a validator stub. No behavior. Unblocks every later phase by giving them stable symbols to reference.
Rationale. Phase 0 is always tag & data scaffolding (per the impl-doc convention). Teleport's tags and its UPrimaryDataAsset shape are referenced by C++ across four later phases; landing them first means no later phase blocks on a missing symbol.
Tasks.
- [x] Tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h /
.cpp: - [x]
Action.Modal.Teleport(.inionly — the HUD'sModalPageClassesTMap<FGameplayTag, TSubclassOf<UCommonActivatableWidget>>on CradlHUDLayout.h is BP-authored, so no C++ symbol is needed to key it) - [x]
Action.Trigger.Modal.Teleport(.ini+ header —ATeleportStation::BeginInteractfiresCradlTags::Action_Trigger_Modal_Teleportin C++, mirroringAction_Trigger_Modal_Crafting) - [x]
Action.Trigger.Teleport.Execute(.ini+ header, under a newAction.Trigger.Teleportroot — the menu widget dispatches it in C++ viaFireReplicatedGameplayEvent) - [x]
Cinematic.Teleport(.ini+ header — passed toBeginCinematicControl/EndCinematicControlin C++, alongside existingCinematic.LevelIntro) - [x]
Teleport.Network.*root namespace + concrete leafTeleport.Network.SpiritTree(Phase 1/4 test fixture) (.inionly — content-authored on assets, matched by tag likeStation.*; the validator resolves it viaRequestGameplayTagstring) - [ ] Deferred to Phase 3: the distinct
Map.*landmark + minimap tags (OQ1) — leaf names depend on MAP_SYSTEM.md bake rules. - [x] Not added (OQ2): no
Cooldown.Teleport. - [x] DataAsset shell — Source/CRADL/World/TeleportDestinationDefinition.h (new):
- [x]
UTeleportDestinationDefinition : UPrimaryDataAssetwithNetwork(FGameplayTag),DisplayName(FText),Icon(TSoftObjectPtr<UTexture2D>— the project-wide icon-field convention),Location(FTransform),Requirements(FQuestRequirements). - [x]
GetPrimaryAssetId()override returningFPrimaryAssetId(TEXT("TeleportDestinationDefinition"), GetFName())— mirrorURecipeDefinition::GetPrimaryAssetId. TheFPrimaryAssetIdis the destination's identity — do not add a redundant id field (perfeedback_push_back_on_duplicate_identity.md). - [x] Primary-asset registration — Config/DefaultGame.ini: added a
PrimaryAssetTypesToScanentry forTeleportDestinationDefinition(mirrors theRecipeDefinitionentry; scans/Game/Definitions/TeleportDestinations). - [x] Validator stub — Source/CRADLEditor/Validators/CradlTeleportDestinationValidator.h (new):
UCradlTeleportDestinationValidator(auto-discovered likeUCradlRecipeDefinitionValidator— no registration list);ValidateLoadedAssetreturns valid (real checks land Phase 1). Existence-in-lockstep now so Phase 1 only fills the body.
Verification.
- Compile clean (Claude builds via
Build.bat; links exit-0). - In-editor: create a
UTeleportDestinationDefinitionasset; confirm it is discoverable as a primary asset of typeTeleportDestinationDefinition(Asset Manager settings show the type; the asset resolves viaGetPrimaryAssetIdList).
Exits. Phases 1–6 now have concrete tags, the definition type, and the validator to fill in.
Footguns.
- Add a header symbol only for tags C++ names (
Action.Trigger.Modal.Teleport,Action.Trigger.Teleport.Execute,Cinematic.Teleport);Action.Modal.TeleportandTeleport.Network.*stay.ini-only (perfeedback_gameplay_tag_decl_minimal.md). Teleport.Network.*is a new root namespace (not under an existing parent). That's intentional — it mirrorsStation.*as a content-filter root — but declare it in.inideliberately, not as a stray.
Phase 1 — Destination Catalogue: Async Registry + Validator
Goal. A subsystem that, given a Teleport.Network.* tag, returns the async-loaded set of UTeleportDestinationDefinition assets for that network; plus a full validator that gates half-authored destinations. This is the data spine every UI/ability phase reads.
Rationale. One spine before its verbs — the destination pool and its requirement evaluation must exist before the list widget (Phase 4) or the execute ability (Phase 5) can consume them. Mirrors the crafting registry so teleport does not introduce a second enumeration idiom (OQ3).
Tasks.
- [x] Registry — Source/CRADL/World/TeleportDestinationRegistry.h (new):
- [x]
UCradlTeleportDestinationRegistry(subsystem, mirrorUCradlRecipeRegistry) buildingTMap<FGameplayTag, FCradlTeleportDestinationArray> ByNetworkfromUAssetManager::GetPrimaryAssetIdList(TeleportDestinationAssetType, …). - [x] Async load the definitions via
UAssetManager::LoadPrimaryAssets(per OQ3 — not the recipe registry's synchronousPath.TryLoad()).EnsureBuilt(FSimpleDelegate)queues callers during an in-flight load and fires them all on completion (immediately if already built); null-handle / empty-id-list paths finalize synchronously so callers are never stranded. - [x]
GetDestinationsForNetwork(FGameplayTag NetworkTag, …)— the analogue ofGetRecipesForStation(reads the built index; empty until built). - [x] Validator body — CradlTeleportDestinationValidator.h: modeled on
UCradlRecipeDefinitionValidator+CradlQuestDefinitionValidator: - [x]
Networkresolves underTeleport.Network.*(viaRequestGameplayTagstring — notCradlTags::). - [x]
DisplayNamenon-empty;Iconset + resolves (AssetRegistry). - [x]
Requirementsaxes resolve: skills viaCradlValidationHelpers::CollectKnownSkillTags, quests viaCollectKnownQuestIds, items against the items DataTable, equipped tags underItem.Equipped.*. - [x]
Locationis not the identity/zero sentinel (FTransform::Identity= unauthored arrival point). - [x] Cheat fixture —
UCradlDebugComponent:Debug_TeleportDumpNetwork <tag>exec (Debug_ prefix — dotted names aren't valid C++ identifiers) — EnsureBuilt then logs every destination the registry returns for a network tag (name, met/unmet against the local player).
Verification.
- Author 2–3
UTeleportDestinationDefinitionassets on oneTeleport.Network.<Name>; one with a satisfiable requirement, one with an unsatisfiable one, one with a zeroedLocation. - Validator fails the zeroed-
Locationasset and the one with an unresolved requirement tag; passes the well-formed ones (editor validation / Data Validation commandlet). TeleportDumpNetwork <tag>lists exactly the well-formed destinations with correct met/unmet flags.
Exits. Phase 4 (UI) and Phase 5 (ability) can enumerate + evaluate destinations.
Footguns.
- Invalid requirement rows pass silently at runtime (
USkillsComponent::MeetsRequirementsskips unresolved tags) so half-authored assets don't lock players out — the validator is the real guard, hence the lockstep body here (per the contract's Requirement-Gating footgun). - Do not
GetAllActorsOfClassand do not synchronouslyLoadObjectfor the pool (CLAUDE.md) — the async registry is the only enumeration path.
Phase 2 — Shared Point-Warm Provider Refactor
Goal. Generalize the respawn streaming-source provider into a shared point-warm provider consumed by both respawn and (later) teleport, with the death→respawn flow proven still-correct after the refactor.
Rationale. Teleport is the genuine second implementer of point-warming; per feedback_interface_rule_reading.md the shared abstraction is introduced now, at the second caller, not after a duplicate rots. Independent of the teleport UI, so it is parallel-able with Phases 3 and 4 — but it is a hard prerequisite for Phase 6.
Pre-work. (Refactor of an existing system.)
- Generalize
URespawnStreamingSourceProvider→ a sharedUPointWarmStreamingSourceProvider(PointWarmStreamingSourceProvider.h): keep the transform field,Register(UWorld*)/Unregister(UWorld*), theIWorldPartitionStreamingSourceProviderimpl, and theIsStreamingCompletedmechanic. RenameSetRespawnTransform→ a neutralSetWarmTransform. The respawn-specific registration trigger (death-state transitions) stays with the respawn caller.
Tasks.
- [x] Renamed/relocated the provider to a system-neutral name + path:
URespawnStreamingSourceProvider(inCombat/) →UPointWarmStreamingSourceProviderin Source/CRADL/World/. OldCombat/RespawnStreamingSourceProvider.{h,cpp}deleted; streaming-source debug name generalizedCradlRespawnPrewarm→CradlPointWarm. Updated the respawn caller (ACradlPlayerControllerrespawn path + forward decl +RespawnStreamingSourcemember type) to the new type +SetWarmTransform. Also dropped a now-stale include of the old header fromCradlDebugComponent.cpp(it referenced the type only via that include). - [x] Confirmed the poll cadence fields on CradlPlayerController.h (
RespawnStreamingPollInterval,MaxStreamingWaitSeconds) are reusable as-is — both are plainEditDefaultsOnlycadence scalars with no respawn-specific semantics. Shared as-is, not aliased: Phase 6's teleport warm polling reuses these two fields directly (no duplication).RespawnStreamingPollIntervalkeeps its name to avoid churn; if the respawn flavor reads badly at the teleport call site in Phase 6, revisit then. - [x] Kept the null-
UWorldPartitionSubsystemguard (non-WP worlds report complete on first poll) intact in the shared mechanic —Register/Unregisterstill no-op when the subsystem is absent, andIsRespawnStreamingComplete's "no WP → true" path is unchanged.
Verification.
- Regression gate (mandatory). Run a full death→respawn cycle (cheat-kill → respawn) and confirm the pre-warm + fade still behave exactly as before the refactor — before any teleport code is layered on. If the refactor proves unsafe, fall back to a dedicated duplicate provider (contract's rejected alternative) and note it here.
Exits. Phase 6 can register a second warm instance at the teleport destination.
Footguns.
- This touches the death→respawn flow — the regression gate is not optional. Generalizing shared streaming code with teleport-shaped assumptions could silently break respawn warming, which has no loud failure (players just see pop-in).
- World partition is optional: keep proceeding-on-null-subsystem behavior; teleport in a non-WP test map must still complete warming trivially.
Phase 3 — Station + Modal Open Path (+ Distinct Map Identity)
Goal. A placed ATeleportStation you can walk up to and interact with, which opens an (initially empty) teleport modal, and which shows a distinct map/minimap identity.
Rationale. Test fixture before content: the station + open path must exist before the list has anywhere to render. Reuses the station→trigger→modal path verbatim from the other terminals — zero new C++ on the open path beyond the station actor and its fired tag.
Tasks.
- [x] Station — Source/CRADL/World/TeleportStation.h +
.cpp(new):ATeleportStation : AActor, IInteractable, mirroringACraftingStation/ALoadoutTerminal. CarriesNetworkTag(Teleport.Network.*,meta=(Categories="Teleport.Network")) with aGetNetworkTag()getter for the widget.CanInteractrange-gates viaInteractionDistanceSquared(fails on invalidNetworkTagwithInteractCheck.Reason.MissingRequirement, mirroring the crafting station's tag gate);BeginInteractfiresCradlTags::Action_Trigger_Modal_Teleporton the interacting pawn's ASC viaHandleGameplayEvent,Target = OptionalObject = this, local-only (non-locally-controlled returns true → server re-run no-ops).GatherActionsreturns the default Teleport action + an Examine. - [x]
GetMenuHeader()returns the station'sFCradlMenuHeaderfor page branding. - [x] Replication:
bReplicates = falseexplicit (mirrorsALoadoutTerminal, notACraftingStationwhich setstrue). Verified the interaction system'sServer_BeginInteract(AActor* Target)RPC resolves this placed actor by its stable level NetGUID without replication, so no replicated state is needed; the teleport event fires on the pawn's own ASC. (P2P audit: no server-mutated station state exists to replicate.) - [ ] Modal routing (BP/data — user): add the BP-authored
ModalPageClassesentryAction.Modal.Teleport → UTeleportMenuWidgeton the HUD layout Blueprint. Author aUCradlModalAbilityBP subclass withModalTag = Action.Modal.Teleport+ aGameplayEventtrigger onAction.Trigger.Modal.Teleport, and grant it to the player (mirror the crafting modal ability). C++ side is complete; this is Blueprint wiring only. - [x] Distinct map identity (OQ1): grounded against MAP_SYSTEM.md — the system splits coarse
Map.Icon.*(live radar) from fineMap.WorldIcon.*(baked landmark). AddedMap.Icon.Teleport+Map.WorldIcon.Teleportto Config/DefaultGameplayTags.ini and as native symbols in CradlGameplayTags.h/.cpp(C++ names them in the station ctor). The station'sUMapTrackerComponentdefaults toMapIconTag = Map_Icon_Teleport+WorldMapIconTag = Map_WorldIcon_Teleport— the DISTINCT teleport iconography, not the sharedMap.Icon.Station. Component isVisibleAnywhereso per-placement override stays available. (Content follow-up: addMap.Icon.Teleport+Map.WorldIcon.Teleportpresentation rows toUMapIconPresentationDataAsset, else the blip/landmark resolve to no icon.) - [x] Menu widget shell — Source/CRADL/UI/TeleportMenuWidget.h +
.cpp(new):UTeleportMenuWidget : UCradlActivatableWidgetthat, onNativeOnActivated, recovers the station viaGetModalContext()->Target(concreteCast<ATeleportStation>— single station source in v1, noITeleportModalSourceinterface yet) and caches itsNetworkTag. Empty list body; a Warning-level diagnostic logs the resolved station + network perfeedback_log_level_warning_for_diagnostics.
Verification.
- Place an
ATeleportStationwith aNetworkTag; walk into range → interact prompt appears (range gate) → interacting opens the (empty) teleport modal on theUI.Layer.Modalstack. - On the world map + minimap, the station shows the distinct teleport iconography, not the shared
Map.Icon.Station.
Exits. Phase 4 renders rows into this widget.
Footguns.
- Don't name a local
SlotorWidgetinATeleportStation/UTeleportMenuWidgetcode (C4458 hidesUWidget::Slot— perfeedback_slot_shadows_uwidget.md); useSpawnedRow/DestSlotData. - The station's map-tracker component is
VisibleAnywhere— decide the distinct landmark deliberately at the CDO, don't leave it defaulting to the shared station icon (that would silently re-collapse OQ1).
Phase 4 — Destination List UI, Selection & Confirmation
Goal. The teleport modal renders one row per destination for the station's network, with met/unmet requirement display, single-selection, and a post-selection Yes/No confirmation that (on Yes) fires the execute trigger.
Rationale. Independent verb built on the Phase 1 spine + Phase 3 open path. The confirmation is bundled here (not split) because it is raised from the menu widget post-selection — it shares the widget's lifetime and selection state, so a single open→select→confirm flow is one verifiable unit.
Tasks.
- [x] List trio (new) — mirrors the loadout entry→button→group trio verbatim:
- [x]
UTeleportEntryListItem(Source/CRADL/UI/TeleportEntryListItem.h +.cpp) — one row; holds theUTeleportDestinationDefinition, exposesSelectButton+GetDestination(), firesOnDestinationRefresh(mirrorsULoadoutEntryListItem). - [x]
UTeleportSelectButton : UCommonButtonBase(Source/CRADL/UI/TeleportSelectButton.h +.cpp) —BindWidget'd in the row; deferredOnDataApplied+ forcedSetIsSelectable(true)inNativeConstruct(mirrorsULoadoutSelectButton); wired into the menu'sUCommonButtonGroupBase. - [x]
UTeleportMenuWidgetpopulates rows from the registry's async result:RebuildList→Registry->EnsureBuilt(CreateUObject(this, &PopulateList));PopulateListguards onIsActivated(), then builds under the suppression guard.ButtonGroupcreated inNativeConstructwithSetSelectionRequired(true). - [x] Presentation — Source/CRADL/UI/TeleportPresentation.h (new):
FTeleportDestinationPresentationcarryingDisplayName, softIcon,DestinationId(FPrimaryAssetId), skill rows viaCradl::UI::BuildSkillRequirementPresentation, quest rows viaBuildQuestRequirementPresentation, andbMeetsRequirements. No rawFGameplayTagreaches the widget — tags resolve toDisplayName+Iconcompanions (perfeedback_presentation_struct_resolve_tags.md), mirroringFLoadoutPresentationData. (Item / equipped-tag axes have no client row — server-enforced only, matching the loadout menu; noted in the struct DevComment.) - [x] Confirmation: on a genuine row selection,
UTeleportMenuWidget::RaiseConfirmcallsUCradlHUDLayout::PushConfirmDialog(FCradlConfirmDialogContent, OnResolved);Title=ConfirmTitle,Body=FText::Format(ConfirmBodyFormat, DisplayName)("Teleport to?"). InCombatTooltipTextpopulated (OQ2) so the confirm disables underStatus.InCombat.OnResolved(true)→DispatchTeleportExecute;OnResolved(false)/ESC dismisses. Callback captures aTWeakObjectPtrto the page (survives the page outliving the closure guard). - [x] Requirement evaluation (client, advisory):
BuildPresentationbuilds met/unmet locally from the destinationRequirements(skills viaSkillsComponent, quests viaQuestComponent) againstBoundPS;HandleSelectedButtonChangedblocks the confirm for!bMeetsRequirementsrows (loadout precedent). Authority re-checks on execute (Phase 5). (Note: the doc originally namedFQuestRequirements::IsSatisfiedByfor this; I used the row-builder path instead — same result for the visible skill/quest axes, and it also produces the checklist rows the WBP needs.IsSatisfiedByis the server's all-axes check in Phase 5.) - [x] Dispatch (Phase 4 stub):
DispatchTeleportExecutefiresAction.Trigger.Teleport.Executeon the player's ASC viaHandleGameplayEvent(Target = station) and logs the destination id + name at Warning (feedback_log_level_warning_for_diagnostics). Phase 5 replaces this with theUTeleportRequestpayload (carryingDestinationId) and the client→authority delivery the ServerOnly ability needs —HandleGameplayEventhere is a local, observable no-op until then. - [x] Fire-and-close (ability-first):
DispatchTeleportExecuteends withRequestCloseOwningModal()(new shared helper onUCradlActivatableWidget, extracted fromNativeOnDeactivated's cancel loop) — teleport is a one-shot verb, not an eager-apply panel like the loadout menu (which deliberately stays open for repeated swaps and owns noAction.ModalClose). The modal ability is the source of truth: canceling it clearsAction.Modal.Teleport, and the HUD router's count-0 branch removes the page — the same direction as the hotkey re-press close. NotDeactivateWidget()(widget-first tears the page down around a still-active ability), and not the craft precedent ofActivationOwnedTags = Action.ModalCloseon the verb ability —UCraftAbilityis LocalPredicted so that tag lands client-side, butUTeleportAbilityis ServerOnly, so its owned tags never reach the client ASC where the modal'sCancelOnTagsAddedlistens. The server's range re-check remains the authoritative backstop against re-dispatch. - [x] Replication: none — presentation + confirm are client-local UI state.
Verification.
- Open the modal at a station whose network has the Phase 1 fixtures → rows appear, one per well-formed destination, with correct met/unmet styling.
- Select a destination → confirm dialog names it. In combat (
Status.InCombatcheat) the confirm is disabled with the in-combat tooltip. Confirm →Action.Trigger.Teleport.Executefires (log/trace at Warning perfeedback_log_level_warning_for_diagnostics.md).
Exits. Phase 5 consumes the fired execute event.
Footguns.
- Bind child-widget delegates in
NativeConstruct, notNativeOnInitialized— the teleport page is a reused modal;NativeDestructunbinds on every close, so aNativeOnInitialized-only binding is dead after the first reopen (perfeedback_bind_child_delegate_in_nativeconstruct.md). - Pages initialize pre-activation — any field read in
NativeOnActivated(GetModalContext, bound player state) must be set in theAddWidgetinit lambda. UCommonButtonGroupBasere-entrant selection —OnSelectedButtonBaseChangedfires onAddWidget(auto-select-first) andRemoveAll(cascade). Append rows beforeAddWidget; snapshot saved selection beforeRemoveAll.
Phase 5 — Execute Ability, Authority & Client-Ready Handshake
Goal. A ServerOnly ability that, on the fired execute event, re-validates on authority and relocates the server pawn — gated on an owning-client readiness RPC (transition/warming layered in Phase 6).
Rationale. The relocate is an authoritative world mutation, so it lives server-side, mirroring UDeathAbility (ServerOnly, waits on a client-readiness RPC). Depends on Phase 1 (definitions to resolve) and Phase 4 (the widget that dispatches the event).
Tasks.
- [x] Ability — Source/CRADL/Abilities/TeleportAbility.h +
.cpp(new):UTeleportAbility : UCradlGameplayAbility,NetExecutionPolicy = ServerOnly,InstancedPerActor(mirrorsUDeathAbility). Triggered by GameplayEventAction.Trigger.Teleport.Execute.ActivationBlockedTagsincludesStatus.InCombat(OQ2 authoritative combat block). No cooldown (OQ2). - [x] Grant — Source/CRADL/Player/CradlPlayerState.cpp (safeguard, not BP-only): granted in C++ in
BeginPlay's ability block, mirroring theURelicSwapAbility/URedeemVoucherAbility/UDeathAbilitypattern — everyHandleGameplayEvent(Action.Trigger.*)-dispatched verb needs a granted spec or the event dispatches to nothing.!DefaultAbilities.Contains(UTeleportAbility::StaticClass())dedupes if a designer also lists it in the BP CDO. So no BP wiring is required for the execute ability — only the Phase 3 modal ability (UCradlModalAbilitysubclass) remains BP-authored. Without this the widget'sServerRequestTeleport → HandleGameplayEventwould silently no-op. - [x] Request payload — Source/CRADL/Abilities/TeleportRequest.h (new):
UTeleportRequesttransientUObjectcarrying the station weak ref + the destination'sFPrimaryAssetId(never a raw definition pointer — the id survives the wire and is re-resolved server-side). MirrorsULoadoutSwapRequest. Built authority-side (not on the client) — see the dispatch deviation below. - [x] Dispatch — DEVIATION from the doc (the Phase-4 report's flag, now resolved): the doc named
FireReplicatedGameplayEvent(…, OptionalObject=Request)for client→authority delivery, but that overload is authority→peers multicast; on a non-authority client it does a local-only fanout, and GAS's own ServerOnly activation forwarding (CallServerTryActivateAbility) drops theOptionalObject— so theFPrimaryAssetIdnever reaches the server. Replaced with a dedicatedACradlPlayerController::ServerRequestTeleport(FPrimaryAssetId, AActor* Station)(Server, Reliable): the widget sends the raw id + station; the authority-side handler rebuilds theUTeleportRequestand firesAction.Trigger.Teleport.Executelocally on the owner's ASC (on authority,HandleGameplayEventreaches the ServerOnly ability with the payload intact). This is idiomatic here — it mirrors the existingServerNotify*controller-RPC pattern the respawn flow uses. - [x] Server-side resolution + validation:
UTeleportAbility::OnDestinationResolvedasync-resolves theFPrimaryAssetId→UTeleportDestinationDefinitionviaUAssetManager::LoadPrimaryAsset(neverLoadObject; null-handle → resolve synchronously so it's never stranded); re-runsFQuestRequirements::IsSatisfiedByon the owner's server-sideACradlPlayerState; re-checks station range via the station's ownCanInteract(Pawn).bCanInteract(skipped whenStationis null — the cheat path). Evaluated once at activation (mirrorUCraftAbility). - [x] Client-ready handshake + the race resolution: added
ServerNotifyTeleportReady(Server, Reliable) +OnTeleportReadyReceiveddelegate on CradlPlayerController.h, parallel to the respawn pair, plus a newUAbilityTask_WaitTeleportReadymodeled onUAbilityTask_WaitRespawnReady. ExtraClientStartTeleportWarm(FTransform)(Client, Reliable) added because — unlike respawn, where a replicated state flip triggers the client — the teleport client has no server-side signal to know when to fire ready. After the ability subscribes the wait task, it callsClientStartTeleportWarmon the owning PC; the client (Phase 5) firesServerNotifyTeleportReadyimmediately (Phase 6 inserts fade + warm here — the transform is handed over for exactly that). This makes the handshake race-free: the ready round-trip is a response to a message sent only after subscription, so the broadcast can never precede the subscription. - [x] Move: on the ready signal,
OnTeleportReadycallsSetActorLocationAndRotation(Destination.Location, …, ETeleportType::TeleportPhysics)on the server pawn (authority-gated), thenEndAbility. The movement component replicates the new transform to peers. - [x] Cheat fixture —
UCradlDebugComponent:Debug_TeleportForce <networkTag> <destId>(Debug_ prefix — dotted names aren't valid C++ identifiers) dispatches straight throughServerRequestTeleportwith a null station, exercising the server resolve + re-validation + move in isolation;destIdaccepts a fullType:Nameid or the bare asset name.
Replication audit (per feedback_p2p_replication_audit.md — all deliberate):
- Pawn transform — already replicated by the movement component; server
SetActorLocationpropagates to peers. ✔ UTeleportRequest— transient, authority-side-only; never leaves the server. Destination identity travels client→authority as a bareFPrimaryAssetIdinside theServerRequestTeleportRPC (see the dispatch deviation), then the request object is built on authority and re-resolved server-side. ✔Action.Modal.Teleport— loose modal tag, client-local UI; not replicated authority. ✔- Requirement re-validation — server-authoritative, reads owner's own skills/quests/inventory/equipped tags (
RequiredEquippedTagsisCOND_OwnerOnlybut the owner is present on its own authority). ✔ - No cooldown state to replicate (OQ2). ✔
Verification.
- Cheat
Debug_TeleportForce <networkTag> <destId>(bypasses UI): dispatches throughServerRequestTeleport→ server pawn relocates toLocation, and the move replicates to a second connected peer (verify on a listen-server + client). Requirement-failing destination is refused on authority even if the client dispatches it.
Exits. Phase 6 inserts warm + fade between confirm and the SetActorLocation.
Footguns.
- Don't make the execute ability LocalPredicted — the move is authoritative and gated on a client-local warm the server can't predict; prediction risks teleporting into unwarmed cells ahead of the server. ServerOnly + client-ready RPC is the correct shape (mirrors respawn; not
ULoadoutSwapAbility's LocalPredicted — that mutates prediction-friendly inventory, not spatial geometry). - Resolve the destination async server-side, never
LoadObject(CLAUDE.md).
Phase 6 — Warm-then-Move Transition
Goal. The full felt experience: confirm → fade to black → warm the destination cells during the fade → move on the server once warm-complete (or timeout) → fade in at the destination. No visible pop-in, no loading screen.
Rationale. Polish/transition lands last, after the authoritative move loop closes. Reuses the cinematic-control fade seam and the Phase 2 shared warm provider. Gating fade-in on warm-complete hides pop-in without a loading-screen floor.
Pre-work. (Depends on Phase 2's shared provider.)
- The generalized
UPointWarmStreamingSourceProvider(Phase 2) — teleport owns a second instance, registered atDestination.Location.
Tasks.
- [x] Owning-client warm polling on CradlPlayerController.h:
StartTeleportWarmPolling/StopTeleportWarmPolling/PollTeleportWarm/FireTeleportReady/IsTeleportStreamingComplete(new), parallel to the respawn pair and reusing the shared poll cadence (RespawnStreamingPollInterval/MaxStreamingWaitSeconds) as-is, not aliased (the Phase 2 decision). Registers a secondUPointWarmStreamingSourceProvider(TeleportStreamingSource, distinct fromRespawnStreamingSource) atPendingTeleportDestination; pollsUWorldPartitionSubsystem::IsStreamingCompleted(poll timer +MaxStreamingWaitSecondsdeadline) until complete, thenFireTeleportReady→ServerNotifyTeleportReady. AllIsLocalController()-gated, never replicated — each peer warms its own cells. The immediate first poll (mirroringStartRespawnReadyPolling) means non-WP / already-resident cells complete same-frame. - [x] Cinematic-control fade: the owning client's
ClientStartTeleportWarm(the seam, not the literal confirm button — the transform is handed over here, per the Phase 5 note)BeginCinematicControl(Cinematic.Teleport)and fades to black (FadeOutDuration, held); warming starts immediately so streaming overlaps the fade-out ramp. Fade-in +EndCinematicControl(Cinematic.Teleport)run inFinishTeleportTransition(FadeInDuration), driven by the completion signal below. - [ ] Cinematic IMC entry (BP/data — user): add a
Cinematic.Teleportentry toCinematicInputContextsonPC_CRADL, pointing at a new empty (no-binding)IMC_Teleport— the map's documented "fully arrest input" form. Without the entry,BeginCinematicControl(Cinematic.Teleport)hits its deliberate "no IMC authored for mode" early-out (Warning in the log) and never removes the default IMC — the player can click-to-move and fire commands throughout the fade/held black. Do not reuseIMC_LevelIntro: it carries the skip-intro binding, which would fire the intro-skip path mid-teleport. - [x] Move gated on FULL black, not the fade-out ramp:
FireTeleportReady(→ServerNotifyTeleportReady→ authoritative move) is gated on both the fade-out completing (OnTeleportFadeOutComplete/TeleportFadeOutTimeratFadeOutDuration) and warm-complete. Because warming is effectively instant, it's never the pacing driver — the fade-out completion is — so the relocation always happens under held black, never mid-ramp (the "never reveal the relocation mid-cut" footgun; firing on warm-complete alone would move during the fade-out ramp, or same-frame on a listen server). Warm-complete only becomes the gate in the rare genuinely-slow-stream case, up to theMaxStreamingWaitSecondssafety cap. This is the concrete meaning of the contract's "gated on warm-complete-or-timeout, not a fixed duration." - [x] Minimum time-under-black floor (
TeleportMinBlackHoldSeconds, default 2s): the fade-in is gated onmax(min-black floor, move confirmed)viaMaybeStartTeleportFadeIn(bothbTeleportMinBlackElapsed+bTeleportMoveConfirmedmust be set). The floor runs from fade-out-complete (OnTeleportMinBlackElapsed/TeleportMinBlackTimer), so even when warming + the move resolve instantly the screen still holds black for the full floor — a feel mechanic per the loading min-hold principle ("ready too fast feels unofficial"), so a teleport reads as a deliberate, weighty jump rather than a hitch. The warm + move both happen inside this window.ClientFinishTeleportnow just sets the move-confirmed gate; the finish backstop still force-completes (it fires atMaxStreamingWaitSeconds> the floor, so black is already satisfied). - [x] DEVIATION — server→client completion signal
ClientFinishTeleport(Client, Reliable): unlike respawn (whose fade-in is gated on the replicatedECradlDeathState::Aliveflip, guaranteed post-move), teleport has no replicated state flip to signal "move done." A purely client-driven fade-in (fade in the instant warm completes) would be correct only on a listen server, whereServerNotifyTeleportReadyruns the move synchronously; on a remote owning client it races the replicated move and could reveal the old location — exactly the footgun "never reveal the relocation mid-cut." SoUTeleportAbility::OnTeleportReadycallsPC->ClientFinishTeleport()immediately after the authoritativeSetActorLocation; the reliable RPC is ordered after the move's property replication, so the reveal always follows the relocation. This is the completion twin ofClientStartTeleportWarmand mirrors respawn's server→client shape. - Finish backstop (
TeleportFinishDeadlineTimer): armed inFireTeleportReadybefore notifying (on a listen server the whole authority chain, includingClientFinishTeleport, runs inline and clears it). IfClientFinishTeleportnever arrives — the ability ended without moving — the backstop releases the fade + input arrest anyway (MaxStreamingWaitSeconds), so a player is never stranded in held black with input locked.FinishTeleportTransitionis guarded bybTeleportTransitionActiveso backstop + real RPC can't double-fire. - [x] Fade durations: reuses
FadeOutDuration/FadeInDuration(the canonical transition-cover fades the respawn flow already uses) rather than introducing teleport-specific fields — the same "share the cadence fields as-is" call as Phase 2's poll cadence. - [x] Replication: the entire channel/fade is client-local cosmetic (per CLAUDE.md's cosmetic-state prohibition); only the mid-sequence
SetActorLocationis authoritative + replicated.ClientStartTeleportWarm/ClientFinishTeleportare directed RPCs to the owning client, not replicated state.
Verification.
- Author a destination in a world-partition-streamed-out cell far from spawn. Teleport there: screen fades to black, cells stream in behind the fade, fade-in reveals fully-loaded geometry with no pop-in. On a slow stream the safety timeout still completes the move (fade covers residual pop-in).
- Non-WP test map: warming reports complete on first poll; the move + fade still sequence correctly (null-subsystem guard).
Footguns.
EndCinematicControlis mode-matched — end with the sameCinematic.Teleportmode you began with, so a concurrent intro/other cinematic mode isn't torn down (per LEVEL_FLOW input-arrest composition).- Input is arrested during the channel — ensure the execute ability isn't also listening for
State.Movingto self-cancel during this window. - Never reveal the relocation mid-cut — keep the fade holding black across the
SetActorLocationframe (spirit ofreference_sequence_exit_fade_pause_at_end.md); a fade-in that races the move shows the player jumping in full view. - Regression: this is the second consumer of the Phase 2 provider — re-confirm respawn still warms after teleport polling is added (shared cadence fields, shared provider type).