CRADL Teleport System
Companion to ARCHITECTURE.md, PLAYFLOW_SYSTEM.md (world-partition warming + spawn-transform resolution), and QUEST_SYSTEM.md (the FQuestRequirements gate this system reuses). This document is the contract teleport must satisfy — its data shape, station/interaction surface, modal UI flow, requirement gating, ability/authority split, world-partition warming, and the warm-then-move transition. Implementation patterns, balance numbers, and per-destination authoring live elsewhere; what's here does not change without a deliberate edit to this file.
North Star
Teleport moves a pilot large distances within a single world-partition world — no level travel, no OpenLevel. It is an OSRS-heritage fast-travel network (spirit-tree / fairy-ring shape): a placed station opens a list of destinations, the player picks one, confirms, and is relocated after the destination's cells are streamed in. Destinations are a global pool filtered by a network tag, gated by the same four-axis requirement format crafting and quests use. Teleport reuses every foundation pattern the station/modal and respawn systems established: it adds a verb (relocate-within-world), not a new foundation — the station→trigger→modal-ability→activatable-widget dispatch, the UCradlConfirmDialogWidget prompt, the FQuestRequirements gate, and the respawn streaming-source warm are all existing surfaces it binds to.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| How destinations are authored | Global pool of UTeleportDestinationDefinition (new) PrimaryDataAssets, filtered by Teleport.Network.* (new) tag |
Destination Catalogue & Requirement Gating |
| How the station opens the UI | ATeleportStation (new) implements IInteractable, fires Action.Trigger.Modal.Teleport (new) on the pawn's ASC; a UCradlModalAbility BP publishes Action.Modal.Teleport (new) |
Station & Interaction Surface |
| List widget shape | UTeleportMenuWidget / UTeleportEntryListItem / UTeleportSelectButton (new) mirror the loadout entry→button→group trio |
Modal UI: Destination List |
| Confirmation | UCradlHUDLayout::PushConfirmDialog(FCradlConfirmDialogContent, OnResolved) after selection |
Confirmation Prompt |
| Requirement gating | FQuestRequirements::IsSatisfiedBy — client shows met/unmet, server re-validates on execute |
Destination Catalogue & Requirement Gating |
| Who performs the move | UTeleportAbility (new), ServerOnly authoritative SetActorLocation; owning client warms + fades |
Teleport Ability & Authority Split |
| World-partition warming | Shared point-warm provider generalized from URespawnStreamingSourceProvider; client-local, poll-until-complete + safety timeout |
World-Partition Warming |
| Transition | In-world channel: cinematic-control fade out → server move → fade in, gated on warm-complete | Warm-then-Move Transition |
Station & Interaction Surface
Rule: A teleport station is ATeleportStation (new), a placed world actor implementing IInteractable (InteractableInterface.h). It carries a NetworkTag (Teleport.Network.*) identifying which destination network it exposes. CanInteract gates on range exactly as ALoadoutTerminal does; BeginInteract fires Action.Trigger.Modal.Teleport (new) on the interacting pawn's ASC via a gameplay event whose FGameplayEventData::Target is the station. A UCradlModalAbility (CradlModalAbility.h) Blueprint — ModalTag = Action.Modal.Teleport — activates, publishes the loose modal tag, and the HUD router pushes UTeleportMenuWidget. No new C++ ability is needed to open the list.
Why: This is the identical station→trigger→modal path already proven by ALoadoutTerminal (LoadoutTerminal.h), AStoreTerminal (StoreTerminal.h), ACraftingStation (CraftingStation.h), and ABankTerminal (BankTerminal.h). Per ARCHITECTURE decision #15 ("modal UI engagements are GAS abilities"), a new modal is one tag pair + one BP subclass + one ModalPageClasses map entry — zero new C++ for the open path.
Implementation surface:
- Files: ATeleportStation (new) — mirror ACraftingStation.
- The station reads back to the widget via UCradlActivatableWidget::GetModalContext()->Target (CradlActivatableWidget.h).
- GetMenuHeader() returns the station's FCradlMenuHeader (CradlMenuHeader.h) for page branding.
- Replication: ATeleportStation is a content actor with no replicated mutable state — interaction fires a gameplay event on the pawn's own ASC, not on the station. Set bReplicates only if the station carries server-authoritative dynamic state (it does not in v1); otherwise leave it a plain placed actor like the other terminals.
Footguns:
- The station's primary action tag (Action.Trigger.Modal.Teleport) is fired by C++ (BeginInteract), so it needs a C++ symbol in CradlGameplayTags.h — see Tag Taxonomy.
- Per the map-tracker footgun flagged on the other terminals (UMapTrackerComponent is VisibleAnywhere, per-placement override stays available) — carry the same Map.Icon.Station default and decide deliberately whether teleport stations get a distinct Map.WorldIcon.* landmark tag (Open Question 1).
Related: ARCHITECTURE #15, #18 (widgets dispatch via ASC GameplayEvents, never PC RPCs); Modal UI: Destination List.
Modal UI: Destination List & Selection
Rule: The destination list reuses the entry→button→group trio verbatim. UTeleportMenuWidget : UCradlActivatableWidget (new) owns a UPanelWidget populated with UTeleportEntryListItem (new) rows; each row BindWidgets a UTeleportSelectButton : UCommonButtonBase (new); all buttons are wired into a UCommonButtonGroupBase for single-selection. On NativeOnActivated the widget recovers the station via GetModalContext()->Target, reads its NetworkTag, resolves the matching destination pool, and builds one row per destination from a pre-resolved presentation struct.
Why: This is the canonical loadout list pattern — ULoadoutSwapWidget (LoadoutSwapWidget.h) + ULoadoutEntryListItem (LoadoutEntryListItem.h) + ULoadoutSelectButton (LoadoutSelectButton.h), also mirrored by the crafting menu (CraftingMenuWidget.h / RecipeEntryListItem.h / RecipeSelectButton.h). ARCHITECTURE decision #18 references this exact trio as the reusable list shape.
Implementation surface:
- Files: UTeleportMenuWidget, UTeleportEntryListItem, UTeleportSelectButton — all (new).
- Presentation: FTeleportDestinationPresentation (new), TeleportPresentation.h — carries DisplayName, resolved Icon, the destination's FPrimaryAssetId, requirement rows built via Cradl::UI::BuildSkillRequirementPresentation (SkillRequirementPresentation.h), and bMeetsRequirements. Per feedback_presentation_struct_resolve_tags.md, never hand the widget a raw FGameplayTag — resolve every tag (network, skill) to a DisplayName + Icon companion in the presentation struct, mirroring FRecipePresentationData (RecipePresentation.h) and FLoadoutPresentationData (LoadoutPresentation.h).
- Replication: none — the presentation struct is client-derived UI state, built locally from the (client-resident) destination pool + the local player's skills/quests. Requirement met/unmet shown here is advisory; authority re-checks on execute.
Footguns:
- Bind child-widget delegates in NativeConstruct, not NativeOnInitialized (per feedback_bind_child_delegate_in_nativeconstruct.md) — the teleport page is a reused modal; NativeDestruct unbinds on every close, so a NativeOnInitialized-only binding is dead after the first reopen.
- Pages initialize pre-activation (ARCHITECTURE #35 / decision-set): any field the widget reads in NativeOnActivated (bound player state, GetModalContext) must be set in the AddWidget init lambda, not after.
- UCommonButtonGroupBase re-entrant selection (ARCHITECTURE #45): with bSelectionRequired=true, OnSelectedButtonBaseChanged fires on AddWidget (auto-select-first) and on RemoveAll (cascade). Append rows before AddWidget; snapshot any saved selection before RemoveAll.
- Don't name a local Slot or Widget in any of these UUserWidget subclasses (per feedback_slot_shadows_uwidget.md — C4458 hides UWidget::Slot); use SpawnedRow / DestSlotData.
- Destination-pool resolution must not use GetAllActorsOfClass and must not synchronously LoadObject (CLAUDE.md) — see Destination Catalogue & Requirement Gating for the AssetManager path.
Related: Confirmation Prompt; ARCHITECTURE #15, #18, #35, #45.
Confirmation Prompt
Rule: Selecting a destination does not teleport immediately. It raises a Yes/No confirmation via UCradlHUDLayout::PushConfirmDialog(FCradlConfirmDialogContent, TFunction<void(bool)> OnResolved) (CradlHUDLayout.h), which spawns UCradlConfirmDialogWidget (CradlConfirmDialogWidget.h) onto the UI.Layer.Modal stack. OnResolved(true) fires the teleport execute event; OnResolved(false) / ESC / movement dismisses. The content struct's Title/Body name the destination ("Teleport to Grand Exchange?").
Why: UCradlConfirmDialogWidget + FCradlConfirmDialogContent are the codebase's generic single-resolve confirm surface, already consumed by IInteractable::GetInteractConfirmation (InteractableInterface.h) through UInteractionComponent (InteractionComponent.cpp) and by AGatedInteractable (GatedInteractable.h). It fires OnResolved(bool) exactly once and already supports an InCombatTooltipText gate.
Implementation surface:
- The confirm is raised from the menu widget, post-selection — not via IInteractable::GetInteractConfirmation (that path fires before the modal opens, at interact time; teleport needs the confirm after a destination is chosen inside the modal).
- Replication: none — confirm dialog is client-local UI; the authoritative side effect is deferred to the execute event.
Footguns:
- Set InCombatTooltipText deliberately: OSRS blocks teleport in combat. If teleport should be combat-blocked, populate it so the confirm button disables under Status.InCombat (CradlGameplayTags.h); the authoritative combat block still lives in the execute ability's activation tags (Open Question 2).
Related: Teleport Ability & Authority Split.
Destination Catalogue & Requirement Gating
Rule: Each destination is a standalone UTeleportDestinationDefinition (new) UPrimaryDataAsset carrying: Network (Teleport.Network.* tag), DisplayName, Icon, Location (FTransform, the authored in-world arrival transform), and Requirements (FQuestRequirements). A station surfaces every destination whose Network matches its NetworkTag. Requirements are the crafting/quest four-axis gate — Skills, PrerequisiteQuests, RequiredItems, RequiredEquippedTags — evaluated by FQuestRequirements::IsSatisfiedBy(const ACradlPlayerState*) (QuestRequirements.h). The client evaluates for met/unmet display; the server re-evaluates authoritatively at execute.
Why: This is the recipe model, not the loadout model: recipes are individual URecipeDefinition (RecipeDefinition.h) assets filtered by a Station.* tag; teleport destinations are individual assets filtered by a Teleport.Network.* tag. It's the right shape when destinations are shared across many stations (every spirit tree reaches every node). The four-axis FQuestRequirements gate is already reused by recipes, quests, UInteractableDefinition (InteractableDefinition.h), and loadouts — centralized in USkillsComponent::MeetsRequirements (SkillsComponent.h) and CradlQuestGate::MeetsPrerequisites (CradlQuestGate.h).
Why Location is authored data, not a placed marker actor: the destination cell may be streamed out in world partition — a placed marker actor would itself be unloaded and unreadable at teleport time. Authoring the arrival transform in the asset lets both the client (warm-source position) and server (SetActorLocation) read it without depending on loaded geometry. This is why warming exists.
Implementation surface:
- Files: UTeleportDestinationDefinition (new).
- Pool resolution: enumerate destinations by Network tag via UAssetManager (GetPrimaryAssetIdList + registry filter) — mirror however recipes are gathered for a station tag. No GetAllActorsOfClass, no synchronous LoadObject (CLAUDE.md); async-load the definition assets via FStreamableManager/UAssetManager if not resident. Cross-check the exact recipe→station enumeration mechanism and reuse it (Open Question 3).
- Validator (lockstep, per CLAUDE.md): UCradlTeleportDestinationValidator (new), Source/CRADLEditor/Validators/ — validate Network resolves under Teleport.Network.*, DisplayName non-empty, Icon resolves, Requirements axes resolve (skills via CollectKnownSkillTags, quests via CollectKnownQuestIds, items against the items DataTable, equipped tags valid), and Location is not the identity/zero sentinel. Model on UCradlRecipeDefinitionValidator (CradlRecipeDefinitionValidator.h) and CradlQuestDefinitionValidator (CradlQuestDefinitionValidator.h).
- Native tag resolution in the validator: per reference_native_tag_no_cross_module_export.md, the CRADLEditor validator must resolve Teleport.Network.* via RequestGameplayTag(FName), not CradlTags:: symbols (LNK2001 across modules).
- Replication: none — definitions are static content read identically on every peer.
Footguns:
- Requirements are eligibility, not enforcement (per QUEST_SYSTEM.md): gate the start of teleport only; once the move commits, dropping an item mid-flight doesn't abort it. Evaluate once at execute activation, like UCraftAbility (CraftAbility.h) does.
- RequiredEquippedTags is COND_OwnerOnly on the ASC. Since the execute ability runs on the pawn's own authority with the owning player present, the owner's equipped tags are readable — but if any peer-visible pre-check is added, honor the acknowledged-wrong peer fallback in lockstep, don't fix it piecemeal (per feedback_cond_owneronly_peer_fallback_wrong.md).
- Invalid requirement rows pass silently (USkillsComponent::MeetsRequirements skips unresolved tags) so half-authored assets don't lock players out — the validator is the real guard, hence lockstep.
Related: Modal UI: Destination List; QUEST_SYSTEM.md requirement axes; feedback_push_back_on_duplicate_identity.md (the destination's FPrimaryAssetId is its identity — do not add a redundant id field).
Teleport Ability & Authority Split
Rule: The move is performed by UTeleportAbility : UCradlGameplayAbility (new), ServerOnly (NetExecutionPolicy::ServerOnly). The menu widget dispatches Action.Trigger.Teleport.Execute (new) via UCradlAbilitySystemComponent::FireReplicatedGameplayEvent (CradlAbilitySystemComponent.h) with a UTeleportRequest (new) payload in OptionalObject carrying the station weak ref + the destination's FPrimaryAssetId (never a raw pointer — the id survives replication and is re-resolved server-side). The ability: (1) re-validates requirements + station range on authority; (2) directs the owning client to warm + fade; (3) on the client's ready signal, calls SetActorLocation(Destination.Location) on the server pawn.
Why: The relocate is an authoritative world mutation, so it lives server-side — mirroring UDeathAbility (DeathAbility.h), which is ServerOnly and waits on a client-readiness RPC before its next phase. The transient UTeleportRequest payload mirrors ULoadoutSwapRequest (LoadoutSwapAbility.h) and ARCHITECTURE #50 payload conventions (Target = station, OptionalObject = typed descriptor). It carries an FPrimaryAssetId rather than a definition pointer so a replicated event can reconstruct it server-side.
Implementation surface:
- Files: UTeleportAbility, UTeleportRequest — (new).
- Client-ready handshake: reuse the respawn shape on ACradlPlayerController (CradlPlayerController.h) — a ServerNotifyTeleportReady (new) RPC parallel to ServerNotifyRespawnReady, fired once warming completes or the safety timeout elapses.
- Replication audit (per feedback_p2p_replication_audit.md):
- Pawn transform — already replicated by the movement component; server SetActorLocation propagates to all peers. Remote peers see a relocation, no teleport UI. ✔
- UTeleportRequest payload — transient, event-only; not a replicated property. Destination identity travels as FPrimaryAssetId inside the replicated gameplay event, re-resolved on the server via UAssetManager. ✔
- Streaming source — client-local, never replicated (see World-Partition Warming). ✔
- Action.Modal.Teleport — loose modal tag, client-local UI state; not replicated authority state. ✔
- Requirement re-validation — server-authoritative, reads owner's skills/quests/inventory/equipped tags. ✔
- Cooldown (if any) — applied as a server-side GE; its tag replicates through GAS (Open Question 2). ✔
- Fade / channel — client-local cosmetic; not replicated (per CLAUDE.md "replicate cosmetic-only state" prohibition). ✔
Footguns:
- Don't make the execute ability LocalPredicted. The move is authoritative and gated on a client-local warm that the server can't predict; prediction would risk a client teleporting into unwarmed cells ahead of the server. ServerOnly + client-ready RPC is the correct shape (mirrors respawn). (Rejected: LocalPredicted like ULoadoutSwapAbility — that ability mutates inventory, which is prediction-friendly; a spatial move into streamed geometry is not.)
- Resolve the destination asset async on the server, not with LoadObject — if the definition isn't resident server-side, async-load via UAssetManager before reading Location (CLAUDE.md).
- FireReplicatedGameplayEvent for the execute trigger, not a raw HandleGameplayEvent — a ServerOnly ability triggered from the client needs the event to reach authority.
Related: World-Partition Warming; Warm-then-Move Transition; ARCHITECTURE #18, #50.
World-Partition Warming
Rule: Before the pawn moves, the owning client registers a streaming-source at Destination.Location and polls UWorldPartitionSubsystem::IsStreamingCompleted until complete or a safety timeout elapses, then signals the server. Warming is generalized out of URespawnStreamingSourceProvider (RespawnStreamingSourceProvider.h) into a shared point-warm provider consumed by both respawn and teleport.
Why: The respawn flow already implements exactly this — URespawnStreamingSourceProvider (an IWorldPartitionStreamingSourceProvider with SetRespawnTransform + register/unregister against UWorldPartitionSubsystem) plus the controller's poll cadence (StartRespawnReadyPolling, PollRespawnReady, IsRespawnStreamingComplete, RespawnStreamingPollInterval, MaxStreamingWaitSeconds on CradlPlayerController.h). Teleport is the genuine second implementer of point-warming, so per feedback_interface_rule_reading.md the shared abstraction is introduced now rather than after a duplicate rots.
Implementation surface:
- Generalize URespawnStreamingSourceProvider → a shared provider (e.g. UPointWarmStreamingSourceProvider (new/renamed)): keep the transform + register/unregister/IsStreamingCompleted mechanics; the respawn-specific registration trigger (death-state transitions) stays with the respawn caller. Teleport owns a second instance.
- Poll on ACradlPlayerController reusing the respawn cadence fields; new StartTeleportWarmPolling / PollTeleportWarm (new) parallel to the respawn pair, terminating in ServerNotifyTeleportReady.
- Replication: the provider and all polling are client-local, IsLocalController()-gated, never replicated — each peer warms its own cells (per the respawn provider's local-only contract). The server does not drive any peer's streaming.
Footguns:
- World partition is optional. Non-WP levels report streaming trivially complete on first poll; the UWorldPartitionSubsystem lookup may be null. Gate on nullptr and proceed (mirror IsRespawnStreamingComplete).
- Polling is non-blocking with a safety timeout (MaxStreamingWaitSeconds). On timeout, proceed with the move anyway (graceful degradation) rather than stalling — the fade covers residual pop-in.
- Regression gate (mandatory): generalizing the respawn provider touches the death→respawn flow. After the refactor, verify a full death→respawn cycle still pre-warms and fades correctly before teleport is layered on. (Rejected alternative: a dedicated duplicate UTeleportStreamingSourceProvider with copied poll logic — zero respawn-regression risk, but violates the "unify at the second implementer" rule; chosen only if the regression gate proves the refactor unsafe.)
Related: Warm-then-Move Transition; PLAYFLOW_SYSTEM.md (respawn streaming + spawn-transform); feedback_loading_feel_min_hold.md (only relevant if the transition ever escalates to a loading screen — it does not in v1).
Warm-then-Move Transition
Rule: Confirmed teleport is an in-world channel with a fade, not a loading screen. Sequence: confirm → owning client enters cinematic control (BeginCinematicControl(Cinematic.Teleport) (new) mode) and fades to black → warming runs during the fade → on warm-complete (or safety timeout) the client fires ServerNotifyTeleportReady → server SetActorLocation → client fades in at the destination and EndCinematicControl(Cinematic.Teleport).
Why: Teleport should feel like an in-world jump, not a map load. The cinematic-control fade seam already exists on ACradlPlayerController (BeginCinematicControl / EndCinematicControl / IsAliveForInput), used by level intros (Cinematic.LevelIntro). Gating fade-in on warm-complete hides pop-in without the 2.5s loading-screen floor.
Implementation surface:
- Reuse ACradlPlayerController::BeginCinematicControl / EndCinematicControl (CradlPlayerController.h) with a new Cinematic.Teleport (new) mode tag.
- Fade-in must be gated on warm-complete-or-timeout, not on a fixed duration — otherwise a slow stream shows the player dropping into unloaded geometry.
- Replication: the entire channel/fade is client-local cosmetic; only the mid-sequence SetActorLocation is authoritative and replicated.
Footguns:
- EndCinematicControl is mode-matched — end with the same Cinematic.Teleport mode 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, so the modal's move-cancel does not fire mid-teleport; ensure the execute ability is not also listening for State.Moving to self-cancel during this window.
- A hard seek-to-end or instant Stop on any sequence teleports the camera in full view — not applicable here (no sequence), but keep the fade holding black across the SetActorLocation frame (per reference_sequence_exit_fade_pause_at_end.md's spirit: never reveal the relocation mid-cut).
Related: Teleport Ability & Authority Split; LEVEL_FLOW_SYSTEM.md input-suppression seam.
Tag Taxonomy
Per feedback_gameplay_tag_decl_minimal.md, Config/DefaultGameplayTags.ini is authoritative; Source/CRADL/CradlGameplayTags.h holds only the subset referenced by C++ symbol.
| Tag | Home | Referenced by C++ symbol? |
|---|---|---|
Action.Modal.Teleport (new) |
.ini + header |
Set as a BP ModalTag property; add a header symbol only if C++ names it (HUD router keys the ModalPageClasses map by tag — if that map is BP-authored, .ini-only suffices). |
Action.Trigger.Modal.Teleport (new) |
.ini + header |
Yes — ATeleportStation::BeginInteract fires it in C++. |
Action.Trigger.Teleport.Execute (new) |
.ini + header |
Yes — the menu widget dispatches it in C++. |
Teleport.Network.* (new namespace) |
.ini only |
No — content-authored on assets, matched by tag (like Station.*). Validator resolves via RequestGameplayTag string, not CradlTags::. |
Cinematic.Teleport (new) |
.ini + header |
Yes — passed to BeginCinematicControl/EndCinematicControl in C++. |
Cooldown.Teleport (new, optional) |
.ini + header if used |
Deferred — see Open Question 2. |
Reused unchanged: Status.InCombat, Map.Icon.Station, UI.Layer.Modal, the Skill.* / Item.Equipped.* axes read by FQuestRequirements.
Forward Code References
- Source/CRADL/World/TeleportStation.h —
ATeleportStation(new) - Source/CRADL/World/TeleportDestinationDefinition.h —
UTeleportDestinationDefinition(new) - Source/CRADL/Abilities/TeleportAbility.h —
UTeleportAbility(new) - Source/CRADL/Abilities/TeleportRequest.h —
UTeleportRequest(new) - Source/CRADL/UI/TeleportMenuWidget.h —
UTeleportMenuWidget(new) - Source/CRADL/UI/TeleportEntryListItem.h —
UTeleportEntryListItem(new) - Source/CRADL/UI/TeleportSelectButton.h —
UTeleportSelectButton(new) - Source/CRADL/UI/TeleportPresentation.h —
FTeleportDestinationPresentation(new) - Shared point-warm provider, generalized from Source/CRADL/Combat/RespawnStreamingSourceProvider.h
- New polling +
ServerNotifyTeleportReadyon Source/CRADL/Player/CradlPlayerController.h - Source/CRADLEditor/Validators/ —
UCradlTeleportDestinationValidator(new)
Open Questions
- Map/minimap identity — do teleport stations get a distinct
Map.WorldIcon.Teleportlandmark tag and/or a non-defaultMap.Icon.*, or do they reuseMap.Icon.Station? Cross-check MAP_SYSTEM.md bake rules. - Combat gate + cooldown — is teleport blocked in combat (populate
InCombatTooltipText+ActivationBlockedTags = Status.InCombaton the execute ability)? Does it carry aCooldown.TeleportGE, or is it free/repeatable like OSRS spirit trees? No cooldown is invented until decided. - Destination-pool enumeration mechanism — confirm exactly how recipes are gathered for a
Station.*tag (registry vsUAssetManager::GetPrimaryAssetIdListfilter) and reuse that mechanism verbatim forTeleport.Network.*, so teleport does not introduce a second enumeration idiom. - Non-owner peer requirement pre-check — if any UI/gameplay path needs a peer-visible requirement check (beyond the owner-authoritative execute), the
COND_OwnerOnlyequipped-tag fallback must be mirrored in lockstep perfeedback_cond_owneronly_peer_fallback_wrong.md; v1 assumes owner-only evaluation and needs none.
Contract drafted at TELEPORT_SYSTEM.md. Review and tell me when aligned, or run /design-system TELEPORT again with iteration feedback. Run /design-system --phase=derive-implementation TELEPORT to derive the phased implementation doc from it.