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

ConceptDescription
GameItemA runtime item instance with a unique ID, stack count, and position
ItemTemplateAn immutable prototype that defines how items are created
ContainerA storage area (inventory, bank, equipment, world ground)
SlotKeyA 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

MethodWhen 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

TypeLayoutItemsStackingUse Case
Grid2D grid (W x H)Multi-cell (1x3 sword, 2x2 shield)YesDiablo, Path of Exile
SlotFlat indexed slotsAlways 1x1YesWoW, FFXIV
EquipmentNamed slotsAlways 1x1, no stackingNoGear 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

Loading diagram...

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 EventHandlerWhat It Does
"move-item"OnMoveItemTransfer between any two containers
"pickup-item"OnPickupItemWorld ground → player inventory
"drop-item"OnDropItemPlayer inventory → world ground
"equip-item"OnEquipItemInventory → equipment + calls OnEquip
"unequip-item"OnUnequipItemEquipment → inventory + calls OnUnequip
"use-item"OnUseItemCalls 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:

StatusValueMeaning
Success0Operation completed
NotEnoughSpace1Target container is full
ItemNotFound2Item instance doesn't exist
StorageNotFound3Container doesn't exist for this owner
InvalidSlot4Slot coordinates out of bounds
NonStackable5Attempted to stack a non-stackable item
StackFull6Stack is at max capacity
BadCount7Invalid count (zero or negative)
CannotMove8Move operation not allowed
IncompatibleSlot9Item category doesn't match equipment slot
ItemExpired10Item has passed its expiry date
ValidationFailed11Custom 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; }
}