TUTORIAL: Gifting Gamepass System (+ Regional Gifts)

This tutorial covers on how to make a Gifting Gamepass System that works even with the player in-game, or offline + uses the new UserPriceLevel API for regionally priced gifts.

You can use this tutorial if:

  • You already have a Player Data Saving system and want to integrate Gamepass Gifting into it
  • If you want a Player Data Saving system from scratch that is compatible with Gamepass Gifting

NOTE: This is ONLY a tutorial, you may use pieces of the tutorial’s code, however some parts are in blank for you to fill in using your game’s systems or systems you have to create yourself (such as notifying a user and applying rate limits on the gift remote)

There may be mistakes in the code or hard-to-read sections, if you stumble upon one of them, please comment in this post

Gamepasses

For this tutorial, a Gamepasses ModuleScript should be present in ReplicatedStorage:

--game.ReplicatedStorage.Gamepasses

local Gamepasses = {
	["Jump Boost"] = {
		Gamepass = 1577725781,
		Product = 3453130190, --Devproduct used for gifting
		RegionalProduct = 3453139395 --Also devproduct used for gifting, but with regional pricing
	}
}

return Gamepasses

Make sure to change the IDs to your game’s gamepass and product IDs.

Player Data Loading/Saving

You can either implement this into your existing data handling script or follow this tutorial to create a new one.
Create a ModuleScript named “PlayerData” in ServerScriptService. These are the initial variables that are gonna be necessary:

local PlayerDataModule = {}

local DataStore = game:GetService("DataStoreService"):GetDataStore("PlayerData")
local MessagingService = game:GetService("MessagingService")
local Marketplace = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local Gamepasses = require(game.ReplicatedStorage.Gamepasses)
local DataCache = {}

local DefaultData = {
	Coins = 0, --Example, won't be used
	Items = {}, --Example, won't be used
	Gamepasses = {},
	Purchases = {}
}

(If you have your own system already, just add the initial variables to it, replace DataStore with your datastore and DefaultData with your system’s Default Data, but make sure it has “Gamepasses” and “Purchases” in it)

Next, you need the RetryRequest function which can retry unsafe calls (like DataStore and Marketplace calls) and also BetterTableClone, which is table.clone but it also copies the tables inside the root table:

function RetryRequest(Object:Instance, Function:string, ...)
	local MaxRetries = 5 --Change this to whatever you prefer
	local Cooldown = 1
	local Tries = 0
	
	local ErrorMessage = "Unknown Error"
	
	while Tries <= MaxRetries do
		Tries += 1
		local Result = {pcall(Object[Function], Object, ...)}
		if Result[1] then
			--Request resulted in success!
			return unpack(Result)
		else
			ErrorMessage = Result[2] or ErrorMessage
		end
		task.wait(Cooldown)
	end
	
	return false, ErrorMessage
end

--We need this for cloning tables that have tables inside them
--(Advanced developers will understand why)
function BetterTableClone(Table)
	local copy = {}
	for key, value in Table do
		if type(value) == "table" then
			copy[key] = BetterTableClone(value) -- recursion
		else
			copy[key] = value
		end
	end
	return copy
end

Now you need a function that handles players when they join the game, if you already have a function that does that great, but if you are following this tutorial step by step then here’s one you can use and it’s steps:

  1. Check’s if the player’s data is already loaded in this server, and doesn’t load it if it already is.
  2. Tries to get the player’s data from datastore, if the player never joined the game Default Data is used instead.
  3. Checks if the player left while step 2 (getting data from datastore) was happening, and halts if the player did leave.
  4. Goes (iterates) through every gamepass in the Gamepasses module and checks if the player has them, if yes, then it gets added to the player’s passes

Code for the PlayerJoined function:

--When a player joins, we call this function
function PlayerJoined(Player:Player)

	local UserId = Player.UserId::number

	if DataCache[UserId] then
		return false --Unlikely, but the player's data could still be loaded when they rejoin
	end
	
	--We retrieve the data from roblox's saving system
	local Success, Data = RetryRequest(DataStore, "GetAsync", UserId)
	if not Success then
		--Roblox services can be down at any time
		--Make sure to warn the player of this error!
		----In this case, Data is the Error Message
		return warn("Error loading data for player "..Player.Name..": "..Data)
	end
	print(Player.Name.."'s data loaded")

	if not Data then
		--First time this player is joining! Let's give them the default data
		Data = BetterTableClone(DefaultData)
	end

	if not Player.Parent then
		--Player left while we were retrieving their data, let's end here
		return false
	end

	DataCache[UserId] = Data

	--Now we iterate through the gamepasses and check if they have them
	local PlayerGamepasses = Data.Gamepasses
	for Name, GamepassData in Gamepasses do --Don't get PlayerGamepasses & Gamepasses mixed up! They are different!

		if not PlayerGamepasses[Name] then --Player doesn't have the gamepass!
			local GamepassId = GamepassData.Gamepass
			local Success, HasPass = pcall(Marketplace.UserOwnsGamePassAsync, Marketplace, UserId, GamepassId)
			if Success and HasPass then
				PlayerGamepasses[Name] = true
			end
		end

	end
	
	return Data
end

If you already have your own saving system, make sure to add this section of code after the player’s data is already loaded:

----Data = Player's Data that just got loaded
--Now we iterate through the gamepasses and check if the player has them
local PlayerGamepasses = Data.Gamepasses
for Name, GamepassData in Gamepasses do --Don't get PlayerGamepasses & Gamepasses mixed up! They are different!

	if not PlayerGamepasses[Name] then --Player doesn't have the gamepass!
		local GamepassId = GamepassData.Gamepass
		local Success, HasPass = pcall(Marketplace.UserOwnsGamePassAsync, Marketplace, UserId, GamepassId)
		if Success and HasPass then
			PlayerGamepasses[Name] = true
		end
	end

end

To end it off, if your PlayerJoined function isn’t connected to the PlayerAdded signal yet, add this:

--Create the connection so when players join the function gets called
Players.PlayerAdded:Connect(PlayerJoined)

for _,Player in Players:GetPlayers() do
	--We call PlayerJoined in a different coroutine so all calls can run simultaneously
	task.spawn(PlayerJoined, Player)
end

Now we move on to the saving part, when the player leaves. If you already have a saving system, nothing needs to be changed. If you don’t, here’s a function that does it (The function is really simple so explaining details is not necessary):

function PlayerLeft(Player:Player)
	local UserId = Player.UserId::number
	
	if not DataCache[UserId] then
		--Player left before their data was loaded or wasn't loaded at all
		--In this case, we return instead of overwriting their data
		return false
	end
	
	local Data = DataCache[UserId]
	DataCache[UserId] = nil
	
	local Success, ErrorMessage = RetryRequest(DataStore, "SetAsync", UserId, Data)
	if not Success then
		--This is a tutorial and does not contain auto-save, you should make an auto-save for your game!
		warn("Error saving "..Player.Name.."'s Data: "..ErrorMessage)
	else
		print(Player.Name.."'s data saved")
	end
	
	return Success
end
```
And don't forget to connect it to the PlayerRemoving signal:
```
Players.PlayerRemoving:Connect(PlayerLeft)
```
Gamepass and Gifting system

The following pieces of code are in the same “PlayerData” ModuleScript

Now, whenever the player buys a gamepass in the Gamepasses module, they should get the gamepass in their data. We can do this using Marketplace.PromptGamePassPurchaseFinished:

--When someone finishes buying a gamepass
Marketplace.PromptGamePassPurchaseFinished:Connect(function(Player,GamepassId,WasPurchased)
	if not WasPurchased then return end --Ignore if not purchased
	
	local GamepassName
	--First we need to find the name of the gamepass using the GamepassId
	for Name, GamepassData in Gamepasses do
		if GamepassData.Gamepass == GamepassId then
			GamepassName = Name
			break
		end
	end
	
	if not GamepassName then
		return --Gamepass not found
	end
	
	print(Player.Name.."'s bought gamepass: "..GamepassName)
	
	local Data = DataCache[Player.UserId]
	
	if Data then
		--We give the player the gamepass!
		Data.Gamepasses[GamepassName] = true
	end
	
end)

Now we head to the gifting logic, first we are gonna need a Gift Remote from where the client is gonna send gift requests. For this tutorial, create a RemoteEvent in ReplicatedStorage and name it “GiftGamepass”, if you put it in a different place or named it something else, make sure to define that in the code below, we will also use a GiftIntents table to know who the senders are currently sending gifts to:

--Gifting Logic:

local GiftRemote = game.ReplicatedStorage.GiftGamepass::RemoteEvent
local GiftIntents = {}

Next is the function necessary to check if a gift-sender can send regional gifts to a player, if not then the sender is forced to send the full-priced gift, we use Marketplace:GetUsersPriceLevelsAsync(ArrayOfUserIds) for that, which returns something like:

{
	{
		UserId = number,
		PriceLevel = [1-1000]
	},
	{
		UserId = number,
		PriceLevel = [1-1000]
	},

}

With that, we can check if the sender’s currency value (PriceLevel) is equal or higher than the receiver’s to determine if the gift will use regional pricing or not (attach this to your script):

function CanSendRegionalGift(Sender,Receiver)
	local Success, Data = RetryRequest(Marketplace, "GetUsersPriceLevelsAsync", {Sender, Receiver})
	if not Success or not Data then
		return false 
	end

	local SenderLevel, ReceiverLevel = 1,1

	for _, Info in Data do
		if Info.UserId == Sender then
			SenderLevel = Info.PriceLevel
		else
			ReceiverLevel = Info.PriceLevel
		end
	end

	return SenderLevel >= ReceiverLevel
end

Now starts the true gifting system, now we need to receive gift requests from the client using our GiftGamepass remote, and also determine if they are valid (avoid exploiters), here are the steps we need to do:
(Exploit warning: you have to implement rate limit yourself for this request or exploiters can spam your datastore requests)

  1. Receive the client’s request with parameters GiftingTo (UserId, number) and GamepassName (string)
  2. Filter the client’s parameters to make sure the Gamepass with given GamepassName exists and that GiftingTo isn’t a fake UserId
  3. Try to get the data from the player who is getting gifted, if that player doesn’t have data, we assume that player never bought any gamepass and continue, but if that player does then:
    • If Data.Gamepasses contains GamepassName as true, then return and halt the request
    • If Data.Gamepasses doesn’t contain GamepassName, then the player never bought the gamepass, and continue with the request
  1. Set GiftIntents[Player] to GiftingTo (memory leak warning: this is only a tutorial, the following piece of code doesn’t remove GiftIntents[Player] when the player leaves for simplicity reasons)
  2. Check if the sender can gift regionally, if yes, prompt them the regional product, if not, then prompt the normal product (full priced)

This is the piece of code following the steps above:

GiftRemote.OnServerEvent:Connect(function(Player:Player, GiftingTo:number, GamepassName:string)
	--WARNING: This part of the tutorial has no rate-limit, and can be
	--abused by exploiters to fill DataStore rate-limits.
	
	--GiftingTo is an UserId!
	GiftingTo = tonumber(GiftingTo)
	
	if not GiftingTo or not GamepassName then
		return --Exploiter might be trying to break the server code
	end
	
	local GamepassData = Gamepasses[GamepassName]
	
	--If you already have a saving system, make sure to replace the line below with your system's equivalent
	local Success, Data = RetryRequest(DataStore, "GetAsync", GiftingTo)
	if Success and Data and Data.Gamepasses[GamepassName] then
		return false --Gifting-to player already owns the gamepass!
		--Make sure to add a way so Player can know that who they are gifting-to already has the gamepass
	elseif not Success then
		return false --To make sure, we will return, what if the gifting-to player has the gamepass?
	end
	
	--MEMORY LEAK WARNING: This part of the tutorial has no prevetion agaisn't memory leaks
	--GiftIntents might get overfilled overtime even though the players in it already left
	GiftIntents[Player] = GiftingTo	
	local Regional = GamepassData.RegionalProduct and CanSendRegionalGift(Player.UserId, GiftingTo)
	if Regional then
		Marketplace:PromptProductPurchase(Player, GamepassData.RegionalProduct)
	else
		Marketplace:PromptProductPurchase(Player, GamepassData.Product)
	end
	
end)

Next, we need to use Messaging Service to SubscribeAsync to the topic “GamepassGift”, this is how we are gonna receive gift-requests if the player is in-game. (If you already have a saving system, make sure to replace the datastore-saving part with your system’s equivalent):

task.spawn(function() --Spawn in a different coroutine so that it doesn't delay other scripts when requiring this module
	--When someone receives a gamepass gift while they are in-game
	RetryRequest(MessagingService, "SubscribeAsync", "GamepassGift", function(Packet)
		local Sent, Data = Packet.Sent, Packet.Data
		--Sent is UNIX Timestamp of when the gamepass was gifted
		--Data is the data of the gift received
		local GiftedTo = Data.GiftingTo
		local Player = game.Players:GetPlayerByUserId(Data.GiftingTo)

		if Player then

			--Now we need to get their player data
			local PlayerData = DataCache[GiftedTo]
			if not PlayerData then
				repeat
					task.wait()
					PlayerData = DataCache[GiftedTo]
				until PlayerData or not Player.Parent
				if not Player.Parent then
					--Player left
					return
				end
			end

			print(Player.Name.." received gift: "..Data.GamepassName)

			PlayerData.Gamepasses[Data.GamepassName] = true

			--If you already have a saving system, modify the line below to match with your saving system
			RetryRequest(DataStore, "SetAsync", GiftedTo, PlayerData)

			local GiftedBy = Data.UserId

			--Notify the Gifted-To player about their gift

		end
	end)
end)

Now, we need to handle when players press buy after getting prompted with the gift from the GiftRemote’s code. For this, we use Marketplace.ProcessReceipt. Warning: ProcessReceipt can only be used once per game, so if in your game ProcessReceipt is already used, move it to the PlayerData module or integrate the PlayerData’s logic into your ProcessReceipt, explaining what the following code does would take too many steps, but the code has lots of comments so you can try to read off that:

--When someone buys a dev-product (gift)
Marketplace.ProcessReceipt = function(Info)
	
	local PurchaseId = Info.PurchaseId or tostring(math.random(10000,100000000))
	local UserId = Info.PlayerId
	local ProductId = Info.ProductId
	local RobuxSpent = Info.CurrencySpent or 0
	
	local Player = Players:GetPlayerByUserId(UserId)
	if not Player then
		--Don't worry, when player rejoins the server will retry to process the gift
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	
	local IsRegionalGift = false
	
	--We need to get the name of the gamepass now
	local GamepassName
	for Name, GamepassData in Gamepasses do
		if GamepassData.Product == ProductId then
			GamepassName = Name
			break
		elseif GamepassData.RegionalProduct == ProductId then
			IsRegionalGift = true
			GamepassName = Name
			break
		end
	end
	if not GamepassName then
		return Enum.ProductPurchaseDecision.NotProcessedYet --Weird behavior
	end
	
	print(Player.Name.."'s bought gift: "..GamepassName)
	
	--We need to get the player's data so we can put this purchase in his purchases
	local PlayerData = DataCache[UserId]
	if not PlayerData then
		repeat
			task.wait()
			PlayerData = DataCache[UserId]
		until PlayerData or not Player.Parent
		if not Player.Parent then
			--Player left
			return Enum.ProductPurchaseDecision.NotProcessedYet
		end
	end
	
	--Track purchase
	local Purchase = PlayerData.Purchases[PurchaseId]
	if not Purchase then
		Purchase = {
			Time = os.time(),
			PurchaseId = PurchaseId,
			ProductId = ProductId,
			GamepassName = GamepassName,
			RobuxSpent = RobuxSpent,
			IsRegional = IsRegionalGift
		}
		PlayerData.Purchases[PurchaseId] = Purchase
	end
	
	--Get who the player is gifting to
	local GiftingTo = Purchase and Purchase.GiftingTo or GiftIntents[Player]
	
	if not GiftingTo then
		--Player might've rejoined but didn't choose someone to gift yet
		-- <-- Add a way here to warn the player to choose someone to gift
		repeat
			task.wait()
			GiftingTo = GiftIntents[Player]
		until GiftingTo or not Player.Parent
		
		if not Player.Parent then
			--Player left before choosing someone to gift
			return Enum.ProductPurchaseDecision.NotProcessedYet
		end
	end
	
	PlayerData.Purchases[PurchaseId].GiftingTo = GiftingTo
	
	if IsRegionalGift then
		local CanSendRegional = CanSendRegionalGift(UserId, GiftingTo)
		if not CanSendRegional then
			--Player tried to gift someone he can't gift
			--Either an exploiter or really unlucky
			Purchase.Denied = true
			return Enum.ProductPurchaseDecision.PurchaseGranted
		end
	end
	
	--First let's update the gifting-to player's data
	local Success1 = RetryRequest(DataStore, "UpdateAsync", GiftingTo, function(Data)
		--Player might have never joined the game before,
		--so we throw in a 'DefaultData' just to make sure
		Data = Data or BetterTableClone(DefaultData)
		
		Data.Gamepasses[GamepassName] = true
		--Gamepass given!!!
		
		return Data
	end)
	
	--Now send a message to all live servers in-case the player is in-game
	local Success2 = RetryRequest(MessagingService, "PublishAsync", "GamepassGift", {
		UserId = UserId,
		GiftingTo = GiftingTo,
		GamepassName = GamepassName,
		ProductId = ProductId
	})
	
	if not Success1 or not Success2 then
		--When the player rejoins, it will retry again
		--Notify the player that he should rejoin to retry gifting!
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	
	return Enum.ProductPurchaseDecision.PurchaseGranted
	
end

And to finally end with the Server Side Gifting Logic, set these last 2 functions in your saving system/playerdata module:

PlayerDataModule.GetUserCache = function(UserId)
	return DataCache[UserId]
end

PlayerDataModule.UserHasGamepass = function(UserId,GamepassName,DoWait)
	local Player = game.Players:GetPlayerByUserId(UserId) or {}
	local PlayerData = DataCache[UserId]
	
	if not PlayerData then
		
		if DoWait then
			
			repeat
				task.wait()
				PlayerData = DataCache[UserId]
			until PlayerData or not Player.Parent
			
			if not Player.Parent then
				--Player left the game
				return false
			end
			
		else
			--PlayerData hasn't loaded in yet
			return false
		end
		
	end
	
	return PlayerData.Gamepasses[GamepassName] or false
end
--If you integrated this tutorial into your existing system, change PlayerDataModule to the name of your module or set it to a BindableFunction
Client Side Example

(LocalScript placed in StarterPlayer/StarterPlayerScripts)
This is not permanent code and you shouldn’t use this unless for testing:

  • Adds !gift <username> command in chat
  • Adds !buy command in chat
local GAMEPASS_NAME = "Jump Boost"

local Marketplace = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local Player = Players.LocalPlayer
local Gamepasses = require(game.ReplicatedStorage:WaitForChild("Gamepasses"))
local GiftRemote = game.ReplicatedStorage:WaitForChild("GiftGamepass")

Player.Chatted:Connect(function(Message)
	if Message:sub(1,5) == "!gift" then
		local Username = Message:split(" ")[2] or ""
		local Success, UserId = pcall(Players.GetUserIdFromNameAsync, Players, Username)
		if Success and UserId then
			GiftRemote:FireServer(UserId, GAMEPASS_NAME)
		else
			warn(UserId)
		end
	elseif Message:sub(1,4) == "!buy" then
		Marketplace:PromptGamePassPurchase(Player, Gamepasses[GAMEPASS_NAME].Gamepass)
	end
end)
Implementing the new gamepass checks

Finally, to test out the system, you can try using it from another script: PlayerDataModule.UserHasGamepass(UserId, PassName, DoWait).
Example of a jump boost script, which is placed separately from the PlayerData module:

--Require our Player Data module
local PlayerDataModule = require(game.ServerScriptService.PlayerData)

local function PlayerAdded(Player:Player)
	--If the player's character already exists, reload
	if Player.Character then
		task.spawn(Player.LoadCharacter,Player)
	end
	
	--When player's character spawns, we check if they have the gamepass and give jump boost
	Player.CharacterAdded:Connect(function(Character)
		
		if PlayerDataModule.UserHasGamepass(Player.UserId, "Jump Boost", true) then
			Character:WaitForChild("Humanoid").JumpPower *= 3
		end
		
	end)
end

local Players = game:GetService("Players")

--Whenever a player joins we call PlayerAdded
Players.PlayerAdded:Connect(PlayerAdded)

for _, Player in Players:GetPlayers() do
	task.spawn(PlayerAdded, Player)
end

Test Place for the system: GiftingSystemTutorial.rbxl (80.6 KB)
Change the GamepassIds and ProductIds in ReplicatedStorage/Gamepasses

Test Commands:

  • Use !gift <username> to gift
  • Use !buy to prompt the normal non-gift gamepass for you
16 Likes

If you have any questions or problems, don’t be afraid to say it here!

I’ve never used or touched ProfileStore, raw datastore has always been easier for me so I don’t know :man_shrugging:

It’s the easiest API ever omg :sob: