In short
Keep your items and prices in a ModuleScript in ReplicatedStorage, build the window from a ScreenGui, a ScrollingFrame and a UIGridLayout, and have each Buy button call a RemoteFunction with only the item's id. A Script in ServerScriptService then looks up the price itself, checks and takes the player's Coins, and gives the item.
- Never let the client send the price. Anyone can call your remotes with any values, so the server reads the price from its own copy of the catalogue.
- Give a bought Tool by cloning it from ServerStorage into the player's Backpack (to use now) and StarterGear (so it comes back after a respawn).
- Things sold for Robux are passes and developer products, handled with MarketplaceService. Roblox also creates an automatic Shop for those, which is separate from a coin shop.
On this page
How a working shop fits together
A shop that works in a live game is split between the player's device and the server. The device draws the window and says "I want to buy the sword". The server, the only side you can trust, decides whether that happens. Shop scripts that "don't work", or that players exploit, often make that decision on the wrong side.
| Piece | Where it lives | What it does |
|---|---|---|
ShopCatalog ModuleScript | ReplicatedStorage | Item ids, names, prices and the Tool each one gives. Both sides read it. |
BuyItem RemoteFunction | ReplicatedStorage, in a folder named ShopRemotes | Carries the buy request to the server and the answer back. |
ShopClient LocalScript | StarterPlayerScripts | Builds the shop window and sends the item id when a Buy button is pressed. |
ShopService Script | ServerScriptService | Checks the request, takes the Coins and gives the Tool. |
| The Tools you sell | ServerStorage, in a folder named ShopItems | The originals the server copies. Nothing in ServerStorage is sent to players. |
ReplicatedStorage
ShopCatalog ModuleScript new
ShopRemotes Folder new
BuyItem RemoteFunction new
ServerStorage
ShopItems Folder new
ClassicSword Tool new
Handle Part
Flashlight Tool new
GrappleHook Tool new
SpeedCoil Tool new
ServerScriptService
Leaderstats Script
ShopService Script new
StarterPlayer
StarterPlayerScripts
ShopClient LocalScript new
Step 1: Set up coins and the items to sell
The shop spends a Coins value in the player's leaderstats. If you followed the leaderboard guide you already have one, so skip this script. Otherwise insert a Script into ServerScriptService named Leaderstats. It starts everyone with 200 coins so you can test purchases straight away.
local Players = game:GetService("Players")
local STARTING_COINS = 200 -- enough to test the shop; set it to 0 once players can earn coins
local function setupLeaderstats(player: Player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = STARTING_COINS
coins.Parent = leaderstats
leaderstats.Parent = player
end
Players.PlayerAdded:Connect(setupLeaderstats)
for _, player in Players:GetPlayers() do
setupLeaderstats(player)
end
Keep one leaderstats script only. Two scripts that each create a leaderstats folder give every player two folders, and the shop may read the wrong one.
Next, create the objects the scripts look for. Names are case-sensitive, so type them exactly as shown.
- In the Explorer, hover over ReplicatedStorage, click the + button and insert a Folder named
ShopRemotes. Inside it, insert a RemoteFunction namedBuyItem. - Hover over ServerStorage and insert a Folder named
ShopItems. - Put a Tool for each item into ShopItems, named
ClassicSword,Flashlight,GrappleHookandSpeedCoil. For a first test, a Tool only needs one unanchored Part namedHandleinside it. Any Tool you already have works too: give it the matching name. - Select each Tool and untick CanBeDropped in the Properties window. When it is ticked, pressing Backspace drops the tool into the Workspace, where any player can pick up what the buyer paid for.
You can start with only one Tool. If an item's Tool is missing, the server refuses to sell that item and prints a warning naming it, so nothing is taken from the player.
Step 2: Put the items and prices in a catalogue
The catalogue is the one list of everything the shop sells. The LocalScript reads it to draw the cards and the server reads it to find prices, so changing a price is one edit in one place.
Hover over ReplicatedStorage, insert a ModuleScript, rename it ShopCatalog and replace its contents with this:
export type ShopItem = {
id: string, -- saved in data stores later: never change it once players own the item
name: string, -- shown on the card; safe to change
price: number, -- in Coins
description: string,
toolName: string, -- the Tool in ServerStorage.ShopItems that the player receives
}
-- Cards appear in this order
local items: { ShopItem } = {
{
id = "sword",
name = "Classic Sword",
price = 50,
description = "A basic sword for close fights.",
toolName = "ClassicSword",
},
{
id = "flashlight",
name = "Flashlight",
price = 25,
description = "Lights up dark corners.",
toolName = "Flashlight",
},
{
id = "grapple",
name = "Grapple Hook",
price = 150,
description = "Reach high ledges.",
toolName = "GrappleHook",
},
{
id = "speedcoil",
name = "Speed Coil",
price = 300,
description = "Run faster while you hold it.",
toolName = "SpeedCoil",
},
}
local byId: { [string]: ShopItem } = {}
for _, item in items do
byId[item.id] = item
end
local ShopCatalog = {}
ShopCatalog.items = items
-- Returns the item with this id, or nil if there is no such item
function ShopCatalog.get(id: string): ShopItem?
return byId[id]
end
-- The Player attribute the server sets to true when the player owns an item
function ShopCatalog.ownedAttribute(id: string): string
return "Owns_" .. id
end
return ShopCatalog
Each item has an id as well as a display name. The id is what the server records when a player owns something, so you can rename "Classic Sword" later without breaking saved purchases. The descriptions are only text on the card; what a Tool does is up to that Tool's own scripts.
Players can read a ModuleScript in ReplicatedStorage, so these prices are public. That is fine: they are printed on the cards anyway. What matters is that the client and the server each run their own copy of the module, so a player who edits the prices on their device changes nothing on the server.
Step 3: Build the shop GUI
This LocalScript builds the whole window in code: a Shop button, a panel with a scrolling grid of cards, one Buy button per catalogue item, a Coins counter and a status line for the server's replies. Because it is all code, you can paste it and press Play. The table after it lists each object if you would rather lay the window out by hand.
- In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named
ShopClient. - Replace its contents with the code below.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- What the ShopCatalog module returns, so strict type checking can check the fields we use
type ShopItem = { id: string, name: string, price: number, description: string, toolName: string }
type Catalog = {
items: { ShopItem },
get: (id: string) -> ShopItem?,
ownedAttribute: (id: string) -> string,
}
local ShopCatalog = require(ReplicatedStorage:WaitForChild("ShopCatalog")) :: Catalog
local buyItem = ReplicatedStorage:WaitForChild("ShopRemotes"):WaitForChild("BuyItem") :: RemoteFunction
local player = Players.LocalPlayer
local COLOURS = {
panel = Color3.fromRGB(24, 20, 36),
card = Color3.fromRGB(38, 32, 56),
accent = Color3.fromRGB(124, 77, 255),
owned = Color3.fromRGB(70, 66, 84),
text = Color3.fromRGB(240, 238, 245),
muted = Color3.fromRGB(170, 165, 185),
good = Color3.fromRGB(120, 220, 150),
}
local REGULAR = Font.fromEnum(Enum.Font.BuilderSans)
local BOLD = Font.fromEnum(Enum.Font.BuilderSansBold)
local function addCorner(parent: GuiObject, radius: number)
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, radius)
corner.Parent = parent
end
local function addPadding(parent: GuiObject, pixels: number)
local padding = Instance.new("UIPadding")
padding.PaddingTop = UDim.new(0, pixels)
padding.PaddingBottom = UDim.new(0, pixels)
padding.PaddingLeft = UDim.new(0, pixels)
padding.PaddingRight = UDim.new(0, pixels)
padding.Parent = parent
end
local function newLabel(parent: GuiObject, text: string, textSize: number, font: Font): TextLabel
local label = Instance.new("TextLabel")
label.BackgroundTransparency = 1
label.Text = text
label.TextSize = textSize
label.FontFace = font
label.TextColor3 = COLOURS.text
label.TextXAlignment = Enum.TextXAlignment.Left
label.TextWrapped = true
label.Parent = parent
return label
end
local function newButton(parent: GuiObject, text: string): TextButton
local button = Instance.new("TextButton")
button.BackgroundColor3 = COLOURS.accent
button.TextColor3 = COLOURS.text
button.FontFace = BOLD
button.TextSize = 18
button.Text = text
button.Parent = parent
addCorner(button, 8)
return button
end
-- ScreenGui: holds everything the shop draws on the player's screen
local gui = Instance.new("ScreenGui")
gui.Name = "ShopGui"
gui.ResetOnSpawn = false -- keep the shop when the character respawns
-- A button on the left edge that opens and closes the shop
local openButton = Instance.new("TextButton")
openButton.Name = "OpenShop"
openButton.AnchorPoint = Vector2.new(0, 0.5)
openButton.Position = UDim2.new(0, 16, 0.5, 0)
openButton.Size = UDim2.fromOffset(110, 44)
openButton.BackgroundColor3 = COLOURS.accent
openButton.TextColor3 = COLOURS.text
openButton.FontFace = BOLD
openButton.TextSize = 20
openButton.Text = "Shop"
openButton.Parent = gui
addCorner(openButton, 10)
-- The shop window: centred, hidden until the player opens it
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(0.5, 0.5)
panel.Position = UDim2.fromScale(0.5, 0.5)
panel.Size = UDim2.fromScale(0.8, 0.75)
panel.BackgroundColor3 = COLOURS.panel
panel.Visible = false
panel.Parent = gui
addCorner(panel, 14)
addPadding(panel, 12)
local sizeLimit = Instance.new("UISizeConstraint")
sizeLimit.MaxSize = Vector2.new(640, 460) -- stops the window growing huge on big screens
sizeLimit.Parent = panel
local title = newLabel(panel, "Shop", 26, BOLD)
title.Size = UDim2.new(0.5, 0, 0, 32)
local coinsLabel = newLabel(panel, "Coins: ...", 20, BOLD)
coinsLabel.AnchorPoint = Vector2.new(1, 0)
coinsLabel.Position = UDim2.new(1, -44, 0, 0)
coinsLabel.Size = UDim2.new(0.5, -44, 0, 32)
coinsLabel.TextXAlignment = Enum.TextXAlignment.Right
local closeButton = newButton(panel, "X")
closeButton.AnchorPoint = Vector2.new(1, 0)
closeButton.Position = UDim2.fromScale(1, 0)
closeButton.Size = UDim2.fromOffset(32, 32)
closeButton.BackgroundColor3 = COLOURS.card
-- Replies from the server, such as "You need 20 more coins.", show here
local status = newLabel(panel, "", 18, REGULAR)
status.AnchorPoint = Vector2.new(0, 1)
status.Position = UDim2.fromScale(0, 1)
status.Size = UDim2.new(1, 0, 0, 24)
status.TextColor3 = COLOURS.muted
-- ScrollingFrame: the area that scrolls when there are more cards than fit
local list = Instance.new("ScrollingFrame")
list.Name = "Items"
list.Position = UDim2.fromOffset(0, 44)
list.Size = UDim2.new(1, 0, 1, -76)
list.BackgroundTransparency = 1
list.BorderSizePixel = 0
list.ScrollBarThickness = 6
list.ScrollingDirection = Enum.ScrollingDirection.Y
list.CanvasSize = UDim2.new() -- starts at zero; AutomaticCanvasSize grows it to fit the cards
list.AutomaticCanvasSize = Enum.AutomaticSize.Y
list.Parent = panel
-- UIGridLayout: lays the cards out in rows and wraps them to the next row
local grid = Instance.new("UIGridLayout")
grid.CellSize = UDim2.fromOffset(180, 200)
grid.CellPadding = UDim2.fromOffset(12, 12)
grid.SortOrder = Enum.SortOrder.LayoutOrder
grid.Parent = list
local function makeCard(item: ShopItem, order: number)
local card = Instance.new("Frame")
card.Name = item.id
card.LayoutOrder = order
card.BackgroundColor3 = COLOURS.card
card.Parent = list
addCorner(card, 10)
addPadding(card, 10)
local nameLabel = newLabel(card, item.name, 20, BOLD)
nameLabel.Size = UDim2.new(1, 0, 0, 26)
local description = newLabel(card, item.description, 15, REGULAR)
description.Position = UDim2.fromOffset(0, 30)
description.Size = UDim2.new(1, 0, 1, -80)
description.TextColor3 = COLOURS.muted
description.TextYAlignment = Enum.TextYAlignment.Top
local buyButton = newButton(card, "")
buyButton.Name = "Buy"
buyButton.AnchorPoint = Vector2.new(0, 1)
buyButton.Position = UDim2.fromScale(0, 1)
buyButton.Size = UDim2.new(1, 0, 0, 40)
local ownedAttribute = ShopCatalog.ownedAttribute(item.id)
local busy = false
local function refresh()
if player:GetAttribute(ownedAttribute) == true then
buyButton.Text = "Owned"
buyButton.BackgroundColor3 = COLOURS.owned
buyButton.AutoButtonColor = false
else
buyButton.Text = `Buy: {item.price} coins`
buyButton.BackgroundColor3 = COLOURS.accent
buyButton.AutoButtonColor = true
end
end
buyButton.Activated:Connect(function()
if busy or player:GetAttribute(ownedAttribute) == true then
return
end
busy = true
buyButton.Text = "Buying..."
-- Send only the item id. The server looks up the price itself.
local ok, bought, message = pcall(function()
return buyItem:InvokeServer(item.id)
end)
if ok and type(message) == "string" then
status.Text = message
status.TextColor3 = if bought == true then COLOURS.good else COLOURS.muted
else
status.Text = "The shop did not answer. Try again."
status.TextColor3 = COLOURS.muted
end
busy = false
refresh()
end)
-- The server sets this attribute when the purchase goes through (or when saved items load)
player:GetAttributeChangedSignal(ownedAttribute):Connect(refresh)
refresh()
end
for index, item in ShopCatalog.items do
makeCard(item, index)
end
openButton.Activated:Connect(function()
panel.Visible = not panel.Visible
status.Text = ""
end)
closeButton.Activated:Connect(function()
panel.Visible = false
end)
gui.Parent = player:WaitForChild("PlayerGui")
-- Show the player's coins. The server owns the real value; this only displays it.
local leaderstats = player:WaitForChild("leaderstats")
local coins = leaderstats:WaitForChild("Coins") :: IntValue
local function updateCoins()
coinsLabel.Text = `Coins: {coins.Value}`
end
coins.Changed:Connect(updateCoins)
updateCoins()
The buttons use Activated rather than MouseButton1Click: it fires for a mouse click, a tap on a phone and the A or cross button on a controller.
The type definitions at the top describe what ShopCatalog returns, so strict type checking can catch a misspelt field such as item.prise. They change nothing when the game runs.
| Object | Job in the shop | Key properties |
|---|---|---|
ScreenGui | Holds everything drawn on the screen | ResetOnSpawn false, so the shop survives a respawn |
Frame | The window, and each item card | AnchorPoint 0.5, 0.5 and Position 0.5, 0.5 to centre the window |
UISizeConstraint | Caps the window on large screens | MaxSize 640 by 460 |
ScrollingFrame | Scrolls when there are more cards than fit | CanvasSize 0 and AutomaticCanvasSize Y, so the scroll area grows with the cards |
UIGridLayout | Places the cards in rows and wraps them | CellSize 180 by 200, CellPadding 12, SortOrder LayoutOrder |
UICorner | Rounds corners | CornerRadius 8 to 14 pixels |
UIPadding | Inner spacing | 10 to 12 pixels on each side |
TextButton | Shop, close and Buy buttons | The Activated event |
To design the window by hand instead, build the same objects under StarterGui with those properties, set the ScreenGui's ResetOnSpawn to false there too, and keep one card as a template that the LocalScript clones for each catalogue item. The script then only fills in the cards and connects the buttons. The purchase logic in the next step does not change.
Step 4: Check and complete purchases on the server
This Script is the only place a purchase can happen. It receives the item id, checks it, looks up the price, checks the player's Coins, then takes the coins and gives the Tool. It answers the Buy button with true or false and a message to show.
Hover over ServerScriptService, insert a Script named ShopService and paste this in:
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
-- What the ShopCatalog module returns, so strict type checking can check the fields we use
type ShopItem = { id: string, name: string, price: number, description: string, toolName: string }
type Catalog = {
items: { ShopItem },
get: (id: string) -> ShopItem?,
ownedAttribute: (id: string) -> string,
}
local ShopCatalog = require(ReplicatedStorage:WaitForChild("ShopCatalog")) :: Catalog
local buyItem = ReplicatedStorage:WaitForChild("ShopRemotes"):WaitForChild("BuyItem") :: RemoteFunction
local shopItems = ServerStorage:WaitForChild("ShopItems")
local COOLDOWN_SECONDS = 0.5
local lastRequest: { [Player]: number } = {}
local function giveTool(player: Player, template: Tool)
-- Backpack: the player can use the tool straight away
local backpack = player:FindFirstChildOfClass("Backpack")
if backpack then
template:Clone().Parent = backpack
end
-- StarterGear: Roblox copies it into the Backpack again every time the character spawns
local starterGear = player:FindFirstChildOfClass("StarterGear")
if starterGear then
template:Clone().Parent = starterGear
end
end
local function onBuyItem(player: Player, itemId: unknown): (boolean, string)
-- 1. The only thing the client sends is an item id, so it must be a string
if type(itemId) ~= "string" then
return false, "That item does not exist."
end
-- 2. Ignore double clicks and scripted floods of requests
local now = os.clock()
local last = lastRequest[player]
if last and now - last < COOLDOWN_SECONDS then
return false, "Slow down a little."
end
lastRequest[player] = now
-- 3. Look the item up in the server's own copy of the catalogue
local item = ShopCatalog.get(itemId)
if not item then
return false, "That item does not exist."
end
local ownedAttribute = ShopCatalog.ownedAttribute(item.id)
if player:GetAttribute(ownedAttribute) == true then
return false, "You already own this."
end
local template = shopItems:FindFirstChild(item.toolName)
if not (template and template:IsA("Tool")) then
warn(`ShopItems has no Tool named {item.toolName} for shop item {item.id}`)
return false, "This item is not available right now."
end
local leaderstats = player:FindFirstChild("leaderstats")
local coins = leaderstats and leaderstats:FindFirstChild("Coins")
if not (coins and coins:IsA("IntValue")) then
return false, "Your coins have not loaded yet."
end
-- 4. The price comes from the catalogue, never from the client
if coins.Value < item.price then
return false, `You need {item.price - coins.Value} more coins.`
end
-- 5. Take the coins, then give the item. Nothing here yields, so two requests
-- from the same player cannot both pass the checks above before this runs.
coins.Value -= item.price
giveTool(player, template)
player:SetAttribute(ownedAttribute, true)
return true, `You bought {item.name}.`
end
buyItem.OnServerInvoke = onBuyItem
Players.PlayerRemoving:Connect(function(player: Player)
lastRequest[player] = nil
end)
Ownership is stored as a Player attribute such as Owns_sword. Attributes the server sets replicate to the player's device, so the LocalScript can switch that card to "Owned". A change a player makes to their own copy never reaches the server.
The Tool goes to two places. The copy in the Backpack appears in the hotbar at once. The copy in StarterGear is what Roblox puts into the fresh Backpack each time the character spawns. Without it, a bought item vanishes the first time the player dies.
Why the client never sends the price
Anything on a player's device can be changed by that player. Exploiters run their own code in their copy of the game: they can read your LocalScripts, edit the GUI, and call BuyItem:InvokeServer() with any arguments, as often as they like. Roblox's security guide says to check both the type and the value of everything a remote receives, and it uses a shop as its example.
| An exploiter sends | If the server trusted it | What ShopService does |
|---|---|---|
| A price of 0 | Every item is free | Ignores it. The price comes from the catalogue. |
| A price of -1000 | Coins.Value -= -1000 gives them 1,000 coins | Ignores it. |
NaN as the price | Every comparison with NaN is false, so a "not enough coins" check never fires | Ignores it. |
| An id that is not in the catalogue, or a table instead of a string | Script errors, or items you never meant to sell | Replies "That item does not exist." |
| Hundreds of requests a second | Server load, and any gap in your checks is hit hundreds of times | Handles at most one request per player every half second. |
| Coins edited on their own screen | Purchases with coins they do not have | Never sees it. The server reads only its own Coins value. |
The rule carries over to every remote you write: send what the player chose, such as an item id or a slot number, never what it costs or what they should receive. The RemoteEvents guide goes further into validating remote arguments.
Step 5: Test the shop
- Press Play. A Shop button appears on the left. Open it and buy the Flashlight: your Coins drop by 25, the tool appears in your hotbar and its button changes to Owned.
- Try the Speed Coil, which costs 300. The status line says how many more coins you need, and nothing is taken.
- Reset your character from the in-game menu. The Flashlight comes back in the new Backpack, thanks to the StarterGear copy.
- To give yourself more coins while testing, click the Client/Server toggle to switch to Server, find
Players, your name,leaderstats,Coinsin the Explorer and change its Value in the Properties window. Doing the same in Client mode changes only your screen, which is exactly the kind of change the server ignores.
If a button replies "The shop did not answer", open the Output window from Studio's Window menu and look for a red error from ShopService. If a button stays on "Buying..." instead, ShopService is not running or is stuck waiting for an object: see Common errors.
Sell a game pass for Robux
Coins are your own currency. To sell something for Robux that a player buys once and keeps, such as VIP perks, use a pass. Roblox's docs now call game passes simply "passes", while the API names still say GamePass. Roblox takes the payment; your job is to give the perk, on the server, to everyone who owns the pass.
- Publish your game first: passes can only be created for a published game.
- In Creator Hub, open Creations, select the game, then go to Monetization, Passes and click Create pass. Add an image, a name, a description and a category, then click Create pass again.
- Put it on sale: open the pass, select Sales, turn on Item for Sale, enter a Price in Robux and save.
- Back in the Passes list, hover over the pass, open its three-dot menu and choose Copy Asset ID. Paste that number into both scripts below in place of
0.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local VIP_PASS_ID = 0 -- replace 0 with your pass ID from Creator Hub
local VIP_WALK_SPEED = 24 -- the default is 16
local vipPlayers: { [Player]: boolean } = {}
local function applyPerks(character: Model)
local humanoid = character:WaitForChild("Humanoid", 10)
if humanoid and humanoid:IsA("Humanoid") then
humanoid.WalkSpeed = VIP_WALK_SPEED
end
end
local function grantVip(player: Player)
-- Skip players who already have VIP, and players who left while Roblox was answering
if vipPlayers[player] or player.Parent ~= Players then
return
end
vipPlayers[player] = true
player:SetAttribute("OwnsVIP", true) -- lets LocalScripts show "VIP owned"
local character = player.Character
if character then
applyPerks(character)
end
end
local function onPlayerAdded(player: Player)
player.CharacterAdded:Connect(function(character: Model)
if vipPlayers[player] then
applyPerks(character)
end
end)
-- Players who bought the pass before joining: ask Roblox once when they arrive
local ok, ownsPass = pcall(function()
return MarketplaceService:UserOwnsGamePassAsync(player.UserId, VIP_PASS_ID)
end)
if not ok then
warn(`Could not check the VIP pass for {player.Name}: {ownsPass}`)
elseif ownsPass then
grantVip(player)
end
end
-- Players who buy the pass during this session. Listen on the server only:
-- here wasPurchased is the real outcome, while a LocalScript's copy can be faked.
MarketplaceService.PromptGamePassPurchaseFinished:Connect(
function(player: Instance, passId: number, wasPurchased: boolean)
if wasPurchased and passId == VIP_PASS_ID and player:IsA("Player") then
grantVip(player)
end
end
)
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player: Player)
vipPlayers[player] = nil
end)
Ownership is checked in two places on purpose. UserOwnsGamePassAsync on join covers players who bought the pass before, and PromptGamePassPurchaseFinished covers a purchase made in this session. Roblox caches the ownership result and updates the cache when that event fires. The player.Parent check matters because UserOwnsGamePassAsync waits for Roblox to answer: without it, a player who leaves during that wait would stay in vipPlayers after PlayerRemoving has already cleaned up.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local VIP_PASS_ID = 0 -- the same pass ID as in the PassPerks script
local player = Players.LocalPlayer
local gui = Instance.new("ScreenGui")
gui.Name = "VipGui"
gui.ResetOnSpawn = false
local button = Instance.new("TextButton")
button.AnchorPoint = Vector2.new(0, 0.5)
button.Position = UDim2.new(0, 16, 0.5, 56) -- just below the Shop button
button.Size = UDim2.fromOffset(110, 44)
button.BackgroundColor3 = Color3.fromRGB(255, 196, 61)
button.TextColor3 = Color3.fromRGB(40, 28, 0)
button.FontFace = Font.fromEnum(Enum.Font.BuilderSansBold)
button.TextSize = 18
button.Parent = gui
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 10)
corner.Parent = button
local function refresh()
if player:GetAttribute("OwnsVIP") == true then
button.Text = "VIP owned"
button.AutoButtonColor = false
else
button.Text = "Get VIP"
button.AutoButtonColor = true
end
end
button.Activated:Connect(function()
if player:GetAttribute("OwnsVIP") ~= true then
-- Roblox shows its own purchase window; the server grants the perk afterwards
MarketplaceService:PromptGamePassPurchase(player, VIP_PASS_ID)
end
end)
player:GetAttributeChangedSignal("OwnsVIP"):Connect(refresh)
refresh()
gui.Parent = player:WaitForChild("PlayerGui")
The LocalScript only opens Roblox's purchase window. It never grants anything, so faking a click or the event on the client gains nothing.
Roblox's pass guide also says cross-game pass sales are disabled from 30 May 2026, so prompt passes that belong to the game the player is in.
Save what players bought
Everything so far lasts one session: coins and owned items reset when the player leaves, until you save them with a data store. Rather than repeat the saving code here, follow the DataStore tutorial (or Step 3 of the leaderboard guide for a shorter version) and keep these shop rules in mind:
- Save item ids, such as
{ "sword", "flashlight" }, in the same table as the Coins value, so one save records both the coins spent and the item bought. Tools and other instances cannot be stored in a data store. - When the data loads, go through the saved ids, skip any the catalogue no longer has, set each
Owns_attribute and give the Tool the same waygiveTooldoes. The shop window updates by itself, because it listens to those attributes. - Pass ownership needs no saving:
UserOwnsGamePassAsyncasks Roblox each time the player joins. - Never save a player whose data failed to load, or a failed load can replace their purchases with an empty list.
Common errors and fixes
When the shop misbehaves, check these first. Open the Output window (Window menu, then Output) before anything else: a script error names the script and the line.
Infinite yield possible on 'ReplicatedStorage:WaitForChild("ShopRemotes")'
A script is waiting for an object that does not exist under that exact name. Check the spelling and capitals of ShopRemotes, BuyItem, ShopCatalog and ShopItems, and that ShopItems is in ServerStorage, not ReplicatedStorage. The script errors guide explains this warning in detail.
A Buy button stays on "Buying..." and never answers
Nothing on the server has set BuyItem.OnServerInvoke, so Roblox holds the request until something does. Make sure ShopService is a Script, not a LocalScript, inside ServerScriptService, and check Output for an Infinite yield possible warning from ShopService: it means the script is stuck waiting for ShopCatalog, ShopRemotes or ShopItems.
A Buy button says "The shop did not answer"
The call to the server failed instead of returning an answer. The usual cause is an error inside ShopService while it handled the request, so look for a red error from ShopService in Output: it names the line.
A button says "This item is not available right now"
The server could not find a Tool with that item's toolName inside ServerStorage, ShopItems. Output shows a warning with the exact name it looked for.
I bought a tool but my character cannot hold it, or gets stuck
A Tool needs exactly one part named Handle as a direct child, and none of its parts may be anchored. For a tool with nothing to hold, untick RequiresHandle instead.
Coins went down on my screen but not for anyone else
The purchase ran in a LocalScript, so only your device changed. Move the coin change and the Tool grant into a server Script, as in Step 4.
Bought items are gone after I rejoin
Nothing is saved yet. See Save what players bought.
Build it with RoCode
If you would rather not wire this up by hand, RoCode can build it in the place you have open. It is an independent AI coding agent: you describe the feature in its web chat, and its Roblox Studio plugin creates the scripts, the remote and the GUI in your Explorer, so you are not copying code out of a chat window.
Add a coin shop: a Shop button that opens a grid of four tools priced in the Coins from my leaderstats. The server should check the price and give the tool to the Backpack and StarterGear.
- Can look through your place for existing leaderstats and shop code with Search Scripts and Read Script, so it can reuse your Coins value instead of adding a second one
- Can scaffold the window with Build UI, whose shop layout comes with placeholder item cards and a LocalScript for hover effects and the Close button; the buying logic is written as separate scripts
- Creates the RemoteFunction with Create Instance, and writes the catalogue ModuleScript, the server Script and the LocalScript with Create Script
- Compile-checks each script inside Studio with Check Script (it does not type-check or run them), and each batch of changes is an undo point in Studio
You still create any pass in Creator Hub yourself, since RoCode cannot create passes or developer products, and you play-test the purchases: RoCode does not start playtests. How RoCode connects to Studio.
Questions
Should the Buy button use a RemoteEvent or a RemoteFunction?
Either works, as long as the server does the checking. A RemoteFunction lets the button wait for the server's answer and show it, which is why this guide uses one, and Roblox's security guide uses a RemoteFunction for its shop example too. Only invoke it from the client: a server that calls InvokeClient can wait forever if the client never answers. The RemoteEvents guide compares the two.
How do I open the shop from an NPC instead of a button?
Put a ProximityPrompt inside a part of the NPC, such as its HumanoidRootPart, listen to its Triggered event in a server Script, and fire a RemoteEvent to that player so the LocalScript shows the panel. If buying should only work near the NPC, also check the distance to the NPC inside the BuyItem handler, because a client can call the remote from anywhere.
How do I sell items players can buy more than once?
Skip the "already own" check for those items and grant something countable instead of a Tool, for example add 1 to an IntValue that counts potions. Keep the price lookup and the coin check exactly as they are.
Why does my shop disappear when my character respawns?
Its ScreenGui has ResetOnSpawn set to true, the default, so Roblox deletes it on respawn. Set it to false, as the script in Step 3 does.
Can I use a free shop GUI template?
Yes, any layout works, as long as its Buy buttons only send an item id and the purchase itself happens in a server Script like ShopService. Before using a template, check its scripts for coins being taken or items being given in a LocalScript.
Sources and further reading
- Roblox Creator Docs: Securing the client-server boundary. validating remote arguments, NaN, rate limiting and the in-game shop example
- Roblox Creator Docs: Remote events and callbacks. RemoteFunction, InvokeServer, OnServerInvoke and the risks of InvokeClient
- Roblox Creator Docs: Passes. creating and selling passes, UserOwnsGamePassAsync and PromptGamePassPurchaseFinished
- Roblox Creator Docs: Shop. Roblox's automatically created in-game Shop
- Roblox Creator Docs: Developer Products. repeatable Robux purchases and ProcessReceipt
- Roblox Creator Docs: StarterGear. why a Tool in StarterGear comes back after a respawn
- Roblox Creator Docs: In-game tools. handles, anchoring and RequiresHandle
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.