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.
-- 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
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
How a safe trade works
| Player does | Client sends | Server does |
|---|---|---|
| Picks a player and presses Trade | request, their name | Remembers the request for 15 seconds and shows the other player a popup. |
| The other player presses Accept | accept, the sender's name | Checks the request exists and is fresh, then opens a trade for both. |
| Presses + or - on an item | offer, item, count | Clamps the count to what the player owns, clears both Ready flags. |
| Presses Ready | ready | Counts it only 3 seconds after the last change. When both are ready, checks and swaps. |
| Presses Cancel or leaves | cancel | Ends 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.
-- 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.
-- 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.
-- 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
- Trading needs two players: on the Test tab, under Clients and Servers, pick 2 players and press Start.
- In Player1's window, press Trade and pick Player2. In Player2's window, press Accept.
- Player1 offers a Sword with +. Player2 offers 3 Gems. Both see each other's offers.
- 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.
- Start another trade, press Ready on Player2, then change Player1's offer: Player2 is no longer ready.
- 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.
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.
- 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
- Roblox Creator Docs: Remote events and functions. OnServerEvent and FireClient
- Roblox Creator Docs: Security tactics. checking remote arguments on the server
- Roblox Creator Docs: Players.PlayerRemoving. ending a trade when a player leaves
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.