Tutorial · Game systems

How to make an inventory system in Roblox Studio

An inventory looks like a UI job, but what decides whether yours can be exploited, or loses items, is where the items live. This guide keeps every player's items in a table on the server, sends each player a copy to draw as a grid, lets them equip Tools and use consumables through requests the server checks, and saves it all with a data store.

Updated 30 September 202615 min readIntermediateLuau

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
  1. How it fits together
  2. Why the server owns items
  3. 1. Set up the Explorer
  4. 2. Item catalogue
  5. 3. Server inventory
  6. 4. Load and save
  7. 5. Item pickups
  8. 6. Equip and use
  9. 7. Grid UI
  10. Test it
  11. Common errors
  12. With RoCode
  13. Questions
  14. Sources

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.

The pieces and where they run
PieceType and locationJob
ItemCatalogModuleScript in ReplicatedStorageWhat each item is: name, description, max stack, and whether it can be equipped or used. The server and every client read it.
InventoryServiceModuleScript in ServerScriptServiceHolds every player's slots. The only code that adds, removes and stacks items.
InventoryDataScript in ServerScriptServiceLoads a player's items when they join and saves them.
InventoryActionsScript in ServerScriptServiceChecks and carries out equip and use requests.
ItemPickupScript inside a PartGives an item to whoever collects the pickup.
InventoryClientLocalScript in StarterPlayerScriptsDraws 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.

  1. In ReplicatedStorage, insert a Folder named InventoryRemotes. Inside it, insert three RemoteEvents named InventoryUpdated, EquipItem and UseItem.
  2. In ServerStorage, insert a Folder named ItemTools. Inside it, insert a Tool named Sword, and inside the Tool a Part named Handle (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.
  3. Add the scripts as you reach each step. The tree shows where every one of them goes.
Explorer after this guide
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:

ModuleScript in ReplicatedStorage named ItemCatalog
-- 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:

ModuleScript in ServerScriptService named 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, add adds what fits and returns that number. Call spaceFor first when it must be all or nothing, as the pickup in Step 5 does.
  • remove is all or nothing. Asking for 3 apples from a player who has 2 removes none and returns false, 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.
What other server scripts can call
FunctionReturnsUse it for
add(player, itemId, amount)How many were addedPickups, rewards, purchases
remove(player, itemId, amount)true if all were removedUsing, selling, crafting
count(player, itemId)The total across every stackChecking what a player has
spaceFor(player, itemId)How many more would fitChecking before an all-or-nothing add
getEquipped, setEquippedThe equipped item id, or nilEquipping, in Step 6
load, serialize, unloadA 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:

Script in ServerScriptService named 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 GetAsync returns 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 canSave are 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. BindToClose saves 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 to SetAsync links 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.

  1. Add a Part to Workspace, name it Apple and 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.
  2. With the part selected, scroll to the Attributes section of the Properties window and click +. Add ItemId with the type string and click Save, then set its value to apple. Add Amount the same way as a number set to 1.
  3. Insert a Script into the part, name it ItemPickup and paste this code:
Script in the Apple Part in Workspace named ItemPickup
-- 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:

Script in ServerScriptService named 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)
What each request is allowed to do
RequestThe client sendsThe server checks before acting
EquipItemAn item idIt 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
UseItemAn item idThe 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:

LocalScript in StarterPlayerScripts named 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

  1. 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.
  2. 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.
  3. Walk to the Apple part and use its prompt. The apple stack goes up to 4, and the part disappears for 15 seconds.
  4. To try healing, lower your health first. Switch the Client/Server toggle to Server, select your character's Humanoid in the Explorer, set Health to 50, switch back to Client and click an Apple.
  5. 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.

You type

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.

RoCode does
  • 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

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.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.