0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI TELEPORT_SYSTEM
UTC 00:00:00
◀ RETURN
TELEPORT_SYSTEM.md 3421 words ~16 min read Updated 2026-07-03

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.


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 YesATeleportStation::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

Open Questions

  1. Map/minimap identity — do teleport stations get a distinct Map.WorldIcon.Teleport landmark tag and/or a non-default Map.Icon.*, or do they reuse Map.Icon.Station? Cross-check MAP_SYSTEM.md bake rules.
  2. Combat gate + cooldown — is teleport blocked in combat (populate InCombatTooltipText + ActivationBlockedTags = Status.InCombat on the execute ability)? Does it carry a Cooldown.Teleport GE, or is it free/repeatable like OSRS spirit trees? No cooldown is invented until decided.
  3. Destination-pool enumeration mechanism — confirm exactly how recipes are gathered for a Station.* tag (registry vs UAssetManager::GetPrimaryAssetIdList filter) and reuse that mechanism verbatim for Teleport.Network.*, so teleport does not introduce a second enumeration idiom.
  4. Non-owner peer requirement pre-check — if any UI/gameplay path needs a peer-visible requirement check (beyond the owner-authoritative execute), the COND_OwnerOnly equipped-tag fallback must be mirrored in lockstep per feedback_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.