0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI ARCHITECTURE
UTC 00:00:00
◀ RETURN
ARCHITECTURE.md 3076 words ~14 min read Updated 2026-07-03

Locked Architectural Decisions

  1. Authority model. UInventoryComponent and USkillsComponent live on ACradlPlayerState, replicated COND_OwnerOnly. A small FActivityDescriptor (active ability tag + target actor + start time) replicates to all clients for spectator animation/UI — only public progression-related data.
  2. Action system: GAS from day one. Both skilling and future combat run through the Gameplay Ability System. UCradlAbilitySystemComponent lives on ACradlPlayerState (standard placement; survives pawn possession). Skilling abilities use NetExecutionPolicy::LocalOnly (or LocalPredicted); combat can switch to ServerInitiated later.
  3. Skills are typed state, not GAS attributes. USkillsComponent holds TMap<FGameplayTag, FSkillProgress { int64 XP; int32 Level; }>. Crossover stats that combat effects need to manipulate (GatherSpeed, CraftSpeed, Stamina, MaxStamina, EncumbranceLimit) live in UCradlAttributeSet as floats.
  4. Inventory is slot-addressed. TArray<FItemSlot> of fixed size. Empty slots are valid (ItemId == NAME_None). Per-slot FGuid InstanceId lets UI track stacks across moves. Replicated via FFastArraySerializer so only changed slots replicate. Drag-and-drop is first-class — MoveSlot / MoveBetween / SplitSlot resolve to move/swap/merge/split atomically (not emulated as remove-then-add).
  5. Item data: DataTable, not row handles. FItemRow lives in a UDataTable; callers reference items by FName ItemId only. UItemRegistry (UGameInstanceSubsystem) loads the table at startup and provides FindItem/HasItem/GetMaxStack/RegisterRow. The DataTable path is set in Project Settings via UCradlInventorySettings (UDeveloperSettings).
  6. Skills/recipes/nodes: PrimaryDataAsset. USkillDefinition, UGatheringNodeDefinition, URecipeDefinition. Soft refs (TSoftObjectPtr) throughout, resolved by UAssetManager.
  7. Input: PlayerController-owned, forwarded. ACradlPlayerController adds UInputMappingContext via Enhanced Input subsystem on possession. Handlers forward by responsibility: - World interact → Pawn->InteractionComp->TryInteract() (null-safe so observer pawns no-op cleanly). - Ability activation → explicit ASC->TryActivateAbilityByClass(...) with payload (target node / recipe). Not GAS-native input binding — payload story is awkward.
  8. Persistence: UCradlPlayerProfile : USaveGame holds skill XP map, inventory snapshot, bank snapshot, attribute baseline. Bank is per-profile (shared across hypothetical future characters).
  9. UI: CommonUI. All player-facing UI runs through CommonUI (UCommonActivatableWidget pages, UCommonActivatableWidgetStack per layer). One root UCradlHUDLayout lives on the local PC with three layers — UI.Layer.Game (non-modal HUD: persistent inventory, XP bars), UI.Layer.GameMenu (modal menus: bank, recipes), UI.Layer.Modal (popups). Persistent + modal coexist. Pages get player data via BindPlayer(APlayerState*) virtual. Decision rationale: future-proofs gamepad routing, ESC/back-stack, modal focus — all things CommonUI ships and we'd otherwise reinvent.
  10. Loose coupling via interfaces. Cross-system references default to interfaces (TScriptInterface<I...>), not concrete types. Example: UI widgets hold TScriptInterface<IItemContainer>, not UInventoryComponent*, so the same widget serves bag/bank/equipment/vendor. Concrete refs inside a single system's boundary are fine. Also mirrored as a rule in CLAUDE.md.
  11. Slot-container variants subclass UInventoryComponent; they don't reimplement IItemContainer. Bank and equipment share the bag's entire mechanical surface — FItemSlot shape, FFastArraySerializer replication, MoveSlotImpl (move/swap/merge/split), OnSlotChanged, GetMaxStackForItem, ClearSlot, COND_OwnerOnly. Reimplementing per container would be hundreds of duplicated lines and would invite drag-drop divergence between containers (a swap from bag→equipment behaving differently than equipment→bag is exactly the kind of bug this avoids). The actual divergence is small enough to hang off virtuals.
    • UBankContainerComponent : UInventoryComponent — same shape, larger SlotCount default. No new behavior.
    • UEquipmentComponent : UInventoryComponentTArray<FEquipmentSlotDef> SlotDefs (each holds an optional FGameplayTag RequiredTag) drives slot count and per-slot type filtering; overrides CanAccept to aggregate capacity only across slots whose RequiredTag matches the item's FItemRow::Tags. Right-click "unequip to bag" is a MoveBetween wrapper, not architecture.
    • Required base-class change: add virtual bool CanPlaceInSlot(int32 Index, FName ItemId) const (default true) on UInventoryComponent, and have MoveSlotImpl consult it in Case 1 (move-into-empty) and Case 3 (swap — both directions must accept). Without this, equipment slot validation can't enforce per-slot type rules. (This subsumes the Phase F follow-up note below.)
    • When NOT to subclass: if a container isn't slot-indexed (e.g. a single-bucket currency wallet, a tag-keyed item set), it shouldn't implement IItemContainer at all — it's a different abstraction.
  12. Widget BPs are views only if you need to expose information for the Widget to print, do it in a presentation friendly manner, see ItemPresentation.h for example.
  13. Ability cancellation: pick the channel that matches the source. GAS offers three ways to cancel an active ability; the right one depends on what the cancel source is, not what's most familiar. Mixing channels (e.g. modeling movement as a GE) creates ceremony with no benefit and makes future cancel rules harder to reason about.
    • Source is another ability → use the ability's tag fields (CancelAbilitiesWithTag, ActivationOwnedTags, ActivationBlockedTags). Ability ↔ ability is what those fields exist for.
    • Source is a gameplay rule with duration / stacks / replication (Stunned 3s, rooted, silenced, encumbered) → Gameplay Effect with GrantedTags. GEs give you duration, stacking, RemoveActiveGameplayEffectsByTag, and replication for free. Lives under the Status.* tag namespace.
    • Source is a runtime fact the engine already tracks (CMC velocity, posture, air state, swim state) → bridge component + loose gameplay tag, not a GE. The fact already exists; a GE would re-encode it. Lives under the State.* tag namespace. Reference impl: UMovementTagBridge on ACradlCharacter publishes State.Moving via ASC->AddLooseGameplayTag from the locally-controlled instance.
    • Consumer side is uniform regardless of channel: abilities listen via UAbilityTask_WaitGameplayTagAdded on a CancelTags array. Adding a new cancel reason means publishing the tag from the right channel and appending it to the array — no per-ability code paths.
    • Locality: loose tags do not replicate by default. Skilling abilities run LocalOnly (decision #2) so this is fine. If a future feature needs other clients to observe a State.* tag (e.g. spectator anim), switch the call site to AddReplicatedLooseGameplayTag — the publish/consume contract is unchanged.
  14. PrimaryAsset registries build lazily, not in Initialize. Subsystems that index a PrimaryAssetType (USkillRegistry, UCradlRecipeRegistry) defer their scan/load to first-use via an EnsureBuilt() guarded by a bBuilt flag. Reason: on cold-start PIE the editor's asset registry scan races against UGameInstanceSubsystem::Initialize, so GetPrimaryAssetIdList / LoadPrimaryAssetsWithType returns empty on first launch and works on every subsequent PIE — silent and confusing. By first call (UI open, ability activation) the scan is long done. Cooked builds aren't affected, but the lazy pattern is uniformly correct and costs nothing. Registries that load a known soft-pointed asset (e.g. UItemRegistryUDataTable) don't have this race and can stay eager.
  15. Modal UI engagements are GAS abilities. Banking, shops, NPC dialogue, and any future "player is engaged with this UI; movement / combat / skilling must cancel it" state is a UCradlModalAbility subclass — not a parallel state machine on the controller or HUD. Adding a new modal is one tag pair (Action.Modal.X + Action.Trigger.Modal.X), one BP subclass, and one entry each in BP_CradlPlayerState.DefaultAbilities, WBP_HUDLayout.ModalPageClasses, and (if hotkeyed) BP_CradlPlayerController.InputToModalRoute. Zero new C++ per modal. The reused machinery:
    • Cancellation channels are exactly #13 — modal abilities self-cancel on Action.Skill / State.Moving / Status.Stunned / Status.InCombat via the base's CancelOnTagsAdded (see below); modal activation cancels skills + other modals via CancelAbilitiesWithTag = { Action.Modal, Action.Skill } (parent-tag matching → free polymorphism).
    • Trigger is a GameplayEvent: interactables (bank chest, NPC) SendGameplayEventToActor(PS, Action.Trigger.Modal.X) without knowing UI exists.
    • NetExecutionPolicy = LocalPredicted, not LocalOnly. Modal tags must reach the server-peer's ASC so future server-authoritative actions can gate via RequiredTags = Action.Modal.Banking (e.g. Deposit ability rejecting deposits without an open bank). The cost is one predicted activation/cancel per open/close — negligible. Note this is the first GAS feature in CRADL to use prediction; combat will follow the same policy.
    • ModalTag is published via AddLooseGameplayTag / RemoveLooseGameplayTag, not ActivationOwnedTags. ActivationOwnedTags is read from the CDO at activate time, so mutating it on instances is brittle; loose tags are explicit and predictable on both peers under LocalPredicted.
    • UCradlGameplayAbility base centralizes the cancel-on-tag-added plumbing. Subclasses populate CancelOnTagsAdded (a FGameplayTagContainer); the base registers ASC tag-event delegates in ActivateAbility and tears them down in EndAbility. No UAbilityTask_WaitGameplayTagAdded per cancel tag — direct ASC->RegisterGameplayTagEvent avoids one UObject allocation per tag per activation, which compounds for any base class on the path of every skill and every modal. (This supersedes the corresponding bullet in #13: ability-side cancellation now flows through the base, not per-ability tasks. Publishing channels — GE for Status.*, bridge for State.* — are unchanged.)
    • HUD router subscribes per-leaf, not on the parent. FGameplayTagCountContainer::GatherTagChangeDelegates (engine GameplayEffectTypes.cpp) broadcasts each ancestor's delegate with CurTag = ancestor, not the leaf that actually changed. So RegisterGameplayTagEvent(Action.Modal) fires with Tag = Action.Modal, leaving us with no way to know which modal opened. The HUD subscribes once per Action.Modal.X entry in ModalPageClasses — N small subscriptions instead of one ambiguous one. Footgun worth remembering for any future "subscribe on the parent" instinct.
    • Pages are initialized pre-activation, not post-push. UCommonActivatableWidgetStack::AddWidget activates synchronously, so any field a page reads in NativeOnActivated (e.g. OwningAbilityTag for GetModalContext, BoundPS for BindPlayer) must be set in the AddWidget<T>(Class, InitFunc) lambda — assigning after the call leaves the first activation blind and "works on the second open" because the reused instance still has stale fields.
    • CommonUI stack is the source of truth. The router does not maintain a parallel OpenModalPages map; on NewCount > 0 it scans GameMenuStack->GetWidgetList() for the page class (no-op if already pushed), on NewCount == 0 it scans for and removes the matching widget. Reentrance under ESC-driven deactivation falls out for free: the widget is already gone from the list by the time the tag-change delegate fires. ESC coherence is closed by UCradlActivatableWidget::OwningAbilityTag — set when the router pushes, cancelled in NativeOnDeactivated, so popping via ESC also ends the ability.
  16. Animation/FX/audio routing. Visuals subscribe to replicated gameplay state (ASC tags, FActivityDescriptor); they are never the source of truth. Three Epic-blessed channels:
    • AnimBP state ← FGameplayTagBlueprintPropertyMap. UCradlAnimInstance binds the map to the owner's ASC in NativeInitializeAnimation, using the same OnPlayerStateReplicated late-bind hook as UCradlVisualStateComponent (PlayerState arrives after BeginPlay on remote proxies). Designer adds tag→property rows in the AnimBP class defaults; state machines read those properties directly. Lyra pattern. Footgun: property names match by string at runtime — renaming or deleting a BP variable silently no-ops the row, and a type mismatch at bind time skips it just as silently. There is no compile-time signal; the only failure mode is "the AnimBP transition doesn't fire."
    • Per-action animation ← UAbilityTask_PlayMontageAndWait from inside the ability. The task handles montage replication, completion/interrupt/cancel signals, and stops-on-ability-end. Don't drive montages from outside the ability.
    • FX/SFX ← GameplayCues. UGameplayCueNotify_Static for one-shots (impacts, cast pulses), UGameplayCueNotify_Actor for stateful effects (channeled auras). Three trigger paths exist; pick by source (same framing as #13): GE GameplayCues array for stateful/duration effects whose lifetime tracks a GE; ASC->ExecuteGameplayCue for ability-instant beats not tied to a GE; AnimNotify_GameplayCue for beats that must land on a specific animation frame. Not yet wired in CRADL; this is the slot for it.
    • Ability owns gameplay timing; montage is parallel decoration. Gameplay tick is always sourced from UAbilityTask_WaitDelay (or equivalent gameplay timer). The montage task runs without a completion hook — it never drives when drops/XP resolve. Per-ability EGatherAnimTiming { ScaleToActionDuration, PlayAtNativeRate } controls whether PlayRate rescales to fit gameplay duration (default — OSRS-style synced swing) or plays at authored rate (loops, tempo-rigid clips). Cancellation correctness flows through EndAbility tearing both tasks down; no per-task cancel callback needed. Reference: UGatherAbility::StartNextTick.
    • UCradlVisualStateComponent is for UI/observer code, not the animation pipeline. Re-broadcasts GAS state as BlueprintAssignable delegates for HUD widgets, buff bars, NPC reactions. Animation subscribes via the tag map instead — more direct, avoids the delegate ceremony layer. Both subsystems end up registered against the same ASC's tag-change events on the same actor; this is intentional (different consumers, different shapes), not accidental duplication.
    • Skilling abilities pick LocalPredicted when owned tags must reach spectators (gathering, crafting, channeling — any ability whose ActivationOwnedTags drives a spectator-visible AnimBP transition). LocalOnly (decision #2's other option) remains valid for self-only utility abilities. Reference: UGatherAbility. Load-bearing: this is the channel by which ActivationOwnedTags reach simulated proxies; downgrading a spectator-visible skilling ability to LocalOnly to "save bandwidth" silently breaks remote AnimBP transitions with no compile-time signal. Treat the policy as part of the ability's contract, not a tuning knob.
  17. UCommonButtonGroupBase re-entrant broadcasts during populate/teardown. With bSelectionRequired=true, the group fires OnSelectedButtonBaseChanged synchronously from inside AddWidget (auto-select-first, on the first add) and from inside RemoveAll (LIFO pop of the selected button cascades into a force-select of the new front button). Two implications for any handler that maps the broadcast back through a parallel index (e.g. SpawnedItems): (a) append to the parallel index before AddWidget so SpawnedItems[0] is valid when the auto-select fires; (b) capture any "preserved selection" state before RemoveAll, since the cascading re-select will overwrite it with index 0. Reference: UCraftingMenuWidget::RebuildList.
  18. Widgets dispatch via ASC GameplayEvents; never call gameplay components or PC RPCs directly. UI pages package context into FGameplayEventData and call ASC->HandleGameplayEvent(Action.Trigger.X, &Payload). The receiving GAS ability owns validation, server authority, side effects, and modal gating. Widget surface is exactly three things: APlayerState, UAbilitySystemComponent, FGameplayTag.
    • Why this and not direct calls / controller RPCs: the ability is reachable from any source that can publish a tag — UI clicks, hotkey routes, controller bindings, debug menus, automation. Authority lives in one place per verb. Per-controller RPCs (the old Server_BuyItem / Server_SellItem) duplicate the modal-gating, inventory-validation, and replication story that abilities already provide; mixing channels invites drift between "click to buy" and "hotkey to buy."
    • Trade-off: widgets need to know about gameplay tags. Acceptable — tags are the public contract anyway.
    • Payload conventions (uniform across all dispatchers):
    • Target = the world actor the action is against (terminal / station / node / item).
    • Instigator = the owning Pawn.
    • OptionalObject = a typed descriptor of what: a long-lived PrimaryDataAsset for designer-authored verbs (URecipeDefinition for craft), or a transient NewObject<> request payload for runtime-parameterised verbs (UTransactRequest for buy/sell).
    • EventMagnitude = scalar count where the descriptor doesn't carry it (-1 = "all" sentinel where applicable; e.g. context-menu Drop carries the source slot index here).
    • Naming: the public widget method that fires the event is DispatchX (DispatchCraft, DispatchDepositInventory, DispatchTransact). Private button handlers route to Dispatch*; nothing else.
    • Ability side: trigger via EGameplayAbilityTriggerSource::GameplayEvent on the matching tag. LocalPredicted for inventory/economy mutations (server is authoritative; client predicts for snappiness — same policy as Deposit/Withdraw). Modal gating is ActivationRequiredTags = { Action.Modal.X }, not in-body checks — failed activations short-circuit before the body runs.
    • Currency follows from this. Once buy/sell is an ability, gold has no reason to be a GAS attribute — the only consumer was the GE-driven mutation path. Coin is an inventory item (UCradlInventorySettings::CoinItemId); buy/sell is a symmetric inventory swap, with capacity checks on both sides so a sell into a full bag is clamped, not dropped on the floor. UI binds to UInventoryComponent::OnSlotChanged and reads CountOf(CoinItemId).
    • References: CraftingMenuWidget.h (DispatchCraft), BankWidget.cpp (DispatchDepositInventory), StoreWidget.cpp (DispatchTransact + Coin readout), TransactItemAbility.cpp.
  19. Selectable list rows are a UUserWidget wrapper around an inner UCommonButtonBase, not a button subclass directly. Two-class pair per row type: an outer UUserWidget that holds a BindWidget'd inner button, and the UCommonButtonBase subclass that the row's UCommonButtonGroupBase actually selects. The slot panel adds Item->SelectButton to the group, never Item itself.
    • Why: if the row IS the button, the WBP root is the button's content slot, so any row-footprint clamp (SizeBox, max desired size, layout wrapper) has to be done by whoever places the row — pushing sizing concerns up into the panel, which is the wrong layer. With the wrapper, the row WBP can root on a SizeBox and the panel stays oblivious to row dimensions.
    • Where data lives: outer wrapper stores the presentation struct and exposes GetData() (panels read selection by SpawnedItems[ButtonIndex]->GetData()). SetData forwards to the inner button, which caches and replays OnDataApplied from NativeConstruct to handle the CreateWidget → SetData → AddChild ordering where the BP graph isn't live yet. Outer wrapper fires its own OnRefresh BIE for any wrapper-level paint.
    • Index alignment: add wrapper to SpawnedItems and inner button to ButtonGroup in lockstep — ButtonIndex from OnSelectedButtonBaseChanged then matches the parallel array (combined with #17's ordering rules: append to the parallel index before AddWidget).
    • When NOT to split: read-only rows (no selection state, no group) don't need this — they can be a single UUserWidget. The split exists to serve UCommonButtonGroupBase.
    • References: LoadoutEntryListItem.h + LoadoutSelectButton.h (loadout list); LoadoutModifierEntryListItem.h + LoadoutModifierSelectButton.h (per-slot modifier list).
  20. Shared utility helpers live on the owning system, not in anonymous namespaces. UBT's Unity Build concatenates module .cpp files into shared TUs. Each .cpp's namespace { ... } block is supposed to give file-scope linkage, but once two files are merged into one TU their anonymous namespaces are the same anonymous namespace, and duplicate definitions inside trigger a compile-time error C2084: function already has a body. The clustering shifts whenever a module file is added or grows, so a passing build on commit N can fail on commit N+1 from an unrelated edit that re-shuffled the clusters. The rule:
    • One site is fine. A small helper used by exactly one .cpp stays in its anonymous namespace.
    • Two sites: extract. The duplication is already real; landing the helper now is cheaper than landing it later through more call sites. Rename-with-prefix (Debug_, Consume_) is the only-if-mid-PR escape hatch, and it leaves a TODO that the next change in the area should clean up.
    • Where it lands: on an existing class that already owns the helper's inputs — as a static method when no instance is needed (UItemRegistry::FindItem(WorldContext, ItemId)), or as an instance method when one is (UEquipmentComponent::FindSlotIndexByRequiredTag(FGameplayTag)). Do not create a new Utils.h / Helpers.h header to host it — callers already include the owning class's header, so an extraction onto that class is IWYU-free; a new utility header adds an include to every new call site for no benefit.
    • Reference extractions: UItemRegistry::FindItem(const UObject* WorldContext, FName) — static overload absorbing the WorldContext → World → GameInstance → Subsystem dance shared by abilities and debug code. UEquipmentComponent::FindSlotIndexByRequiredTag(FGameplayTag) — instance method absorbing the for (SlotDefs) { if (RequiredTag == X) } loop shared by auto-attack and combat-stats debug. Same precedent in CradlGameplayTags.h: the Skill_Combat_* tag block lives in the shared header for the same reason (multiple combat .cpp files need them).
    • Detection signal: grep — "two .cpp files declare the same function name inside namespace { ... }" is the warning. The compile error is deterministic for a given source tree, but TU clustering is opaque, so it's hard to predict when the duplicate will start biting. The grep result tells you a collision is queued; don't wait for the compiler to surface it.