Inventory System
Altruist provides a complete inventory system with support for grid-based inventories (Diablo-style), slot-based inventories (WoW-style), equipment slots, and world ground items. Everything is driven by templates you define — the framework imposes no item properties beyond the essentials.
Core Architecture
| Concept | Description |
|---|---|
| GameItem | A runtime item instance with a unique ID, stack count, and position |
| ItemTemplate | An immutable prototype that defines how items are created |
| Container | A storage area (inventory, bank, equipment, world ground) |
| SlotKey | A universal address for any slot in any container |
Step 1: Define Your Item Types
Extend GameItem for each item type in your game. Add whatever properties your game needs:
using Altruist.Gaming.Inventory;
using MessagePack;
[MessagePackObject]
public class Weapon : GameItem
{
[Key(10)] public int AttackPower { get; set; }
[Key(11)] public int Durability { get; set; }
// This item goes in the "weapon_main" equipment slot
public override string? EquipmentSlotType => "weapon_main";
public override void OnEquip(PlayerEntity player)
{
// Apply stat bonuses when equipped
}
public override void OnUnequip(PlayerEntity player)
{
// Remove stat bonuses when unequipped
}
}
[MessagePackObject]
public class HealthPotion : GameItem
{
[Key(10)] public int HealAmount { get; set; } = 50;
public override void OnUse(PlayerEntity player)
{
// Heal the player, consume 1 from stack
}
}
Note:
GameItem has these built-in fields: InstanceId, TemplateId, Category, Count, Stackable, MaxStack, Size, ExpiryDate, CurrentSlot. Everything else — stats, prices, durability — is yours to define on your subclasses.
GameItem Lifecycle Hooks
| Method | When It's Called |
|---|---|
OnEquip(player) | Item moves into an equipment slot |
OnUnequip(player) | Item removed from an equipment slot |
OnUse(player) | Player uses the item (via UseItemAsync) |
Step 2: Define Item Templates
Templates are immutable prototypes. Extend ItemTemplate with your game-specific properties and override CreateInstance() to produce your GameItem type:
public class WeaponTemplate : ItemTemplate
{
public int AttackPower { get; set; }
public int Durability { get; set; }
public int SellPrice { get; set; }
public Dictionary<string, int> Limits { get; set; } = new();
public override GameItem CreateInstance(short count = 1) => new Weapon
{
TemplateId = ItemId,
Category = Category,
Count = count,
Size = Size,
AttackPower = AttackPower,
Durability = Durability,
};
}
public class PotionTemplate : ItemTemplate
{
public int HealAmount { get; set; } = 50;
public override GameItem CreateInstance(short count = 1) => new HealthPotion
{
TemplateId = ItemId,
Category = Category,
Count = count,
Stackable = true,
MaxStack = MaxStack,
HealAmount = HealAmount,
};
}
Note:
The framework only requires 8 base fields on ItemTemplate: ItemId, Key, Name, Category, Stackable, MaxStack, Size, EquipmentSlotType. Everything else — prices, stats, limits, flags — is your domain. Add whatever properties your game needs.
Step 3: Register Templates
Programmatic Registration
Register templates in a [PostConstruct] method:
[Service]
public class ItemSetup
{
private readonly IItemTemplateProvider _templates;
public ItemSetup(IItemTemplateProvider templates)
{
_templates = templates;
}
[PostConstruct]
public void RegisterItems()
{
_templates.Register(new WeaponTemplate
{
ItemId = 1001,
Key = "iron_sword",
Name = "Iron Sword",
Category = "weapon",
Size = new ByteVector2(1, 3),
EquipmentSlotType = "weapon_main",
AttackPower = 25,
Durability = 100,
SellPrice = 500,
});
_templates.Register(new PotionTemplate
{
ItemId = 2001,
Key = "health_potion",
Name = "Health Potion",
Category = "consumable",
Stackable = true,
MaxStack = 99,
HealAmount = 50,
});
}
}
Loading from JSON
If your item data lives in a JSON file, load it directly into your template class:
[PostConstruct]
public void LoadItems()
{
_templates.LoadFromJson<WeaponTemplate>("Resources/weapons.json");
_templates.LoadFromJson<PotionTemplate>("Resources/potions.json");
}
The JSON is deserialized directly into your template subclass. Your custom properties are included automatically:
[
{
"itemId": 1001,
"key": "iron_sword",
"name": "Iron Sword",
"category": "weapon",
"size": { "x": 1, "y": 3 },
"equipmentSlotType": "weapon_main",
"attackPower": 25,
"durability": 100,
"sellPrice": 500,
"limits": { "MIN_LEVEL": 5 }
},
{
"itemId": 1002,
"key": "fire_axe",
"name": "Fire Axe",
"category": "weapon",
"size": { "x": 2, "y": 3 },
"equipmentSlotType": "weapon_main",
"attackPower": 40,
"durability": 80,
"sellPrice": 1200,
"limits": { "MIN_LEVEL": 15, "MIN_STR": 20 }
}
]
Note:
Because LoadFromJson<T> deserializes into your class, you control the JSON shape entirely. Add sellPrice, limits, properties, wearFlags — whatever your game needs. The framework doesn't dictate the schema.
Loading from Database or Other Sources
Templates are just objects — register them from any source:
[PostConstruct]
public async Task LoadFromDatabase()
{
var items = await _vault.Where(i => true).ToListAsync();
foreach (var item in items)
{
_templates.Register(new WeaponTemplate
{
ItemId = item.Vnum,
Name = item.Name,
Category = item.Category,
AttackPower = item.Attack,
// ... map your DB fields
});
}
}
Step 4: Create Player Containers
When a player joins, create their containers via IInventoryService:
[Service]
public class PlayerInventorySetup
{
private readonly IInventoryService _inventory;
public PlayerInventorySetup(IInventoryService inventory)
{
_inventory = inventory;
}
public void SetupPlayer(string playerId)
{
// Grid-based inventory (Diablo-style: items occupy multiple cells)
_inventory.CreateContainer(playerId, new ContainerConfig
{
ContainerId = "inventory",
ContainerType = ContainerType.Grid,
Width = 10,
Height = 6,
SlotCapacity = 99
});
// Equipment (named slots with category restrictions)
_inventory.CreateContainer(playerId, new ContainerConfig
{
ContainerId = "equipment",
ContainerType = ContainerType.Equipment,
EquipmentSlots =
[
new() { SlotName = "head", SlotIndex = 0, AcceptedCategories = ["helmet"] },
new() { SlotName = "chest", SlotIndex = 1, AcceptedCategories = ["armor"] },
new() { SlotName = "weapon_main", SlotIndex = 2, AcceptedCategories = ["weapon"] },
new() { SlotName = "weapon_off", SlotIndex = 3, AcceptedCategories = ["shield", "weapon"] },
new() { SlotName = "legs", SlotIndex = 4, AcceptedCategories = ["pants"] },
new() { SlotName = "feet", SlotIndex = 5, AcceptedCategories = ["boots"] },
]
});
// Bank (extra storage)
_inventory.CreateContainer(playerId, new ContainerConfig
{
ContainerId = "bank",
ContainerType = ContainerType.Slot,
SlotCount = 100,
SlotCapacity = 99
});
}
}
Container Types
| Type | Layout | Items | Stacking | Use Case |
|---|---|---|---|---|
| Grid | 2D grid (W x H) | Multi-cell (1x3 sword, 2x2 shield) | Yes | Diablo, Path of Exile |
| Slot | Flat indexed slots | Always 1x1 | Yes | WoW, FFXIV |
| Equipment | Named slots | Always 1x1, no stacking | No | Gear slots (head, chest, weapon) |
Note:
For slot-based inventories, items always occupy exactly one slot regardless of their Size property. Grid containers use Size to determine how many cells an item occupies.
Step 5: Item Operations
All operations use SlotKey — a universal address for any slot in any container:
SlotKey(X, Y, ContainerId, OwnerId)
│ │ │ │
│ │ │ └── Who owns it: "player123", "world", "guild42"
│ │ └── Which container: "inventory", "equipment", "bank"
│ └── Row (0 for slot-based and equipment)
└── Column / slot index
Creating and Adding Items
// Create from template
var sword = _inventory.CreateItem(templateId: 1001);
// Add to inventory (auto-find slot)
_inventory.AddItem(playerId, "inventory", sword);
// Add at specific grid position
_inventory.AddItem(playerId, "inventory", sword,
at: new SlotKey(3, 0, "inventory", playerId));
Moving Items
// Move between containers (inventory → bank)
await _inventory.MoveItemAsync(
from: new SlotKey(0, 0, "inventory", playerId),
to: SlotKey.Auto("bank", playerId));
// Swap two items
await _inventory.SwapItemsAsync(slotA, slotB);
Equipment
// Equip (inventory → equipment)
await _inventory.EquipItemAsync(playerId,
fromSlot: new SlotKey(0, 0, "inventory", playerId),
equipSlotName: "weapon_main");
// Unequip (equipment → inventory)
await _inventory.UnequipItemAsync(playerId, "weapon_main");
Note:
EquipItemAsync calls item.OnEquip(player) after placing the item. UnequipItemAsync calls item.OnUnequip(player) before removing it. Use these hooks to apply/remove stat bonuses.
World Drop & Pickup
// Drop to world ground
await _inventory.DropItemAsync(playerId,
fromSlot: new SlotKey(0, 0, "inventory", playerId));
// Pickup from world ground
await _inventory.PickupItemAsync(playerId, itemInstanceId: "some-item-id");
Use and Remove
// Use item (calls item.OnUse)
await _inventory.UseItemAsync(playerId, slot);
// Remove / destroy item
_inventory.RemoveItem(new SlotKey(0, 0, "inventory", playerId), count: 1);
Step 6: Create a Portal
Extend AltruistInventoryPortal to get all Gate handlers for free:
[Portal("/game")]
public class MyInventoryPortal : AltruistInventoryPortal
{
private readonly IGameSessionService _sessions;
public MyInventoryPortal(
IInventoryService inventoryService,
IAltruistRouter router,
ILoggerFactory loggerFactory,
IGameSessionService sessions)
: base(inventoryService, router, loggerFactory)
{
_sessions = sessions;
}
protected override Task<string> ResolvePlayerIdAsync(string clientId)
{
var session = _sessions.GetSession(clientId);
return Task.FromResult(session?.Id ?? clientId);
}
protected override Task<PlayerEntity?> ResolvePlayerAsync(string clientId)
{
// Return your player entity for OnEquip/OnUnequip/OnUse hooks
return Task.FromResult<PlayerEntity?>(null);
}
}
This gives you these Gate handlers automatically:
| Client Event | Handler | What It Does |
|---|---|---|
"move-item" | OnMoveItem | Transfer between any two containers |
"pickup-item" | OnPickupItem | World ground → player inventory |
"drop-item" | OnDropItem | Player inventory → world ground |
"equip-item" | OnEquipItem | Inventory → equipment + calls OnEquip |
"unequip-item" | OnUnequipItem | Equipment → inventory + calls OnUnequip |
"use-item" | OnUseItem | Calls item.OnUse(player) |
Note:
You can override any Gate handler to customize behavior. For example, override OnEquipCompleted to broadcast equipment changes to nearby players.
Status Codes
All operations return an ItemStatus indicating success or the reason for failure:
| Status | Value | Meaning |
|---|---|---|
Success | 0 | Operation completed |
NotEnoughSpace | 1 | Target container is full |
ItemNotFound | 2 | Item instance doesn't exist |
StorageNotFound | 3 | Container doesn't exist for this owner |
InvalidSlot | 4 | Slot coordinates out of bounds |
NonStackable | 5 | Attempted to stack a non-stackable item |
StackFull | 6 | Stack is at max capacity |
BadCount | 7 | Invalid count (zero or negative) |
CannotMove | 8 | Move operation not allowed |
IncompatibleSlot | 9 | Item category doesn't match equipment slot |
ItemExpired | 10 | Item has passed its expiry date |
ValidationFailed | 11 | Custom validation rejected the operation |
Note:
Always check the returned ItemStatus before assuming an operation succeeded. For example, EquipItemAsync returns IncompatibleSlot if a weapon is placed in a helmet slot.
Client Types
Packets the client sends for inventory operations:
// All sent as messageCode: 4 with event field matching the gate name
[MessagePackObject]
public class MoveItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("fromSlot")] public SlotKey FromSlot { get; set; }
[Key(2)][JsonPropertyName("toSlot")] public SlotKey ToSlot { get; set; }
[Key(3)][JsonPropertyName("count")] public short Count { get; set; } = 1;
}
[MessagePackObject]
public class PickupItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("itemInstanceId")] public string ItemInstanceId { get; set; } = "";
}
[MessagePackObject]
public class DropItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("fromSlot")] public SlotKey FromSlot { get; set; }
}
[MessagePackObject]
public class EquipItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("fromSlot")] public SlotKey FromSlot { get; set; }
[Key(2)][JsonPropertyName("equipSlotName")] public string? EquipSlotName { get; set; }
}
[MessagePackObject]
public class UnequipItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("equipSlotName")] public string EquipSlotName { get; set; } = "";
}
[MessagePackObject]
public class UseItemPacket
{
[Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
[Key(1)][JsonPropertyName("slot")] public SlotKey Slot { get; set; }
}
[MessagePackObject]
public struct SlotKey
{
[Key(0)][JsonPropertyName("x")] public short X { get; set; }
[Key(1)][JsonPropertyName("y")] public short Y { get; set; }
[Key(2)][JsonPropertyName("containerId")] public string ContainerId { get; set; }
[Key(3)][JsonPropertyName("ownerId")] public string OwnerId { get; set; }
}