Inventory system
The inventory system lives in the PulseInventory module. It is a replicated inventory component with a grid layout, item definitions, stacking and moving rules, and a trait system that lets you add rules and data per inventory and per item without changing the layout.
It uses standard property replication and Fast Array serialization. It has no dependency on Iris and works with the classic net driver and with Iris.
Equipment slots and the hotbar are on their own page: Equipment; loot tables and random stats: Loot; consumables and stats while equipped: Item effects.
Overview
| Part | Class | Purpose |
|---|---|---|
| Inventory | UPulseInventoryComponent | Actor component that holds the items. Add it to any actor. |
| Item type | UPulseItemDefinition | Data asset that describes a kind of item. Derive from it to give items their own behavior. |
| Item stack | FPulseItemInstance | One stack of items in an inventory, with position, rotation and instance traits. |
| Layout | FPulseInventoryLayout | Grid size, grid or single-slot mode, rotation, cell size. |
| Preset | UPulseInventoryLayoutPreset | Data asset with a layout and default traits. |
| Traits | FPulseInventoryTrait, FPulseItemTrait | Extra rules and data for inventories and items. |
| Pickup | APulseItemPickup, UPulsePickupSubsystem | Items lying in the world, created when an item is dropped. |
| Access | UPulseInventoryAccessComponent | Component on the player controller that lets a client change inventories it does not own, such as chests. |
| ViewModels | UMVVMPulseInventory, UMVVMPulseInventoryItem, UMVVMPulseItemPickup | Expose an inventory, its items and pickups to UI. |
Quick start
- Create a data asset of class
UPulseItemDefinition(or a subclass) for each item type. SetSize(cells),MaxStackSize, and add traits such as Weight. - Add a
UPulseInventoryComponentto an actor. - Set
InitialPreset(aUPulseInventoryLayoutPresetasset) orInitialLayouton the component. Optionally addDefaultTraits, for example Weight Limit. - On the server, add items:
FPulseInventoryAddResult result = Inventory->TryAddItem(SwordDefinition, 1, {});
if (!result.WasFullyAdded())
{
// result.Result tells why (NoSpace, WeightExceeded, ...), result.Remaining how many were left over.
}
- Show the inventory in UI with a
UMVVMPulseInventoryViewModel: callSetInventoryand bind widgets to its fields.
The quick start as examples that you can create in your project from the Pulse Docs tab of the editor (Create in project…). The chest has the inventory component of Chests and other inventories without an owner with the preset below and fills it in BeginPlay on the authority with Try Add Item: 5 potions, a sword, and then 8 more swords, which do not fit (the result and the number of added items are printed). Creating the chest also creates the component, the preset and the items.
```blueprint title=”Chest that fills its inventory” file=”examples/Inventory/BP_InventoryExample_Chest.blueprint.txt”
## Item definitions
`UPulseItemDefinition` is a `UPrimaryDataAsset` that is `Blueprintable`.
| Property | Meaning |
| --- | --- |
| `DisplayName`, `Description`, `Icon` | Presentation data. The icon is a soft pointer, the UI loads it. |
| `Tags` | Gameplay tags, used for example by the item filter trait. |
| `Size` | Number of cells the item covers (width, height). Ignored in single cell mode. |
| `bCanRotate` | Whether the item can be rotated by 90 degrees. |
| `MaxStackSize` | Maximum items in one stack. 1 means the item does not stack. |
| `Traits` | Item traits of the definition, for example the weight. |
Size and stacking are fixed properties because the grid needs them. Everything else is an item trait.
Example: a sword that covers 1 x 3 cells, can be rotated, does not stack and weighs 3 (**Weight** trait).
```dataasset title="Sword" file="examples/Inventory/Items/DA_Item_Example_Sword.dataasset.txt"
Behavior in subclasses
Derive from UPulseItemDefinition in C++ or Blueprint and override these events:
| Event | When it runs |
|---|---|
CanAddToInventory(Inventory, Count) | Before items are added. Return false to reject them. |
OnAddedToInventory(Inventory, Count) | On the authority after items were added. |
OnRemovedFromInventory(Inventory, Count) | On the authority after items were removed. |
UseItem(Inventory, ItemId, User) | On the authority when the item is used. Return true to consume one item. |
OnAddedToInventory and OnRemovedFromInventory are not called when stacks are only merged or moved inside one inventory, and not while importing saved data.
Example: a Blueprint item class with a HealAmount variable that overrides UseItem, prints who uses it and returns true, so one item is consumed. The potion is a data asset of this class (stacks up to 10, weight 0.5).
```blueprint title=”Item class: consumable” file=”examples/Inventory/BP_ItemDefinition_Example_Consumable.blueprint.txt”
```dataasset title="Healing potion" file="examples/Inventory/Items/DA_Item_Example_Potion.dataasset.txt"
Layout and presets
FPulseInventoryLayout describes how the inventory is built:
| Property | Meaning |
|---|---|
GridMode | Grid: items cover their Size. SingleCell: every item takes exactly one slot, item size and rotation are ignored. |
GridSize | Cells horizontally (X) and vertically (Y). |
bAllowRotation | Allow rotating items by 90 degrees (grid mode only). |
CellSize | Size of one cell in UI units. Only a hint for the UI, the inventory logic does not use it. |
UPulseInventoryLibrary has Blueprint helpers to create layouts: MakeGridLayout(Columns, Rows, bAllowRotation, CellSize) and MakeSimpleSlotsLayout(SlotCount, CellSize).
Presets
UPulseInventoryLayoutPreset is a data asset with a Layout and a list of DefaultTraits. Create one asset per kind of inventory (backpack, chest, hotbar, …). Assign it to InitialPreset or apply it at runtime with ApplyPreset(Preset, bReplaceTraits).
Example: a grid of 6 x 4 cells with rotation and a Weight Limit of 20.
```dataasset title=”Chest preset” file=”examples/Inventory/DA_InventoryPreset_Example_Chest.dataasset.txt”
### Changing the layout at runtime
`SetLayout(NewLayout)` and `ApplyPreset` run on the authority. If all items still fit at their current places, they stay where they are. Otherwise they are packed again (biggest items first, rotating them if allowed). If they still do not fit, nothing changes and the result is `LayoutTooSmall`. `ApplyPreset` changes the layout and the traits in one step (one `OnInventoryChanged`) and replaces the initial setup, also when it runs before `BeginPlay`.
`InitialPreset`, `InitialLayout` and `DefaultTraits` are applied in `BeginPlay` or by the first change. Code that reads the layout or the traits before the inventory began play (e.g. another component while loading) calls `EnsureSetup()` first; it does nothing on clients or when it already ran.
## Placement rules
The inventory keeps a grid of cells. An item covers the cells of its footprint (`Size`, swapped when rotated). Items cannot overlap and cannot leave the grid.
For example, in a grid of 5 x 8 cells with a 4 x 4 item at the top left, a 4 x 2 item cannot be placed at (0, 3) because it would overlap. It fits at (0, 4).
`TryAddItem` fills existing stacks first and then places new stacks on the first free place, scanning row by row. It tries the rotated footprint only if the normal one does not fit anywhere. Only stacks without instance traits are merged.
### Moving, merging and swapping
`MoveItem(ItemId, Position, bRotated)` handles the target place in this order:
1. The target is free: the item moves there.
2. The target holds exactly one other stack of the same item with room: the stacks are merged. The rest stays at the origin.
3. The target holds exactly one other item and `bAllowSwap` is set: the two items swap. The other item takes the old place of the moved item and is rotated if that is what makes it fit. This only happens if both fit afterwards.
4. Otherwise the action fails with `Occupied` and nothing changes.
`TransferItem` follows the same rules between two inventories. Swapping needs the whole stack. Both inventories check their traits before anything changes, each in the state it will have once the outgoing stack left (so a weight limit or your own trait judges the real result). A swap keeps the item ids of both stacks where the receiving inventory has no other stack with that id; a stack moved to a free place gets a new id. Both inventories are changed first, then the source fires its events, then the target.
For drag and drop feedback, `CanMoveItem(ItemId, Position, bRotated)` returns the result of `MoveItem` without changing anything. `CanPlaceDefinitionAt` checks whether an item that is not in the inventory yet would fit in free cells.
### Results
Operations return `EPulseInventoryResult`, which also tells the UI why something is not possible:
`Success`, `NoAuthority`, `InvalidItem`, `InvalidDefinition`, `InvalidCount`, `InvalidTarget`, `NotAllowed`, `OutOfBounds`, `Occupied`, `NoSpace`, `StackFull`, `WeightExceeded`, `RejectedByTrait`, `RejectedByDefinition`, `RejectedByInventory`, `LayoutTooSmall`, `CannotDrop`, `RateLimited`, `SpawnFailed`.
`TryAddItem` and `TryAddItemAt` return `FPulseInventoryAddResult` with `Added`, `Remaining` and the reason for the remaining items. They add as many items as possible.
## Traits
Traits are how you add data and rules to an inventory or an item. They are similar to traits on a Mass entity: nothing is fixed in the layout, you add what you need. Traits can be changed at runtime.
- `FPulseInventoryTrait`: a trait of an inventory. It can limit which items are accepted.
- `FPulseItemTrait`: a trait of an item.
Both derive from `FPulseTrait`, which has two flags:
| Flag | Meaning |
| --- | --- |
| `bReplicated` (default true) | Replicate the trait to clients. When false the trait exists on the server only. |
| `bPersisted` (default true) | Write the trait when the inventory is saved. |
There is at most one trait per struct type on the same holder. Setting a trait of the same type replaces the existing one. Traits are stored in a `FPulseTraitContainer`, which keeps replicated and server-only traits in separate lists. Changing `bReplicated` on a trait takes effect the next time you set it.
### Inventory traits
Add them with `DefaultTraits` on the component, `DefaultTraits` of a preset, or at runtime with `SetTrait` / `RemoveTrait` on the authority. Read them with `FindTrait<T>()` (C++) or `GetTrait` / `HasTrait` (Blueprint). Clients only see replicated traits.
| Trait | Purpose |
| --- | --- |
| `FPulseInventoryTrait_WeightLimit` | `MaxWeight`: the total weight of all items cannot exceed it. |
| `FPulseInventoryTrait_ItemFilter` | `RequiredTags` (the item needs at least one) and `BlockedTags` (the item must have none). |
### Item traits
Item traits can be on the definition and on the item instance. A trait on the instance overrides the trait of the same type on the definition. When all traits of an item are walked (`Pulse::Inventory::ForEachEffectiveItemTrait`, for example for stats), an instance trait also overrides a definition trait whose type derives from it or that it derives from (below `FPulseItemTrait`), so a stat counts once.
| Trait | Purpose |
| --- | --- |
| `FPulseItemTrait_Weight` | `Weight` of one item. Items without it weigh nothing. |
| `FPulseItemTrait_Bound` | Binds the item to its inventory, e.g. quest items or items lent by a skill. `DropItem` returns `CannotDrop` and `TransferItem` into another inventory returns `NotAllowed`, unless `bCanDrop` / `bCanTransfer` are set. Moving inside the inventory and `RemoveItem` still work. |
Definition traits are part of a static asset and are not replicated: every machine reads them from the asset. Values that change at runtime, such as durability or a different weight, belong on the instance: `SetItemTrait(ItemId, Trait)` and `RemoveItemTrait(ItemId, TraitType)` on the authority. Instance traits are replicated according to `bReplicated`. `FindItemTrait<T>(ItemId)` and `GetItemTrait` return the trait as the item sees it, instance first and then definition.
Items with instance traits are not merged into other stacks.
`GetTotalWeight()` sums the weight of all items (weight of one item times the stack count).
### Writing your own trait
Custom traits are C++ structs. (Blueprint structs cannot derive from a C++ struct.)
```cpp
USTRUCT(BlueprintType, meta = (DisplayName = "Max Stacks"))
struct FMyTrait_MaxStacks : public FPulseInventoryTrait
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Trait")
int32 MaxStacks = 10;
virtual int32 GetMaxAcceptedCount(const FPulseInventoryView& InInventory, const FPulseInventoryAddContext& InContext, EPulseInventoryResult& OutBlockReason) const override
{
if (InInventory.Items.Num() < MaxStacks)
{
return InContext.Count;
}
OutBlockReason = EPulseInventoryResult::RejectedByTrait;
return 0;
}
};
GetMaxAcceptedCount returns how many of InContext.Count items the trait accepts. Returning less than InContext.Count lets TryAddItem add part of the items.
Read the inventory only from the view (FPulseInventoryView, State/PulseInventoryView.h): InInventory.Items (the stacks), InInventory.Layout, InInventory.FindTrait<T>() and the helpers in Pulse::Inventory (GetTotalWeight(InInventory), CountItems, FindFreePosition, …). The view is not always the live component: a swap between inventories asks each side with the planned state after the outgoing stack left, equipping asks with the inventory after the equipped stack left, and code without components (specs, later entities) asks with a plain FPulseInventoryState. So a trait that reads the view is right for all of them.
The context holds the definition, the instance traits and ExcludeItemId, a stack that is about to leave (the weight limit does not count it). The built-in moves and swaps do not set it: they remove the leaving stack from the planned state instead, so traits see the inventory as it will be; own code that checks an add against the live inventory can still set it. To read item data, use InContext.FindItemTrait<T>(), which returns the trait as the item sees it (instance first, then definition), for example InContext.FindItemTrait<FPulseItemTrait_Weight>().
The pure rule of the item type, UPulseItemDefinition::CanAddToInventoryView (C++), runs first for every view, also planned states and states without a component. The hooks of the definition (CanAddToInventory) and of the inventory (CanAcceptItem) run next and get the live component, also when a planned state is checked; states without a component skip them. Rules that must hold for planned states belong into CanAddToInventoryView or an inventory trait. Hooks must not change inventories: if one does, the check stops with InvalidItem and nothing is added.
An item trait is a struct that derives from FPulseItemTrait and only holds data. All non-transient properties of a persisted trait are saved, they do not need the SaveGame flag.
Lent items. An item trait on an instance can mark the stack as only lent to the player by overriding IsLent() (returns true), as the items that PulseSkillInventoryBridge gives while a skill is learned. Item costs and Take Items never take lent stacks, and Has Item counts them only with bIncludeLent (see Conditions, costs and rewards). Otherwise a player could pay with a lent item and get it again from the system that lent it. The lending system removes its items itself; Pulse::Inventory::IsLent(Traits) tells whether a stack is lent.
Replication and requests
All changes are made on the authority (server or standalone) and replicated to clients.
Replication mode
The replication mode decides who receives the layout, the items and the replicated traits:
| Mode | Receivers |
|---|---|
Everyone (default) | Everyone the owning actor is relevant to. |
OwnerOnly | Only the connection that owns the actor. Other clients see an empty inventory. |
The mode can only be set while the component is constructed, with SetReplicationMode. There are two places to do it:
// In the constructor of the inventory (or of a subclass):
UMyInventoryComponent::UMyInventoryComponent()
{
SetReplicationMode(EPulseInventoryReplicationMode::OwnerOnly);
}
// Or in the constructor of the owning actor, right after creating the component:
AMyCharacter::AMyCharacter()
{
Inventory = CreateDefaultSubobject<UPulseInventoryComponent>(TEXT("Inventory"));
Inventory->SetReplicationMode(EPulseInventoryReplicationMode::OwnerOnly);
}
A later call, for example at runtime or from BeginPlay, is ignored and logged as a warning. The mode is not a property, so it cannot be changed in the editor or in a Blueprint, and a component that you add in a Blueprint has the default mode. Use a C++ subclass if you need OwnerOnly for a Blueprint-only actor. GetReplicationMode reads it.
With OwnerOnly:
- The owner is found through the owner chain of the actor: pawn to controller, player state to controller.
- Clients that are not the owner receive no layout, items or traits. Do not bind UI or ViewModels to the inventory there.
The mode is implemented with a dynamic replication condition that is read once when the inventory starts to replicate. It works with the classic net driver and with Iris.
Requests
Clients ask for changes with the request functions: RequestMoveItem, RequestRemoveItem, RequestTransferItem and RequestUseItem. Where a request goes depends on where it is called:
- On the authority the operation runs directly.
- On a client that owns the inventory’s actor (its pawn or controller), a reliable server RPC on the inventory is sent.
- On a client that does not own it (a chest), the request is sent through the
UPulseInventoryAccessComponentof the local player, see below.
The server accepts a request only if CanRequesterModify(Requester) returns true. For a transfer, both inventories are asked. The default accepts the controller that owns the component. HandleMoveRequest, HandleRemoveRequest, HandleTransferRequest and HandleUseRequest run the check and the operation. The RPCs call them, and you can call them from your own request paths.
CanAcceptItem(Definition, Count) lets the inventory itself refuse items. The default accepts everything.
Trait changes (SetTrait, SetItemTrait, …) are only possible on the authority.
Chests and other inventories without an owner
A client can only send a server RPC on an actor that its connection owns, and a chest belongs to nobody. Set it up like this:
- Add a
UPulseInventoryAccessComponentto the player controller (or the pawn). TheRequest...functions of every inventory the local player does not own are then sent through it, with the inventory as a parameter. Without it the request is dropped and a warning is logged. With several local players the first local player is used. - Override
CanRequesterModifyon the chest inventory. The default only accepts the owner, so a chest refuses everyone until you decide who may use it, for example:
bool UMyChestInventory::CanRequesterModify_Implementation(AController* Requester) const
{
const APawn* Pawn = Requester ? Requester->GetPawn() : nullptr;
return Pawn && FVector::Dist(Pawn->GetActorLocation(), GetOwner()->GetActorLocation()) <= MaxUseDistance;
}
UPulseInventoryComponent is Blueprintable, so the same works in Blueprint: create a Blueprint class with Pulse Inventory Component as parent and override Can Requester Modify. Example: the requester’s pawn must be within MaxUseDistance (300 by default, editable per instance) of the chest.
```blueprint title=”Chest inventory: Can Requester Modify” file=”examples/Inventory/BP_InventoryExample_ChestInventory.blueprint.txt”
3. Choose who sees the contents:
- **Everyone (default):** The chest replicates like any actor, to every client it is relevant to (`NetCullDistance`). Simple, and fine for small, public inventories. The contents are visible to every client in range.
- **Only the player who opened it:** Give the chest the mode `OwnerOnly` and let the server call `Chest->SetOwner(PlayerController)` when a player opens it and `Chest->SetOwner(nullptr)` when it is closed. Only that player receives the contents, and only one player can use the chest at a time. The chest then also has a direct RPC connection to its owner. Verify this in your project with two clients, it depends on how the net driver handles a change of owner.
Moving items between the player's inventory and a chest is a normal `RequestTransferItem`. Both inventories have to agree in `CanRequesterModify`.
## Dropping and picking up items
`DropItem` puts items into the world as a `APulseItemPickup`. The pickup keeps the definition, the count and the item traits, and `TryPickup` turns it back into items.
### Dropping
```cpp
// On the server. Count 0 drops the whole stack.
Inventory->DropItem(ItemId, /*Count*/ 1, FPulseDropParams());
// On a client, or from UI: only the item and the count are sent.
Inventory->RequestDropItem(ItemId, 1);
The server decides where the item lands, so a client cannot drop items at arbitrary places. By default the item is placed in front of the requester’s pawn at DropDistance, on the ground below (GetDropTransform, overridable). For a drop from a chest the requester is the player, not the chest. FPulseDropParams can set a place (bOverrideTransform), physics, an impulse and a scatter radius.
A drop puts every item into the world or changes nothing. The order is: check everything, plan the removal right after the checks, check the drop budget of the requester (without taking from it), create the new pickup completely (spawn, Init, physics, BeginPlay) while nothing else changes, remove the items from the inventory, then merge into the planned pickups nearby and remove the oldest ones for the limit, and only then take one drop from the budget. If user code changes the inventory after the checks (GetDropTransform, the spawn of the pickup), the planned removal is stale: the drop fails with InvalidItem and nothing changes. OnItemDropped fires right after the removal events of the same drop. If the new pickup cannot be created (no class, a class that cannot be spawned, a pickup that is gone after BeginPlay), the result is SpawnFailed and nothing changed: the stack keeps its id and place, the pickups nearby keep their counts, no pickup was removed and the budget is untouched. Checks:
UPulseItemDefinition::bCanDropand the eventsCanDropon the definition andCanDropItemon the inventory. Otherwise the result isCannotDrop.- The drop rate of a requester (
MaxDropsPerSecond). Otherwise the result isRateLimited. The budget belongs to the player: it is keyed by the unique net id of the player state (so it survives a reconnect), else by the controller, else by the inventory. - A pickup class must exist (
SpawnFailed).
The pickup subsystem plans a drop in two steps that you can also call yourself: PreparePickup (plan the merges, create the new pickup) and CommitPickup (merge and evict; runs no user code). SpawnPickup does both. Until the commit the new pickup is inert (IsPendingCommit): it is not registered, cannot be picked up, takes no merges and fires its first OnPickupChanged at the commit. A merge target that changed in between (count, definition, physics) cancels the drop.
OnItemDropped fires with the dropped stack and the pickup. UPulseItemDefinition::OnDropped is called as well. DropAllItems drops everything, for example when the owner dies. It ignores the rate limit, use ScatterRadius to spread the items.
Pickups
APulseItemPickup is a replicated actor with a mesh. Set the mesh on the definition (WorldMesh) and, if you want another actor, PickupClass. Otherwise DefaultPickupClass from the settings is used.
TryPickup(Receiver, Requester)moves the items into an inventory. If everything fits, the pickup is destroyed. If not, it keeps the rest.CanBePickedUpcan refuse.- Items with instance traits are never merged and keep their traits through a drop and a pickup.
Interaction: The module PulseInventoryInteract has APulseInteractablePickup, a pickup that works with the PulseAbility interaction. Use it as DefaultPickupClass. It finds the inventory on the interactor, its pawn, its controller or its player state. The interaction has to reach the server, so use UPulseRepInteractAbility (the pickup asks for replicated interaction). Without the interaction module, call TryPickup from your own code.
Settings
Project Settings under Pulse > Inventory (UPulseInventorySettings):
| Setting | Default | Meaning |
|---|---|---|
DefaultPickupClass | APulseItemPickup | Pickup class for definitions without their own PickupClass. |
PickupLifetime | 300 s | Seconds until a pickup disappears. 0 keeps it. |
MaxWorldPickups | 500 | Limit of pickups in a world. The oldest is removed first; pickups a drop merges into are kept, so the limit can be exceeded for a moment. 0 means no limit. |
MergeRadius | 150 cm | Drops of the same item this close are merged into one stack. 0 turns it off. |
DropDistance | 120 cm | Distance in front of the requester. |
MaxDropsPerSecond | 5 | Drops per second and requester. 0 means no limit. |
bDefaultSimulatePhysics | off | Whether dropped items are thrown with physics. |
PhysicsSettleSpeed, PhysicsMaxTime | 10 cm/s, 4 s | When a simulated pickup counts as resting. |
PickupNetCullDistance | 5000 cm | Pickups are only relevant to clients this close. |
Keeping the network cost low
The cost of pickups grows with the number of replicated actors, not with the bytes of one actor. So a pickup costs nothing while it lies still:
- Dormancy. A pickup replicates once, then goes dormant (
DORM_DormantAll, works with the classic net driver and with Iris). Clients that join later still get its state. Only a change of the count wakes it for one update (FlushNetDormancy). - No movement replication at rest. Without physics the item is placed and never replicates movement. With physics, movement is replicated only while it moves. When it rests, the simulation stops, the final position is sent once, then movement replication is turned off and the pickup goes dormant. A simulated item needs a
WorldMesh, otherwise it is placed instead. - Little data. The definition and the instance traits are sent once (
COND_InitialOnly). Only the count can change. - Relevance. A pickup is not always relevant, has a net cull distance and a low update frequency, and does not tick.
- Fewer actors. The limit of pickups, the lifetime and the merging of nearby drops keep the number of actors down.
- Abuse protection. The drop request carries only the item and the count, the server picks the place, and the drop rate is limited.
- No hitches on clients. The mesh of a pickup is loaded in the background.
Rule for your own pickup classes: a dormant actor does not send changes by itself. Change replicated state on the server only through the functions of APulseItemPickup. If you add replicated properties, call FlushNetDormancy() after changing them.
To check it in PIE, turn on net.DormancyValidate 1 and look at the dormant actors in stat net.
ViewModel
UMVVMPulseItemPickup exposes a pickup to UI, for example for an interaction prompt. Call SetPickup, or SetPickupFromActor with any actor, such as the SelectedInteractable of UMVVMPulseInteract. Fields: Pickup, Definition, DisplayName, Description, Icon, Count, MaxStackSize, UnitWeight, TotalWeight and bIsValid. The count follows the pickup on the server and on clients. On a client, bIsValid is false until the definition has arrived.
Events
UPulseInventoryComponent broadcasts these events, as Blueprint delegates and as native delegates with a Native suffix:
| Event | When |
|---|---|
OnItemAdded | An item stack was added. |
OnItemRemoved | An item stack was removed. On clients the stack is still listed in GetItems() while the event runs, but FindItem of its id returns null (or the new stack when one with the same id arrived in the same update): use the Item of the event, and IsItemPendingRemoval for code that walks GetItems(). |
OnItemChanged | A stack changed: count, place or traits. |
OnItemDropped | Items were dropped into the world (authority only). |
OnLayoutChanged | The layout changed. |
OnTraitsChanged | The traits of the inventory changed. |
OnInventoryChanged | Anything changed. Once per operation on the authority. |
On the authority they fire after the whole operation is applied: a TryAddItem that fills two stacks and opens a third fires its events once all three are changed, and every event carries the stack as it was right after its own step (a snapshot). So a listener of the first event already sees the final inventory. OnInventoryChanged fires once per operation. On clients they fire from the replication callbacks.
Order per change: a new stack calls the definition’s OnAddedToInventory first, then OnItemAdded; items added to or taken from a stack fire OnItemChanged first, then the definition hook; a removed stack calls OnRemovedFromInventory first, then OnItemRemoved. Then OnLayoutChanged, OnTraitsChanged and OnInventoryChanged.
A listener (or a hook) may change the inventory again, for example a quest objective that takes the items it counted. That change is applied at once, but its events wait until the events of the running operation are done (first in, first out per inventory). Operations on two inventories (transfers, equip, item costs) apply everything first and then fire the events of each holder in turn.
Messages
The same item events also go to the PulseFoundation message bus (UPulseMessageSubsystem), so anything can react without knowing the inventory component (a toast for a new item, a sound, a statistic, a quest objective):
| Channel | When | Payload |
|---|---|---|
Pulse.Message.Inventory.ItemAdded | OnItemAdded | FPulseInventoryItemMessage |
Pulse.Message.Inventory.ItemRemoved | OnItemRemoved | FPulseInventoryItemMessage |
Pulse.Message.Inventory.ItemChanged | OnItemChanged | FPulseInventoryItemMessage |
Pulse.Message.Inventory.ItemDropped | OnItemDropped (authority only) | FPulseInventoryItemMessage with Pickup |
FPulseInventoryItemMessage has Inventory, Item (the stack after the change; for drops StackCount is the dropped amount), Pickup and Count: how many items were added or removed (also when they were merged into a stack or taken from one), the stack after a change, or the dropped amount. On the server, adding to an existing stack sends ItemAdded with the added amount and taking a part of a stack sends ItemRemoved; clients only see whole stacks, so there a merge arrives as ItemChanged.
The context of the messages has the owner of the inventory as Target and, when it is a player’s actor, as Player, the item definition as Source, its tags as Tags and Count as Amount, so quest objectives (“collect 5 wood”) and conditions of other plugins read them without the payload. Messages stay inside one game instance: the server and every client that receives the inventory send their own. Listen in Blueprint with Listen For Pulse Message (channel Pulse.Message.Inventory, match Partial for all of them). UI that shows one inventory binds to UMVVMPulseInventory instead.
Rules as data (C++)
The rules of the inventory are plain functions on plain data in Pulse::Inventory (State/PulseInventoryState.h, State/PulseInventoryView.h), so they can be tested without a world and later run on entities:
FPulseInventoryStateis the contents as one value (layout, traits, stacks, next id, revision);UPulseInventoryComponent::GetStatecopies the component into it.FPulseInventoryViewis what rules read: pointers into a component (GetView) or a state (FPulseInventoryView::Of). On the authority plan againstGetSetUpView(it runs the setup first, which raises the revision).- Stacks carry their item type twice:
DefinitionId(PulseItem:<asset name>, not replicated; clients fill it) andDefinition(the loaded asset).ValidatePlanrefuses aCreatewhose id does not match its definition. Plan...functions (PlanAdd,PlanRemove,PlanRemoveMany, …) read a view and append steps to anFPulseInventoryPlan. A plan belongs to the revision of the view it was made from; several planners may fill one plan from the same view as long as they touch different stacks. A planner that fails appends nothing.ValidatePlanchecks a plan before anything changes;ApplyPlanapplies it in one step (all or nothing) and returns the changes with snapshots.UPulseInventoryComponent::ApplyPlandoes that for the component, marks replication and fires the events.
FPulseInventoryPlan plan;
FPulseInventoryAddRequest request;
request.Definition = Wood;
request.Count = 5;
const FPulseInventoryAddResult planned = Pulse::Inventory::PlanAdd(Inventory->GetSetUpView(), request, plan);
if (planned.Added > 0 && Inventory->ApplyPlan(plan) == EPulseInventoryResult::Success)
{
// planned.Added items are in the inventory now
}
The component functions (TryAddItem, RemoveItem, …) do exactly this. They report the planned result only when it was applied: if a hook changed the inventory while the plan was made, the plan is stale and nothing changes.
ViewModels
UMVVMPulseInventory exposes an inventory to UI. Call SetInventory with a component. The ViewModel listens to the events, so it works the same on the server and on clients.
Fields (FieldNotify): Inventory, GridSize, CellSize, GridMode, Items, TotalWeight, MaxWeight (from the weight limit trait, 0 if there is none) and UsedCells.
Functions: FindItemModel, CanMoveItemTo (drag and drop feedback), RequestMoveItem, RequestRemoveItem, RequestUseItem and Refresh.
Items holds one UMVVMPulseInventoryItem per item stack. The item ViewModels are kept for as long as the item exists and updated in place, so widgets stay bound while items change. Fields: ItemId, Definition, DisplayName, Icon, StackCount, Position, Size (with the current rotation), bRotated and TotalWeight.
There are no widgets in this module. Position and size are in cells, multiply them with CellSize to place a widget.
Saving and loading
Saving and loading run on the authority only. The functions return false on clients.
ExportSaveData(OutData)collects the layout, the persisted traits and all items into anFPulseInventorySaveData(a plain struct).ImportSaveData(Data, OutReport)replaces the state of the inventory. Items whose definition cannot be loaded, or that do not fit, are skipped and listed in theFPulseInventoryImportReport. An item that no longer fits at its saved place is moved to the first free place. Traits are not checked while importing, the saved state is trusted. Item ids are kept; a save with the same id twice gives the second item a new id and a message. The import is one step (Pulse::Inventory::PlanImport, planned against the saved layout only, so a save fits even when the current inventory is full) and fires its events afterwards: removed stacks, new stacks, layout, traits, oneOnInventoryChanged.SaveToBytes/LoadFromByteswrite and read the data as bytes (Pulse::Inventory::SaveDataToBytes/LoadDataFromBytes). Use them to embed the data in any save system. All non-transient properties are written, including trait properties.SaveToSlot/LoadFromSlotuse an engine save slot withUPulseInventorySaveGame.
The data has a Version (now 2). Data written by a newer version is refused; version 1 data still loads.
Items are saved by their id. Item definitions have the primary asset type PulseItem (every item class, Blueprint item classes included), so their id is PulseItem:<asset name>. A saved stack stores the id next to the path. Loading uses the id first: where the asset manager has that id now (a Primary Asset Id Redirect always wins), so a moved item loads also in a packaged build. The path is the fallback, for version 1 data (no id) and for an id the asset manager does not know (an item outside the scanned content, or one whose asset registry data is older than the plugin). The plugin registers the type in code for /Game and the content of every plugin; nothing has to be set up. Two rules follow:
- Item asset names must be unique. Two items with the same asset name share one id: the editor shows a “Duplicate Asset ID” warning, packaged non-shipping builds report an ensure, and a save may load the other item. The setup step “Give every item, recipe and recipe book its own asset name” and the data validation of items, recipes and recipe books list duplicates (the same rule holds for recipes and recipe books within their type).
- Renaming an item changes its id. For items that are already in saves, add a redirect in Project Settings > Asset Manager > Primary Asset Id Redirects, for example
PulseItem:SwordtoPulseItem:IronSword.
Pulse::Inventory::ResolveSavedItem / ResolveSavedAsset (Save/PulseInventorySave.h) do this lookup for your own save data.
PulseInventory does not depend on a save plugin. To use it with PulseSave, use the separate PulseCoreSaveBridge plugin.
Tests
The module has Automation specs in Source/PulseInventory/Private/Tests:
PulseInventory.Grid: the occupancy grid.PulseInventory.View: the view rules and traits read, acceptance with hooks, the grid with pending removals.PulseInventory.State: plans (add, remove, move, transfer, layout, import),ValidatePlan/ApplyPlan, events after the whole operation.PulseInventory.Pickups: the merge plan of drops, the drop budget and its key.PulseInventory.ItemRules: in use, stats of equipped items, wear and repair data.PulseInventory.Ids: primary asset ids of items and the resolver of saved assets.PulseInventory.Equipment,PulseInventory.Conditions,PulseInventory.Loot: equipment plans, item costs as one take, loot.PulseInventory.Traits: the trait container and item weights.PulseInventory.Drop: dropping, rate limit, merging, limit of pickups, dormancy and physics settling, picking up, pickup ViewModel.PulseInventoryInteract.Pickup: finding the inventory of an interactor and picking up through the interaction.PulseInventory.Component: placement, stacking, weight limit, moving, swapping, transfers, layout changes, events, replication mode (constructor rules), save and load.PulseInventory.Messages: the item messages on the message bus.
Run them with the Automation RunTests PulseInventory command.
Debugging
Every inventory component is a snapshot provider of PulseFoundation (category Inventory): Tools > Debug > Pulse Debugger > Live (pick the server or a client to compare) and the gameplay debugger category Pulse for the selected actor show authority, replication mode, grid, used cells, total weight, inventory traits and every item with id, definition, stack count, position and rotation. The save bytes are written with Pulse::Serialization.
Cheats (console, development builds; on a client they run on the server):
| Cheat | Does |
|---|---|
PulseInventoryGive <item> [count] | Adds items to the player’s inventory (found on controller, player state or pawn). <item> is the asset name (DA_Stone) or path of a UPulseItemDefinition. |
PulseInventoryClear | Removes all items from the player’s inventory. |
Known limitations
- Only rectangular items are supported.
- Changing inventories that the player does not own needs a
UPulseInventoryAccessComponenton the local player controller, and aCanRequesterModifyoverride on the inventory. - Pickups are one replicated actor per stack. That is meant for some hundreds of pickups. Thousands would need an aggregated replication actor.
- Pickups are not saved with the inventory. Saving dropped items in the world needs its own solution.
- The replication mode is fixed at construction. There is no per-connection filter: contents go to everyone in range or only to the owner.
- Swapping has no partial mode: it needs the whole stack.
- Traits defined in Blueprint structs are not possible, custom traits are C++ structs.