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 aUSTRUCTinheritingFItemInstanceStateModule, stored in aTArray<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 touchingUItemObject.
Table of Contents¶
- Architecture Overview
- Core Concepts
- Storage & Polymorphism
- Replication
- Persistence
- Schema Versioning
- Adding a New Module Type
- Public Contracts
- Source References
- Related Systems
- Recent Changes
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
SchemaVersionin aPostLoad-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.cpp → MarkStateModulesDirty, OnRep_StateModules |
| World-drop / pickup carry | Public/Inventory/Items/Components/ItemComponent.h → InitItemManifestWithState |
| Persistence wrapper struct | Public/Persistence/Types/PersistenceTypes.h → FItemStateModuleSaveEntry |
| Serializer helpers | Public/Persistence/PersistenceHelpers.h → SerializeStateModules, DeserializeStateModules |
| Serializer implementation | Private/Persistence/PersistenceHelpers.cpp |
| First consumer | Public/Remnant/Types/RemnantTypes.h → FRemnantItemState |
Related Systems¶
- Item System —
UItemObjectlifecycle, identity, manifest - Item Fragments — Static template counterpart to state modules
- Remnant Item System — First consumer of the state module pattern
- Replication Overview — Property replication and
OnReppatterns - API Layer — Client-side save payload shape
- Backend Server — Server-side
state_modulescolumn and JSONB queries
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 |