Skip to content

Equipment System

Summary: Server-authoritative equipment management using fragment-driven behavior. Equipment slots use GameplayTags, visuals rebuild from the replicated equipment container on every instance, and GAS effects are applied through a unified context pattern.

Table of Contents


Architecture Overview

UEquipmentComponent (on AEternalPlayerState - replicates to all players, survives pawn death)
├── EquipmentContainer (UItemContainerComponent - single store for equipped items)
│   └── Slot ↔ grid mapping: GetGridIndexForSlot() / GetSlotForGridIndex()
│   └── Queries: GetEquippedItemBySlot(), GetItemGuidForSlot(), GetAllEquippedItems()
├── Character equipment meshes (visual representations)
│   └── Skeletal mesh components created directly on the character per slot
└── Fragment Handlers
    └── Each fragment type processed via FFragmentEquipContext

Key Design Principles

Principle Implementation
Server Authority All equip/unequip via Server RPCs
Fragment-Driven Behavior defined by item fragments, not equipment component
Context Pattern Fragments receive FFragmentEquipContext with all needed references
Single Store EquipmentContainer (a UItemContainerComponent) is the sole authoritative store for equipped items; slots map to fixed grid indices
Visual Separation Equipment meshes are visual-only; the container holds the logical state

Core Concepts

PlayerState Ownership

Equipment lives on AEternalPlayerState (created in its constructor, exposed via IEquipmentOwner::GetEquipmentComponent) because: - Equipped gear must replicate to other players, not just the owner - Equipment persists across pawn death/respawn - Matches the System Ownership Matrix (long-term player identity data → PlayerState)

Fragment-Driven Architecture

The equipment component doesn't know how to equip items. Instead: 1. Component orchestrates the flow 2. Fragments define their own equip/unequip behavior 3. Context object provides all needed references

This means adding new equipment types (e.g., mounts, companions) only requires new fragments.


Equipment Flow

Equip Sequence

┌──────────────────┐
│ Client: UI Click │
└────────┬─────────┘
┌────────────────────────────┐
│ Server_EquipItem(Item,Slot)│  ← Server RPC
└────────┬───────────────────┘
┌─────────────────────────────────────┐
│ Atomic container transaction        │  ← EquipTransactionDepth guards
│  ├─ Validate item ownership         │    intermediate states
│  ├─ GatherDisplaced() occupants     │
│  ├─ Park/move displaced items       │
│  └─ Place item in EquipmentContainer│
└────────┬────────────────────────────┘
         │  Container contents replicate (FastArray);
         │  OnRep drives the same handler on clients
┌──────────────────────────────────┐
│ OnEquipmentContainerChanged()    │  ← Runs on server AND all clients
│  ├─ ClearAllVisuals()            │
│  ├─ RefreshAllVisuals()          │  → ProcessItemEquip per equipped item
│  ├─ RefreshHandWeaponCache()     │  → weapon-changed broadcasts
│  └─ ReconcileUnequippedItems()   │  → server-only teardown
└────────┬─────────────────────────┘
┌────────────────────────────┐
│ ProcessItemEquip()         │
│  ├─ BuildEquipContext()    │
│  ├─ HandleEquipFragment()  │  → Visuals (all instances)
│  ├─ HandleWeaponFragment() │  → Combat setup, hit trace
│  └─ HandleAbilityFragment()│  → Grant abilities (server only)
└────────────────────────────┘

Unequip Sequence

There is no explicit unequip path per removal reason. Any route that takes an item out of its slot in the container (Server_UnequipItem, drag to bag, world drop, swap eviction, quick action) converges on the same reconcile:

Item leaves its slot in EquipmentContainer
    │  (container change → OnEquipmentContainerChanged)
ReconcileUnequippedItems()   ← server-only; AppliedEquipSlots is the source of truth
ProcessItemUnequip()
    ├─ BuildEquipContext()
    ├─ RemoveEquipmentEffects(Context)
    ├─ HandleWeaponFragmentOnUnEquip()
    └─ HandleAbilityFragmentOnUnEquip()

(Visuals are handled separately: every instance clears and rebuilds
 equipment meshes from the container in OnEquipmentContainerChanged.)

Fragment Context Pattern

The Problem (Before)

Fragments needed to look up references repeatedly:

// Every fragment did this
UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(PC->GetPawn());

The Solution (Now)

Context built once, passed to all fragments:

FFragmentEquipContext
├── PlayerController*    ← Owning player's controller (resolved from the PlayerState)
├── OwningPawn*         ← Current pawn
├── AbilitySystem*      ← Cached ASC
├── EquipmentSlot       ← Target slot tag
└── EquippedItem*       ← The item being equipped

Benefits

  • Single point of reference lookup (BuildEquipContext())
  • Fragments don't need to know how to get references
  • Easier testing - can mock the context
  • Future-proof - add more context without changing fragment signatures

Source Reference

  • Context struct: ItemFragment.h:27-68
  • Context builder: EquipmentComponent.cppBuildEquipContext()

Slot Management

Slot Tags

Equipment slots are GameplayTags, not enums:

Every slot tag lives under the single flat GameItems.Equipment.Types.* family — there is no per-category (.Armor., .Weapon.) split in the slot namespace.

Slot Tag
Head GameItems.Equipment.Types.Head
Chest GameItems.Equipment.Types.Chest
Belt GameItems.Equipment.Types.Belt
Feet GameItems.Equipment.Types.Feet
Hands GameItems.Equipment.Types.Hands
Right Hand GameItems.Equipment.Types.RightHand
Left Hand GameItems.Equipment.Types.LeftHand
Amulet GameItems.Equipment.Types.Amulet
Left Ring GameItems.Equipment.Types.LeftRing
Right Ring GameItems.Equipment.Types.RightRing
Consumable GameItems.Equipment.Types.Consumable
Edible GameItems.Equipment.Types.Edible

Do not confuse with GameItems.Equipment.Armor.Types.* (Helmet, Chest, Legs, Boots, Gloves). That family describes an armor piece's kind, not a slot — it is a separate axis and never appears as a slot tag.

Both families are defined in Source/ProjectEternal/Private/Inventory/Items/ItemTags.cpp.

Item Class Tags

Items are classified by a GameItems.Class tag:

Class Tag Description
Weapon GameItems.Class.Weapon All weapon types
Armor GameItems.Class.Armor Body armor pieces
Accessory GameItems.Class.Accessory Rings, amulets
Consumable GameItems.Class.Consumable Usable items

These tags drive modifier spawn eligibility and UI filtering.

EquipmentContainer (single-store model)

Equipped items live in a single UItemContainerComponent (EquipmentContainer) owned by UEquipmentComponent. Each slot tag maps to a fixed grid index inside the container; there is no separate slot struct.

Method Purpose
GetEquippedItemBySlot(SlotTag) Returns item in slot or nullptr
GetItemGuidForSlot(SlotTag) Item instance ID for a slot (works on server + clients)
GetAllEquippedItems() All currently equipped items
GetGridIndexForSlot(SlotTag) / GetSlotForGridIndex(Index) Static slot ↔ grid index mapping
CanEquipItemInSlot(Item, SlotTag) Authoritative slot-compatibility rule (shared by UI, inventory controller, drop preview)
IsHandBlockedByTwoHander(SlotTag) Off-hand reserved by an equipped two-hander

Because the container is the single source of truth, unequip is universal: ReconcileUnequippedItems() tears down any item that had equip effects applied but is no longer in its slot — drag to bag, world drop, swap eviction, and quick actions all reduce to "the item left the container".

Source Reference

  • Container property + queries: EquipmentComponent.hEquipmentContainer, GetEquipmentContainer()
  • Tag definitions: ItemTags.hGameItems::Equipment::Armor

Visual Actors

Character-Attached Meshes (no separate actor)

There is no AEquipActor. Equipment visuals are USkeletalMeshComponents created directly on the character, tracked per slot in CharacterEquipmentMeshes (plus CharacterEquipmentVFX for Niagara components). They are local/cosmetic only — never replicated — because every instance rebuilds them from the replicated container:

  • Armor uses leader pose (follows character animations)
  • Weapons attach to hand sockets (no leader pose)
  • Body-part hiding masks (SlotBodyPartHideMasks / RefreshBodyPartVisibility) prevent the body clipping through armor

Rebuild Flow

OnEquipmentContainerChanged()      ← server AND all clients
    ├─ ClearAllVisuals()
    └─ RefreshAllVisuals()
         └─ HandleEquipFragmentOnEquip() per equipped item
              ├─ CreateCharacterEquipmentMeshes() / SpawnFromVisualConfig()
              ├─ Attach to skeleton socket for the slot
              └─ RefreshBodyPartVisibility()

Source Reference

  • Mesh tracking: EquipmentComponent.hCharacterEquipmentMeshes, CharacterEquipmentVFX
  • Spawn logic: EquipmentComponent.cppHandleEquipFragmentOnEquip(), SpawnFromVisualConfig()

GAS Integration

Dynamic GameplayEffect Pattern

Equipment effects are runtime-created UGameplayEffect objects, not Blueprint GE classes. Each modifier becomes a dynamic GE applied to the character's ASC:

FEquipmentFragment::ApplyEquipmentEffects(Context)
    ├─ For each global modifier on the item:
    │   ├─ Create dynamic UGameplayEffect (NewObject on ASC)
    │   ├─ Set DurationPolicy = Infinite
    │   ├─ Add modifier targeting TargetProperty attribute
    │   │   ├─ EModifierOperation::Flat → GAS Additive
    │   │   └─ EModifierOperation::Increased → GAS Multiplicative
    │   └─ ApplyGameplayEffectToSelf → returns ActiveEffectHandle
    └─ Store handle for cleanup on unequip

Modifier Operations

EModifierOperation GAS Operation Stacking Behavior
Flat Additive Direct value addition
Increased Multiplicative (bias 1.0) Percentages stack additively, not compounding

The Increased operation uses GAS's bias-adjusted sum so multiple "+10% damage" modifiers yield +20% total, not +21% (compounding). This follows PoE-style "Increased" stacking.

Modifier Source & Apply Gate

Each FRolledModifier carries an ECraftingModifierSource tag identifying how it was acquired. The apply loop consults this before spawning a GE so mechanics like Remnant Echo Mods can stay attached to an item without applying until their runtime gate allows it.

Source Apply Gate
Drop Always applies
Crafted Always applies
Echo (Remnant) Applies only when the item's FRemnantItemState::EchoMods[].bIsActive is true

Echo Mods are not stored in FEquipmentFragment::Modifiers; they live on the item's state module (FRemnantItemState::EchoMods, each wrapping a FRolledModifier). The equipment apply loop treats them uniformly with standard modifiers once the gate passes — they spawn as regular equipment-owned GE handles, not a parallel handle pool.

RefreshItemModifiers

When the gate state changes at runtime (e.g. a Remnant transitioning Sealed → Awakened), the equipment component re-runs its apply loop for that item via UEquipmentComponent::RefreshItemModifiers(Item):

  1. Remove all currently-tracked GE handles for the item
  2. Re-run the apply loop; modifiers whose gate now passes spawn GEs

This keeps UEquipmentComponent the sole owner of all modifier GE handles for an item, regardless of source. No dual ownership, no parallel lifecycle management.

Effect Cleanup

Effects tracked via handles, removed on unequip: - ActiveGlobalEffects - Map of modifier ID → active effect handle

Source Reference

  • Apply effects: ItemFragment.cppFEquipmentFragment::ApplyEquipmentEffects()
  • Remove effects: ItemFragment.cppFEquipmentFragment::RemoveEquipmentEffects()
  • Dynamic GE helper: EternalAbilitySystemLibrary.hApplyDynamicModifierEffect()
  • Modifier operations: ModifierDefinitions.hEModifierOperation

Combat Integration

Weapon Equip Processing

HandleWeaponFragmentOnEquip()
    ├─ Notify PlayerCombatComponent of weapon change
    │   └─ Combat component updates its own montages
    ├─ Register hit trace profile
    │   └─ Links weapon mesh to trace system
    ├─ Activate animation overlay mode
    │   └─ Changes character animation set
    └─ Add weapon type tags
        └─ For animation queries

Key Change: Montage Ownership

Before: EquipmentComponent called Combat->SetCombatMontage() Now: Combat component reacts to OnWeaponChanged() and updates itself

This follows proper component ownership - combat component owns its combat state.

Source Reference

  • Weapon handling: EquipmentComponent.cppHandleWeaponFragmentOnEquip()
  • Combat updates: PlayerCombatComponent.cppUpdateCombatMontages()

Replication Strategy

What Replicates

Property Replication
EquipmentContainer contents FastArraySerializer (inside UItemContainerComponent); its OnRep drives client-side visual rebuild
WeaponTags Standard DOREPLIFETIME

Equipment meshes/VFX do NOT replicate — they are rebuilt locally on every instance from the container.

RPC Pattern

There are no multicast RPCs. Clients see equipment changes through container replication:

Client                    Server                          All Clients
  │                         │                                 │
  ├─Server_EquipItem()─────▶│                                 │
  │                         ├─Validate & atomic container txn │
  │                         ├─Container FastArray replicates─▶│
  │                         │                                 ├─OnRep → OnEquipmentContainerChanged()
  │                         ├─OnEquipmentContainerChanged()   ├─Rebuild visuals, refresh weapon cache

Source Reference

  • Replication props: EquipmentComponent.cppGetLifetimeReplicatedProps()

Public Contracts

Server RPCs

RPC Parameters Purpose
Server_EquipItem (UItemObject*, FGameplayTag) Equip item to slot
Server_UnequipItem (UItemObject*) Remove item from slot

Events

Event Payload When Fired
OnItemEquippedEvent UItemObject* After ProcessItemEquip completes
OnItemUnEquippedEvent UItemObject* After ProcessItemUnequip completes
OnConsumableUsageChangedEvent UItemObject* When consumable charges change

Query Methods

Method Returns Purpose
GetEquippedItemBySlot(SlotTag) UItemObject* Get item in specific slot
GetItemGuidForSlot(SlotTag) FGuid Item instance ID for a slot (server + clients)
IsHandBlockedByTwoHander(SlotTag) bool Off-hand reserved by an equipped two-hander
GetEquippedWeapons() TArray<UItemObject*> All equipped weapons
GetLeftEquippedWeapon() UItemObject* Left hand weapon
GetRightEquippedWeapon() UItemObject* Right hand weapon


Recent Changes

Date Change Impact
2026-08-06 Slot-tag table corrected to GameItems.Equipment.Types.* The table had documented a GameItems.Equipment.Armor.* namespace that no slot uses; added the Edible slot and a warning distinguishing the armor piece-kind family GameItems.Equipment.Armor.Types.* from slots
2026-07-03 Doc rewrite: ownership + single-store model Corrected ownership to AEternalPlayerState; replaced deleted FEquippedItemSlots with the EquipmentContainer (UItemContainerComponent) single-store model (slot ↔ grid index mapping); removed nonexistent multicast RPCs and AEquipActor in favor of container-OnRep-driven visual rebuild; refreshed query-method table to the current header API
2026-04 ECraftingModifierSource-based apply gate + RefreshItemModifiers Echo Mods (Remnant) apply only when their state module flag allows. Equipment stays sole owner of all mod GE handles regardless of source.
2026-02 Dynamic GE pattern Equipment effects are runtime-created GEs, not Blueprint classes
2026-02 EModifierOperation (Flat/Increased) Flat maps to GAS Additive, Increased maps to GAS Multiplicative
2026-02 Slot refactoring Leg → Belt, Ring split into LeftRing/RightRing, added item class tags
2025-12-27 FFragmentEquipContext pattern Fragments receive context instead of PlayerController
2025-12-27 RPC simplification Separate Server_EquipItem/Server_UnequipItem instead of combined
2025-12-27 FEquippedItemSlots helpers Slot logic encapsulated in struct, not scattered