Skip to content

Passes

Looks up game pass ownership once per session, stores it on session.Passes, and marks in-game purchases owned from the server's purchase event, without polling.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Commerce.Passes
Since
v0.9.0
Source
Commerce/Passes.luau

Passes tracks game pass ownership for each session. Every configured pass is looked up once when the session starts and stored on session.Passes, and a purchase made in the experience marks the pass owned from the server’s PromptGamePassPurchaseFinished event. The defining decision is that nothing polls: ownership changes on join and on a server purchase event, and a pass bought outside the experience shows on the player’s next join. Game passes are not receipts, so they never go through Receipts, which handles developer products. Platform badges are awarded by AchievementKit.

When a session starts, Passes sets session.Passes to a table keyed by pass id and starts one UserOwnsGamePassAsync lookup per configured pass, all at once. The engine batches concurrent calls and caches their results, which is why they start together instead of one after another.

Each lookup ends one of three ways:

Result Effect
Owned session.Passes[passId] = true and Commerce.PassOwned(session, passId, name) is published once.
Not owned session.Passes[passId] = false.
Failed Retried after RetrySeconds * 2^attempt (2 s, then 4 s by default) on a Background-priority Scheduler deadline, up to Retries times. After the last failure it warns that ownership of that pass is unknown and leaves the entry unset, which reads as not owned.

When every lookup has finished, whatever the outcome, Passes publishes Commerce.PassesResolved(session) as a milestone, so a service that subscribes late still sees it.

A server-side PromptGamePassPurchaseFinished with wasPurchased = true marks the pass owned at once, without another lookup, even while the join lookup for that pass is still in flight. Within a session ownership only ever turns on: a late lookup that returns false cannot undo a purchase that just happened. Purchase events for pass ids that are not configured are ignored.

When the session ends, its state is dropped: pending retry deadlines are cancelled and lookups still in flight discard their results.

src/Server/Game/Services/PerkService.luau
--!strict
-- PerkService: game pass perks.
local ServerScriptService = game:GetService("ServerScriptService")
local Passes = require(ServerScriptService.ChloeKernelServer.Commerce.Passes)
local PerkService = {
Name = "PerkService",
}
function PerkService.init(self: any, kernel: any)
self.Kernel = kernel
self.Passes = Passes.attach(kernel, { Passes = { VIP = 123456, DoubleCoins = 234567 } })
end
function PerkService.start(self: any)
-- Replays passes already owned in live sessions, then fires for each new ownership
self.Owned = self.Passes:onOwned(function(session, _passId, name)
if name == "VIP" then
session.Player:SetAttribute("VIP", true)
end
end)
end
function PerkService.coinReward(self: any, session: any, base: number): number
return if self.Passes:owns(session, "DoubleCoins") then base * 2 else base
end
function PerkService.stop(self: any)
if self.Owned then
self.Owned:disconnect()
end
self.Passes:destroy()
end
return PerkService

The client prompts the purchase with MarketplaceService:PromptGamePassPurchase(player, passId). The server-side event that follows is what Passes trusts. session.Passes lives on the server; replicate what the client needs to show through an attribute, as above, or a channel.

The price next to the prompt differs per player: Roblox Plus subscribers pay less, and regional pricing and price tests change it too. Pricing reads each player’s own price on the client and publishes Commerce.RobuxSpent with what a pass purchase cost.

Code that must not act before ownership is known waits for the milestone:

local Entitlements = Passes.attach(kernel, { Passes = { VIP = 123456 } })
local Resolved = Milestones.on(kernel, "Commerce.PassesResolved", function(session)
-- Every configured lookup has finished for this session
local Starter = if Entitlements:owns(session, "VIP") then "GoldStarter" else "Starter"
session.Player:SetAttribute("StarterKit", Starter)
end)

Passes.attach(kernel, options):

Field Default Description
Passes required { [name] = passId }. Every id must be a positive number and appear once; attach errors otherwise.
Retries 2 Lookups retried after a failure.
RetrySeconds 2 First retry delay, doubling per attempt.
Marketplace MarketplaceService Injectable for specs.
Member Description
Passes.attach(kernel, options) -> Passes Connects the purchase event and starts lookups for every current and future session.
passes:owns(session, pass) -> boolean Whether the session’s player owns pass, given by name or id. false while the lookup is pending or failed. An unknown name errors.
passes:resolved(session) -> boolean true once every configured lookup for the session has finished.
passes:onOwned(fn) -> connection Calls fn(session, passId, name) for passes already owned in live sessions (each on its own thread), then for every new ownership. Returns the Bus connection; disconnect() it on stop.
passes:destroy() Disconnects the purchase event and session hooks and cancels pending retries.
session.Passes { [passId] = boolean }, filled as lookups finish. Read it through owns, which accepts names.
Topic Payload Direction
Commerce.PassOwned session, passId, name Published once per owned pass per session, from the join lookup or a purchase.
Commerce.PassesResolved session Published once per session when every lookup has finished. A milestone: Milestones.on replays it.
Kernel.SessionEnd session, ... Consumed: drops the session’s lookups and retries.
  • A pass bought on the website during a session shows on the next join. Nothing polls for it.
  • owns returns false while the lookup is pending. Perks that must be right from the first frame should wait for Commerce.PassesResolved.
  • A lookup that fails every retry reads as not owned for that visit. The warning names the pass and the player; the next join looks it up again.
  • onOwned replays on new threads, so replayed calls can run after onOwned returns.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.