Tutorial · Game systems

Roblox game pass script: sell perks the right way

A game pass (Roblox now just says "pass", and many people write "gamepass") is a one-time Robux purchase that unlocks something for good, such as a VIP area, a speed boost or a tool. Roblox takes the payment. Everything else is your script's job: knowing who owns the pass, opening the purchase window, and giving the perk the moment someone buys it. This guide builds all of it, server first.

Updated 9 October 202616 min readBeginnerLuau

In short

Create the pass in Creator Hub (Creations, your game, Monetization, Passes, Create pass) and copy its ID. Then put a Script in ServerScriptService that calls MarketplaceService:UserOwnsGamePassAsync when each player joins and listens to MarketplaceService.PromptGamePassPurchaseFinished, so it gives the perk to owners and to anyone who buys during the session. A LocalScript only opens Roblox's purchase window with PromptGamePassPurchase.

  • Wrap UserOwnsGamePassAsync in pcall: it asks Roblox's servers and can fail. Remember the answer for each player instead of asking again.
  • Grant perks on the server only. Roblox's API reference says PromptGamePassPurchaseFinished should only be listened to in a server script, where wasPurchased is the final outcome.
  • A pass is bought once and kept. Anything a player can buy again, such as a bag of coins, is a developer product, handled with ProcessReceipt.
On this page
  1. How passes work
  2. 1. Create the pass
  3. 2. Get the pass ID
  4. 3. Pass IDs module
  5. 4. Server script
  6. 5. Buy button
  7. Perk examples
  8. 6. Test it
  9. Passes vs products
  10. Common errors
  11. With RoCode
  12. Questions
  13. Sources

How game passes work in 2026

A pass charges a player a one-time Robux fee for a privilege inside one game: Roblox's own examples are entry to a restricted area, an in-game avatar item and a permanent power-up. Roblox records who owns which pass. It does not apply the perk: your scripts look up ownership and give the player whatever the pass promises.

Roblox's documentation now calls game passes simply passes. The API still uses the old name, so you will see UserOwnsGamePassAsync, PromptGamePassPurchase and Enum.InfoType.GamePass next to "pass" in Creator Hub. Three things in Roblox's current pass documentation are newer than many tutorials:

  • Cross-game pass sales are disabled from 30 May 2026. Prompt passes that belong to the game the player is in. Roblox points games that relied on selling other games' passes to Robux transfers instead.
  • Managed pricing is on by default for passes. Roblox can show players in some regions a lower price than the default you set. Its regional pricing page says a regional price is never more than 70% below your default price and never above it.
  • Players can buy passes away from your button. Roblox's automatic in-game Shop lists your passes, and Roblox can show passes outside your game, for example on the Roblox homepage and in search results. A player can arrive already owning the pass, which is why the join check matters.
What runs where
JobWhere it runsWhy there
Check who already owns the passServer Script, when the player joinsThe server's answer is the one your game can trust
Open the purchase windowLocalScript, when the button is pressedRoblox draws the window on the player's screen and takes the payment
Give the perk after a purchaseServer Script, on PromptGamePassPurchaseFinishedOn the server, wasPurchased is the real result
Show "Owned" on the buttonLocalScript, reading an attribute the server setsDisplay only: changing it on the client unlocks nothing

Step 1: Create the pass in Creator Hub

A pass belongs to one game, and the game must be published first. If you have only ever saved your place to your computer, publish it from Studio (File, Publish to Roblox) before you start.

  1. Go to Creations in Creator Hub (create.roblox.com) and select your game.
  2. Go to Monetization, then Passes.
  3. Click Create pass.
  4. Upload an image for the pass icon: .jpg, .png or .bmp, no larger than 512 by 512 pixels, with nothing important outside the circle Roblox crops it to.
  5. Enter a name and a description.
  6. Choose a category. Roblox uses it to group items in your game's in-game Shop.
  7. Click Create pass.

Before anyone can buy the pass, it has to be on sale with a price in Robux. Open the pass from the Passes list and check its Sales page. Roblox's Passes page does not walk through this screen (its regional pricing page is where the Sales page is named), so the exact switch and field names may look slightly different on your screen.

Step 2: Get the pass ID (not an asset ID)

  1. In Creator Hub, go to Monetization, Passes.
  2. Hover over the pass's thumbnail, click the ⋯ button and choose Copy Asset ID.

The menu item says "Asset ID", but Roblox's Passes page uses exactly this step to get the pass ID, which is the number every script on this page needs. Two other numbers look similar and do not work:

  • A legacy asset ID. Passes made long ago also have an asset ID. Roblox's GamePassService reference says new passes only have a pass ID, and that asset IDs belong to the legacy functions (GamePassService:PlayerHasPass and MarketplaceService:PromptPurchase).
  • The ProductId from GetProductInfoAsync. The PromptGamePassPurchase reference says the pass ID is the TargetId that GetProductInfoAsync returns for a pass, not its ProductId.

Your game ID, place ID and developer product IDs are different numbers again. Paste the pass ID into your script as a number, without quotes.

Step 3: Keep every pass ID in one ModuleScript

The server needs the IDs to check ownership and the buy button needs them to open the purchase window. Keeping them in one ModuleScript in ReplicatedStorage means a new pass is one line in one place. The rest of this guide sells three example passes: VIP (walk through a VIP door), Speed (walk faster) and GravityCoil (a Tool).

Explorer when the guide is finished
ReplicatedStorage
  PassConfig ModuleScript new
ServerScriptService
  GamePassHandler Script new
ServerStorage
  PassItems Folder new
    GravityCoil Tool new
      Handle Part
StarterPlayer
  StarterPlayerScripts
    PassButton LocalScript new
Workspace
  VIPDoor Part new

Hover over ReplicatedStorage in the Explorer, click +, insert a ModuleScript, rename it PassConfig and replace its contents with this:

ModuleScript in ReplicatedStorage named PassConfig
-- Every pass the game sells, by name. The server and LocalScripts both read this.
local PassConfig = {}

-- Paste each pass ID from Creator Hub in place of 0.
-- A pass left at 0 is skipped, so you can set them up one at a time.
local ids: { [string]: number } = {
	VIP = 0, -- walk through the VIP door
	Speed = 0, -- walk faster
	GravityCoil = 0, -- a Tool kept in ServerStorage.PassItems
}
PassConfig.ids = ids

-- The Player attribute the server sets to true once a player owns the pass
function PassConfig.attributeFor(passName: string): string
	return "OwnsPass_" .. passName
end

return PassConfig

Pass IDs do not need hiding. The buy button needs the ID to open the purchase window anyway, and owning a pass is decided by Roblox, not by anything a player can read or change in ReplicatedStorage.

Step 4: Check ownership and give perks on the server

This Script does all the deciding. When a player joins it asks Roblox whether they own each pass, and when a player buys one during the session it grants it straight away, with no rejoin. It keeps the answers in a table for each player, so other code never has to ask Roblox twice.

First build the two objects the perks use:

  1. Add a Part to Workspace, name it VIPDoor, size it to fill a doorway, and tick Anchored and CanCollide. A Transparency of about 0.5 lets players see it is a door.
  2. Hover over ServerStorage, insert a Folder named PassItems, and put a Tool named GravityCoil inside it. For a first test the Tool only needs one unanchored Part named Handle. Any Tool you already have works too, renamed to match.

Then hover over ServerScriptService, insert a Script named GamePassHandler and paste this in:

Script in ServerScriptService named GamePassHandler
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local ServerStorage = game:GetService("ServerStorage")

-- What the PassConfig module returns, so strict type checking can check how it is used
type PassConfigModule = {
	ids: { [string]: number },
	attributeFor: (passName: string) -> string,
}

local PassConfig = require(ReplicatedStorage:WaitForChild("PassConfig")) :: PassConfigModule
local passItems = ServerStorage:WaitForChild("PassItems")

-- Roblox's docs say Studio does not mock pass purchasing. Set this to true to try every
-- perk while testing in Studio. RunService:IsStudio() is false on live servers, so this
-- never gives perks away in the published game.
local GRANT_ALL_IN_STUDIO = false

local SPEED_PASS_WALK_SPEED = 24 -- the default WalkSpeed is 16
local VIP_PLAYERS_GROUP = "VIPPlayers"
local VIP_DOOR_GROUP = "VIPDoor"
local CHECK_ATTEMPTS = 3

-- passesOwned[player] holds the passes that player owns, e.g. passesOwned[player].VIP == true
local passesOwned: { [Player]: { [string]: boolean } } = {}

-- VIP door: parts in the VIPDoor group block everyone except characters in VIPPlayers
for _, groupName in { VIP_PLAYERS_GROUP, VIP_DOOR_GROUP } do
	if not workspace:IsCollisionGroupRegistered(groupName) then
		workspace:RegisterCollisionGroup(groupName)
	end
end
workspace:CollisionGroupSetCollidable(VIP_PLAYERS_GROUP, VIP_DOOR_GROUP, false)

local vipDoor = workspace:FindFirstChild("VIPDoor")
if vipDoor and vipDoor:IsA("BasePart") then
	vipDoor.CollisionGroup = VIP_DOOR_GROUP
else
	warn("No Part named VIPDoor in Workspace, so the VIP pass opens no door")
end

local function giveTool(player: Player, toolName: string)
	local template = passItems:FindFirstChild(toolName)
	if not (template and template:IsA("Tool")) then
		warn(`ServerStorage.PassItems has no Tool named {toolName}`)
		return
	end
	-- StarterGear: Roblox copies it into the Backpack every time the character spawns.
	-- It can appear a moment after the player joins, so wait for it briefly.
	local starterGear = player:WaitForChild("StarterGear", 10)
	if starterGear and starterGear:IsA("StarterGear") then
		template:Clone().Parent = starterGear
	else
		warn(`No StarterGear for {player.Name}, so {toolName} will not come back after a respawn`)
	end
	-- Backpack: if the character is already in the world, hand over a copy now too
	local backpack = player:FindFirstChildOfClass("Backpack")
	if backpack and player.Character then
		template:Clone().Parent = backpack
	end
end

-- Perks that live on the character. A respawn creates a new character without them,
-- so this runs for the current character and again for every new one.
local function applyCharacterPerks(player: Player, character: Model)
	local owned = passesOwned[player]
	if not owned then
		return
	end

	if owned.VIP then
		for _, descendant in character:GetDescendants() do
			if descendant:IsA("BasePart") then
				descendant.CollisionGroup = VIP_PLAYERS_GROUP
			end
		end
	end

	if owned.Speed then
		local humanoid = character:WaitForChild("Humanoid", 10)
		if humanoid and humanoid:IsA("Humanoid") then
			humanoid.WalkSpeed = SPEED_PASS_WALK_SPEED
		end
	end
end

local function onCharacterAdded(player: Player, character: Model)
	-- Accessories and other parts can arrive after the character spawns
	character.DescendantAdded:Connect(function(descendant: Instance)
		local owned = passesOwned[player]
		if owned and owned.VIP and descendant:IsA("BasePart") then
			descendant.CollisionGroup = VIP_PLAYERS_GROUP
		end
	end)
	applyCharacterPerks(player, character)
end

-- Gives a player everything one pass includes. Safe to call more than once.
local function grantPass(player: Player, passName: string)
	local owned = passesOwned[player]
	if not owned or owned[passName] then
		return -- the player has left, or already has this pass
	end
	owned[passName] = true
	-- Attributes set by the server replicate, so the player's LocalScripts can show "Owned"
	player:SetAttribute(PassConfig.attributeFor(passName), true)

	if passName == "GravityCoil" then
		giveTool(player, "GravityCoil")
	end

	local character = player.Character
	if character then
		applyCharacterPerks(player, character)
	end
end

-- Asks Roblox whether the player owns one pass, retrying if the request fails
local function checkPass(player: Player, passName: string, passId: number)
	for attempt = 1, CHECK_ATTEMPTS do
		local ok, result = pcall(function()
			return MarketplaceService:UserOwnsGamePassAsync(player.UserId, passId)
		end)
		if ok then
			if result == true then
				grantPass(player, passName)
			end
			return
		end
		warn(`Could not check the {passName} pass for {player.Name} (try {attempt}): {result}`)
		if attempt < CHECK_ATTEMPTS then
			task.wait(attempt * 2)
			if not passesOwned[player] then
				return -- the player left while we waited
			end
		end
	end
end

local function onPlayerAdded(player: Player)
	passesOwned[player] = {}

	player.CharacterAdded:Connect(function(character: Model)
		onCharacterAdded(player, character)
	end)
	local character = player.Character
	if character then
		onCharacterAdded(player, character)
	end

	for passName, passId in PassConfig.ids do
		if GRANT_ALL_IN_STUDIO and RunService:IsStudio() then
			task.spawn(grantPass, player, passName)
		elseif passId ~= 0 then
			-- One thread per pass: Roblox can batch checks started together into fewer requests
			task.spawn(checkPass, player, passName, passId)
		end
	end
end

-- A player bought a pass in this server. Listen on the server only: here
-- wasPurchased is the final result, and a LocalScript's copy is not to be relied on.
MarketplaceService.PromptGamePassPurchaseFinished:Connect(
	function(player: Player, passId: number, wasPurchased: boolean)
		if not wasPurchased then
			return
		end
		for passName, id in PassConfig.ids do
			if id == passId then
				grantPass(player, passName)
			end
		end
	end
)

Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
	task.spawn(onPlayerAdded, player)
end

Players.PlayerRemoving:Connect(function(player: Player)
	passesOwned[player] = nil
end)

Roblox's API reference says UserOwnsGamePassAsync caches its results, updates the cache when PromptGamePassPurchaseFinished fires, and always returns true the first time a player enters a server after buying the pass. The passesOwned table is still worth keeping: it is the server's own answer that other scripts and the perk code read without waiting on Roblox.

Ownership is checked in two places on purpose. The join check covers everyone who bought the pass before, including in Roblox's in-game Shop or outside your game. PromptGamePassPurchaseFinished covers a purchase made from your button during this session, so the perk arrives as soon as the purchase window closes. Roblox's Shop page says purchases made through its in-game Shop use your existing purchase handling. grantPass ignores a second grant, so both firing for one player does no harm.

Other server scripts can read the same answer from the attribute. For example, a coin pad that pays VIPs double can check player:GetAttribute("OwnsPass_VIP") == true before paying. Only read it on the server for anything that matters: a player can change attributes on their own copy, but the change never reaches the server.

Step 5: Add a button that prompts the purchase

The button opens Roblox's purchase window and nothing else. It also asks Roblox for the price this player will see and shows it on the button. Expand StarterPlayer, hover over StarterPlayerScripts, insert a LocalScript named PassButton and paste this in:

LocalScript in StarterPlayerScripts named PassButton
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- What the PassConfig module returns, so strict type checking can check how it is used
type PassConfigModule = {
	ids: { [string]: number },
	attributeFor: (passName: string) -> string,
}

local PassConfig = require(ReplicatedStorage:WaitForChild("PassConfig")) :: PassConfigModule

local PASS_NAME = "VIP" -- the pass this button sells: one of the names in PassConfig.ids
local passId = PassConfig.ids[PASS_NAME] or 0
local ownedAttribute = PassConfig.attributeFor(PASS_NAME)

local player = Players.LocalPlayer
local priceText = "" -- filled in once Roblox returns the price for this player

local gui = Instance.new("ScreenGui")
gui.Name = "PassButtonGui"
gui.ResetOnSpawn = false -- keep the button when the character respawns

local button = Instance.new("TextButton")
button.Name = "BuyPass"
button.AnchorPoint = Vector2.new(0, 1)
button.Position = UDim2.new(0, 16, 1, -16)
button.Size = UDim2.fromOffset(200, 48)
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(ownedAttribute) == true then
		button.Text = `{PASS_NAME} owned`
		button.AutoButtonColor = false
	else
		button.Text = `Get {PASS_NAME}{priceText}`
		button.AutoButtonColor = true
	end
end

button.Activated:Connect(function()
	if player:GetAttribute(ownedAttribute) == true then
		return
	end
	if passId == 0 then
		warn(`Paste the {PASS_NAME} pass ID into PassConfig first`)
		return
	end
	-- Roblox shows its own purchase window and takes the payment.
	-- This script never grants anything: the server does that when the purchase finishes.
	MarketplaceService:PromptGamePassPurchase(player, passId)
end)

-- The server sets this attribute when the player owns the pass, or buys it now
player:GetAttributeChangedSignal(ownedAttribute):Connect(refresh)
refresh()
gui.Parent = player:WaitForChild("PlayerGui")

-- Show the price this player sees. GetProductInfoAsync asks Roblox's servers, so it is in a pcall.
if passId ~= 0 then
	local ok, info = pcall(function()
		return MarketplaceService:GetProductInfoAsync(passId, Enum.InfoType.GamePass)
	end)
	if ok and info.IsForSale == true and type(info.PriceInRobux) == "number" then
		priceText = `: {info.PriceInRobux} Robux`
		refresh()
	elseif not ok then
		warn(`Could not load the {PASS_NAME} pass price: {info}`)
	end
end

The price comes from GetProductInfoAsync, which replaced the deprecated GetProductInfo that older tutorials (and some of Roblox's own examples) still use. Roblox's Passes page says to load prices on the client if you use its price optimization feature, so each player sees their own price. Activated fires for a click, a tap and a controller button press.

To sell a second pass from another button, copy the script and change PASS_NAME. Roblox's Passes page also suggests prompting when a player talks to a vendor NPC: listen to the Triggered event of a ProximityPrompt on the NPC in a server Script, fire a RemoteEvent to that player, and call PromptGamePassPurchase in the LocalScript that receives it. The grant still happens in GamePassHandler.

Perk examples: VIP door, extra speed and a tool

The three example passes show the three kinds of perk most games sell. The difference that matters is whether the perk lives on the character, and so has to be applied again after every respawn, or on the player.

How GamePassHandler gives each perk
PassWhat owners getHow the script gives itWhy it survives a respawn
VIPWalk through the VIPDoor partPuts every part of the character in the VIPPlayers collision group, which does not collide with the VIPDoor groupapplyCharacterPerks runs for every new character, and DescendantAdded catches accessories that load late
SpeedWalkSpeed 24 instead of the default 16Sets Humanoid.WalkSpeedEach respawn creates a new Humanoid at 16, so the same function sets it again
GravityCoilA Tool in the hotbarClones the Tool from ServerStorage.PassItems into the player's StarterGear, and into the Backpack if the character existsRoblox copies StarterGear into the new Backpack every time the character spawns

Collision groups are set up the way Roblox's collisions page shows for characters: register the groups once on the server, turn off collisions between the two, then assign each part's CollisionGroup. A part can belong to one group only, so if another script already puts characters in its own group (to stop players colliding with each other, for example), merge the two into one script.

To add your own perk, add the pass to PassConfig.ids, then add an if owned.YourPass then branch to applyCharacterPerks for character perks, or a line in grantPass for things given once, like a tool or a title.

Step 6: Test it in Studio and in the live game

Roblox's main Passes page says nothing about testing in Studio. The one direct statement is in its Season Passes feature package guide, which says to test pass purchases by publishing the game and testing in the Roblox client, because Studio does not mock pass purchasing. So test in two halves.

In Studio: the perks

  1. Set GRANT_ALL_IN_STUDIO to true in GamePassHandler and press Play. Every pass in PassConfig is granted, even ones still at ID 0.
  2. Walk through the VIP door, check you run faster, and look for the GravityCoil in your hotbar.
  3. Reset your character from the in-game menu. All three perks should come back on the new character.
  4. Switch the Explorer to the server view with the Client/Server toggle, select your player under Players, and check the OwnsPass_ attributes in the Properties window.
  5. Set GRANT_ALL_IN_STUDIO back to false. On a live server it does nothing anyway, but leaving it off means Studio shows the real join check.

In the published game: the purchase

  1. Paste your real pass IDs into PassConfig, make sure each pass is on sale, and publish.
  2. Join the live game from the Roblox app with an account that does not own the pass. The button should show the price. If the button says "VIP owned" before you buy anything, the join check found that this account already owns the pass, so test the purchase with another account.
  3. Buy the pass. The perk should arrive without rejoining, and the button should switch to "VIP owned".
  4. Leave and rejoin. The join check should grant the perk again straight away.

We found no Roblox documentation of a free test purchase for passes, so treat a purchase in the live game as a real one. If anything misbehaves on a live server, press F9 in the Roblox app to open the Developer Console, where server warnings from GamePassHandler appear.

Game passes vs developer products

Roblox sells two kinds of in-game Robux purchase. Choosing the wrong one leaves players with a perk they lose or a purchase they cannot repeat. The test is simple: can a player sensibly buy it a second time?

Which one to use
PassDeveloper product
BoughtOnce, kept for goodAs many times as the player likes
Good forVIP, a permanent speed boost, a tool, a restricted areaCoins, potions, revives, temporary boosts
Who records itRoblox records ownership; UserOwnsGamePassAsync asks for itYou: save what the purchase gave, such as the new coin total
Grant it withUserOwnsGamePassAsync on join and PromptGamePassPurchaseFinished on the serverThe ProcessReceipt callback on the server
Prompt it withPromptGamePassPurchasePromptProductPurchase
Created inMonetization, PassesMonetization, Developer Products

Developer products need a different pattern. Roblox's API reference says not to use PromptProductPurchaseFinished to process purchases, because the prompt closing does not mean the purchase succeeded. Instead, set MarketplaceService.ProcessReceipt once, in one server Script, and return PurchaseGranted only after the player has what they paid for:

Script in ServerScriptService named ProductReceipts
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local COINS_100_PRODUCT_ID = 0 -- replace 0 with your developer product ID

-- One function per developer product. Each returns true only once the player has what they paid for.
local productHandlers: { [number]: (player: Player) -> boolean } = {}

productHandlers[COINS_100_PRODUCT_ID] = function(player: Player): boolean
	local leaderstats = player:FindFirstChild("leaderstats")
	local coins = leaderstats and leaderstats:FindFirstChild("Coins")
	if coins and coins:IsA("IntValue") then
		coins.Value += 100
		return true
	end
	return false
end

local function processReceipt(receiptInfo: { [string]: any }): Enum.ProductPurchaseDecision
	local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
	if not player then
		-- The buyer left. Roblox keeps the purchase open and offers it again when they rejoin.
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local handler = productHandlers[receiptInfo.ProductId]
	if not handler then
		warn(`No handler for developer product {receiptInfo.ProductId}`)
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local ok, granted = pcall(handler, player)
	if ok and granted == true then
		return Enum.ProductPurchaseDecision.PurchaseGranted
	end

	warn(`Could not grant product {receiptInfo.ProductId} to {player.Name}: {granted}`)
	return Enum.ProductPurchaseDecision.NotProcessedYet
end

-- Set this once, in one server Script, for all of your developer products
MarketplaceService.ProcessReceipt = processReceipt

This assumes a leaderstats folder with a Coins IntValue, as in the leaderboard guide. Returning NotProcessedYet leaves the purchase open: Roblox calls ProcessReceipt again when the player rejoins or makes another developer product purchase.

This version is enough to try developer products. Before you sell anything valuable, read the limits on Roblox's ProcessReceipt reference: if you set no callback, receipts are acknowledged automatically and cannot be recovered; the callback for one purchase can run on two servers at once if the player joins a second server before it returns; and a granted result can still fail to record on Roblox's side, leaving the purchase open. The usual answer is to save the new coin total, together with the receipt's PurchaseId, in a data store before returning PurchaseGranted, so a receipt delivered twice is not paid twice. The DataStore tutorial covers the saving side.

Roblox's API reference also documents BindReceiptHandler, a newer way to route developer product receipts to separate handlers by product ID. A receipt that no bound handler claims falls through to ProcessReceipt, so you can move over one product at a time.

Common errors and fixes

Open the Output window (Window menu, then Output) in Studio, or the Developer Console with F9 in a live game, before anything else. GamePassHandler and PassButton print a warning naming the pass when a check or a lookup fails.

UserOwnsGamePassAsync always returns false

Check the ID first. It must be the pass ID from Monetization, Passes, Copy Asset ID, entered as a number: not the game ID, the place ID, a developer product ID, the ProductId that GetProductInfoAsync returns, or an old pass's legacy asset ID. If the player bought the pass on the Roblox website while already in your game, the API reference says the cached answer can take several minutes to update; the pass shows up as owned the next time they enter a server.

The perk only works after the player rejoins

Nothing on the server handles PromptGamePassPurchaseFinished, or it is connected in a LocalScript. Connect it in a server Script and grant the perk there, as GamePassHandler does.

Players get the perk without buying the pass

Something trusts the client: a LocalScript that grants the perk itself, a LocalScript reacting to its own copy of PromptGamePassPurchaseFinished, or a RemoteEvent that lets the client report a purchase. Remove them. Only the server, through UserOwnsGamePassAsync and the server-side event, should decide who owns a pass.

The speed boost is gone after the player dies

WalkSpeed belongs to the Humanoid, and every respawn creates a new Humanoid at the default speed of 16. Apply character perks on CharacterAdded, as applyCharacterPerks does.

The pass tool disappears after a respawn

Roblox replaces the Backpack when the character dies. Put a copy of the Tool in the player's StarterGear, which Roblox copies into each new Backpack.

The purchase window does not open, or shows an error

If Output says to paste the pass ID, the ID in PassConfig is still 0. Otherwise check that the ID belongs to a pass of this game, that the pass is on sale with a Robux price, and that the game is published. Roblox's Passes page says cross-game pass sales are disabled from 30 May 2026, so prompt only passes that belong to this game.

VIP players cannot get through the door

Check that the part is named VIPDoor and sits directly in Workspace when the server starts, and that no other script changes the CollisionGroup of character parts. A part can be in one collision group only, so the last script to set it wins.

An old tutorial uses GamePassService:PlayerHasPass or PromptPurchase

Those are for legacy passes identified by an asset ID. Roblox's GamePassService reference says PlayerHasPass does not work with new passes. Use UserOwnsGamePassAsync and PromptGamePassPurchase with the pass ID.

Build it with RoCode

If you would rather not wire this up by hand, RoCode can write it in the place you have open. RoCode is an independent AI agent for Roblox Studio: you describe the feature in its web chat at rocode.app, and its free Studio plugin creates the scripts and parts in your Explorer, so you are not copying code out of a chat window.

You type

Add a VIP game pass with pass ID 0 for now. Owners walk through a VIPDoor part and get WalkSpeed 24, checked on join and granted right after purchase on the server. Add a Get VIP button that prompts the purchase.

RoCode does
  • Can look through your place first with Search Scripts and Read Script for existing MarketplaceService code, so it can extend your purchase handling instead of adding a second copy
  • Creates the door part with Create Instance, and writes the ModuleScript, the server Script and the LocalScript with Create Script
  • Compile-checks each script it wrote inside Studio with Check Script. That is a compile check, not a type check or a playtest
  • Sends its changes in batches, and each batch is marked as an undo point in Studio's history

You still create the pass and copy its ID in Creator Hub yourself, since RoCode has no tools for passes or developer products, and you test the purchase in your published game: RoCode does not start playtests. How RoCode connects to Studio. RoCode is an independent product, not affiliated with, endorsed by or sponsored by Roblox Corporation. Roblox and Roblox Studio are trademarks of Roblox Corporation.

Questions

Is a gamepass the same as a pass?

Yes. Roblox's documentation now calls game passes "passes", while the API names, such as UserOwnsGamePassAsync and PromptGamePassPurchase, still say GamePass. Gamepass, game pass and pass all mean the same thing.

How do I make a gamepass on mobile?

Roblox documents pass creation in Creator Hub at create.roblox.com and does not describe a separate phone or tablet flow. We have not checked those pages in a phone browser. Either way the pass needs a published game to belong to, and the scripts are written in Roblox Studio on a computer.

How do I make a game pass for PLS DONATE?

The pass is created the same way, in one of your own published games. How PLS DONATE finds and lists passes is up to that game and is not covered by Roblox's documentation, and we have not tested it. Roblox's Passes page says cross-game pass sales are disabled from 30 May 2026 and points games built on them to Robux transfers, so check PLS DONATE's own current instructions.

Do I need to save game passes in a data store?

Not for ownership: UserOwnsGamePassAsync asks Roblox every time a player joins. Roblox does not keep a per-player purchase history for you, though, so store one yourself if you need it. Developer products are different: you must save what they gave, such as coins.

Can a LocalScript check whether a player owns a pass?

Yes. Roblox's API reference says UserOwnsGamePassAsync works on the client and the server. Use the client answer only for display, such as hiding a Buy button, and grant perks on the server.

Should I use a game pass or a developer product for double coins?

A permanent double coins perk is a pass: bought once, checked with UserOwnsGamePassAsync. A boost that lasts 30 minutes and can be bought again is a developer product, granted in ProcessReceipt.

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, 9 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.

Try RoCode on your own place.

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