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:
- Server authority — all item data lives in server memory. Clients never directly modify inventory state, eliminating an entire class of exploits.
- Slot-based storage — inventories are organized into numbered slots with
configurable limits (
maxSlots). Set to0for unlimited slots. - Weight system — optional per-item weight with a global
maxWeightcap. Disabled whenmaxWeightis0. - Stackable items — items flagged as
stackableautomatically merge into existing stacks up tomaxStackSize. - Atomic transfers — player-to-player transfers use ordered locking and automatic rollback to guarantee consistency.
- Custom attributes — every item carries a free-form
attributestable for durability, enchantments, color, or any game-specific metadata.
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
config.maxSlots | number? | 0 (unlimited) | Maximum inventory slots per player. |
config.maxStackSize | number? | 1 | Maximum quantity per stack for stackable items. |
config.maxWeight | number? | 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.
| Parameter | Type | Description |
|---|---|---|
playerId | number | The 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.
| Parameter | Type | Description |
|---|---|---|
params.id | string | Unique item template identifier. |
params.name | string | Human-readable display name. |
params.quantity | number? | Amount to add. Default 1. |
params.stackable | boolean? | Whether the item stacks. Default false. |
params.weight | number? | 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.
| Parameter | Type | Description |
|---|---|---|
playerId | number | The player's UserId. |
itemId | string | Item template ID to remove. |
quantity | number? | 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.
| Parameter | Type | Description |
|---|---|---|
fromId | number | Source player's UserId. |
toId | number | Destination player's UserId. |
itemId | string | Item template ID to transfer. |
quantity | number? | 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.
| Field | Type | Description |
|---|---|---|
items | {[number]: SlotData} | All occupied slots, keyed by slot index. |
usedSlots | number | Number of occupied slots. |
totalWeight | number | Sum 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.
| Parameter | Type | Description |
|---|---|---|
playerId | number | The player's UserId. |
itemId | string | Item template ID to update. |
updater | (ItemAttributes) → ItemAttributes | Function 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.
| Field | Type | Description |
|---|---|---|
usedSlots | number | Number of occupied slots. |
maxSlots | number | Configured slot limit (0 = unlimited). |
totalWeight | number | Current total weight. |
maxWeight | number | Configured weight limit (0 = disabled). |
itemCount | number | Total 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:
| Remote | Direction | Purpose |
|---|---|---|
InventorySync | Server → Client | Sends full inventory snapshot (on join/load) |
InventoryUpdate | Server → Client | Sends incremental updates (add, remove, update, clear) |
InventoryRequest | Client → Server | Receives 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.
| Parameter | Type | Description |
|---|---|---|
player | Player | The 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:
| Action | Required Fields | Server Behavior |
|---|---|---|
"drop" | itemId, quantity (optional, default 1) | Validates ownership → RemoveItem → sends result |
"use" | itemId | Validates ownership → removes 1 unit → sends result |
"equip" | itemId | Validates ownership → sets attributes.equipped = true |
"unequip" | itemId | Validates 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
- All incoming client data is validated before processing — malformed requests are rejected
- Player must have a loaded inventory or the request is ignored
- Item ownership is verified via
HasItembefore any mutation - Quantity is validated against actual inventory (can't drop more than you have)
- Suspicious activity (requesting items you don't own, exceeding rate limits) is logged with player ID
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:
- Automatic retry with exponential backoff on DataStore failures
- In-memory caching to reduce redundant reads
- Request budget management to avoid throttling
- Schema migrations handled by the Pro Kit's migration engine
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
- Input validation — every
AddItemcall validates theid,name,quantity, andweightfields before touching inventory state. Invalid parameters are rejected with a clear reason code. - Re-entrancy guards — each player inventory has a
lockedflag that prevents concurrent mutations. If a second operation arrives while one is in progress, it fails with"inventory_busy"instead of corrupting state. - Atomic operations —
AddItemrolls back partial stack fills if it runs out of slots.TransferItemrolls back the source removal if the destination can't accept the items. - Ordered locking —
TransferItemacquires locks in ascendingUserIdorder to prevent deadlocks in concurrent trades.
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.