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 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.
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.
Always verify capacity before committing any add operation. Never silently discard items.
local MAX_BACKPACK_SLOTS = 30local function getUsedSlotCount(backpack: { [number]: any }): number local count = 0 for _ in backpack do count += 1 end return countendlocal function hasSpace(backpack: { [number]: any }): boolean return getUsedSlotCount(backpack) < MAX_BACKPACK_SLOTSend
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).
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, nilend
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].itemIdend
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 nonelocal 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 itemIdend
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.
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, nilend
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.
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, nilend
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 serializedend
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}`) endend
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.
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.
Trusting client item data
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.
No overflow handling
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 full definitions in DataStore
Storing name, description, stats, and icon per instance wastes space and causes data drift when definitions change. Store only itemId and instance-specific metadata.
No save retry / no BindToClose
If the save on PlayerRemoving fails and there is no BindToClose fallback, data is lost when the server shuts down. Always implement both.