Tutorial · Rewards

How to make a trading system in Roblox Studio

Trading is what turns a pet or item game into a community, and it is also where scams and duplication bugs live. The rule that keeps it safe is simple: the client only asks, the server holds the trade. This guide builds a complete trade flow: a request, a window where both players offer items, a Ready button that resets when an offer changes, and a swap the server checks one last time and performs all at once.

Updated 23 October 20267 min readAdvancedLuau

In short

Keep each player's items on the server, as IntValues in an Items folder. One Script holds every trade in a table: who is trading, what each offers and who has pressed Ready. Clients fire a Trade RemoteEvent with an action (request, accept, offer, ready, cancel). Any offer change clears both Ready flags. When both are ready, the server checks each player still owns what they offered, then moves the items with no waits in between.

  • Never trust the client's idea of what it owns: clamp every offer to what the server says the player has.
  • Changing an offer un-readies both sides, and Ready only counts a few seconds after the last change, which stops the swap-at-the-last-second scam.
  • Do the final check and the swap in the same step, with nothing that yields in between.

Get the script

Trading system server script

A Script in ServerScriptService that runs trade requests, offers and a ready check, then re-checks and swaps both players' items in one step.

Script in ServerScriptService named TradeService
-- Script in ServerScriptService named TradeService.
-- Two players offer items from their Items folders; when both press Ready the server
-- checks every item again and swaps them in one step. The client only asks.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local REQUEST_SECONDS = 15 -- how long a trade request stays open
local READY_DELAY = 3 -- seconds after the last offer change before Ready counts

local tradeEvent = Instance.new("RemoteEvent")
tradeEvent.Name = "Trade"
tradeEvent.Parent = ReplicatedStorage

type Offer = { [string]: number }
type Trade = {
	a: Player,
	b: Player,
	offers: { [Player]: Offer },
	ready: { [Player]: boolean },
	changedAt: number,
}

local trades: { [Player]: Trade } = {} -- each player in a trade points at it
local requests: { [Player]: { [Player]: number } } = {} -- requests[target][from] = time sent

-- Each player's items are IntValues in a folder named Items: the value is how many they own
local function getItems(player: Player): Folder?
	local items = player:FindFirstChild("Items")
	return if items and items:IsA("Folder") then items else nil
end

local function owned(player: Player, itemName: string): number
	local items = getItems(player)
	local item = items and items:FindFirstChild(itemName)
	return if item and item:IsA("IntValue") then item.Value else 0
end

local function otherOf(trade: Trade, player: Player): Player
	return if trade.a == player then trade.b else trade.a
end

-- Sends both players the current state of their trade
local function sync(trade: Trade)
	for _, player in { trade.a, trade.b } do
		local other = otherOf(trade, player)
		tradeEvent:FireClient(player, "update", {
			with = other.Name,
			mine = trade.offers[player],
			theirs = trade.offers[other],
			myReady = trade.ready[player] == true,
			theirReady = trade.ready[other] == true,
		})
	end
end

local function endTrade(trade: Trade, reason: string)
	for _, player in { trade.a, trade.b } do
		if trades[player] == trade then
			trades[player] = nil
			if player.Parent then
				tradeEvent:FireClient(player, "closed", reason)
			end
		end
	end
end

-- Moves the offered counts from one Items folder to another
local function moveItems(offer: Offer, fromItems: Folder, toItems: Folder)
	for itemName, count in offer do
		local source = fromItems:FindFirstChild(itemName)
		if source and source:IsA("IntValue") then
			source.Value -= count
		end
		local dest = toItems:FindFirstChild(itemName)
		if dest and dest:IsA("IntValue") then
			dest.Value += count
		else
			local newItem = Instance.new("IntValue")
			newItem.Name = itemName
			newItem.Value = count
			newItem.Parent = toItems
		end
	end
end

local function complete(trade: Trade)
	-- Check everything again at the last moment: nothing may have changed hands since
	for _, player in { trade.a, trade.b } do
		for itemName, count in trade.offers[player] do
			if owned(player, itemName) < count then
				endTrade(trade, "An item is no longer there. Trade cancelled.")
				return
			end
		end
	end
	local itemsA, itemsB = getItems(trade.a), getItems(trade.b)
	if not (itemsA and itemsB) then
		endTrade(trade, "Trade cancelled.")
		return
	end
	-- No waits from here to the end, so the swap happens all at once
	moveItems(trade.offers[trade.a], itemsA, itemsB)
	moveItems(trade.offers[trade.b], itemsB, itemsA)
	endTrade(trade, "Trade complete!")
end

local handlers: { [string]: (player: Player, arg1: unknown, arg2: unknown) -> () } = {}

handlers.request = function(player, targetName)
	local target = if typeof(targetName) == "string" then Players:FindFirstChild(targetName) else nil
	if not (target and target:IsA("Player")) or target == player or trades[player] or trades[target] then
		return
	end
	requests[target] = requests[target] or {}
	requests[target][player] = os.clock()
	tradeEvent:FireClient(target, "requested", player.Name)
end

handlers.accept = function(player, fromName)
	local found = if typeof(fromName) == "string" then Players:FindFirstChild(fromName) else nil
	if not (found and found:IsA("Player") and requests[player]) then
		return
	end
	local from: Player = found
	local sentAt = requests[player][from]
	if not sentAt or os.clock() - sentAt > REQUEST_SECONDS then
		return
	end
	requests[player][from] = nil
	if trades[player] or trades[from] then
		return
	end
	local trade: Trade = { a = from, b = player, offers = { [from] = {}, [player] = {} }, ready = {}, changedAt = os.clock() }
	trades[from] = trade
	trades[player] = trade
	sync(trade)
end

handlers.offer = function(player, itemName, count)
	local trade = trades[player]
	if not trade or typeof(itemName) ~= "string" or typeof(count) ~= "number" then
		return
	end
	-- Whole numbers from 0 up to what the player owns
	local wanted = math.clamp(math.floor(count), 0, owned(player, itemName))
	local offer = trade.offers[player]
	if wanted > 0 then
		offer[itemName] = wanted
	else
		offer[itemName] = nil
	end
	-- Any change un-readies both players, so nobody can swap an item after the other said yes
	trade.ready = {}
	trade.changedAt = os.clock()
	sync(trade)
end

handlers.ready = function(player)
	local trade = trades[player]
	if not trade or os.clock() - trade.changedAt < READY_DELAY then
		return
	end
	trade.ready[player] = true
	if trade.ready[trade.a] and trade.ready[trade.b] then
		complete(trade)
	else
		sync(trade)
	end
end

handlers.cancel = function(player)
	local trade = trades[player]
	if trade then
		endTrade(trade, player.Name .. " cancelled the trade.")
	end
end

tradeEvent.OnServerEvent:Connect(function(player: Player, action: unknown, arg1: unknown, arg2: unknown)
	local handler = if typeof(action) == "string" then handlers[action] else nil
	if handler then
		handler(player, arg1, arg2)
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	local trade = trades[player]
	if trade then
		endTrade(trade, player.Name .. " left the game.")
	end
	requests[player] = nil
	for _, list in requests do
		list[player] = nil
	end
end)

RoCode builds it in the place you have open in Roblox Studio, on Windows or macOS with a desktop browser; on a phone the button sends the prompt and the link to your computer. Free to start, with free credits every day. More Roblox Studio scripts

Where to put it

Explorer: items, the trade server and the trade window
ServerScriptService
  PlayerItems Script new
  TradeService Script new
StarterPlayer
  StarterPlayerScripts
    TradeClient LocalScript new

Settings you can change

REQUEST_SECONDSIn the script
How long a trade request can be accepted. Default: 15.
READY_DELAYIn the script
Seconds after the last offer change before a Ready press counts, so nobody can swap an item and accept in the same moment. Default: 3.

Players need an Items folder of IntValues: Step 1 makes one. The window is the TradeClient LocalScript in Step 3.

What a cheater can and cannot doEverything is checked on the server: the trade partner, each offer against what the player owns, the ready state and, at the end, every item again before the swap. A cheater can send any action, but cannot offer what they do not have or change an offer after the other player accepted.

Type-checked with luau-lsp strict against Roblox's API definitions on 23 October 2026. Not playtested in Studio yet: press Play and watch the Output window before you publish.

Prompt for RoCode

Add player trading: each player has an Items folder of IntValues, a trade window opened by a Trade button with a player list, a request popup, offers with plus and minus buttons, and a server Script that holds the trade, clamps offers to what each player owns, un-readies both players on any change, and re-checks and swaps the items when both press Ready.

On this page
  1. Get the script
  2. How it works
  3. 1. Items
  4. 2. Server
  5. 3. Window
  6. Common errors
  7. With RoCode
  8. Questions
  9. Sources

How a safe trade works

The trade, step by step
Player doesClient sendsServer does
Picks a player and presses Traderequest, their nameRemembers the request for 15 seconds and shows the other player a popup.
The other player presses Acceptaccept, the sender's nameChecks the request exists and is fresh, then opens a trade for both.
Presses + or - on an itemoffer, item, countClamps the count to what the player owns, clears both Ready flags.
Presses ReadyreadyCounts it only 3 seconds after the last change. When both are ready, checks and swaps.
Presses Cancel or leavescancelEnds the trade for both. Nothing moves.

The client never says what it owns or what the other side offered. It only sees the trade the server sends back after each change, and draws it.

Step 1: Give players items

Trades move items between players, so players need items the server can see. This script gives each player an Items folder: each IntValue inside is an item, and its value is how many they have. Insert it as a Script in ServerScriptService named PlayerItems:

Script in ServerScriptService named PlayerItems
-- Script in ServerScriptService named PlayerItems.
-- Gives every player an Items folder. Each IntValue inside is an item; its Value is how many.
local Players = game:GetService("Players")

-- Items to start with, for testing trades. Replace with your own game's items.
local STARTER_ITEMS: { [string]: number } = {
	Sword = 1,
	Shield = 1,
	Gem = 5,
}

Players.PlayerAdded:Connect(function(player: Player)
	local items = Instance.new("Folder")
	items.Name = "Items"
	for itemName, count in STARTER_ITEMS do
		local item = Instance.new("IntValue")
		item.Name = itemName
		item.Value = count
		item.Parent = items
	end
	items.Parent = player
end)

If your game already stores items, for example pets from the egg hatching guide or the inventory guide, point TradeService's getItems at that folder instead. Save the folder with the DataStore guide, and save both players right after a trade.

Step 2: The trade server

Insert a Script in ServerScriptService named TradeService:

Script in ServerScriptService named TradeService
-- Script in ServerScriptService named TradeService.
-- Two players offer items from their Items folders; when both press Ready the server
-- checks every item again and swaps them in one step. The client only asks.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local REQUEST_SECONDS = 15 -- how long a trade request stays open
local READY_DELAY = 3 -- seconds after the last offer change before Ready counts

local tradeEvent = Instance.new("RemoteEvent")
tradeEvent.Name = "Trade"
tradeEvent.Parent = ReplicatedStorage

type Offer = { [string]: number }
type Trade = {
	a: Player,
	b: Player,
	offers: { [Player]: Offer },
	ready: { [Player]: boolean },
	changedAt: number,
}

local trades: { [Player]: Trade } = {} -- each player in a trade points at it
local requests: { [Player]: { [Player]: number } } = {} -- requests[target][from] = time sent

-- Each player's items are IntValues in a folder named Items: the value is how many they own
local function getItems(player: Player): Folder?
	local items = player:FindFirstChild("Items")
	return if items and items:IsA("Folder") then items else nil
end

local function owned(player: Player, itemName: string): number
	local items = getItems(player)
	local item = items and items:FindFirstChild(itemName)
	return if item and item:IsA("IntValue") then item.Value else 0
end

local function otherOf(trade: Trade, player: Player): Player
	return if trade.a == player then trade.b else trade.a
end

-- Sends both players the current state of their trade
local function sync(trade: Trade)
	for _, player in { trade.a, trade.b } do
		local other = otherOf(trade, player)
		tradeEvent:FireClient(player, "update", {
			with = other.Name,
			mine = trade.offers[player],
			theirs = trade.offers[other],
			myReady = trade.ready[player] == true,
			theirReady = trade.ready[other] == true,
		})
	end
end

local function endTrade(trade: Trade, reason: string)
	for _, player in { trade.a, trade.b } do
		if trades[player] == trade then
			trades[player] = nil
			if player.Parent then
				tradeEvent:FireClient(player, "closed", reason)
			end
		end
	end
end

-- Moves the offered counts from one Items folder to another
local function moveItems(offer: Offer, fromItems: Folder, toItems: Folder)
	for itemName, count in offer do
		local source = fromItems:FindFirstChild(itemName)
		if source and source:IsA("IntValue") then
			source.Value -= count
		end
		local dest = toItems:FindFirstChild(itemName)
		if dest and dest:IsA("IntValue") then
			dest.Value += count
		else
			local newItem = Instance.new("IntValue")
			newItem.Name = itemName
			newItem.Value = count
			newItem.Parent = toItems
		end
	end
end

local function complete(trade: Trade)
	-- Check everything again at the last moment: nothing may have changed hands since
	for _, player in { trade.a, trade.b } do
		for itemName, count in trade.offers[player] do
			if owned(player, itemName) < count then
				endTrade(trade, "An item is no longer there. Trade cancelled.")
				return
			end
		end
	end
	local itemsA, itemsB = getItems(trade.a), getItems(trade.b)
	if not (itemsA and itemsB) then
		endTrade(trade, "Trade cancelled.")
		return
	end
	-- No waits from here to the end, so the swap happens all at once
	moveItems(trade.offers[trade.a], itemsA, itemsB)
	moveItems(trade.offers[trade.b], itemsB, itemsA)
	endTrade(trade, "Trade complete!")
end

local handlers: { [string]: (player: Player, arg1: unknown, arg2: unknown) -> () } = {}

handlers.request = function(player, targetName)
	local target = if typeof(targetName) == "string" then Players:FindFirstChild(targetName) else nil
	if not (target and target:IsA("Player")) or target == player or trades[player] or trades[target] then
		return
	end
	requests[target] = requests[target] or {}
	requests[target][player] = os.clock()
	tradeEvent:FireClient(target, "requested", player.Name)
end

handlers.accept = function(player, fromName)
	local found = if typeof(fromName) == "string" then Players:FindFirstChild(fromName) else nil
	if not (found and found:IsA("Player") and requests[player]) then
		return
	end
	local from: Player = found
	local sentAt = requests[player][from]
	if not sentAt or os.clock() - sentAt > REQUEST_SECONDS then
		return
	end
	requests[player][from] = nil
	if trades[player] or trades[from] then
		return
	end
	local trade: Trade = { a = from, b = player, offers = { [from] = {}, [player] = {} }, ready = {}, changedAt = os.clock() }
	trades[from] = trade
	trades[player] = trade
	sync(trade)
end

handlers.offer = function(player, itemName, count)
	local trade = trades[player]
	if not trade or typeof(itemName) ~= "string" or typeof(count) ~= "number" then
		return
	end
	-- Whole numbers from 0 up to what the player owns
	local wanted = math.clamp(math.floor(count), 0, owned(player, itemName))
	local offer = trade.offers[player]
	if wanted > 0 then
		offer[itemName] = wanted
	else
		offer[itemName] = nil
	end
	-- Any change un-readies both players, so nobody can swap an item after the other said yes
	trade.ready = {}
	trade.changedAt = os.clock()
	sync(trade)
end

handlers.ready = function(player)
	local trade = trades[player]
	if not trade or os.clock() - trade.changedAt < READY_DELAY then
		return
	end
	trade.ready[player] = true
	if trade.ready[trade.a] and trade.ready[trade.b] then
		complete(trade)
	else
		sync(trade)
	end
end

handlers.cancel = function(player)
	local trade = trades[player]
	if trade then
		endTrade(trade, player.Name .. " cancelled the trade.")
	end
end

tradeEvent.OnServerEvent:Connect(function(player: Player, action: unknown, arg1: unknown, arg2: unknown)
	local handler = if typeof(action) == "string" then handlers[action] else nil
	if handler then
		handler(player, arg1, arg2)
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	local trade = trades[player]
	if trade then
		endTrade(trade, player.Name .. " left the game.")
	end
	requests[player] = nil
	for _, list in requests do
		list[player] = nil
	end
end)

Each action has a handler. Every argument from the client is unknown until checked: names must be strings that match a player, counts must be numbers, and an offer is rounded down and clamped between 0 and what the player owns. complete walks both offers once more against the Items folders, and only if everything is still there does it call moveItems twice. Nothing in that path waits, so no other request can run between the check and the swap.

When a player leaves mid-trade, PlayerRemoving ends the trade for the other player and clears any requests to or from the one who left.

Step 3: The trade window

Insert a LocalScript in StarterPlayer > StarterPlayerScripts named TradeClient. It draws a Trade button, a player list, the request popup and the trade window, and redraws the window whenever the server sends an update:

LocalScript in StarterPlayerScripts named TradeClient
-- LocalScript in StarterPlayerScripts named TradeClient.
-- A Trade button, a list of players to ask, the request popup and the trade window.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local tradeEvent = ReplicatedStorage:WaitForChild("Trade") :: RemoteEvent
local player = Players.LocalPlayer

type TradeState = {
	with: string,
	mine: { [string]: number },
	theirs: { [string]: number },
	myReady: boolean,
	theirReady: boolean,
}

local gui = Instance.new("ScreenGui")
gui.Name = "TradeGui"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")

local function make(className: string, props: { [string]: any }, parent: Instance): any
	local object = Instance.new(className) :: any
	for key, value in props do
		object[key] = value
	end
	if className == "Frame" or className == "TextButton" then
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0, 8)
		corner.Parent = object
	end
	object.Parent = parent
	return object
end

local DARK = Color3.fromRGB(30, 26, 40)
local WHITE = Color3.new(1, 1, 1)
local function button(text: string, parent: Instance): TextButton
	return make("TextButton", { Text = text, Size = UDim2.fromOffset(110, 36), BackgroundColor3 = Color3.fromRGB(110, 80, 220), TextColor3 = WHITE, Font = Enum.Font.GothamBold, TextSize = 16 }, parent)
end
local function list(parent: Instance)
	make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }, parent)
end

-- 1. The Trade button and the list of players
local openButton = button("Trade", gui)
openButton.Position = UDim2.new(0, 12, 1, -60)
local playerList = make("Frame", { Position = UDim2.new(0, 12, 1, -110), AnchorPoint = Vector2.new(0, 1), Size = UDim2.fromOffset(180, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundColor3 = DARK, Visible = false }, gui)
list(playerList)

openButton.Activated:Connect(function()
	for _, child in playerList:GetChildren() do
		if child:IsA("TextButton") then
			child:Destroy()
		end
	end
	for _, other in Players:GetPlayers() do
		if other ~= player then
			local b = button(other.DisplayName, playerList)
			b.Size = UDim2.fromOffset(180, 36)
			b.Activated:Connect(function()
				tradeEvent:FireServer("request", other.Name)
				playerList.Visible = false
			end)
		end
	end
	playerList.Visible = not playerList.Visible
end)

-- 2. The popup when someone asks you
local popup = make("Frame", { AnchorPoint = Vector2.new(0.5, 0), Position = UDim2.new(0.5, 0, 0, 80), Size = UDim2.fromOffset(300, 100), BackgroundColor3 = DARK, Visible = false }, gui)
local popupText = make("TextLabel", { Size = UDim2.new(1, 0, 0, 50), BackgroundTransparency = 1, TextColor3 = WHITE, Font = Enum.Font.Gotham, TextSize = 16 }, popup)
local acceptButton = button("Accept", popup)
acceptButton.Position = UDim2.fromOffset(30, 56)
local declineButton = button("Decline", popup)
declineButton.Position = UDim2.fromOffset(160, 56)
local pendingFrom: string? = nil
acceptButton.Activated:Connect(function()
	if pendingFrom then
		tradeEvent:FireServer("accept", pendingFrom)
	end
	popup.Visible = false
end)
declineButton.Activated:Connect(function()
	popup.Visible = false
end)

-- 3. The trade window
local window = make("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(460, 320), BackgroundColor3 = DARK, Visible = false }, gui)
local title = make("TextLabel", { Size = UDim2.new(1, 0, 0, 36), BackgroundTransparency = 1, TextColor3 = WHITE, Font = Enum.Font.GothamBold, TextSize = 18 }, window)
local mineFrame = make("Frame", { Position = UDim2.fromOffset(12, 40), Size = UDim2.fromOffset(240, 220), BackgroundTransparency = 1 }, window)
list(mineFrame)
local theirsText = make("TextLabel", { Position = UDim2.fromOffset(264, 40), Size = UDim2.fromOffset(184, 220), BackgroundTransparency = 1, TextColor3 = WHITE, Font = Enum.Font.Gotham, TextSize = 15, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, TextWrapped = true }, window)
local readyButton = button("Ready", window)
readyButton.Position = UDim2.new(0, 12, 1, -48)
local cancelButton = button("Cancel", window)
cancelButton.Position = UDim2.new(1, -122, 1, -48)
readyButton.Activated:Connect(function()
	tradeEvent:FireServer("ready")
end)
cancelButton.Activated:Connect(function()
	tradeEvent:FireServer("cancel")
end)

local function drawTrade(state: TradeState)
	window.Visible = true
	title.Text = "Trading with " .. state.with
	for _, child in mineFrame:GetChildren() do
		if child:IsA("Frame") then
			child:Destroy()
		end
	end
	local items = player:FindFirstChild("Items")
	local owned: { Instance } = if items then items:GetChildren() else {}
	for _, item in owned do
		if item:IsA("IntValue") and item.Value > 0 then
			local offered = state.mine[item.Name] or 0
			local row = make("Frame", { Size = UDim2.fromOffset(240, 32), BackgroundTransparency = 1 }, mineFrame)
			make("TextLabel", { Size = UDim2.fromOffset(150, 32), BackgroundTransparency = 1, TextColor3 = WHITE, Font = Enum.Font.Gotham, TextSize = 15, TextXAlignment = Enum.TextXAlignment.Left, Text = string.format("%s  %d / %d", item.Name, offered, item.Value) }, row)
			local minus = button("-", row)
			minus.Size = UDim2.fromOffset(36, 30)
			minus.Position = UDim2.fromOffset(156, 1)
			local plus = button("+", row)
			plus.Size = UDim2.fromOffset(36, 30)
			plus.Position = UDim2.fromOffset(198, 1)
			minus.Activated:Connect(function()
				tradeEvent:FireServer("offer", item.Name, offered - 1)
			end)
			plus.Activated:Connect(function()
				tradeEvent:FireServer("offer", item.Name, offered + 1)
			end)
		end
	end
	local lines = { state.with .. " offers:" }
	for itemName, count in state.theirs do
		table.insert(lines, string.format("%d x %s", count, itemName))
	end
	if #lines == 1 then
		table.insert(lines, "nothing yet")
	end
	table.insert(lines, "")
	table.insert(lines, if state.theirReady then "They are READY" else "They are not ready")
	theirsText.Text = table.concat(lines, "\n")
	readyButton.Text = if state.myReady then "Waiting..." else "Ready"
end

local function message(text: string)
	title.Text = text
	task.delay(2, function()
		if title.Text == text then
			window.Visible = false
		end
	end)
end

tradeEvent.OnClientEvent:Connect(function(kind: string, data: any)
	if kind == "requested" then
		pendingFrom = data
		popupText.Text = tostring(data) .. " wants to trade"
		popup.Visible = true
	elseif kind == "update" then
		drawTrade(data :: TradeState)
	elseif kind == "closed" then
		message(tostring(data))
	end
end)

The window is plain on purpose, so you can see how it works. Restyle it with your own colours, or build it in StarterGui and connect the same buttons. The UI animations guide adds hover and open effects.

Test it

  1. Trading needs two players: on the Test tab, under Clients and Servers, pick 2 players and press Start.
  2. In Player1's window, press Trade and pick Player2. In Player2's window, press Accept.
  3. Player1 offers a Sword with +. Player2 offers 3 Gems. Both see each other's offers.
  4. Press Ready on both within 3 seconds of the last change: nothing happens yet. Wait 3 seconds and press again: the trade completes and the Items folders under each Player (in the server view) show the swap.
  5. Start another trade, press Ready on Player2, then change Player1's offer: Player2 is no longer ready.
  6. Start a trade and close Player2's window: Player1 sees "Player2 left the game."

Common errors and fixes

Watch the Output window on the Server view during a 2-player test.

Pressing Ready does nothing

Ready only counts 3 seconds after the last offer change. Wait and press again, or lower READY_DELAY.

No items show in the trade window

The player needs an Items folder of IntValues with values above 0. Check PlayerItems ran, or that getItems points at your own item folder.

The request popup never appears

The other player is already in a trade, or more than 15 seconds passed. Requests only work between two players who are both free.

Items come back after rejoining

The trade worked but the Items folder is not saved. Save both players' items with a DataStore right after a completed trade.

Build it with RoCode

If you would rather not set this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin creates the scripts in your Explorer.

You type

Add pet trading between players: a trade request, a window where both offer pets, a ready check that resets on any change, and a server swap that re-checks ownership. Use my existing Pets folder.

RoCode does
  • Reads your place first to find where your items or pets live, and wires the trade to that folder
  • Writes the server trade script and the trade window, and is told to compile-check each with Check Script
  • Sets undo points around each batch of changes

RoCode's test robot plays as one player, so a trade needs your own 2-player test in Studio. For the rest of a pet game, RoCode can insert ready-made pet inventory, store and egg hatching screens from its premium UI kits in one step. How RoCode connects to Studio.

Questions

How do I make a trading system in Roblox?

Keep items on the server, let clients send only requests and offers through a RemoteEvent, clear Ready on every change, and have the server re-check and swap the items in one step. Steps 2 and 3 have the scripts.

How do I stop trade scams?

Clear both players' Ready on any offer change and only accept Ready a few seconds after the last change, so nobody can switch an item at the last moment.

How do I stop item duplication in trades?

Re-check that both players still own every offered item right before the swap, and move the items with no yields between the check and the end of the swap.

Can players trade Robux?

No. Robux cannot be moved between players by a script. Trades in games move items and in-game currency only.

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, 23 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest 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.