Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Inventory systems manage the full lifecycle of items a player owns, equips, trades, and consumes. The core principle is server authority: the server owns all inventory state, and the client is only a rendering layer. Every mutation — picking up loot, equipping gear, buying from a shop, or trading with another player — flows through server validation before any change is committed. Giving the client control over item state is the single most common source of item duplication exploits on Roblox.

Item Data Architecture

Item Definitions (Shared Schema)

Item definitions are static, read-only templates describing what an item is. Store them in a shared ModuleScript inside ReplicatedStorage so both server and client can reference them without duplication. Never store the full definition in a player’s saved data — only reference the itemId.
-- ReplicatedStorage/Shared/ItemDefinitions.luau
local ItemDefinitions = {
    [1001] = {
        Id = 1001,
        Name = "Iron Sword",
        Description = "A sturdy blade forged from iron.",
        Rarity = "Common",
        Category = "Weapon",
        Stats = { Attack = 10, Speed = 1.2 },
        Icon = "rbxassetid://123456789",
        Stackable = false,
        MaxStack = 1,
    },
    [1002] = {
        Id = 1002,
        Name = "Health Potion",
        Description = "Restores 50 HP.",
        Rarity = "Common",
        Category = "Consumable",
        Stats = { HealAmount = 50 },
        Icon = "rbxassetid://123456790",
        Stackable = true,
        MaxStack = 99,
    },
    [2001] = {
        Id = 2001,
        Name = "Dragon Helmet",
        Description = "Helm forged from dragon scales.",
        Rarity = "Legendary",
        Category = "Helmet",
        Stats = { Defense = 45, FireResist = 20 },
        Icon = "rbxassetid://123456791",
        Stackable = false,
        MaxStack = 1,
    },
}

return ItemDefinitions

Item Instances (Player-Specific Data)

An item instance is a player’s copy of an item. It references the definition by itemId and stores only instance-specific data.
-- Example item instance stored in a player's inventory slot
{
    itemId = 1001,          -- references ItemDefinitions[1001]
    quantity = 1,           -- always 1 for non-stackable
    metadata = {
        durability = 85,    -- instance-specific
        enchantments = { "Sharpness II" },
        uuid = "a1b2c3d4",  -- unique identifier for trading/logging
    },
}
Generate a uuid for every non-stackable item using HttpService:GenerateGUID(false). This lets you distinguish individual copies — essential for trading logs and preventing duplication exploits.

Inventory Storage

Two Layout Types

Used when each slot has a specific purpose. Keys are named strings.
local equipment = {
    Weapon     = nil,
    Helmet     = nil,
    Armor      = nil,
    Boots      = nil,
    Accessory1 = nil,
    Accessory2 = nil,
}

Capacity Checking

Always verify capacity before committing any add operation. Never silently discard items.
local MAX_BACKPACK_SLOTS = 30

local function getUsedSlotCount(backpack: { [number]: any }): number
    local count = 0
    for _ in backpack do
        count += 1
    end
    return count
end

local function hasSpace(backpack: { [number]: any }): boolean
    return getUsedSlotCount(backpack) < MAX_BACKPACK_SLOTS
end
Overflow handling strategies:
  • Reject — Refuse and notify the player (“Inventory full”).
  • Mailbox / overflow stash — Store excess items in a secondary collection the player retrieves later.
  • Drop to ground — Spawn the item near the player (risky if other players can loot it).

Equipment System

Equip / Unequip Flow

1

Validate ownership

Confirm the item exists in the player’s backpack by slot index.
2

Validate slot compatibility

Check the item’s category against the target slot using CATEGORY_TO_SLOT.
3

Unequip current item

If the slot is occupied, move the current item back to backpack (requires a free slot check first).
4

Swap

Remove from backpack, place in equip slot.
5

Recalculate stats

Iterate all equip slots, sum stat bonuses from ItemDefinitions, apply via player:SetAttribute.
6

Update visuals

Attach the Tool or Accessory to the character model server-side.
function InventoryManager.equip(
    player: Player,
    backpackSlot: number,
    equipSlot: string
): (boolean, string?)
    local playerData = InventoryManager._getPlayerData(player)
    if not playerData then
        return false, "Player data not found"
    end

    local item = playerData.backpack[backpackSlot]
    if not item then
        return false, "No item in that backpack slot"
    end

    local def = getItemDef(item.itemId)
    if not def then
        return false, "Unknown item"
    end

    -- Validate slot compatibility
    local validSlots = CATEGORY_TO_SLOT[def.Category]
    if not validSlots or not table.find(validSlots, equipSlot) then
        return false, "Item cannot be equipped in that slot"
    end

    -- If slot is occupied, ensure we have room to unequip
    local currentEquip = playerData.equipment[equipSlot]
    if currentEquip then
        local emptySlot = findEmptyBackpackSlot(playerData)
        if not emptySlot then
            return false, "Inventory full, cannot unequip current item"
        end
        playerData.backpack[emptySlot] = currentEquip
    end

    -- Move item from backpack to equipment
    playerData.equipment[equipSlot] = item
    playerData.backpack[backpackSlot] = nil

    -- Recalculate stats
    InventoryManager._recalculateStats(player)

    -- Update character visuals
    InventoryManager._updateVisuals(player, equipSlot, item)

    return true, nil
end

Loot and Drop Tables

Rarity Weights

RarityWeightApproximate Chance
Common6060%
Uncommon2525%
Rare1010%
Epic44%
Legendary11%

Weighted Random Selection

local RARITY_WEIGHTS: { [string]: number } = {
    Common = 60,
    Uncommon = 25,
    Rare = 10,
    Epic = 4,
    Legendary = 1,
}

local function weightedRandomPick(dropTable: { { itemId: number, rarity: string } }): number?
    local entries = {}
    local totalWeight = 0
    for _, entry in dropTable do
        local weight = RARITY_WEIGHTS[entry.rarity] or 0
        if weight > 0 then
            totalWeight += weight
            table.insert(entries, { itemId = entry.itemId, weight = weight })
        end
    end

    if totalWeight == 0 then return nil end

    local roll = math.random() * totalWeight
    local cumulative = 0
    for _, entry in entries do
        cumulative += entry.weight
        if roll <= cumulative then
            return entry.itemId
        end
    end

    return entries[#entries].itemId
end

Pity System (Guaranteed Drop After N Attempts)

Prevents extreme bad luck by guaranteeing a rare+ drop after a configurable threshold of attempts with no rare+ outcome:
local PITY_THRESHOLD = 50  -- guarantee rare+ after 50 kills with none

local pityCounters: { [number]: number } = {}  -- [playerId] = count since last rare+

local function rollWithPity(player: Player, dropTable): number?
    local userId = player.UserId
    pityCounters[userId] = pityCounters[userId] or 0

    local itemId = weightedRandomPick(dropTable)
    if not itemId then return nil end

    local def = getItemDef(itemId)
    local isRarePlus = def and (
        def.Rarity == "Rare" or def.Rarity == "Epic" or def.Rarity == "Legendary"
    )

    if isRarePlus then
        pityCounters[userId] = 0
        return itemId
    end

    pityCounters[userId] += 1

    if pityCounters[userId] >= PITY_THRESHOLD then
        local rareItems = {}
        for _, entry in dropTable do
            local r = entry.rarity
            if r == "Rare" or r == "Epic" or r == "Legendary" then
                table.insert(rareItems, entry.itemId)
            end
        end
        if #rareItems > 0 then
            pityCounters[userId] = 0
            return rareItems[math.random(1, #rareItems)]
        end
    end

    return itemId
end

Trading System

If the server does not verify both players own the items they are offering at the exact moment of execution, a player can offer an item, drop it, and still “trade” it — duplicating it. Always re-validate ownership immediately before the atomic swap.

Trade Execution (Atomic Swap)

function InventoryManager.executeTrade(
    playerA: Player,
    playerB: Player,
    slotsFromA: { number },
    slotsFromB: { number }
): (boolean, string?)
    local dataA = InventoryManager._getPlayerData(playerA)
    local dataB = InventoryManager._getPlayerData(playerB)
    if not dataA or not dataB then
        return false, "Player data not found"
    end

    -- Snapshot items for validation and rollback
    local itemsA = {}
    for _, slot in slotsFromA do
        local item = dataA.backpack[slot]
        if not item then
            return false, `Player A missing item in slot {slot}`
        end
        table.insert(itemsA, { slot = slot, item = item })
    end

    local itemsB = {}
    for _, slot in slotsFromB do
        local item = dataB.backpack[slot]
        if not item then
            return false, `Player B missing item in slot {slot}`
        end
        table.insert(itemsB, { slot = slot, item = item })
    end

    -- Space check (accounting for items being freed by the trade)
    local freeA, freeB = 0, 0
    for i = 1, MAX_BACKPACK_SLOTS do
        if dataA.backpack[i] == nil then freeA += 1 end
        if dataB.backpack[i] == nil then freeB += 1 end
    end

    if (freeA + #itemsA - #itemsB) < 0 then
        return false, "Player A lacks inventory space"
    end
    if (freeB + #itemsB - #itemsA) < 0 then
        return false, "Player B lacks inventory space"
    end

    -- Remove all offered items first
    for _, entry in itemsA do dataA.backpack[entry.slot] = nil end
    for _, entry in itemsB do dataB.backpack[entry.slot] = nil end

    -- Grant B's items to A
    for _, entry in itemsB do
        local emptySlot = findEmptyBackpackSlot(dataA)
        if not emptySlot then
            for _, e in itemsA do dataA.backpack[e.slot] = e.item end
            for _, e in itemsB do dataB.backpack[e.slot] = e.item end
            return false, "Trade failed: space error during swap"
        end
        dataA.backpack[emptySlot] = entry.item
    end

    -- Grant A's items to B
    for _, entry in itemsA do
        local emptySlot = findEmptyBackpackSlot(dataB)
        if not emptySlot then
            for _, e in itemsA do dataA.backpack[e.slot] = e.item end
            for _, e in itemsB do dataB.backpack[e.slot] = e.item end
            return false, "Trade failed: space error during swap"
        end
        dataB.backpack[emptySlot] = entry.item
    end

    return true, nil
end
Log every trade with timestamp, both player IDs, and item UUIDs to a DataStore key like trade_{os.time()}_{playerA.UserId}_{playerB.UserId}. This is essential for support tickets and detecting duplication exploits.

Shop System

Atomic Purchase Flow

function InventoryManager.buyFromShop(
    player: Player,
    shopId: string,
    itemIndex: number,
    quantity: number
): (boolean, string?)
    local shop = ShopDefinitions[shopId]
    if not shop then return false, "Shop not found" end

    local listing = shop.items[itemIndex]
    if not listing then return false, "Item not in shop" end

    local totalCost = listing.buyPrice * quantity
    local playerData = InventoryManager._getPlayerData(player)
    if not playerData then return false, "Player data not found" end

    -- Validate currency
    if playerData.currency < totalCost then
        return false, "Not enough currency"
    end

    local def = getItemDef(listing.itemId)
    if not def then return false, "Unknown item" end

    -- Try stacking for stackable items
    if def.Stackable then
        local existingSlot = nil
        for slot, item in playerData.backpack do
            if item.itemId == listing.itemId then
                if item.quantity + quantity <= def.MaxStack then
                    existingSlot = slot
                    break
                end
            end
        end
        if existingSlot then
            playerData.currency -= totalCost
            playerData.backpack[existingSlot].quantity += quantity
            return true, nil
        end
    end

    -- Need a new slot
    local emptySlot = findEmptyBackpackSlot(playerData)
    if not emptySlot then return false, "Inventory full" end

    -- Atomic commit: deduct then grant
    playerData.currency -= totalCost
    playerData.backpack[emptySlot] = {
        itemId = listing.itemId,
        quantity = quantity,
        metadata = {},
    }

    return true, nil
end

DataStore Integration

Serialization Rules

  • No Instance references — only plain tables of numbers, strings, and booleans.
  • Store itemId only — definitions live in code; only instance data goes to the DataStore.
  • Use string keys for DataStore dictionary tables, but keep numeric keys at runtime for fast iteration.
local function serializeInventory(playerData): { [string]: any }
    local serialized = {
        currency = playerData.currency,
        backpack = {},
        equipment = {},
    }

    for slot, item in playerData.backpack do
        serialized.backpack[tostring(slot)] = {
            itemId = item.itemId,
            qty = item.quantity,
            meta = item.metadata or {},
        }
    end

    for slotName, item in playerData.equipment do
        if item then
            serialized.equipment[slotName] = {
                itemId = item.itemId,
                qty = item.quantity,
                meta = item.metadata or {},
            }
        end
    end

    return serialized
end

Save and Load Pattern

local DataStoreService = game:GetService("DataStoreService")
local InventoryStore = DataStoreService:GetDataStore("PlayerInventory_v1")

local function savePlayerInventory(player: Player)
    local playerData = InventoryManager._getPlayerData(player)
    if not playerData then return end

    local serialized = serializeInventory(playerData)
    local key = `player_{player.UserId}`

    local success, err = pcall(function()
        InventoryStore:SetAsync(key, serialized)
    end)

    if not success then
        warn(`[Inventory] Failed to save for {player.Name}: {err}`)
    end
end
Always bind saves to both PlayerRemoving and game:BindToClose(). If the server shuts down unexpectedly, PlayerRemoving may not fire — BindToClose is your safety net.
DataStore limits: The 4 MB per-key limit is rarely hit in practice (a 500-slot inventory with moderate metadata is well under 1 MB). Use HttpService:JSONEncode(data) and #json to estimate payload size before saving if you’re concerned.

Common Mistakes

Never store the “real” inventory on the client. An exploiter can modify LocalScripts and RemoteEvents to give themselves any item. The client should only receive a read-only mirror for UI rendering.
Never accept full item definitions from the client (“I have an item with 9999 Attack”). The client sends an action (“equip slot 3”) and the server looks up what is actually in slot 3.
If loot is granted without checking capacity, items either vanish silently (data loss) or the system errors. Always check before granting, and handle the full case gracefully.
Storing name, description, stats, and icon per instance wastes space and causes data drift when definitions change. Store only itemId and instance-specific metadata.
If the save on PlayerRemoving fails and there is no BindToClose fallback, data is lost when the server shuts down. Always implement both.

Best Practices Summary

Server Authority

The server owns all state. Every add, remove, equip, trade, and purchase is a server operation. The client renders what the server tells it.

Validate Everything

Check ownership, quantities, capacity, and item existence before every mutation. Never trust client-supplied item data.

Log Transactions

Record every significant inventory change with timestamps and player IDs. Essential for support and exploit detection.

UUID Non-Stackables

Use HttpService:GenerateGUID(false) for non-stackable items so individual copies are distinguishable in trades and logs.

Build docs developers (and LLMs) love