0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI MAP_SYSTEM
UTC 00:00:00
◀ RETURN
MAP_SYSTEM.md 5889 words ~27 min read Updated 2026-07-03

CRADL Map System

Companion to ARCHITECTURE.md, PLAYFLOW_SYSTEM.md (per-level identity), and COMBAT_SYSTEM.md (the tag→presentation registry this mirrors). This document is the contract the map and minimap must satisfy — its actor data source (the live minimap registry), icon presentation pipeline, widget render strategy, per-level map data, baked landmark POIs (the full map's data source), modal vs. persistent surfacing, and replication posture. Implementation patterns, balance numbers, and per-widget design briefs live elsewhere; what's here does not change without a deliberate edit to this file.

North Star

The map system is a pure local view over already-replicated actors — a radar minimap that is always present and a full per-level map you open like any other panel. The minimap keeps the player at the center, north fixed up, and paints only live in-radius actor icons (no world backdrop) via an overlap query; the full map shows the whole level over an authored background with pan and zoom, painting offline-baked landmarks plus the live local-player dot — it does not iterate world actors. The two surfaces have deliberately different data sources (a live range-filtered registry vs. a precomputed landmark store, because a whole level is too large to live-gather each refresh) but resolve icons from the same tag→DataAsset table, so adding a new kind of blip is a content edit, not a code edit.

The map reuses every foundation pattern the combat and UI systems established: it adds a view, not a new authority. Icon presentation mirrors UCombatPresentationRegistry; the full map surfaces through the existing Action.Modal.* router; persistent minimap mounting copies the UCradlLoadoutPreviewWidget self-binding precedent. Nothing in the map is replicated gameplay — it represents replicated players and actors but owns no authoritative state of its own.

Quick Reference

Topic Answer Section
What feeds the minimap Live in-radius OverlapMultiByChannel on ECC_Visibility (the channel UInteractionComponent already uses), filtered to UMapTrackerComponent owners, + GameState->PlayerArray (players) — O(in-radius), no registry sweep Actor Data Source
What feeds the full map Offline-baked landmark POIs (UMapPoiBakeDataAsset) + the live local-player dot — no live actor iteration Baked Landmark POIs
How icons are chosen Map.Icon.* / Map.WorldIcon.* tag → UMapIconPresentationDataAsset row → icon + color, via UMapIconPresentationRegistry Icon Presentation
Minimap vs. world-map icon Coarse MapIconTag (Map.Icon.*) drives the live radar; fine WorldMapIconTag (Map.WorldIcon.*) drives baked landmarks, and its presence gates bake membership — both resolve through the same registry Baked Landmark POIs
How blips are drawn UMG UUserWidget shell + NativeOnPaint single-pass blip layer (no pooled child widgets, no bespoke SWidget) Render Strategy
Minimap refresh Settings-tunable timer on UCradlMapSettings; collapsed minimap clears the timer Minimap
Minimap projection Top-down ortho of world XY, player-centered; north = configurable UCradlMapSettings::NorthVector (default world +X → screen up) Minimap
Full map data New MapBackgroundTexture + WorldBounds + baked-POI store ref on ULevelDefinition, resolved via UCradlGameFlowSubsystem Per-Level Map Data
How landmarks are baked In-editor "Bake Map POIs" Tools-menu utility (FCradlMapPoiBaker) snapshots placed UMapTrackerComponents with a set WorldMapIconTag into UMapPoiBakeDataAsset from the open editor world — bakes what's loaded, no headless WP force-load Baked Landmark POIs
Full map surfacing Modal page on UI.Layer.GameMenu via new Action.Modal.Map + UCradlModalAbility Full Map Surfacing
Replication None new — all sources are already-replicated; registry/components/widgets are client-local cosmetic Replication Posture

Actor Data Source

Rule: This is the minimap's live data source (the full map paints offline-baked landmarks and does not read it — see Baked Landmark POIs). The minimap gathers in-radius trackables through an overlap query, never a full registry sweep, merging two client-local channels:

  1. UMapTrackerComponent (new) — a pure data-marker component carrying the Map.Icon.* minimap tag (and the independent WorldMapIconTag that governs only the offline bake — see Baked Landmark POIs). It has no behavior — no BeginPlay, no collision setup, no registration list to maintain. The minimap's overlap finds the owner because the owner already responds to ECC_Visibility (every trackable interactable blocks it in its own constructor for cursor traces, and any visible mesh does by default); the gather then filters the overlap hits down to owners that carry this component. It reaches actors two ways: (a) known trackable classes (gathering nodes, crafting stations, enemies) get it created in C++ via CreateDefaultSubobject with the Map.Icon.* tag assigned as a C++ constructor default — never relying on a Blueprint override; (b) ad-hoc content actors (one-off ground items, terminals, quest givers) may add the component in Blueprint and author the tag there. The C++ path guarantees the core set; the BP path is a supported content escape hatch.
  2. GameState->PlayerArray — replicated players are resolved at query time (APlayerState::GetPawn()), assigned Map.Icon.Player, and range-checked against the same radius. They are not part of the overlap, because PlayerArray already maintains the authoritative live set across possession/respawn churn; the local player's pawn is skipped (it is the center reticle, not a blip).

UMapActorRegistry::GatherTrackables(FVector2D Center, float RadiusCm, TArray<FMapTrackable>& Out) (new) runs OverlapMultiByChannel on ECC_Visibility at Center/RadiusCm (sphere centered at the local pawn's Z so vertical offset doesn't eat the radius), resolves each hit owner's UMapTrackerComponent via FindComponentByClass (deduped per owner, non-tracker hits discarded), merges the PlayerArray channel, and returns the flat FMapTrackable array the minimap projects and paints. The full map never calls it.

Why: A flat registry sweep is O(total registered trackers) every refresh — it visits every trackable in the world to range-filter, the exact per-actor cost that makes a large level (Lumbridge↔Falador scale) too expensive to live-iterate. An overlap query rides the physics broadphase (a spatial index) and is O(in-radius): work scales with what is near the player, not with the level's total trackable count. This is now strictly the better tool because the full map is baked and no longer needs a whole-level gather — the minimap is the registry's sole consumer and only ever wants a local radius, so the original reason to prefer a flat list ("one path for both widgets; the full map needs the whole level") is void. The pattern — and the channel — are already proven in-repo: UInteractionComponent (InteractionComponent.cpp) does exactly this OverlapMultiByChannel on ECC_Visibility then filters to IInteractable; the map mirrors it, filtering to UMapTrackerComponent. Reusing ECC_Visibility means zero new collision config: the trackable classes already block it, so no dedicated channel and no per-component BeginPlay response-setting. Overlap also eliminates the stale-weak-pointer problem: a streamed-out actor's collision leaves the physics scene automatically, so there is nothing to compact and no registration lifecycle to get wrong.

Implementation surface: - Files: Source/CRADL/Map/MapActorRegistry.h (new), Source/CRADL/Map/MapTrackerComponent.h (new). No collision-channel config — the gather reuses ECC_Visibility. - Channel: ECC_Visibility — the same channel UInteractionComponent::InteractChannel defaults to, which every trackable interactable already blocks. No dedicated MapTrackable channel and no ECC_GameTraceChannelN alias: the overlap returns visible geometry too, but the FindComponentByClass<UMapTrackerComponent> filter discards everything that is not a tracker (exactly how the interaction overlap discards non-IInteractable hits). - Classes: UMapActorRegistry (new, UWorldSubsystem) — a query facade: GatherTrackables runs the overlap + player merge. No Trackers array, no Register/Unregister — overlap replaces them. UMapTrackerComponent (new, UActorComponent, bReplicates = false)UPROPERTY(EditAnywhere) FGameplayTag MapIconTag (coarse minimap category) plus UPROPERTY(EditAnywhere, meta=(Categories="Map.WorldIcon")) FGameplayTag WorldMapIconTag (new) (fine landmark identity; empty by default, governs the bake only); it is a pure marker with no behavior. Known classes set both tags as C++ constructor defaults; EditAnywhere keeps them authorable for BP-added instances. - Structs: FMapTrackable (new){ FVector WorldLocation; FGameplayTag IconTag; } (resolved per-gather, deduped by owner, never stored long-term). - Interface note: actors only add the component — no IMapTrackable interface needed. The overlap-channel coupling is inside the map system's own boundary, so the concrete reference is fine per CLAUDE.md's interface rule.

Footguns: - Reuse ECC_Visibility, then filter by component — overlapping this broad channel returns walls/floor/props, so the gather must discard every hit whose owner has no UMapTrackerComponent (the FindComponentByClass filter). This mirrors UInteractionComponent, which overlaps ECC_Visibility then keeps only IInteractable hits. The trade for skipping a dedicated channel is paying that filter pass — cheap, and it removes all per-actor channel config. - The owner must respond to ECC_Visibility. Known trackables already do (they block it for cursor traces); an ad-hoc BP content actor with collision disabled on its primitives won't be found — give it a primitive that at least overlaps ECC_Visibility (any visible mesh already does). - Overlap is 3D; the minimap is 2D. The sphere is centered at the local pawn's Z, and excludes same-XY trackables that are far in Z. For a flat level a sphere is fine; for a vertically-stacked level use a tall capsule (MinimapRadiusCm radius, large half-height) to emulate an XY cylinder. - Dedup by owner — a multi-primitive owner can be returned more than once; collapse to one FMapTrackable per owner (a TSet<const AActor*> seen-set). - Don't reintroduce a registration list (the replaced approach) — the flat TArray<TWeakObjectPtr> cost O(N) per refresh and carried a stale-pointer compaction burden; overlap has neither. If a future need pulls toward a maintained list, re-open this decision in the contract first. - Don't put players in the overlap — possession/respawn churn; PlayerArray already maintains them. The local player is the center reticle (a static widget element), not a blip; only remote players get Map.Icon.Player blips. - UMapTrackerComponent has no BeginPlay — it does no collision config (the owner already responds to ECC_Visibility) and no registration. It is a pure tag marker, identical on every peer; nothing to gate on authority.

Related: UInteractionComponent (InteractionComponent.cpp) OverlapMultiByChannel precedent; UMapActorRegistry is a UWorldSubsystem (not UGameInstanceSubsystem) because trackables are world-scoped; Replication Posture; Baked Landmark POIs.


Icon Presentation

Rule: A map icon tag — either a coarse minimap Map.Icon.* leaf or a fine landmark Map.WorldIcon.* leaf — resolves to a presentation row (display name, icon texture, color, draw size) through one lazy registry, identical in shape to the combat presentation trio. Both namespaces live in the same UMapIconPresentationDataAsset and resolve through the same UMapIconPresentationRegistry; widgets call the registry, never dereferencing the DataAsset directly. (The split exists because the minimap is a coarse live radar while the full map is a fine landmark reference — see Baked Landmark POIs — but they share one resolution path.)

Why: The tag→{DisplayName, Icon} pattern is fully proven by UCombatPresentationDataAsset + UCombatPresentationRegistry (CombatPresentationRegistry.h) and re-used for Enemy.Family.*. Copying it keeps icon authoring uniform and data-driven: a new blip kind is a new row, the actor's component points at its tag, done. Per feedback_presentation_struct_resolve_tags.md, the row pre-resolves the tag to name + icon so no widget does tag→label lookups in Blueprint.

Implementation surface: - Files: Source/CRADL/Map/MapIconPresentationDataAsset.h (new), Source/CRADL/Map/MapIconPresentationRegistry.h (new) - Classes: UMapIconPresentationDataAsset (new, UDataAsset)TMap<FGameplayTag, FMapIconPresentationRow> Rows; UMapIconPresentationRegistry (new, UGameInstanceSubsystem)Resolve(FGameplayTag) with lazy LoadSynchronous + leaf-name fallback + mutable cache + bResolveAttempted sentinel (mirrors UCombatPresentationRegistry). - Structs: FMapIconPresentationRow (new){ FText DisplayName; TSoftObjectPtr<UTexture2D> Icon; FLinearColor Color = FLinearColor::White; float DrawSize = 16.f; }. Kept separate from FCombatTagPresentationRow so map-only fields (color, size) don't pollute the combat row — same rationale the codebase used to split FEnemyFamilyPresentationRow. - Settings pointer: UCradlMapSettings::MapIconPresentation (TSoftObjectPtr<UMapIconPresentationDataAsset>).

Footguns: - Icon is a TSoftObjectPtr<UTexture2D>; wrap it in an FSlateBrush at paint time. Async-load (or pre-warm on registry init) if first-paint flash on map-open is objectionable — same caveat the combat/spell icon rows carry. - Registry resolves lazily on first Resolve, not in Initialize (ARCH #14) — a broken soft path surfaces on first blip, caught by the validator below, not at startup. - The leaf-name fallback keeps dev flows rendering a non-blank label when the asset is unset — don't remove it.

Related: COMBAT_SYSTEM.md presentation section; feedback_presentation_struct_resolve_tags.md; Tag Taxonomy.


Render Strategy

Rule: Both widgets are UMG UUserWidget subclasses. The minimap's live icon field is drawn in a single NativeOnPaint pass via FSlateDrawElement::MakeBox over a TArray<FMapBlip> the refresh step recomputes — not a pool of UCanvasPanel child widgets, and not a bespoke SWidget — because its blips churn as actors stream in/out of radius. The full map paints a baked, static, low-count landmark layer built once on open (not per refresh), so the churn this rule guards against does not occur there; it may use pooled UCanvasPanel child UImages positioned by UCanvasPanelSlot::SetPosition, which lets the engine glue blips to the background through pan/zoom for free. Frame chrome (circular border, compass labels, zoom buttons, the full map's background UImage and pan viewport) is ordinary Blueprint-authored UMG.

Why: Two pure options were weighed and both lose. Pooled UCanvasPanel child widgets churn as actors stream in/out of radius and spawn/die every refresh — create/destroy or pool management per tick. A bespoke SActorCanvas-style SWidget (Lyra's pattern, SActorCanvas.h) fixes the churn but discards CommonUI integration, the collapse hook, and Blueprint-authorable chrome for far more code. NativeOnPaint on a UMG widget captures the actual Slate win — single-pass variable-count painting of blips, range rings, and the circular clip from one flat array — while keeping the UMG shell. The UCradlContextMenuWidget (CradlContextMenuWidget.h) UCanvasPanelSlot::SetPosition idiom remains available for the few interactive chrome elements; only the blip field is custom-painted.

Implementation surface: - Files: Source/CRADL/UI/CradlMinimapWidget.h (new), Source/CRADL/UI/CradlMapWidget.h (new) - Classes: UCradlMinimapWidget (new, UCradlUserWidget); UCradlMapWidget (new, UCradlActivatableWidget) — base classes per CradlUserWidget.h / CradlActivatableWidget.h. - Structs: FMapBlip (new){ FVector2D LocalPos; FSlateBrush IconBrush; FLinearColor Tint; float Size; }. Recomputed by the refresh step into a mutable member; NativeOnPaint (const) reads it. - Repaint cadence: the timer recompute calls Invalidate(EInvalidateWidgetReason::Paint); paint itself is not on a tick.

Footguns: - NativeOnPaint is const — the blip array must be a mutable member populated by the (non-const) refresh step, never computed inside paint. - On the minimap, don't add a per-blip UWidget and reposition it — that reintroduces the churn this rule exists to avoid; minimap blips are draw elements, not widgets. (The full map's static baked layer is the deliberate exception — pooled child UImages are fine there because the layer is built once on open, not per refresh.) - UMG widget UPROPERTYs exposed for designer tuning use EditAnywhere, not EditDefaultsOnly, or they vanish from the Designer Details panel (feedback_umg_widget_uproperty_edit_specifier.md). - Avoid Slot as a local name anywhere in these widget subclasses — it shadows UWidget::Slot (feedback_slot_shadows_uwidget.md); use BlipSlot/IconSlot.

Related: Lyra SActorCanvas/UIndicatorDescriptor (reference only — Lyra ships no minimap); Minimap; Full Map Surfacing.


Minimap

Rule: The minimap is a persistent BindWidgetOptional child of UCradlHUDLayout that self-binds — the HUD does not drive it. It centers on the local player's pawn, fixes north up, paints in-radius blips only, supports small +/- zoom, cannot pan, and clears its refresh timer when collapsed. Projection is a top-down orthographic mapping of world XY (no camera projection): the world-space UCradlMapSettings::NorthVector (default (1,0) = world +X) defines screen-up, the orthogonal axis is screen-right, scaled by minimapPixelRadius / MinimapRadiusCm * zoom.

Why: Mounting copies the UCradlLoadoutPreviewWidget (CradlLoadoutPreviewWidget.h) self-binding precedent — an optional cosmetic widget that the HUD references but never pumps. Top-down ortho is the right projection because the minimap shows world position, not what's on screen; Lyra's ULocalPlayer::GetPixelPoint camera projection is the wrong tool here. The settings-driven timer (not per-frame) follows the UCradlAuthScreen (CradlAuthScreen.h) FTimerHandle refresh idiom; collapsing to skip work is a direct read of requirement 7.

Implementation surface: - Files: Source/CRADL/UI/CradlMinimapWidget.h (new); mounted via a new BindWidgetOptional member on Source/CRADL/UI/CradlHUDLayout.h alongside MessageLog / PlayerHUD / LoadoutPreview. - Refresh loop: FTimerHandle set from UCradlMapSettings::MinimapRefreshInterval (seconds, matching the combat settings' seconds convention); on tick, GatherTrackables(playerXY, MinimapRadiusCm) → project each through NorthVector into FMapBlipInvalidate(Paint). - Collapse: NativeVisibilityChanged (or the MessageLog OnRequestToggle precedent) clears/sets the timer so a hidden minimap does zero work.

Footguns: - The local player icon is a static center reticle drawn by the widget, not a GatherTrackables blip — only remote players and world actors are blips. - North is not hardcoded — the projection helper reads UCradlMapSettings::NorthVector (default (1,0) = world +X → screen up). A re-oriented level is a settings edit, not a code change; never bake the axis into the helper. - Out-of-radius actors are culled in v1 (the range filter drops them); rim-clamped off-radar arrows are a v2 nicety, not v1 scope. - Zoom is bounded by MinimapMinZoom/MinimapMaxZoom and only scales the projection — it never enables panning (requirement 6).

Related: Render Strategy; Actor Data Source; UCradlLoadoutPreviewWidget self-binding.


Full Map Surfacing

Rule: The full map opens as a modal UCradlActivatableWidget page pushed onto UI.Layer.GameMenu via a new Action.Modal.Map tag and a UCradlModalAbility, exactly like bank / shop / crafting. ESC-close and Status.InCombat gating are inherited from the existing modal router; movement does not cancel the mapStatus.Moving is stripped from this page's cancel tags (the skill-lineage modal precedent) so the player may walk while it is open. The page hosts a clipped pan viewport (inner container, EClipping::ClipToBounds) over the per-level background UImage, with the baked-landmark blip layer plus the live local-player dot painted on top — the full map does not call GatherTrackables. Pan = inner render-translation, zoom = inner render-scale. Because the player can move, the local-player dot refreshes on a light timer (one GetPawn() transform read); the baked landmarks are static and painted once on open.

Why: The modal router is already built — UCradlHUDLayout::SubscribeToModalTags / HandleModalTagChanged (CradlHUDLayout.h) push/pop pages on per-leaf Action.Modal.* count, and UCradlModalAbility (CradlModalAbility.h) publishes the tag LocalPredicted. Routing the map through it inherits focus, ESC, and in-combat gating for free, matching every other panel; movement-cancel is the one inherited behavior deliberately overridden off (per-page cancel-tag edit, the skill-lineage precedent), because a world map you must stand still to read is the wrong feel. The rejected persistent-toggle option would hand-roll all of the inherited plumbing.

Implementation surface: - Files: Source/CRADL/UI/CradlMapWidget.h (new); a UCradlModalAbility subclass (or reused config) granting Action.Modal.Map; ModalPageClasses entry on the HUD WBP mapping Action.Modal.MapUCradlMapWidget. - Clipping/pan: outer panel ClipToBounds; inner container SetRenderTransform for pan/zoom. No bespoke Slate — UMG render transform + clipping suffices (Lyra ships no pan/zoom map pattern to copy). - Data binding: BindPlayer override resolves the active ULevelDefinition for background + bounds + the baked POI store (see below); landmark blips come from that store, the player dot from GetOwningPlayer()->GetPawn().

Footguns: - Don't reach for a persistent non-modal toggle (the rejected option) — see Why: it forgoes the router's focus/ESC/gating and duplicates MessageLog-style toggle plumbing by hand. - Bind child-widget delegates (zoom buttons, drag handlers) in NativeConstruct, not NativeOnInitialized — modal pages are reused and NativeDestruct unbinds on every close (feedback_bind_child_delegate_in_nativeconstruct.md). - The router subscribes per-leaf Action.Modal.Map, not a parent tag — the parent delegate fires with the parent tag, not the leaf (existing HandleModalTagChanged contract). - The full map's blips are baked landmarks + the local player only — never the live registry. The local player must be added explicitly: it is the minimap's center reticle and is absent from any gather, so the full map paints its own "you are here" dot.

Related: CradlHUDLayout.h modal routing; Per-Level Map Data; Baked Landmark POIs; Tag Taxonomy.


Per-Level Map Data

Rule: Full-map background, world extents, and the baked-POI store are new optional fields on ULevelDefinition, not a separate identity asset. The map widget resolves the active level via UCradlGameFlowSubsystem::GetActiveLevelId()UAssetManagerULevelDefinition, then reads MapBackgroundTexture, WorldBounds, and the MapPoiBake store. The background/bounds pair is all-or-nothing: a level with neither simply has no full map (the minimap still works). The MapPoiBake store is independently optional (a level can have a chart with no landmarks), but a store without background+bounds is meaningless — the validator enforces that a set MapPoiBake requires the chart it normalizes into.

Why: Requirement 8 names ULevelDefinition (CradlLevelDefinition.h) as the per-level home, and the grounding confirmed it extends cleanly. Identity is reached forward through UCradlGameFlowSubsystem (CradlGameFlowSubsystem.h) per feedback_carry_identity_forward.md — never by reverse-mapping the live UWorld (PIE-prefix string matching is the trap). Adding fields to the existing asset avoids a duplicate identity per feedback_push_back_on_duplicate_identity.md; FPrimaryAssetId already names the level.

Implementation surface: - Files: Source/CRADL/Levels/CradlLevelDefinition.h — new UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Map") TSoftObjectPtr<UTexture2D> MapBackgroundTexture (new), FBox2D WorldBounds (new, the world XY extents the texture spans → normalizes blip positions into texture space), and TSoftObjectPtr<UMapPoiBakeDataAsset> MapPoiBake (new, the offline-baked landmark store for this level). - Validator: Source/CRADLEditor/Validators/CradlLevelDefinitionValidator.h — extend in the same change to enforce both-or-neither + non-degenerate WorldBounds when MapBackgroundTexture is set, and that a set MapPoiBake requires MapBackgroundTexture + WorldBounds (a landmark store needs a chart to draw on) (CLAUDE.md "validators in lockstep"). - Authoring: the chart pair is produced by the in-editor "Bake Level Map" Tools-menu utility (Source/CRADLEditor/MapBake/CradlMapBackgroundBaker.h) — an orthographic top-down capture via FWorldPartitionMiniMapHelper::CaptureBoundsMiniMapToTexture, with bounds auto-resolved from placed AWorldPartitionMiniMapVolumes (editor-only, no WP dependency — works in non-WP test levels) or, absent any, UWorldPartition::GetRuntimeWorldBounds(). Texture and WorldBounds are stamped onto the definition together (they can never drift), and the POI bake is chained so all three artifacts come from one pass. Hand-setting the fields remains valid (e.g. a borrowed placeholder chart on a test level).

Footguns: - Resolve the level definition through the GameFlow subsystem, not GetWorld()->GetName() — reverse world→id lookup is explicitly banned (feedback_carry_identity_forward.md). - The minimap needs no per-level data — it is purely player-relative ortho. Only the full map consumes WorldBounds/MapBackgroundTexture. Don't couple the minimap to level-definition resolution. - Soft-pointer texture: sync-load is acceptable at map-open (rare, user-initiated), matching the GetMaxHealthCurve sync-load precedent.

Related: PLAYFLOW_SYSTEM.md "Map Selection on Play"; UCradlLevelDefinitionValidator; Full Map Surfacing; Baked Landmark POIs.


Baked Landmark POIs

Rule: The full map's blip layer is not a live actor query — it is an edit-time-baked set of landmark points plus the live local-player dot. The in-editor "Bake Map POIs" Tools-menu utility (FCradlMapPoiBaker) snapshots every placed UMapTrackerComponent whose WorldMapIconTag is set, in the currently-open editor world, into a UMapPoiBakeDataAsset of FMapBakedPoi records { FVector2D WorldXY; FGameplayTag WorldMapIconTag; }. The store is soft-referenced from ULevelDefinition::MapPoiBake (matched to the open world by its forward Level pointer); the full map loads it on open and paints each POI by normalizing WorldXY into WorldBounds. The tag's presence is the membership signal — a tracker with an empty WorldMapIconTag is minimap-only and is never baked. The bake logs every tracker-bearing actor it includes and every one it skips on an empty tag, so a forgotten landmark surfaces in the bake output, not as a silent blank spot on the chart.

Why: A single level spans Lumbridge↔Falador scale — too large to live-iterate every actor each refresh, which is the very cost the minimap's range filter exists to bound and which the full map (whole level, no radius) cannot. Landmarks are positionally static, so their positions are known at edit time and belong in a precomputed store, not a per-tick gather (CLAUDE.md GetAllActorsOfClass ban + no synchronous gameplay scans). The bake is an in-editor utility, not a headless commandlet: it snapshots the world the designer already has open and loaded, so it never has to force-load World-Partition cells — "bake what you see" sidesteps the partial-bake trap a headless load carries. It derives map data from the gameplay source of truth (the same UMapTrackerComponent the minimap uses) rather than a hand-duplicated POI list. The decoupled WorldMapIconTag keeps the world map a landmark reference (banks, altars, anvils, quest-givers) instead of mirroring the minimap's coarse radar: a gathering node is on the minimap (Map.Icon.Resource) but leaves WorldMapIconTag empty, so it is not baked — exactly the OSRS world-map-vs-minimap split.

Implementation surface: - Files: Source/CRADLEditor/MapBake/CradlMapPoiBaker.h (new), Source/CRADL/Map/MapPoiBakeDataAsset.h (new), Source/CRADLEditor/Validators/CradlMapPoiBakeValidator.h (new). Menu entry registered in Source/CRADLEditor/CRADLEditor.cpp via UToolMenus. - Classes / Structs: FCradlMapPoiBaker (new, plain class — BakeCurrentLevel() static, invoked from the Tools menu); UMapPoiBakeDataAsset (new, UDataAsset)TArray<FMapBakedPoi> Pois + SourceLevel / BakedAtUtc (for the staleness check); FMapBakedPoi (new){ FVector2D WorldXY; FGameplayTag WorldMapIconTag; }. - Component field: UMapTrackerComponent::WorldMapIconTag (new)UPROPERTY(EditAnywhere, meta=(Categories="Map.WorldIcon")) FGameplayTag, distinct from MapIconTag. Known landmark classes set it as a C++ constructor default; empty by default so ad-hoc content opts in and transient actors stay out. - Level field: ULevelDefinition::MapPoiBake (new, TSoftObjectPtr<UMapPoiBakeDataAsset>) — the per-level baked store (see Per-Level Map Data). - Icon resolution: WorldMapIconTag resolves through the existing UMapIconPresentationRegistry against the existing UMapIconPresentationDataAssetMap.WorldIcon.* rows live alongside Map.Icon.* rows; no new presentation asset and no new icon validator (the existing CradlMapIconPresentationValidator covers the added rows).

Footguns: - The bake covers only loaded actors. Because the utility iterates the open editor world, World-Partition cells that aren't loaded aren't baked. This is far safer than a headless commandlet (no silent partial bake from a naive load), but the designer must load all WP cells before baking; the utility logs that reminder and every included/skipped tracker so a missing landmark is visible in the output. - A bake is a snapshot — staleness is the accepted cost of not live-tracking. Moving a placed landmark without re-baking drifts the map. CradlMapPoiBakeValidator flags a bake older than its SourceLevel package so drift surfaces in validation; re-baking (re-run the Tools-menu utility) is a documented step, not automatic. - WorldMapIconTag presence — not a bool — gates membership. Don't add a parallel bBakeToFullMap; that's the duplicate-identity smell (feedback_push_back_on_duplicate_identity.md). The tag being set is the flag, and its value is the icon. - Transient actors can't be baked. AGroundItem (runtime-spawned) and enemies have no edit-time position; they are structurally minimap-only and leave WorldMapIconTag empty. The split is not arbitrary — those actors self-select out of the bake. - The world-map icon set is fine-grained and content-extensible, unlike the deliberately coarse minimap set. Resist collapsing them: a bank and an altar share Map.Icon.Station on the minimap but carry distinct Map.WorldIcon.Bank / Map.WorldIcon.Altar landmarks.

Related: Full Map Surfacing; Per-Level Map Data; Icon Presentation; Replication Posture; FCradlMapPoiBaker (Tools-menu utility).


Replication Posture

Rule: The map introduces zero replicated state. Every source is already-replicated; every map-owned object is client-local cosmetic.

New object Replication answer Reasoning
UMapActorRegistry (UWorldSubsystem) None (not an actor) Each peer runs its own overlap query against locally-present actors; a view, not authority.
UMapTrackerComponent bReplicates = false Pure data marker; both MapIconTag and WorldMapIconTag are C++ constructor defaults (known classes) or BP-authored (ad-hoc content), identical on all peers. It does no collision config — the gather reuses ECC_Visibility, which the owner already responds to — so there is no per-peer behavior at all, nothing to replicate, and cosmetic state must not replicate (CLAUDE.md).
UMapPoiBakeDataAsset + FCradlMapPoiBaker None (editor-time) The store is baked at edit time by the in-editor Tools-menu utility; at runtime the full map only reads it. The baker is CRADLEditor, never shipped. No runtime mutation, nothing to replicate.
Action.Modal.Map loose tag Local UI state "Map is open" is local; the UCradlModalAbility is LocalPredicted like every other modal — matches the existing pattern, no AddReplicatedLooseGameplayTag.
Player blips Read-only over PlayerArray + replicated pawn movement No new property; the map reads what replication already maintains.
Presentation registry / DataAsset / UCradlMapSettings None No runtime mutation.

Why: Requirement 10 — maps are not replicated gameplay but must represent replicated players. The deliberate audit (per feedback_p2p_replication_audit.md) confirms there is no server-mutated field anywhere in the system; the map is a strict consumer of replicated actor state. This is the cleanest possible P2P story: nothing to desync.

Footguns: - Don't be tempted to replicate the registry "so clients agree" — they already agree via the underlying actors' own replication. A replicated registry would duplicate state and invite desync. - If a future trackable's icon must change at runtime based on replicated state (e.g. a node depleting), drive the icon off the actor's already-replicated field at gather time on the minimap — do not add a replicated property to UMapTrackerComponent. The full map cannot reflect such runtime status at all: it is a baked snapshot and updates only on re-bake (the accepted staleness cost, Baked Landmark POIs).

Related: Actor Data Source; feedback_p2p_replication_audit.md; CLAUDE.md "replicate cosmetic-only state" prohibition.


Tag Taxonomy

Per feedback_gameplay_tag_decl_minimal.md: Config/DefaultGameplayTags.ini holds the authoritative list; CradlGameplayTags.h declares only the subset referenced by name in C++.

Tag Where declared Why
Map.Icon.Player C++ (CradlGameplayTags.h) Named in code — the PlayerArray path assigns it to remote-player blips.
Map.Icon.Enemy .ini only Authored on the enemy's UMapTrackerComponent; code never names it.
Map.Icon.Resource .ini only Gathering-node component default.
Map.Icon.Station .ini only Crafting-station component default.
Map.Icon.Item .ini only Ground-item component default.
Map.Icon.NPC .ini only Quest-giver / terminal component default.
Map.WorldIcon.* (e.g. Bank, Altar, Anvil, QuestGiver) .ini only Authored on a landmark's WorldMapIconTag; baked into the full map. Fine-grained and content-extensible, decoupled from the coarse Map.Icon.* minimap set. Code never names individual leaves.
Action.Modal.Map C++ (CradlGameplayTags.h) Referenced by the modal ability + HUD ModalPageClasses subscription.
Action.Trigger.Modal.Map C++ (CradlGameplayTags.h) Mirrors the existing Action.Trigger.Modal.* counterpart convention for every Action.Modal.* leaf.

Both the Map.Icon.* (minimap) and Map.WorldIcon.* (full-map landmark) sets are content-extensible: a new blip kind is a new .ini leaf + a new presentation row + a component tag pointed at it — no code change. A landmark class additionally sets its Map.WorldIcon.* leaf in C++ so it bakes without a designer remembering to.

Forward Code References

Stable landing zones for the implementation phasing (new module folder Source/CRADL/Map/, consistent with Combat/, Enemy/, Levels/):

Open Questions

All launch questions are resolved; recorded here as the decision log.

  1. North axis — RESOLVED. North is configurable, not hardcoded: UCradlMapSettings::NorthVector, default (1,0) = world +X → screen up. The projection helper reads the vector, so a re-oriented level is a settings edit. See Minimap.
  2. Trackable integration — RESOLVED (hybrid). Known trackable classes (enemy, station, gathering node) create UMapTrackerComponent and set its Map.Icon.* tag as a C++ constructor default — they must not depend on a Blueprint override to appear. Ad-hoc content actors may still add the component in Blueprint and author the tag there (a supported escape hatch). See Actor Data Source. (Player blips come from PlayerArray, not a component. The content rules for whether depleted nodes / unowned ground items draw are enumerated at implementation; they are bounded by the Map.Icon.* row set, not by code shape.)
  3. Refresh unit — RESOLVED. Seconds. UCradlMapSettings::MinimapRefreshInterval (seconds), matching the combat settings convention.
  4. Off-radar indication — RESOLVED (v1: none). Out-of-radius actors are culled; v1 renders nothing off-radar. Rim-clamped arrows remain a deferred v2 candidate (Lyra SActorCanvas edge-clamp reference).
  5. Full-map data source — RESOLVED (baked landmarks + player, revised; bake is an in-editor utility). The full map does not live-gather actors — a single level is too large (Lumbridge↔Falador scale) to iterate every refresh. It paints an edit-time-baked landmark store (UMapPoiBakeDataAsset, produced by the in-editor "Bake Map POIs" Tools-menu utility FCradlMapPoiBaker) plus the live local-player dot. Landmark membership is gated by a placed actor's WorldMapIconTag being set; the minimap remains the live radar over UMapActorRegistry. The bake is an in-editor utility, not a headless commandlet — it snapshots the open editor world's loaded actors, sidestepping the World-Partition force-load trap. See Baked Landmark POIs. (Supersedes the original Quick-Reference assumption that "both widgets read the same registry," and the interim plan to bake via a UCommandlet.)
  6. Minimap-only fixtures vs. baked landmarks — RESOLVED. Gathering nodes and ground items stay minimap-only (empty WorldMapIconTag); only landmark fixtures (banks, altars, anvils, quest-givers) bake. Transient/runtime-spawned actors (AGroundItem, enemies) are structurally unbakeable and self-select out. (Refines original Q2's "content rules for whether depleted nodes / unowned ground items draw" — for the full map the answer is "they don't; that's the minimap's job.")
  7. Map movement-cancel — RESOLVED (movement allowed). Unlike the default modal, the full map strips Status.Moving from its cancel tags (skill-lineage precedent) so the player can walk while reading it; the player dot keeps a light per-tick refresh.
  8. Minimap gather mechanism — RESOLVED (overlap on ECC_Visibility, non-negotiable). The minimap gathers via OverlapMultiByChannel on ECC_Visibility — the same channel UInteractionComponent queries — then filters hits to UMapTrackerComponent owners. O(in-radius), not a flat registry sweep (which was O(total registered) per refresh). The UMapActorRegistry becomes a query facade with no registration list; UMapTrackerComponent is a pure marker with no behavior (no BeginPlay, no collision config) — the owner already responds to ECC_Visibility, so reusing that channel needs zero new collision config. Fully mirrors the UInteractionComponent overlap precedent (overlap a broad channel, filter by component/interface). (Supersedes both the original "self-registering component into a flat registry" spine and the interim "dedicated MapTrackable channel" plan — the channel was collapsed onto ECC_Visibility to reuse the existing interactable collision and avoid per-actor channel setup.) See Actor Data Source.

Contract drafted at MAP_SYSTEM.md. Review and tell me when aligned, or run /design-system MAP again with iteration feedback. Run /design-system --phase=derive-implementation MAP to derive the phased implementation doc from it.