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.
How it works
Section titled “How it works”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.
--!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 baseend
function PerkService.stop(self: any) if self.Owned then self.Owned:disconnect() end self.Passes:destroy()end
return PerkServiceThe 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)Configuration
Section titled “Configuration”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. |
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| 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. |
Gotchas
Section titled “Gotchas”- A pass bought on the website during a session shows on the next join. Nothing polls for it.
ownsreturnsfalsewhile the lookup is pending. Perks that must be right from the first frame should wait forCommerce.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.
onOwnedreplays on new threads, so replayed calls can run afteronOwnedreturns.