Tutorial · Game systems

How to make a shop GUI in Roblox Studio

A shop in a Roblox game is two jobs: a GUI that shows the items, and a server script that decides whether a purchase happens. This guide builds both for a coin shop that sells tools, then adds a game pass sold for Robux.

Updated 30 September 202613 min readBeginnerLuau

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
  1. How it fits together
  2. 1. Set up
  3. 2. Item catalogue
  4. 3. Shop GUI
  5. 4. Server purchases
  6. Why no price from the client
  7. 5. Test it
  8. Game pass
  9. Save purchases
  10. Common errors
  11. With RoCode
  12. Questions
  13. Sources

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.

The five pieces of the shop
PieceWhere it livesWhat it does
ShopCatalog ModuleScriptReplicatedStorageItem ids, names, prices and the Tool each one gives. Both sides read it.
BuyItem RemoteFunctionReplicatedStorage, in a folder named ShopRemotesCarries the buy request to the server and the answer back.
ShopClient LocalScriptStarterPlayerScriptsBuilds the shop window and sends the item id when a Buy button is pressed.
ShopService ScriptServerScriptServiceChecks the request, takes the Coins and gives the Tool.
The Tools you sellServerStorage, in a folder named ShopItemsThe originals the server copies. Nothing in ServerStorage is sent to players.
Explorer when the shop is finished
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.

Script in ServerScriptService named Leaderstats
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.

  1. In the Explorer, hover over ReplicatedStorage, click the + button and insert a Folder named ShopRemotes. Inside it, insert a RemoteFunction named BuyItem.
  2. Hover over ServerStorage and insert a Folder named ShopItems.
  3. Put a Tool for each item into ShopItems, named ClassicSword, Flashlight, GrappleHook and SpeedCoil. For a first test, a Tool only needs one unanchored Part named Handle inside it. Any Tool you already have works too: give it the matching name.
  4. 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:

ModuleScript in ReplicatedStorage named ShopCatalog
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.

  1. In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named ShopClient.
  2. Replace its contents with the code below.
LocalScript in StarterPlayerScripts named ShopClient
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.

The GUI objects and the properties that matter
ObjectJob in the shopKey properties
ScreenGuiHolds everything drawn on the screenResetOnSpawn false, so the shop survives a respawn
FrameThe window, and each item cardAnchorPoint 0.5, 0.5 and Position 0.5, 0.5 to centre the window
UISizeConstraintCaps the window on large screensMaxSize 640 by 460
ScrollingFrameScrolls when there are more cards than fitCanvasSize 0 and AutomaticCanvasSize Y, so the scroll area grows with the cards
UIGridLayoutPlaces the cards in rows and wraps themCellSize 180 by 200, CellPadding 12, SortOrder LayoutOrder
UICornerRounds cornersCornerRadius 8 to 14 pixels
UIPaddingInner spacing10 to 12 pixels on each side
TextButtonShop, close and Buy buttonsThe 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:

Script in ServerScriptService named ShopService
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.

What happens if the server trusts the client
An exploiter sendsIf the server trusted itWhat ShopService does
A price of 0Every item is freeIgnores it. The price comes from the catalogue.
A price of -1000Coins.Value -= -1000 gives them 1,000 coinsIgnores it.
NaN as the priceEvery comparison with NaN is false, so a "not enough coins" check never firesIgnores it.
An id that is not in the catalogue, or a table instead of a stringScript errors, or items you never meant to sellReplies "That item does not exist."
Hundreds of requests a secondServer load, and any gap in your checks is hit hundreds of timesHandles at most one request per player every half second.
Coins edited on their own screenPurchases with coins they do not haveNever 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

  1. 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.
  2. Try the Speed Coil, which costs 300. The status line says how many more coins you need, and nothing is taken.
  3. Reset your character from the in-game menu. The Flashlight comes back in the new Backpack, thanks to the StarterGear copy.
  4. To give yourself more coins while testing, click the Client/Server toggle to switch to Server, find Players, your name, leaderstats, Coins in 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.

  1. Publish your game first: passes can only be created for a published game.
  2. 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.
  3. Put it on sale: open the pass, select Sales, turn on Item for Sale, enter a Price in Robux and save.
  4. 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.
Script in ServerScriptService named PassPerks
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.

LocalScript in StarterPlayerScripts named VipButton
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 way giveTool does. The shop window updates by itself, because it listens to those attributes.
  • Pass ownership needs no saving: UserOwnsGamePassAsync asks 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.

You type

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.

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

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.