Skip to content

Item State Modules

Summary: A reusable architectural pattern for attaching polymorphic per-instance runtime state to UItemObject. Mirrors the fragment pattern but for mutable gameplay state rather than static template data. Each module is a USTRUCT inheriting FItemInstanceStateModule, stored in a TArray<TInstancedStruct<>> on the item, replicated as a unit, and persisted as a type-tagged JSON object. Remnant Items (FRemnantItemState) is the first consumer; Corruption, Fracture, Veil, and other PoE-adjacent mechanics will land as sibling module types without touching UItemObject.

Table of Contents


Architecture Overview

┌────────────────────────────────────────────────────────────────┐
│                       UItemObject                              │
│                                                                │
│  ItemManifest (FInstancedStruct, static template)              │
│     └── Fragments[] — what the item IS                         │
│                                                                │
│  InstanceId, TotalStackCount, OwningContainer                  │
│                                                                │
│  StateModules (TArray<TInstancedStruct<                        │
│                 FItemInstanceStateModule>>)                    │
│     └── Modules[] — what the item has BECOME                   │
│              │                                                 │
│              ├── FRemnantItemState  (Sealed/Awakened + mods)   │
│              ├── FCorruptedState    (future)                   │
│              ├── FFracturedState    (future)                   │
│              └── FVeiledState       (future)                   │
└────────────────────────────────────────────────────────────────┘

Key Design Principles

Principle Implementation
Separation of template vs state Fragments carry static data (icon, stats, weapon class). Modules carry mutable runtime data (progress, reveal flags, corruption flag).
Polymorphism without subclassing UItemObject New module types plug in as structs; UItemObject stays closed for modification
Type-safe queries GetStateModule<FRemnantItemState>() returns a typed pointer
One-byte-per-module versioning SchemaVersion on the base struct rides in the JSON payload for future migrations
Backend-queryable persistence Modules serialize to structured JSON (StructName + nested Data) — not opaque text-export

Core Concepts

Why Not Just Add Fields to UItemObject?

Two paths were available for per-instance state like Remnant progression:

Approach Trade-off
Add fields directly to UItemObject Every new mechanic balloons the base class; orthogonal features collide; replication lists grow; non-participating items carry wasted bytes
Polymorphic module array (chosen) UItemObject stays stable. Adding Corruption is a new struct + a factory hook. Items without the mechanic pay zero cost.

The module pattern mirrors what fragments already do for static data — one instanced-struct array, type-safe queries, canonical UE5 idiom. See Item Fragments.

Fragment vs State Module

Aspect Fragment (FItemFragment) State Module (FItemInstanceStateModule)
Mutability Static template, replaced via re-manifest Mutable runtime state
Authoring Designer-configured on UItemManifestDataAsset Server-side runtime code (factory, tracker, crafting)
Lifecycle Copied from template when item manifests Attached/detached at runtime; persists with the instance
Persistence strategy Reconstructed from template via ItemID lookup Serialized per-instance as a polymorphic wrapper entry
Example FEquipmentFragment (stats, modifiers), FRemnantFragment (marker) FRemnantItemState (Sealed/Awakened, Desire progress)

A useful heuristic: if two items with the same ItemID would have different values for this data, it belongs on a state module. If they'd always share it, it belongs on a fragment.

When to Reach for a State Module

Candidate Good fit?
Remnant lifecycle (Sealed/Awakened, Echo reveal, Desire progress) ✅ Per-instance, mutates during play
Corruption (fixed post-corrupt state, cannot be modified) ✅ Per-instance, authored by the corruption crafting step
Socket links / glyph placements ❌ Already handled by a dedicated container component (UPlayerGlyphComponent)
Stack count ❌ Already a top-level UItemObject property
Item name / icon / grid size ❌ Static — fragment
Rolled prefix/suffix values ❌ Already in FEquipmentFragment::Modifiers; modules are for mechanics that don't fit the standard modifier pipeline

Storage & Polymorphism

The Array

UItemObject::StateModules  (private)
    ├── Type: TArray<TInstancedStruct<FItemInstanceStateModule>>
    ├── Replication: ReplicatedUsing = OnRep_StateModules
    └── Access: typed template helpers on UItemObject

TInstancedStruct<Base> is UE5's canonical container for polymorphic structs in UPROPERTYs. Each entry stores:

  • A UScriptStruct* identifying the concrete type (e.g. FRemnantItemState::StaticStruct())
  • Raw memory for the struct's fields

Typed Access

Callers look up modules by compile-time type — no casting, no name-based dispatch at the call site:

Helper Purpose
GetStateModule<T>() Const lookup. nullptr if absent.
GetStateModuleMutable<T>() Non-const lookup. Server-only mutation sites.
AddStateModule<T>(Initial) Add if not present. No-op if already present. Returns the (new or existing) module.
RemoveStateModule<T>() Removes the module of that type, if present.
GetStateModules() Full array — used by persistence + world-drop / pickup carry paths.
SetStateModules(Array) Replace the whole array — used by the persistence loader and the equipment re-outer path.

AddStateModule<T> is intentionally additive-only: a second call with different data is a no-op, not an overwrite. If a caller needs to mutate, they go through GetStateModuleMutable<T>() and modify in place.

Non-Participating Items Pay Zero Cost

The vast majority of items (weapons, armor, glyphs, consumables) have no state module. Their StateModules array is empty — one TArray header per UItemObject, no module-specific memory, no persistence entries, no replication bandwidth beyond an empty-array delta.


Replication

How Modules Sync

UItemObject::StateModules is UPROPERTY(ReplicatedUsing = OnRep_StateModules). Clients receive the full array on change via standard property replication; the OnRep handler broadcasts a generic FOnItemStateModulesChanged multicast delegate.

Server mutates state module
    ├── GetStateModuleMutable<T>() → modifies field
    ├── UItemObject::MarkStateModulesDirty()
    │       ├── OwningActor->ForceNetUpdate()  (sub-struct mutations don't auto-flag)
    │       └── Broadcasts OnItemStateModulesChanged locally (listen-server host)
Replication delivers to clients
    ├── OnRep_StateModules fires
    └── Broadcasts OnItemStateModulesChanged
Consumers (tooltip VMs, equipment UI) re-snapshot the state module and refresh

The Sub-Struct Dirty Problem

UE's replication layer diffs the top-level property of an array of TInstancedStruct. Mutating a field inside one of the struct payloads does not automatically mark the array dirty. Without explicit nudging, clients would never see the change.

MarkStateModulesDirty() is the server-side escape hatch: it calls ForceNetUpdate on the owning actor and broadcasts the delegate locally so the listen-server host (which never receives its own OnRep) also refreshes. Every server-side mutation site must call it after editing a module.

Consumer Pattern

UI and game systems that care about module state subscribe to FOnItemStateModulesChanged and re-snapshot from the state module on every fire — they do not expect granular per-field callbacks. This keeps the consumer pattern uniform across all module types without forcing each module to fan out its own delegates.


Persistence

Wrapper Shape

State modules don't serialize through TInstancedStruct's native text-export (which would produce /Script/ProjectEternal.RemnantItemState(...) opaque strings). Instead, each module is wrapped:

FItemStateModuleSaveEntry
    ├── FString StructName  (e.g. "RemnantItemState")
    └── FJsonObjectWrapper Data  (nested JSON object — NOT a string)

FJsonObjectWrapper is a UE5 engine struct that FJsonObjectConverter special-cases to emit as a first-class JSON object inside the outer payload. The backend sees structured JSON, not escaped text-export.

Why a Wrapper Instead of Native?

The native path (trusting TInstancedStruct::ExportTextItem) works and would save ~30-50 lines of UE glue. It was rejected because:

Reason Impact
Backend trade validation The GDD trading pillar needs server-side checks (e.g. an awakened Remnant's desire must be at target) — requires queryable state fields on the server
Analytics on circulation "How often do players awaken Remnants?" / "What's the corruption rate across gear?" — needs JSONB path queries, impossible on text-export strings
SQL migrations Adding a field with default to all existing RemnantItemState rows is a jsonb_set one-liner; on text-export it's a UE batch job touching every save
Support / debugging Reading a player's save row in psql stays human; reading a text-export blob does not

Save / Load Flow

Save (FItemSaveState inside FCharacterSaveData):
    Item's state modules
    For each TInstancedStruct:
        FJsonObjectConverter::UStructToJsonObject(ScriptStruct, Memory, DataJson)
        Build entry: { StructName, Data: DataJson }
    FItemSaveState.StateModules[] — array of wrapper entries
    UStructToJsonObjectString → file / HTTP body

Load:
    JsonObjectStringToUStruct → FCharacterSaveData
    For each FItemStateModuleSaveEntry on each item:
        FindFirstObject<UScriptStruct>(StructName)
            ├── not found → log + skip (forward-compat with newer saves)
            └── found, IsChildOf(FItemInstanceStateModule):
                 TInstancedStruct.InitializeAsScriptStruct(S)
                 FJsonObjectConverter::JsonObjectToUStruct(Data, S, Memory)
                 Push onto rebuilt array
    UItemObject::SetStateModules(RebuiltArray)

What the Backend Sees

Key casing note: outer wrapper keys (StructName, Data) are PascalCase — the server-side DTO's [JsonPropertyName] attributes force that on re-serialize. Inner Data keys are lowerCamelCase because UE's FJsonObjectConverter lowercases the first character of every UPROPERTY name via StandardizeCase, and the server passes the inner object through as an opaque JsonElement. Backend queries must match:

[
  {
    "StructName": "RemnantItemState",
    "Data": {
      "schemaVersion": 1,
      "state": "Awakened",
      "echoMods": [ { "rolledModifier": {...}, "bIsRevealed": true, "bIsActive": true } ],
      "activeDesire": { "taskTag": {"tagName": "Desire.Kill.Any"}, "progress": 15, "target": 15 },
      "era": { "tagName": "Era.Debug" },
      "poolRef": "/Game/DataAssets/Items/Modifiers/RemnantItems.RemnantItems"
    }
  }
]

Stored in persistence_items.state_modules JSONB on the backend. Queryable:

-- Find characters with an awakened Remnant
SELECT character_id FROM persistence_items
WHERE state_modules @> '[{"StructName":"RemnantItemState","Data":{"state":"Awakened"}}]';

See Backend Server for the DTO and column schema.

Unknown Module Types

A save produced by a newer client might contain module types the loader doesn't recognise. The deserializer logs a warning and skips those entries — the rest of the item's modules load normally. This is the only tolerant behavior; malformed JSON inside a known struct is still treated as a load failure for that entry.

Equipment Re-Outer Carry

When the restore path rebuilds an equipped item with the player-state as its outer (required for replication subobject parentage), the rebuilt item must copy over StateModules alongside manifest + id + stack count. UItemObject::SetStateModules(Source->GetStateModules()) handles this. Without the copy, an equipped awakened Remnant would silently revert to Sealed on load — the flagship regression this pattern has to avoid.


Schema Versioning

Every module inherits uint8 SchemaVersion = 1 from the base struct. It rides in the JSON payload as a UPROPERTY, so:

  • UE-side migration on load: a module can check SchemaVersion in a PostLoad-style hook and backfill new fields from old data before the rest of the system reads it.
  • Backend batch migration: UPDATE persistence_items SET state_modules = jsonb_set(...) WHERE state_modules @> '[{"StructName":"...","Data":{"schemaVersion":1}}]' — SQL-native, no UE involvement.

Pre-prod, SchemaVersion defaults to 1 and no migration exists. Bump it the first time a live module's field layout changes in a non-compatible way, and author the migration step alongside the field change.

Why Bake It In Now

Adding per-module versioning retroactively once live saves exist means picking a default for the missing field on every legacy row. Doing it pre-prod — when every save is wipe-safe — costs one byte per module and closes the decision permanently.


Adding a New Module Type

Worked example: adding Corruption (the next GDD-committed mechanic).

1. Define the Struct

USTRUCT(BlueprintType)
struct FCorruptedState : public FItemInstanceStateModule
{
    GENERATED_BODY()

    UPROPERTY() bool bCorrupted = false;
    UPROPERTY() FGameplayTag CorruptionType;   // which flavor of corruption
    UPROPERTY() FRolledModifier CorruptedMod;  // the rolled corruption modifier, if any
};

That's the full data surface — no base-class overrides, no lifecycle hooks, no registration.

2. Attach at Crafting Time

In whatever system introduces corruption (a corrupting crafting step, a realm effect, a boss mechanic):

Item->AddStateModule<FCorruptedState>(FCorruptedState{ /* initial fields */ });
UItemObject::MarkStateModulesDirty();

3. Read at Apply / Query Time

if (const FCorruptedState* C = Item->GetStateModule<FCorruptedState>())
{
    if (C->bCorrupted) { /* block further crafting, show visual, ... */ }
}

4. Persistence — Zero Work

The wrapper serializer picks up FCorruptedState by reflection automatically. Backend schema doesn't change — it's the same state_modules JSONB column holding a new StructName string. SQL queries against corruption state work out of the box.

5. Cross-Module Invariants

If Corruption interacts with other modules (e.g. "Corrupted items can't be Remnants and vice versa"), that rule lives at the site that enforces it (crafting validation, awaken check) — not on the module structs. Modules stay pure data.

That's it

No changes to UItemObject. No changes to FItemSaveState. No backend DTO additions. No new replication plumbing. That's the point of the pattern.


Public Contracts

FItemInstanceStateModule (base)

Field Purpose
uint8 SchemaVersion Per-module migration version, default 1

UItemObject (state module API)

Method Returns Purpose
GetStateModule<T>() const T* Const lookup by compile-time type
GetStateModuleMutable<T>() T* Non-const lookup (server-only mutation)
AddStateModule<T>(Initial = T()) T* Add if absent, no-op if present
RemoveStateModule<T>() void Remove if present
GetStateModules() const TArray<TInstancedStruct<...>>& Full array access
SetStateModules(Array) void Replace whole array — used by persistence / re-outer
MarkStateModulesDirty() void Server-side. Forces net update + broadcasts locally. Call after mutating any module.

UItemObject::OnItemStateModulesChanged (delegate)

Delegate Payload When Fired
FOnItemStateModulesChanged Server: on MarkStateModulesDirty. Client: on OnRep_StateModules. Subscribers re-snapshot.

FItemStateModuleSaveEntry (persistence wrapper)

Field Purpose
FString StructName Type tag for reconstruction on load
FJsonObjectWrapper Data Nested JSON object containing the module's UPROPERTY fields

UPersistenceHelpers (serializer API)

Method Purpose
SerializeStateModules(Item) Build wrapper entries from the item's modules
DeserializeStateModules(Item, Entries) Resolve types, rebuild modules, replace the item's array

Source References

Component Location
FItemInstanceStateModule base Public/Inventory/Items/ItemInstanceStateModule.h
State module API on UItemObject Public/Inventory/Items/ItemObject.h
Replication / dirty marking Private/Inventory/Items/ItemObject.cppMarkStateModulesDirty, OnRep_StateModules
World-drop / pickup carry Public/Inventory/Items/Components/ItemComponent.hInitItemManifestWithState
Persistence wrapper struct Public/Persistence/Types/PersistenceTypes.hFItemStateModuleSaveEntry
Serializer helpers Public/Persistence/PersistenceHelpers.hSerializeStateModules, DeserializeStateModules
Serializer implementation Private/Persistence/PersistenceHelpers.cpp
First consumer Public/Remnant/Types/RemnantTypes.hFRemnantItemState


Recent Changes

Date Change Impact
2026-04-21 Clarified outer PascalCase / inner camelCase convention Inner Data keys ship as lowerCamelCase (UE StandardizeCase default); outer wrapper stays PascalCase via DTO [JsonPropertyName]. Backend queries updated.
2026-04 SchemaVersion on base struct Per-module migration lane in place before live data exists
2026-04 Wrapper-shape persistence (FItemStateModuleSaveEntry) Backend receives queryable structured JSON instead of UE text-export strings; trade validation / analytics / migrations are all SQL-native
2026-04 TArray<TInstancedStruct<FItemInstanceStateModule>> on UItemObject Polymorphic per-instance runtime state pattern lands; FRemnantItemState is the first consumer