← All Docs Secure Inventory System

Secure Inventory System

Server-authoritative inventory management with slot-based storage, weight limits, and built-in anti-exploit patterns.

Overview

The Secure Inventory System is a server-authoritative inventory manager for Roblox experiences. Every mutation — adding items, removing items, transferring between players — is validated and executed exclusively on the server. Clients may request changes, but the server is the single source of truth.

Core design principles:

Installation

Copy the inventory-system module folder into ServerStorage in your Roblox Studio place. The only file you need to require is InventoryManager.luau:

game.ServerStorage
└── inventory-system
    └── src
        └── InventoryManager.luau   ← require this

The module has zero external dependencies — no Wally packages or third-party libraries required. It works with any Roblox experience that uses server Scripts.

Quick Start

The typical lifecycle is: create a manager once, load inventories when players join, mutate as needed during gameplay, serialize on leave.

-- ServerScriptService/InventoryScript.server.luau
local InventoryManager = require(game.ServerStorage["inventory-system"].src.InventoryManager)

-- Create a single manager for all players
local manager = InventoryManager.new({
    maxSlots     = 50,
    maxStackSize = 99,
    maxWeight    = 500,
})

game.Players.PlayerAdded:Connect(function(player)
    -- Load saved data (nil = fresh inventory)
    local savedData = nil -- replace with DataStore load
    manager:LoadInventory(player.UserId, savedData)

    -- Grant a starter item
    local ok, err = manager:AddItem(player.UserId, {
        id         = "wooden_sword",
        name       = "Wooden Sword",
        quantity   = 1,
        stackable  = false,
        weight     = 3,
        attributes = { durability = 100, damage = 10 },
    })

    if not ok then
        warn("Failed to add starter item:", err)
    end
end)

game.Players.PlayerRemoving:Connect(function(player)
    -- Serialize to a DataStore-friendly table
    local data = manager:Serialize(player.UserId)
    if data then
        -- save `data` to DataStore here
    end

    -- Free memory
    manager:UnloadInventory(player.UserId)
end)

Server API Reference

All methods below are called on an InventoryManager instance returned by InventoryManager.new().

InventoryManager.new(config?)

Creates a new manager instance. A single manager handles all players.

ParameterTypeDefaultDescription
config.maxSlotsnumber?0 (unlimited)Maximum inventory slots per player.
config.maxStackSizenumber?1Maximum quantity per stack for stackable items.
config.maxWeightnumber?0 (disabled)Maximum total weight. 0 disables weight checks.
local manager = InventoryManager.new({
    maxSlots     = 40,
    maxStackSize = 64,
    maxWeight    = 200,
})

manager:LoadInventory(playerId, savedData?)

Initialises a player's inventory in memory. Pass savedData (a table of SlotData keyed by slot index) to restore a previous session, or nil for a fresh inventory. Each entry is validated and sanitised on load.

ParameterTypeDescription
playerIdnumberThe player's UserId.
savedData{[number]: SlotData}?Previously serialized inventory data, or nil.
-- Fresh inventory
manager:LoadInventory(player.UserId, nil)

-- Restore from DataStore
local saved = dataStore:GetAsync(tostring(player.UserId))
manager:LoadInventory(player.UserId, saved)

manager:AddItem(playerId, params) → (boolean, string?)

Adds item(s) to a player's inventory. Stackable items fill existing stacks first, then allocate new slots. Returns (true, nil) on success or (false, reason) on failure. The operation is atomic — if there isn't enough space for the full quantity, nothing is added.

ParameterTypeDescription
params.idstringUnique item template identifier.
params.namestringHuman-readable display name.
params.quantitynumber?Amount to add. Default 1.
params.stackableboolean?Whether the item stacks. Default false.
params.weightnumber?Weight per unit. Default 0.
params.attributes{[string]: any}?Custom metadata (deep-copied on add).

Possible failure reasons: "invalid_item", "player_not_loaded", "inventory_busy", "weight_exceeded", "inventory_full", "internal_error".

local ok, err = manager:AddItem(player.UserId, {
    id         = "health_potion",
    name       = "Health Potion",
    quantity   = 5,
    stackable  = true,
    weight     = 0.5,
    attributes = { healAmount = 50 },
})

if not ok then
    warn("AddItem failed:", err)
end

manager:RemoveItem(playerId, itemId, quantity?) → (boolean, string?)

Removes a quantity of the specified item. Items are removed from higher-index slots first (LIFO order). Returns (false, "insufficient_quantity") if the player doesn't own enough.

ParameterTypeDescription
playerIdnumberThe player's UserId.
itemIdstringItem template ID to remove.
quantitynumber?Amount to remove. Default 1.

Possible failure reasons: "invalid_item", "player_not_loaded", "inventory_busy", "not_found", "insufficient_quantity".

-- Use one health potion
local ok, err = manager:RemoveItem(player.UserId, "health_potion", 1)

manager:TransferItem(fromId, toId, itemId, quantity?) → (boolean, string?)

Atomically transfers items between two players. Acquires locks in consistent order (lower UserId first) to prevent deadlocks. If the recipient's inventory cannot accept the items, the entire operation is rolled back.

ParameterTypeDescription
fromIdnumberSource player's UserId.
toIdnumberDestination player's UserId.
itemIdstringItem template ID to transfer.
quantitynumber?Amount to transfer. Default 1.

Possible failure reasons: "invalid_item", "same_player", "player_not_loaded", "inventory_busy", "not_found", "insufficient_quantity", "inventory_full", "weight_exceeded".

-- Trade: player A gives 10 iron ore to player B
local ok, err = manager:TransferItem(
    playerA.UserId,
    playerB.UserId,
    "iron_ore",
    10
)

if not ok then
    warn("Trade failed:", err)
end

manager:GetInventory(playerId) → InventorySnapshot?

Returns a deep-copied snapshot of the player's inventory, or nil if the player isn't loaded. Safe to read/modify without affecting the real inventory.

FieldTypeDescription
items{[number]: SlotData}All occupied slots, keyed by slot index.
usedSlotsnumberNumber of occupied slots.
totalWeightnumberSum of (weight × quantity) across all slots.
local snapshot = manager:GetInventory(player.UserId)
if snapshot then
    for slotIdx, item in snapshot.items do
        print(slotIdx, item.name, "x" .. item.quantity)
    end
end

manager:HasItem(playerId, itemId) → (boolean, number)

Checks whether a player owns at least one of the given item. Returns a boolean and the total quantity owned across all stacks.

local has, total = manager:HasItem(player.UserId, "iron_ore")
if has then
    print("Player has " .. total .. " iron ore")
end

manager:GetItem(playerId, itemId) → SlotData?

Returns a deep copy of the first slot matching the given item ID, or nil if not found. Useful for inspecting a specific item's attributes.

local sword = manager:GetItem(player.UserId, "wooden_sword")
if sword then
    print("Durability:", sword.attributes.durability)
end

manager:UpdateItemAttributes(playerId, itemId, updater) → (boolean, string?)

Updates the attributes table for every slot matching the given item ID. The updater function receives a deep copy of the current attributes and must return the new attributes table.

ParameterTypeDescription
playerIdnumberThe player's UserId.
itemIdstringItem template ID to update.
updater(ItemAttributes) → ItemAttributesFunction that transforms the attributes.

Possible failure reasons: "invalid_item", "invalid_updater", "player_not_loaded", "inventory_busy", "not_found".

-- Reduce sword durability by 10
manager:UpdateItemAttributes(player.UserId, "wooden_sword", function(attrs)
    attrs.durability = math.max(0, (attrs.durability or 100) - 10)
    return attrs
end)

manager:Serialize(playerId) → {[number]: SlotData}?

Returns a deep-copied plain table of the player's slots, suitable for saving to a DataStore. Returns nil if the player isn't loaded. Pass the result directly to DataStore:SetAsync().

game.Players.PlayerRemoving:Connect(function(player)
    local data = manager:Serialize(player.UserId)
    if data then
        dataStore:SetAsync(tostring(player.UserId), data)
    end
    manager:UnloadInventory(player.UserId)
end)

manager:UnloadInventory(playerId)

Removes a player's inventory from memory. Always call this in PlayerRemoving after serializing to prevent memory leaks.

manager:UnloadInventory(player.UserId)

manager:GetStats(playerId) → InventoryStats?

Returns summary statistics for a player's inventory. Useful for UI displays (e.g. "12 / 50 slots used") or server-side analytics.

FieldTypeDescription
usedSlotsnumberNumber of occupied slots.
maxSlotsnumberConfigured slot limit (0 = unlimited).
totalWeightnumberCurrent total weight.
maxWeightnumberConfigured weight limit (0 = disabled).
itemCountnumberTotal number of individual items (sum of all quantities).
local stats = manager:GetStats(player.UserId)
if stats then
    print(stats.usedSlots .. "/" .. stats.maxSlots .. " slots")
    print(stats.totalWeight .. "/" .. stats.maxWeight .. " weight")
    print(stats.itemCount .. " total items")
end

Client Replication

The InventoryReplicator module handles all server↔client communication for inventory state. It creates RemoteEvent instances automatically, syncs inventory snapshots to clients, processes client requests with full server-side validation, and includes built-in rate limiting.

Setup

local InventoryReplicator = require(path.to.InventoryReplicator)

local replicator = InventoryReplicator.new({
    inventoryManager = manager,   -- Your InventoryManager instance
    remoteFolder = game.ReplicatedStorage.Baseplate, -- Where RemoteEvents are created
})

replicator:Start()

Calling :Start() creates three RemoteEvents in your remoteFolder:

RemoteDirectionPurpose
InventorySyncServer → ClientSends full inventory snapshot (on join/load)
InventoryUpdateServer → ClientSends incremental updates (add, remove, update, clear)
InventoryRequestClient → ServerReceives validated action requests from clients

API Reference

replicator:ReplicateFullInventory(player)

Sends the player's complete inventory as a deep-copied, read-only snapshot via InventorySync. Call this after loading their data on PlayerAdded.

-- After loading inventory from DataStore:
replicator:ReplicateFullInventory(player)

replicator:ReplicateUpdate(player, updateType, updateData)

Sends an incremental inventory change to a specific player. Call this after any server-side mutation so the client UI stays in sync.

ParameterTypeDescription
playerPlayerThe player to send the update to.
updateType"add" | "remove" | "update" | "clear"What kind of change occurred.
updateData{ slotIndex: number?, itemData: table? }Slot index and/or item data relevant to the change.
-- After adding an item server-side:
replicator:ReplicateUpdate(player, "add", {
    slotIndex = 3,
    itemData = { id = "iron_sword", name = "Iron Sword", quantity = 1 },
})

replicator:Destroy()

Disconnects all event listeners and cleans up rate-limit state. Call on shutdown.

Client → Server Requests

Clients send requests by firing InventoryRequest with a table containing an action string. The server validates ownership, rate-limits, and processes:

ActionRequired FieldsServer Behavior
"drop"itemId, quantity (optional, default 1)Validates ownership → RemoveItem → sends result
"use"itemIdValidates ownership → removes 1 unit → sends result
"equip"itemIdValidates ownership → sets attributes.equipped = true
"unequip"itemIdValidates ownership → sets attributes.equipped = false
-- Client-side example (LocalScript):
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local requestRemote = ReplicatedStorage.Baseplate:WaitForChild("InventoryRequest")

-- Request to drop 5 wood
requestRemote:FireServer({ action = "drop", itemId = "wood", quantity = 5 })

-- Request to use a health potion
requestRemote:FireServer({ action = "use", itemId = "health_potion" })

-- Request to equip a sword
requestRemote:FireServer({ action = "equip", itemId = "iron_sword" })

Rate Limiting

The replicator enforces a limit of 10 requests per second per player using a sliding-window counter. Excess requests are silently dropped and logged as suspicious. This prevents exploiters from flooding the server with rapid-fire requests.

Anti-Exploit Features

Persistence

The InventoryPersistence module bridges InventoryManager with Roblox DataStores. It handles loading, saving, schema migrations, auto-save, and supports two backends: the Baseplate DataStore Pro Kit (for retries, caching, and session locking) or raw DataStoreService with built-in 3-attempt exponential backoff.

Setup

local InventoryPersistence = require(path.to.InventoryPersistence)

local persistence = InventoryPersistence.new({
    inventoryManager = manager,         -- Your InventoryManager instance
    dataStoreName = "PlayerInventory",  -- DataStore name
    useBaseplateDataStore = true,       -- true = use DataStore Pro Kit, false = raw DataStoreService
    schemaVersion = 2,                  -- Current inventory data version
    migrations = {
        [1] = function(data)
            -- v0 → v1: Add rarity to all items
            for _, item in data.items do
                item.attributes = item.attributes or {}
                item.attributes.rarity = item.attributes.rarity or "common"
            end
            return data
        end,
        [2] = function(data)
            -- v1 → v2: Rename "hp_potion" to "health_potion"
            for _, item in data.items do
                if item.id == "hp_potion" then
                    item.id = "health_potion"
                    item.name = "Health Potion"
                end
            end
            return data
        end,
    },
})

API Reference

persistence:Load(playerId) → (boolean, data | errorMessage)

Reads from DataStore, runs schema migrations if needed, and loads the data into InventoryManager. Returns (true, inventorySnapshot) on success or (false, errorMessage) on failure. If no saved data exists, creates a fresh empty inventory.

game.Players.PlayerAdded:Connect(function(player)
    local success, result = persistence:Load(player.UserId)
    if success then
        print("Loaded inventory:", result)
        replicator:ReplicateFullInventory(player)
    else
        warn("Failed to load inventory:", result)
        player:Kick("Failed to load your data. Please rejoin.")
    end
end)

persistence:Save(playerId) → (boolean, errorMessage?)

Serializes the player's inventory via InventoryManager:Serialize(), stamps _schemaVersion and _savedAt, and writes to DataStore. Returns (true, nil) on success or (false, errorMessage) on failure.

game.Players.PlayerRemoving:Connect(function(player)
    local success, err = persistence:Save(player.UserId)
    if not success then
        warn("Failed to save inventory:", err)
    end
    manager:UnloadInventory(player.UserId)
end)

persistence:SaveAll()

Saves all loaded inventories in parallel using task.spawn. Designed for game:BindToClose() where you have ~30 seconds to save everything.

game:BindToClose(function()
    persistence:StopAutoSave()
    persistence:SaveAll()
end)

persistence:StartAutoSave(intervalSeconds?)

Starts a background loop that periodically saves all dirty inventories (those modified since last save). Default interval is 300 seconds (5 minutes). Only saves inventories that have actually changed.

-- Save dirty inventories every 3 minutes
persistence:StartAutoSave(180)

persistence:StopAutoSave()

Stops the auto-save loop. Call before SaveAll() in BindToClose.

DataStore Pro Kit Integration

When useBaseplateDataStore = true, the persistence module automatically creates a DataStoreManager instance internally. This gives you:

The module looks for the DataStore Pro Kit in the standard Baseplate folder structure. If it can't find it, it automatically falls back to raw DataStoreService with a warning.

Standalone Mode

When useBaseplateDataStore = false (or when the Pro Kit isn't found), the module uses raw DataStoreService with a built-in 3-attempt retry and exponential backoff. Schema migrations are handled internally by the persistence module itself.

Dirty Tracking

The module tracks which player inventories have been modified since their last save. SaveAll() only writes dirty entries, and StartAutoSave() only saves inventories that have actually changed — preventing unnecessary DataStore writes and staying within request budgets.

Anti-Exploit Patterns

Why Server Authority Matters

In a client-authoritative inventory, exploiters can modify local memory to give themselves any item, any quantity, at any time. The Secure Inventory System prevents this by keeping all item data exclusively on the server. The client never has a writable reference to inventory state — it only receives read-only snapshots for rendering.

Built-in Protections

Rate Limiting (Recommended)

The module doesn't enforce rate limits internally — that's your game's responsibility. Wrap RemoteEvent/RemoteFunction handlers with a per-player cooldown to prevent spam:

local COOLDOWN = 0.2 -- seconds between requests
local lastRequest = {}

remoteFunction.OnServerInvoke = function(player, action, ...)
    local now = tick()
    if lastRequest[player.UserId] and (now - lastRequest[player.UserId]) < COOLDOWN then
        return false, "rate_limited"
    end
    lastRequest[player.UserId] = now

    -- Validate `action` and dispatch to InventoryManager
    if action == "drop" then
        return manager:RemoveItem(player.UserId, ...)
    end
    return false, "unknown_action"
end

Validation Patterns

Never trust client-supplied item data. Maintain a server-side item registry and only allow adding items that exist in your definitions:

local ITEM_REGISTRY = {
    health_potion = { name = "Health Potion", stackable = true, weight = 0.5 },
    iron_sword    = { name = "Iron Sword",    stackable = false, weight = 5 },
}

local function grantItem(playerId: number, templateId: string, quantity: number)
    local template = ITEM_REGISTRY[templateId]
    if not template then
        return false, "unknown_item"
    end

    return manager:AddItem(playerId, {
        id        = templateId,
        name      = template.name,
        quantity  = quantity,
        stackable = template.stackable,
        weight    = template.weight,
    })
end

FAQ

How does the weight system work?

Each item has a weight value (per unit). The manager tracks totalWeight as the sum of weight × quantity for all slots. When maxWeight is greater than 0, AddItem rejects additions that would exceed the cap with "weight_exceeded". Set maxWeight = 0 in the config to disable weight limits entirely.

How does stacking work?

Mark items with stackable = true when calling AddItem. Stackable items first fill existing stacks of the same id up to maxStackSize, then allocate new slots for any remaining quantity. Non-stackable items always occupy one slot each (quantity is always 1).

Can I attach custom data to items?

Yes — use the attributes table. It's a free-form {[string]: any} dictionary. Common uses: durability, enchantment level, color, rarity tier, creation timestamp. Use UpdateItemAttributes() to modify attributes after creation. Attributes are deep-copied on every read and write to prevent reference leaks.

What happens if the server shuts down unexpectedly?

InventoryManager stores all data in memory. If the server crashes without running Serialize(), unsaved changes are lost. Always use game:BindToClose() alongside PlayerRemoving to save data during graceful shutdown. For maximum reliability, pair with DataStore Pro Kit's session locking and auto-save features.

Is there a slot limit?

Set maxSlots in the config. A value of 0 (the default) means unlimited slots — the inventory will grow as items are added. Any positive number caps the slot count, and AddItem returns "inventory_full" when all slots are occupied.