In short
Keep each player's items in a table on the server, inside a ModuleScript in ServerScriptService that is the only code allowed to add, remove or stack them. List your items once in a ModuleScript in ReplicatedStorage (id, name, max stack). After every change the server fires a RemoteEvent to that player with a copy of their inventory, and a LocalScript draws it as a grid with a ScrollingFrame and a UIGridLayout.
- The client never adds items. It can only ask, for example "equip my sword", and the server checks that the player owns one before it acts.
- Items only arrive through server code you control: pickups, shop purchases the server has checked, rewards.
- Save item ids and counts, not Tools, with DataStoreService when players leave, when the server shuts down and every few minutes. Never save a player whose data failed to load.
On this page
How the inventory fits together
The system is six small pieces, each with one job. Every rule about items lives in one server-side module, so when something goes wrong there is exactly one place to look.
| Piece | Type and location | Job |
|---|---|---|
ItemCatalog | ModuleScript in ReplicatedStorage | What each item is: name, description, max stack, and whether it can be equipped or used. The server and every client read it. |
InventoryService | ModuleScript in ServerScriptService | Holds every player's slots. The only code that adds, removes and stacks items. |
InventoryData | Script in ServerScriptService | Loads a player's items when they join and saves them. |
InventoryActions | Script in ServerScriptService | Checks and carries out equip and use requests. |
ItemPickup | Script inside a Part | Gives an item to whoever collects the pickup. |
InventoryClient | LocalScript in StarterPlayerScripts | Draws the grid and sends requests. It changes nothing itself. |
Data flows one way. The server changes an inventory, then fires InventoryUpdated to that player with the whole inventory, and the client redraws from it. When the player clicks a slot, the client fires EquipItem or UseItem with the item's id; the server checks the request, makes the change, and the new copy arrives the same way. Sending the whole inventory is simpler than sending changes, and for a few dozen slots the message stays small.
Why the client never adds items
It is tempting to add an item in the LocalScript that shows it: the player clicks, the slot fills. That breaks in two ways.
- Nobody else sees it. A change made in a LocalScript exists only on that player's device. The server never hears about it, so it is not saved and no server script can use it.
- Anyone can fake it. If you fix that with a RemoteEvent such as
AddItem(itemId, amount), an exploiter can fire that event themselves, with any arguments, as often as they like. Roblox's security guide says the server must validate everything a client sends before using it.
So in this guide the client can only make requests about items the player already owns: "equip this" and "use this". Each request carries one item id and no amounts, and passes one check on the server: is it a string, is it a real item, does the player own at least one, and is the player sending no more than four requests a second. The server then looks up the Tool itself from the catalogue. It never accepts a Tool, a path or an instance from the client, which the same guide warns against.
Items get in only through server code: a pickup the server has checked, a purchase the server has charged for (the shop GUI guide builds one), a quest reward or a drop. For the RemoteEvent pattern in general, with validation for other kinds of arguments, read the RemoteEvents guide.
Step 1: Set up the Explorer
Create these objects by hand. The scripts in later steps find them by name, so spell each name exactly as shown.
- In ReplicatedStorage, insert a Folder named
InventoryRemotes. Inside it, insert three RemoteEvents namedInventoryUpdated,EquipItemandUseItem. - In ServerStorage, insert a Folder named
ItemTools. Inside it, insert a Tool namedSword, and inside the Tool a Part namedHandle(1 by 4 by 1 studs is fine). Make sure the Handle's Anchored box is off: Roblox warns that characters get stuck in place when they hold a tool with anchored parts. A Tool you already have works too, as long as its name matches the catalogue in Step 2. - Add the scripts as you reach each step. The tree shows where every one of them goes.
ReplicatedStorage
ItemCatalog ModuleScript new
InventoryRemotes Folder new
InventoryUpdated RemoteEvent new
EquipItem RemoteEvent new
UseItem RemoteEvent new
ServerScriptService
InventoryService ModuleScript new
InventoryData Script new
InventoryActions Script new
ServerStorage
ItemTools Folder new
Sword Tool new
Handle Part new
StarterPlayer
StarterPlayerScripts
InventoryClient LocalScript new
Workspace
Apple Part new
ItemPickup Script new
Step 2: List your items in a catalogue
The catalogue describes every item that can exist, once. Inventories then store only an item's id and a count. That keeps saves small, and it means you can rename an item or rewrite its description later without touching anyone's saved data.
Insert a ModuleScript into ReplicatedStorage, name it ItemCatalog, and replace its contents with this:
-- Every item that can exist in the game. The server and every client read this
-- list; only the server decides who owns what.
export type Item = {
id: string, -- must match the key below
name: string,
description: string,
maxStack: number, -- the most of this item that fits in one slot
icon: string?, -- optional image, written as "rbxassetid://" plus your image's ID
toolName: string?, -- a Tool in ServerStorage.ItemTools: the item can be equipped
heal: number?, -- health restored on use: the item is a consumable
}
local items: { [string]: Item } = {
sword = {
id = "sword",
name = "Sword",
description = "Equip it to put it in your hand.",
maxStack = 1,
toolName = "Sword",
},
apple = {
id = "apple",
name = "Apple",
description = "Use it to restore 25 health.",
maxStack = 10,
heal = 25,
},
wood = {
id = "wood",
name = "Wood",
description = "A building material.",
maxStack = 50,
},
}
local ItemCatalog = {}
-- Returns the item with this id, or nil if no such item exists
function ItemCatalog.get(id: string): Item?
return items[id]
end
return ItemCatalog
The optional fields decide what an item does. toolName makes it equippable, heal makes it usable, and an item with neither, like wood, stays in the inventory until other server code (crafting, selling) removes it.
To add an item, add an entry whose key and id match. Ids are case-sensitive and they are what gets saved, so never change an id once players own the item: change its name instead.
Step 3: Hold inventories on the server
This ModuleScript owns every player's inventory. Each inventory is a list of slots, each slot an item id and a count, plus a capacity of 20 slots. Other server scripts call its functions, and nothing else edits the table.
Insert a ModuleScript into ServerScriptService and name it InventoryService:
-- The only code that changes inventories. It sits in ServerScriptService,
-- where clients can neither see nor run it.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Strict mode cannot follow a require through WaitForChild, so we state
-- the part of the module this script uses
type Item = { id: string, maxStack: number }
type Catalog = { get: (id: string) -> Item? }
local ItemCatalog = require(ReplicatedStorage:WaitForChild("ItemCatalog")) :: Catalog
local remotes = ReplicatedStorage:WaitForChild("InventoryRemotes")
local inventoryUpdated = remotes:WaitForChild("InventoryUpdated") :: RemoteEvent
export type Slot = { id: string, count: number }
type Inventory = { slots: { Slot }, capacity: number, equipped: string? }
local CAPACITY = 20 -- slots per player
local STARTER_ITEMS: { Slot } = { -- what a brand new player starts with
{ id = "sword", count = 1 },
{ id = "apple", count = 3 },
}
local inventories: { [Player]: Inventory } = {}
local InventoryService = {}
-- Sends the owner a copy of their inventory. Runs after every change.
local function sync(player: Player)
local inventory = inventories[player]
if inventory then
inventoryUpdated:FireClient(player, {
capacity = inventory.capacity,
slots = inventory.slots,
equipped = inventory.equipped,
})
end
end
-- Stacking rules: top up stacks that are not full, then start new stacks in
-- empty slots. Returns how many were added, which can be less than asked.
local function addToInventory(inventory: Inventory, item: Item, amount: number): number
local remaining = amount
for _, slot in inventory.slots do
if remaining == 0 then
break
end
if slot.id == item.id and slot.count < item.maxStack then
local moved = math.min(item.maxStack - slot.count, remaining)
slot.count += moved
remaining -= moved
end
end
while remaining > 0 and #inventory.slots < inventory.capacity do
local moved = math.min(item.maxStack, remaining)
table.insert(inventory.slots, { id = item.id, count = moved })
remaining -= moved
end
return amount - remaining
end
function InventoryService.count(player: Player, itemId: string): number
local inventory = inventories[player]
local total = 0
if inventory then
for _, slot in inventory.slots do
if slot.id == itemId then
total += slot.count
end
end
end
return total
end
-- How many more of an item fit: space left in its stacks plus empty slots
function InventoryService.spaceFor(player: Player, itemId: string): number
local inventory = inventories[player]
local item = ItemCatalog.get(itemId)
if not inventory or not item then
return 0
end
local space = (inventory.capacity - #inventory.slots) * item.maxStack
for _, slot in inventory.slots do
if slot.id == itemId then
space += item.maxStack - slot.count
end
end
return space
end
-- Adds up to `amount` of an item. Returns how many were added (0 if the
-- item does not exist, the inventory is full or has not loaded yet).
function InventoryService.add(player: Player, itemId: string, amount: number): number
local inventory = inventories[player]
local item = ItemCatalog.get(itemId)
local wanted = math.floor(amount)
if not inventory or not item or wanted < 1 then
return 0
end
local added = addToInventory(inventory, item, wanted)
if added > 0 then
sync(player)
end
return added
end
-- Removes exactly `amount` of an item, taking from the last stacks first.
-- If the player has fewer than that, removes nothing and returns false.
function InventoryService.remove(player: Player, itemId: string, amount: number): boolean
local inventory = inventories[player]
local wanted = math.floor(amount)
if not inventory or wanted < 1 or InventoryService.count(player, itemId) < wanted then
return false
end
local remaining = wanted
-- Walk backwards so table.remove never skips a slot
for index = #inventory.slots, 1, -1 do
local slot = inventory.slots[index]
if slot.id == itemId then
local taken = math.min(slot.count, remaining)
slot.count -= taken
remaining -= taken
if slot.count == 0 then
table.remove(inventory.slots, index)
end
if remaining == 0 then
break
end
end
end
sync(player)
return true
end
function InventoryService.getEquipped(player: Player): string?
local inventory = inventories[player]
return if inventory then inventory.equipped else nil
end
function InventoryService.setEquipped(player: Player, itemId: string?)
local inventory = inventories[player]
if inventory then
inventory.equipped = itemId
sync(player)
end
end
-- Called once a player's saved data has loaded. `saved` is whatever the data
-- store returned: nil for a new player. Every entry goes back through the
-- stacking rules, so removed items and old stack sizes cannot break anything.
function InventoryService.load(player: Player, saved: any)
local inventory: Inventory = { slots = {}, capacity = CAPACITY }
local entries: { Slot } = STARTER_ITEMS
if type(saved) == "table" and type(saved.slots) == "table" then
entries = {}
for _, entry in saved.slots do
if type(entry) == "table" and type(entry.id) == "string" and type(entry.count) == "number" then
table.insert(entries, { id = entry.id, count = math.floor(entry.count) })
end
end
end
for _, entry in entries do
local item = ItemCatalog.get(entry.id)
if item and entry.count > 0 then
addToInventory(inventory, item, entry.count)
end
end
inventories[player] = inventory
sync(player)
end
-- A plain copy to save, or nil if this player's data never loaded
function InventoryService.serialize(player: Player): { version: number, slots: { Slot } }?
local inventory = inventories[player]
if not inventory then
return nil
end
local slots: { Slot } = {}
for index, slot in inventory.slots do
slots[index] = { id = slot.id, count = slot.count }
end
return { version = 1, slots = slots }
end
function InventoryService.unload(player: Player)
inventories[player] = nil
end
return InventoryService
The type lines and the :: cast after require only help Luau's type checker, which cannot see what a module found with WaitForChild returns. They change nothing when the game runs. The other scripts in this guide do the same.
How the stacking rules play out with the catalogue above, where apples stack to 10:
- Adding 15 apples to a player who has a stack of 7 fills that stack to 10, then starts a new stack of 5.
- When the slots run out,
addadds what fits and returns that number. CallspaceForfirst when it must be all or nothing, as the pickup in Step 5 does. removeis all or nothing. Asking for 3 apples from a player who has 2 removes none and returnsfalse, so check the result before you give anything in exchange.- Slots have no gaps: when one empties, the rest move up. That keeps the list simple to send and save. The questions at the end cover slots that keep their position.
| Function | Returns | Use it for |
|---|---|---|
add(player, itemId, amount) | How many were added | Pickups, rewards, purchases |
remove(player, itemId, amount) | true if all were removed | Using, selling, crafting |
count(player, itemId) | The total across every stack | Checking what a player has |
spaceFor(player, itemId) | How many more would fit | Checking before an all-or-nothing add |
getEquipped, setEquipped | The equipped item id, or nil | Equipping, in Step 6 |
load, serialize, unload | A copy ready to save (serialize) | Loading and saving, in Step 4 |
Step 4: Load and save each inventory
Inventories live in server memory, so they are gone when the player leaves. This script loads a player's slots from a data store when they join and writes them back when they leave, when the server shuts down, and every three minutes in case the server crashes. It saves item ids and counts only: data stores cannot store Instances such as Tools.
Insert a Script into ServerScriptService and name it InventoryData:
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local RunService = game:GetService("RunService")
local ServerScriptService = game:GetService("ServerScriptService")
type InventoryApi = {
load: (player: Player, saved: any) -> (),
serialize: (player: Player) -> any,
unload: (player: Player) -> (),
}
local InventoryService = require(ServerScriptService:WaitForChild("InventoryService")) :: InventoryApi
local STORE_NAME = "PlayerInventory_v1"
local LOAD_ATTEMPTS = 3
local AUTOSAVE_SECONDS = 180
-- Only players whose saved data loaded are ever saved. A failed load must
-- never be followed by a save, or the empty inventory replaces the real one.
local canSave: { [Player]: boolean } = {}
local activeSaves = 0
local function keyFor(player: Player): string
return "User_" .. player.UserId
end
-- One failed request should not cost anyone their items, so retry a few times.
-- GetDataStore sits inside the pcall too, so a Studio session that cannot
-- reach data stores gets a warning instead of stopping this script.
local function loadSaved(player: Player): (boolean, any)
for attempt = 1, LOAD_ATTEMPTS do
local ok, result = pcall(function()
return DataStoreService:GetDataStore(STORE_NAME):GetAsync(keyFor(player))
end)
if ok then
return true, result
end
warn(`Inventory load failed for {player.Name} (attempt {attempt}): {result}`)
if attempt < LOAD_ATTEMPTS then
task.wait(attempt * 2)
end
end
return false, nil
end
local function onPlayerAdded(player: Player)
local ok, saved = loadSaved(player)
if player.Parent ~= Players then
return -- they left while we were loading
end
if ok then
InventoryService.load(player, saved) -- saved is nil for a new player
canSave[player] = true
elseif RunService:IsStudio() then
-- Usually means Studio access to API services is off. Play with the
-- starter items so you can test, but never save them.
warn("Inventory not loaded: playing with starter items that will not be saved")
InventoryService.load(player, nil)
else
player:Kick("Your inventory could not be loaded. Please rejoin in a minute.")
end
end
local function save(player: Player)
if not canSave[player] then
return -- their data never loaded, so there is nothing safe to write
end
local data = InventoryService.serialize(player)
if not data then
return
end
activeSaves += 1
local ok, err = pcall(function()
DataStoreService:GetDataStore(STORE_NAME):SetAsync(keyFor(player), data, { player.UserId })
end)
activeSaves -= 1
if not ok then
warn(`Inventory save failed for {player.Name}: {err}`)
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player: Player)
save(player)
canSave[player] = nil
InventoryService.unload(player)
end)
-- When the server shuts down, save everyone still here, then wait for every
-- save in progress, including ones that PlayerRemoving started
game:BindToClose(function()
for _, player in Players:GetPlayers() do
task.spawn(save, player)
end
while activeSaves > 0 do
task.wait()
end
end)
-- Save everyone every few minutes, so a server crash costs minutes, not a session
while true do
task.wait(AUTOSAVE_SECONDS)
for _, player in Players:GetPlayers() do
task.spawn(save, player)
end
end
What the script does, and why:
- Load with defaults. A new player has no saved value, so
GetAsyncreturns nil and they get the starter items. Saved slots go back through the stacking rules, so an item you removed from the catalogue or a smaller max stack cannot break a load. Anything that no longer fits, or whose id is no longer in the catalogue, is dropped, and the next save makes that permanent, so keep every id players may own in the catalogue. - Never save after a failed load. Only players in
canSaveare ever written. In a live server, a player whose data still will not load after three tries is asked to rejoin. In Studio they get unsaved starter items instead, a few seconds late because of the retries, which is what you see until the place is published with API access on. - Save on leave and on shutdown.
BindToClosesaves everyone still in the server and waits for saves already running. Roblox gives bound functions up to 30 seconds. - Stay inside the limits. One write per player every three minutes, plus one when they leave, is far below the default per-server budget of 60 writes a minute plus 40 for each player (as documented on 30 September 2026). The
User_key pattern follows Roblox's best practices, and passing the user ID toSetAsynclinks the data to that player, which Roblox highly recommends for handling data removal requests.
That is a solid base for a small game. Session locking, which stops a player who hops servers quickly from loading data before the old server has saved it, and when to use UpdateAsync instead of SetAsync, are covered in the DataStore tutorial.
Step 5: Give items from the world
A pickup is the simplest way items enter an inventory: a part with a ProximityPrompt that adds an item when a player uses it. The script listens to its Triggered event on the server, so the item is added where the client cannot interfere.
- Add a Part to Workspace, name it
Appleand colour it red. Turn on Anchored and turn off CanCollide, so nobody bumps into it while it is invisible. The prompt shows the part's name. - With the part selected, scroll to the Attributes section of the Properties window and click +. Add
ItemIdwith the type string and click Save, then set its value toapple. AddAmountthe same way as a number set to1. - Insert a Script into the part, name it
ItemPickupand paste this code:
-- Put this Script inside a Part and give the Part two attributes:
-- ItemId (a string, such as apple) and Amount (a number, such as 1).
local ServerScriptService = game:GetService("ServerScriptService")
type InventoryApi = {
add: (player: Player, itemId: string, amount: number) -> number,
spaceFor: (player: Player, itemId: string) -> number,
}
local InventoryService = require(ServerScriptService:WaitForChild("InventoryService")) :: InventoryApi
local part = script.Parent :: BasePart
local RESPAWN_SECONDS = 15
local itemId = part:GetAttribute("ItemId")
local amount = part:GetAttribute("Amount")
if typeof(itemId) ~= "string" or typeof(amount) ~= "number" or amount < 1 then
error(`{part:GetFullName()} needs an ItemId (string) and an Amount (number, 1 or more) attribute`)
end
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Pick up"
prompt.ObjectText = part.Name
prompt.Parent = part
local visibleTransparency = part.Transparency
prompt.Triggered:Connect(function(player: Player)
if not prompt.Enabled then
return -- someone else collected it a moment ago
end
-- Check the distance on the server instead of trusting the request
local character = player.Character
if not character then
return
end
local distance = (character:GetPivot().Position - part.Position).Magnitude
if distance > prompt.MaxActivationDistance + 5 then
return
end
if InventoryService.spaceFor(player, itemId) < amount then
return -- not enough room: leave the pickup where it is
end
InventoryService.add(player, itemId, amount)
prompt.Enabled = false
part.Transparency = 1
task.wait(RESPAWN_SECONDS)
part.Transparency = visibleTransparency
prompt.Enabled = true
end)
Copy the part to place more pickups. Change the attributes to hand out wood, another sword or a stack of apples.
Roblox checks distance for Triggered, but its security guide still tells you to validate prompts on the server: an exploiter can fire a prompt's events even while Enabled is false, and can pull an unanchored part next to their character. That is why the script checks Enabled and the distance itself, and why the part is anchored.
Any other server script gives items the same way. After a purchase, a quest or a kill, require InventoryService as the scripts above do and call InventoryService.add(player, "wood", 5). Check spaceFor first whenever the item must not be lost.
Step 6: Equip Tools and use items
Two RemoteEvents carry the only requests a client can make. EquipItem puts a copy of the item's Tool from ServerStorage into the player's Backpack (the hotbar at the bottom of the screen) and into their hand, and asking again takes it away. UseItem removes one of a usable item and applies its effect, which here is healing.
Insert a Script into ServerScriptService and name it InventoryActions:
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
type Item = { id: string, toolName: string?, heal: number? }
type Catalog = { get: (id: string) -> Item? }
type InventoryApi = {
count: (player: Player, itemId: string) -> number,
remove: (player: Player, itemId: string, amount: number) -> boolean,
getEquipped: (player: Player) -> string?,
setEquipped: (player: Player, itemId: string?) -> (),
}
local ItemCatalog = require(ReplicatedStorage:WaitForChild("ItemCatalog")) :: Catalog
local InventoryService = require(ServerScriptService:WaitForChild("InventoryService")) :: InventoryApi
local remotes = ReplicatedStorage:WaitForChild("InventoryRemotes")
local equipItem = remotes:WaitForChild("EquipItem") :: RemoteEvent
local useItem = remotes:WaitForChild("UseItem") :: RemoteEvent
local itemTools = ServerStorage:WaitForChild("ItemTools")
local COOLDOWN_SECONDS = 0.25
local lastRequest: { [Player]: number } = {}
-- Every request starts here. A client can send anything at any time, so check
-- the type, that the item exists, that the player owns one, and the rate.
local function ownedItem(player: Player, itemId: unknown): Item?
if typeof(itemId) ~= "string" then
return nil
end
local now = os.clock()
if now - (lastRequest[player] or 0) < COOLDOWN_SECONDS then
return nil
end
lastRequest[player] = now
local item = ItemCatalog.get(itemId)
if not item or InventoryService.count(player, itemId) < 1 then
return nil
end
return item
end
-- Destroys this item's Tool wherever a copy is: hotbar, hand or StarterGear
local function destroyTools(container: Instance?, itemId: string)
if not container then
return
end
for _, child in container:GetChildren() do
if child:IsA("Tool") and child:GetAttribute("InventoryItem") == itemId then
child:Destroy()
end
end
end
local function unequip(player: Player)
local equippedId = InventoryService.getEquipped(player)
if equippedId then
destroyTools(player:FindFirstChildOfClass("Backpack"), equippedId)
destroyTools(player.Character, equippedId)
destroyTools(player:FindFirstChildOfClass("StarterGear"), equippedId)
InventoryService.setEquipped(player, nil)
end
end
local function equip(player: Player, item: Item, toolName: string)
local template = itemTools:FindFirstChild(toolName)
local backpack = player:FindFirstChildOfClass("Backpack")
if not template or not template:IsA("Tool") or not backpack then
warn(`Cannot equip {item.id}: no Tool named {toolName} in ServerStorage.ItemTools`)
return
end
unequip(player) -- one equipped tool at a time
local tool = template:Clone()
tool.CanBeDropped = false -- a dropped copy could be picked up by someone else
tool:SetAttribute("InventoryItem", item.id)
tool.Parent = backpack
-- A second copy in StarterGear is copied back into the Backpack after a respawn
local starterGear = player:FindFirstChildOfClass("StarterGear")
if starterGear then
local gearCopy = tool:Clone()
gearCopy.Parent = starterGear
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid and humanoid.Health > 0 then
humanoid:EquipTool(tool) -- put it straight in their hand
end
InventoryService.setEquipped(player, item.id)
end
equipItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
local item = ownedItem(player, itemId)
if not item or not item.toolName then
return
end
if InventoryService.getEquipped(player) == item.id then
unequip(player) -- clicking the equipped item again takes it away
else
equip(player, item, item.toolName)
end
end)
useItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
local item = ownedItem(player, itemId)
if not item or not item.heal then
return
end
local heal = item.heal
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 or humanoid.Health >= humanoid.MaxHealth then
return -- dead or already at full health: do not waste the item
end
-- Take the item first, and only heal if that worked
if InventoryService.remove(player, item.id, 1) then
humanoid.Health = math.min(humanoid.MaxHealth, humanoid.Health + heal)
end
end)
Players.PlayerRemoving:Connect(function(player: Player)
lastRequest[player] = nil
end)
| Request | The client sends | The server checks before acting |
|---|---|---|
EquipItem | An item id | It is a string, the item exists, the player owns one, requests are not arriving faster than four a second, the item has a toolName, and a Tool with that name is in ServerStorage.ItemTools |
UseItem | An item id | The same type, ownership and rate checks, the item has a heal value, the character is alive and hurt, and one item was removed before any health is given |
Three details stop a Tool being duplicated or lost. The copy has CanBeDropped set to false, because a dropped Tool lands in Workspace, where another player could take it while the original stays in the inventory. Each copy carries an InventoryItem attribute, so unequipping can find and destroy every copy wherever it is. And when the player has a StarterGear container, a second copy goes into it. Roblox copies StarterGear into the Backpack each time the character spawns, so an equipped sword comes back after a respawn.
Only one Tool is equipped at a time, and what is equipped is not saved, so a rejoining player starts with an empty hotbar. If you later add selling or dropping, unequip an item before its last copy leaves the inventory.
Step 7: Draw the inventory grid
The LocalScript builds the whole window in code, so there is no UI to lay out by hand. A ScrollingFrame holds the slots, a UIGridLayout inside it arranges them in rows, and AutomaticCanvasSize grows the scrolling area as rows are added.
In the Explorer, open StarterPlayer, insert a LocalScript into StarterPlayerScripts and name it InventoryClient:
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
type Item = { name: string, description: string, icon: string?, toolName: string?, heal: number? }
type Catalog = { get: (id: string) -> Item? }
type Slot = { id: string, count: number }
type Snapshot = { capacity: number, slots: { Slot }, equipped: string? }
local ItemCatalog = require(ReplicatedStorage:WaitForChild("ItemCatalog")) :: Catalog
local remotes = ReplicatedStorage:WaitForChild("InventoryRemotes")
local inventoryUpdated = remotes:WaitForChild("InventoryUpdated") :: RemoteEvent
local equipItem = remotes:WaitForChild("EquipItem") :: RemoteEvent
local useItem = remotes:WaitForChild("UseItem") :: RemoteEvent
local player = Players.LocalPlayer
local TOGGLE_KEY = Enum.KeyCode.B
local COLOURS = {
panel = Color3.fromRGB(24, 20, 36),
slot = Color3.fromRGB(44, 38, 64),
empty = Color3.fromRGB(34, 30, 48),
equipped = Color3.fromRGB(124, 77, 255),
text = Color3.fromRGB(240, 238, 245),
muted = Color3.fromRGB(170, 165, 185),
}
local function addCorner(parent: GuiObject, radius: number)
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, radius)
corner.Parent = parent
end
local function makeLabel(parent: GuiObject, text: string, textSize: number): TextLabel
local label = Instance.new("TextLabel")
label.BackgroundTransparency = 1
label.Font = Enum.Font.BuilderSansBold
label.TextColor3 = COLOURS.text
label.TextSize = textSize
label.Text = text
label.Parent = parent
return label
end
-- 1. Build the window once. ResetOnSpawn = false keeps it through respawns.
local gui = Instance.new("ScreenGui")
gui.Name = "InventoryGui"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")
local openButton = Instance.new("TextButton")
openButton.AnchorPoint = Vector2.new(0, 0.5)
openButton.Position = UDim2.new(0, 16, 0.5, 0)
openButton.Size = UDim2.fromOffset(140, 40)
openButton.BackgroundColor3 = COLOURS.panel
openButton.Font = Enum.Font.BuilderSansBold
openButton.TextColor3 = COLOURS.text
openButton.TextSize = 18
openButton.Text = "Inventory (B)"
addCorner(openButton, 8)
openButton.Parent = gui
local panel = Instance.new("Frame")
panel.AnchorPoint = Vector2.new(0.5, 0.5)
panel.Position = UDim2.fromScale(0.5, 0.5)
panel.Size = UDim2.fromScale(0.9, 0.8)
panel.BackgroundColor3 = COLOURS.panel
panel.Visible = false
addCorner(panel, 12)
panel.Parent = gui
local sizeLimit = Instance.new("UISizeConstraint")
sizeLimit.MaxSize = Vector2.new(440, 400)
sizeLimit.Parent = panel
local title = makeLabel(panel, "Inventory", 22)
title.Position = UDim2.fromOffset(16, 8)
title.Size = UDim2.new(1, -32, 0, 32)
title.TextXAlignment = Enum.TextXAlignment.Left
local slotCount = makeLabel(panel, "", 16)
slotCount.Position = UDim2.fromOffset(16, 8)
slotCount.Size = UDim2.new(1, -32, 0, 32)
slotCount.TextColor3 = COLOURS.muted
slotCount.TextXAlignment = Enum.TextXAlignment.Right
local grid = Instance.new("ScrollingFrame")
grid.Position = UDim2.fromOffset(12, 48)
grid.Size = UDim2.new(1, -24, 1, -100)
grid.BackgroundTransparency = 1
grid.BorderSizePixel = 0
grid.CanvasSize = UDim2.new() -- AutomaticCanvasSize grows it to fit the cells
grid.AutomaticCanvasSize = Enum.AutomaticSize.Y
grid.ScrollingDirection = Enum.ScrollingDirection.Y
grid.ScrollBarThickness = 6
grid.Parent = panel
local layout = Instance.new("UIGridLayout")
layout.CellSize = UDim2.fromOffset(72, 72)
layout.CellPadding = UDim2.fromOffset(8, 8)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = grid
local info = makeLabel(panel, "Click an item to equip or use it.", 16)
info.AnchorPoint = Vector2.new(0, 1)
info.Position = UDim2.new(0, 16, 1, -8)
info.Size = UDim2.new(1, -32, 0, 40)
info.Font = Enum.Font.BuilderSans
info.TextColor3 = COLOURS.muted
info.TextWrapped = true
info.TextXAlignment = Enum.TextXAlignment.Left
-- 2. One cell per slot. This only draws what the server sent; it never
-- changes the inventory itself.
local function makeCell(index: number, slot: Slot?, equippedId: string?)
local cell = Instance.new("TextButton")
cell.LayoutOrder = index
cell.Text = ""
cell.AutoButtonColor = slot ~= nil
cell.BackgroundColor3 = if slot then COLOURS.slot else COLOURS.empty
addCorner(cell, 8)
cell.Parent = grid
if not slot then
return -- an empty slot
end
local itemId = slot.id
local item = ItemCatalog.get(itemId)
local name = if item then item.name else itemId
if item and item.icon then
local image = Instance.new("ImageLabel")
image.BackgroundTransparency = 1
image.Size = UDim2.fromScale(1, 1)
image.Image = item.icon
image.Parent = cell
else
local nameLabel = makeLabel(cell, name, 14)
nameLabel.Size = UDim2.fromScale(1, 1)
nameLabel.TextWrapped = true
end
if slot.count > 1 then
local countLabel = makeLabel(cell, `x{slot.count}`, 14)
countLabel.AnchorPoint = Vector2.new(1, 1)
countLabel.Position = UDim2.new(1, -6, 1, -4)
countLabel.Size = UDim2.fromOffset(40, 16)
countLabel.TextXAlignment = Enum.TextXAlignment.Right
end
if itemId == equippedId then
local outline = Instance.new("UIStroke")
outline.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
outline.Color = COLOURS.equipped
outline.Thickness = 3
outline.Parent = cell
end
cell.MouseEnter:Connect(function()
info.Text = if item then `{item.name}: {item.description}` else name
end)
cell.Activated:Connect(function()
-- Ask, never tell: the server checks the request and sends back the result
if item and item.toolName then
equipItem:FireServer(itemId)
elseif item and item.heal then
useItem:FireServer(itemId)
end
end)
end
local function render(snapshot: Snapshot)
for _, child in grid:GetChildren() do
if child:IsA("GuiButton") then
child:Destroy()
end
end
for index = 1, snapshot.capacity do
makeCell(index, snapshot.slots[index], snapshot.equipped)
end
slotCount.Text = `{#snapshot.slots} / {snapshot.capacity} slots`
end
-- The server fires this after loading and after every change
inventoryUpdated.OnClientEvent:Connect(render)
-- 3. Open and close with the button or the B key
local function toggle()
panel.Visible = not panel.Visible
end
openButton.Activated:Connect(toggle)
UserInputService.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
if not gameProcessedEvent and input.KeyCode == TOGGLE_KEY then
toggle()
end
end)
A LocalScript in StarterPlayerScripts runs once per session, and ResetOnSpawn = false keeps the ScreenGui when the character respawns, so the grid still shows the last copy the server sent.
The client draws one cell for every slot, empty ones included, so a 20-slot inventory always looks like 20 slots. Each update destroys the old cells and builds new ones, which is simple and fine for a few dozen slots. If the server fires InventoryUpdated before this script has connected to it, Roblox queues the message and delivers it once the handler connects, so the first inventory is not lost.
Prefer to design the window in Studio? Build a ScreenGui with one template slot and Clone it in makeCell. For icons, set an item's icon in the catalogue to an image you have uploaded, and its cell shows the image instead of the name.
Test the inventory
- Press Play. Open the inventory with the button on the left or B: a new player has a Sword and 3 Apples, and the counter reads 2 / 20 slots. Until Studio can reach data stores, the items take a few seconds to appear, after load warnings in the Output, and are not saved.
- Click the Sword. It appears in your hand and in the hotbar, and its slot gets a purple outline. Click it again to put it away.
- Walk to the Apple part and use its prompt. The apple stack goes up to 4, and the part disappears for 15 seconds.
- To try healing, lower your health first. Switch the Client/Server toggle to Server, select your character's
Humanoidin the Explorer, set Health to 50, switch back to Client and click an Apple. - With Studio access to API services on, stop and play again. The apple you collected is still there. If a load or save failed, the Output says so.
To see it with more than one player, pick Server & Clients from the test options with 2 clients. Each player sees only their own inventory, while both can see a sword in the other's hand.
Common errors and fixes
Most problems show up in the Output window as a warning or a red error line. Read the first one: later errors are often a result of it.
Infinite yield possible on a WaitForChild call
A script is waiting for an object that does not exist under that exact name. Compare your Explorer with the tree in Step 1: InventoryRemotes with its three RemoteEvents in ReplicatedStorage, ItemTools in ServerStorage, and the two modules. Names are case-sensitive. The script errors guide explains this warning in more depth.
The grid opens but stays empty
The client only draws what the server sends. Check the Output for Inventory load failed warnings, and check that InventoryData is a Script, not a LocalScript, in ServerScriptService, with the InventoryService ModuleScript beside it. If the Output shows a Remote event invocation error, nothing on the client is listening to InventoryUpdated: look for an earlier error from InventoryClient.
Items reset every time I play in Studio
If the Output says Inventory not loaded: playing with starter items that will not be saved, the place is not published or Studio access to API services is off. Turn it on as described in Step 4, in a test copy of your game.
Clicking the sword does nothing
Look for Cannot equip sword in the Output: the Tool in ServerStorage.ItemTools must be named exactly like toolName in the catalogue. A Tool also needs a part named Handle to be held, unless you set its RequiresHandle to false.
The pickup prompt shows, but nothing is added
The ItemId attribute must match a catalogue id exactly, including case: apple, not Apple. Nothing is added while the player's inventory is still loading, or when there is not room for the whole amount.
Saving fails after I changed what gets saved
Data stores accept plain data: strings, numbers, booleans and tables of them. Saving a Tool, a Part or any other Instance fails, or the value is stored as nil. Save the item id and count, and rebuild everything else from the catalogue, as serialize does.
Build it with RoCode
If you would rather describe the system than wire it up by hand, RoCode can build it in the place you have open. It is an AI agent for Roblox Studio: you describe the feature in a web chat, and its Studio plugin creates the scripts and objects in your Explorer instead of handing you code to paste.
Add an inventory with 20 slots that stacks items, a grid UI that opens with B, a sword from ServerStorage that players can equip, apples that heal, and saving with DataStoreService.
- Can look through your place when a step needs it, with Search Game and Search Scripts, so it can build on items, remotes or save code you already have instead of adding a second system
- Creates the folders and RemoteEvents with Create Instance, and the ModuleScripts, Scripts and LocalScript with Create Script
- Can scaffold the window with Build UI, which has an inventory layout with placeholder slots; the code that fills the slots from the server is written as separate scripts
- Compile-checks each script inside Studio with Check Script, which catches compile errors and a few risky patterns but does not type-check, lint or run your code
Each batch of changes is an undo point in Studio. RoCode does not start playtests or change Experience Settings, so you turn on Studio access to API services and playtest the saving yourself. How RoCode connects to Studio.
Questions
Should I use the Roblox Backpack instead of a custom inventory?
The Backpack is Roblox's built-in inventory: every Tool in it shows as a button in the hotbar. If every item in your game is a Tool and you need no stacks, counts or slot limit, it may be enough, although you still have to save which Tools each player owns. A custom inventory is for items that are not Tools, such as materials and consumables. This guide uses both: the inventory stores everything, and the Backpack holds whichever Tool is equipped.
Can I hide the default hotbar?
Yes. From a LocalScript, call StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false). With the hotbar hidden, players need another way to hold and put away an equipped Tool, for example a button that asks the server to call Humanoid:EquipTool or Humanoid:UnequipTools.
How do I make slots keep their position for drag and drop?
Store a fixed-length list with one entry per slot, and mark empty slots with a value such as an empty id rather than nil, because Roblox advises against nil values in tables sent through a RemoteEvent. Then add a MoveItem RemoteEvent that sends two slot numbers, and have the server check both are whole numbers between 1 and the capacity before it swaps them.
How big can a saved inventory get?
One data store value can be up to 4,194,304 characters once serialised (as documented on 30 September 2026). A list of ids and counts uses a tiny part of that. Per-item text, such as custom names or long descriptions saved for every item, is what grows a save.
How do I buy or sell items for coins?
Do it all on the server. To sell, call InventoryService.remove and pay out only if it returned true. To buy, check the price and spaceFor, take the coins, then call add. The shop GUI guide builds the purchase side.
Sources and further reading
- Roblox Creator Docs: Remote events and callbacks. FireClient, OnServerEvent, argument limits and how undelivered messages are queued
- Roblox Creator Docs: Securing the client-server boundary. validating remote inputs, ProximityPrompt checks and data store manipulation
- Roblox Creator Docs: Data stores. GetAsync, SetAsync and enabling Studio access
- Roblox Creator Docs: Best practices for data stores. one key per player, key patterns and periodic saves
- Roblox Creator Docs: Data store error codes and limits. per-server request limits and the size limit for one value
- Roblox Creator Docs: In-game tools. handles, anchoring, the Backpack and StarterGear
- Roblox Creator Docs: Grid and table layouts. UIGridLayout cell size, padding and sort order
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 30 September 2026). A type check catches misspelt APIs and wrong types, not game logic, so play-test in Studio before you publish.