Pricing and Roblox Plus
Reads each player's own pass and product prices on the client, mirrors Roblox Plus on session.RobloxPlus, and publishes the Robux each purchase cost, with client pass quotes capped at the list price.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .Commerce .Pricing - Since
- v0.9.1
- Source
- Commerce/Pricing.luauPrices.luau
Pricing is a server module with a client half. The client module, Prices, reads the local player’s own price for each configured game pass and developer product, and reads every price again when the player’s Roblox Plus status changes. The server module, Commerce.Pricing, mirrors Roblox Plus on session.RobloxPlus and publishes Commerce.RobuxSpent on the Bus with the Robux each purchase cost. The defining decision is where prices come from: only a client call to GetProductInfoAsync applies Roblox Plus discounts, regional pricing and price tests, so prices are read on the client, and the server, which sees list prices, accepts a client’s pass price only up to the list price. Developer product grants live in Receipts and pass ownership in Passes.
Roblox Plus subscribers pay 10% less on passes and developer products (20% from their third month). Roblox covers the difference, so the experience’s earnings are unchanged. Regional pricing and price tests also change prices per player, so a hard-coded price shows some players the wrong number.
Mental model
Section titled “Mental model”Where prices are read
Section titled “Where prices are read”The server’s GetProductInfoAsync call returns the list price, with no personal discount. Prices therefore runs on the client and refuses to start without a local player, the only player whose personalized price a client can read. AGENTS.md makes this a rule for game code: never hard-code Robux prices in UI, show them from a Prices created at boot, and count Robux spent from Commerce.RobuxSpent.
The server needs a player’s price for one job: counting what a pass purchase cost, which Roblox does not report. It takes that price from the client and bounds it by the list price (see Robux spent).
Quotes
Section titled “Quotes”A quote is a frozen table built from one GetProductInfoAsync reply:
| Field | Meaning |
|---|---|
Name |
The name given in Passes or Products. |
Id |
The pass or product id. |
Kind |
"Pass" or "Product". |
Price |
What this player pays: the reply’s PriceInRobux, or 0 when it has none. |
BasePrice |
The price before any discount: UserBasePriceInRobux, falling back to Price, and never below Price. |
Discounts |
The reply’s PriceDiscountDetails in Roblox’s order, each a frozen { Type, Percent, Amount }. Amount is the Robux that discount takes off (AmountInRobux). A missing Percent or Amount reads as 0. |
PlusPercent |
The sum of Percent over discounts whose Type is "RobloxPlusSubscription". 0 without Roblox Plus. |
ForSale |
false only when the reply has IsForSale = false. |
get(name) returns nil until that quote has loaded. Show the list price until then.
Refreshes
Section titled “Refreshes”Prices.new reads every configured price at once: one GetProductInfoAsync call per item, each on its own thread, so the engine can batch them into fewer requests. Passes are read with Enum.InfoType.GamePass and products with Enum.InfoType.Product. Every quote is read again in three cases:
- The local player’s
HasRobloxSubscriptionchanges. The server sets that property, so its change is the authoritative signal. PromptRobloxSubscriptionPurchaseFinishedfires for the local player withdidTryPurchasing = true. This covers a Plus prompt that closes while the property change is still replicating.- Game code calls
refresh().
Each refresh starts a new generation. A reply from an earlier generation is dropped when it lands, and so is its retry, so a slow full-price reply from before a Plus purchase cannot overwrite the discounted price read after it.
A failed lookup (an error or a reply that is not a table) is retried after RetrySeconds * 2^attempt (2 s, then 4 s by default), up to Retries times. After the last failure Prices warns no price for <Kind> "<name>" (<id>) after <n> attempts and keeps the quote it already had for that name, if any.
A quote is stored, and onChanged(name, quote) fires, when it is the first for that name or when its Price, BasePrice, PlusPercent or ForSale differ from the stored one. A re-read that returns the same values fires nothing. When every lookup of a generation has finished, successful or not, Prices reports the pass prices to the server (see Quote reports).
Robux spent
Section titled “Robux spent”Commerce.RobuxSpent(session, kind, id, amount, source) is published once per purchase, after the buyer’s profile has loaded:
| Purchase | kind |
amount |
source |
|---|---|---|---|
| A developer product | "Product" |
The receipt’s CurrencySpent: the Robux paid after Roblox Plus and every other discount. |
"Receipt" |
| A pass the player’s client quoted | "Pass" |
The quoted price, capped at the pass’s list price. | "ClientQuote" |
| A pass without a quote | "Pass" |
The pass’s list price. | "ListPrice" |
| A pass whose list price stayed unreadable | "Pass" |
The quoted price, uncapped. | "UnverifiedQuote" |
Products come from Receipts. Pricing subscribes to Commerce.PurchaseGranted, which Receipts publishes once a grant has persisted and which carries currencySpent as its fifth argument since 0.9.1. Nothing is published when the receipt has no numeric CurrencySpent or the buyer has no session, and a game that grants products without Receipts gets no "Product" amounts.
Roblox reports no paid price for a pass. Pricing listens to the server-side PromptGamePassPurchaseFinished and counts every pass bought with wasPurchased = true, configured or not. It takes the latest quote report from that player’s session and the pass’s list price, which is cached or read with GetProductInfoAsync. A failed read is tried again Retries times (default 2), after RetrySeconds (default 2) doubling per attempt. If the list price is still unreadable, a quote counts as it is with source "UnverifiedQuote", since there is nothing to cap it with, and handlers decide whether to trust it. With no quote either, nothing is published and Pricing warns Robux spent on pass <id> is unknown.
The cap exists because the client is not trusted. A quote can only lower what counts: the list price bounds a client’s claim, so a client that reports 5000 for a 100 Robux pass counts 100. A price test that charges more than the list price also counts as the list price.
Handlers usually write the amount into the profile, so a spend waits for it. A purchase made while DataDriver is still loading the buyer’s profile (session.ProfilePending) is held and published once Kernel.ProfileLoaded fires for that session. A session that ends first drops its held spends with the warning <n> purchase(s) by <name> not counted in Commerce.RobuxSpent: the session ended before its profile loaded, and a purchase that arrives after the session ended is dropped the same way. A game without DataDriver has no pending profile, so spends publish at once.
Roblox Plus on the session
Section titled “Roblox Plus on the session”When a session starts, Pricing sets session.RobloxPlus to the player’s HasRobloxSubscription and binds a property-changed connection to the session. Sessions already live when attach runs are covered too, because kernel:onSession replays them. When the property changes to a different value, session.RobloxPlus follows and Commerce.PlusChanged(session, subscribed) is published. The value at session start publishes nothing, and neither does a change event that leaves the value as it was.
pricing:hasPlus(session) reads session.RobloxPlus == true, so it is false for a session Pricing has not started. On the client, prices:hasPlus() reads the local player’s HasRobloxSubscription directly.
The ids live in one shared module so the server and the client use the same names:
--!strict-- Store: the game's passes and developer products by name. Ids only; prices come from Roblox per player.
return { Passes = { VIP = 123456, DoubleCoins = 234567 }, Products = { Gems100 = 987654 },}--!strict-- StoreService: Roblox Plus status and the Robux each player has spent.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local Pricing = require(ServerScriptService.ChloeKernelServer.Commerce.Pricing)local Store = require(ReplicatedStorage.Game.Config.Store)
local Log = Logger.scope("StoreService")
local StoreService = { Name = "StoreService", Dependencies = { "DataService" },}
function StoreService.init(self: any, kernel: any) self.Kernel = kernel -- Reads each pass's list price now, so a purchase does not wait for the lookup self.Pricing = Pricing.attach(kernel, { Passes = Store.Passes })end
function StoreService.start(self: any) self.Spent = self.Kernel.Bus:subscribe( "Commerce.RobuxSpent", function(_topic: string, session: any, kind: string, id: number, amount: number, source: string) local Profile = session.Profile -- loaded: Pricing holds spends until it is (nil only without DataDriver) if Profile then Profile.Data.RobuxSpent += amount -- RobuxSpent = 0 in the Data/Profile.luau defaults end Log.info(`{session.Player.Name} spent {amount} Robux on {kind} {id} ({source})`) end ) self.PlusChanged = self.Kernel.Bus:subscribe("Commerce.PlusChanged", function(_topic: string, session: any, subscribed: boolean) Log.info(`{session.Player.Name} {if subscribed then "started" else "ended"} Roblox Plus`) end)end
-- For support tools: Roblox Plus now, and the Robux spent across visitsfunction StoreService.spender(self: any, session: any): (boolean, number) local Profile = session.Profile return self.Pricing:hasPlus(session), if Profile then Profile.Data.RobuxSpent else 0end
function StoreService.stop(self: any) if self.Spent then self.Spent:disconnect() end if self.PlusChanged then self.PlusChanged:disconnect() end if self.Pricing then self.Pricing:destroy() endend
return StoreServicePasses preloads list prices; it does not limit which passes are counted. Products are counted through Receipts, so attach it as well. The DataService that python tools/scaffold.py data creates already does (Scaffolding).
--!strict-- StoreController: the local player's own price for every pass and product in the store.
local MarketplaceService = game:GetService("MarketplaceService")local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Prices = require(ReplicatedStorage.ChloeKernel.Prices)local Store = require(ReplicatedStorage.Game.Config.Store)
local StoreController = { Name = "StoreController",}
function StoreController.init(self: any, kernel: any) self.Kernel = kernel -- Created at boot: every price starts loading now self.Prices = Prices.new({ Passes = Store.Passes, Products = Store.Products, Report = true, -- pass prices go to Commerce.Pricing on the server })end
function StoreController.start(self: any) -- Store screens redraw one item on Store.PriceChanged and read its label from priceText self.Prices:onChanged(function(name: string, _quote: any) self.Kernel.Bus:publish("Store.PriceChanged", name) end) -- onChanged does not replay: announce quotes that loaded before it connected for Name in self.Prices:all() do self.Kernel.Bus:publish("Store.PriceChanged", Name) endend
-- The price label for one item, or nil until its quote loads (the screen keeps the list price until then)function StoreController.priceText(self: any, name: string): string? local Quote = self.Prices:get(name) if not Quote then return nil end if not Quote.ForSale then return "Off sale" end if Quote.PlusPercent > 0 then return `{Quote.Price} (Roblox Plus: {Quote.PlusPercent}% off {Quote.BasePrice})` end if Quote.Price < Quote.BasePrice then return `{Quote.Price} (was {Quote.BasePrice})` end return tostring(Quote.Price)end
-- Prices reads every quote again when this prompt closes after a purchase attemptfunction StoreController.offerPlus(self: any) if not self.Prices:hasPlus() then MarketplaceService:PromptRobloxSubscriptionPurchase(Players.LocalPlayer) endend
function StoreController.stop(self: any) if self.Prices then self.Prices:destroy() -- also disconnects every onChanged handler endend
return StoreControllerReport = true gives Prices a NetClient of its own; pass an existing NetClient instead to share one. PR_Report is a framework channel that Pricing.attach defines, so it has no entry in Game.Channels and no self.Net handle. A controller’s self.Net is the Registry handle table, not a NetClient, and passing it errors with Report takes true or a NetClient (NetClient.new()), not the Registry handle table. Without Report, every pass purchase counts "ListPrice".
Quote reports
Section titled “Quote reports”With Report set, Prices fires PR_Report each time every lookup of a refresh has finished. The payload maps each pass id, as a string, to this player’s price, for every pass quote Prices holds that is on sale with a price of at least 1: { ["700"] = 80 }. Product prices are never sent, because products count from the receipt, and nothing is sent when no pass quote qualifies. The fire runs on its own thread; a failure warns pass prices were not reported: <error>.
Pricing.attach defines the channel on the NetDriver unless Intents = false or the kernel has no network:
| Channel | Kind | Schema | Wire name | Notes |
|---|---|---|---|---|
PR_Report |
Intent | { "Any" } |
CKI_PR_Report |
RateLimit = 1 per second per player, Burst = 3. Validated by Pricing’s hook on Intent.PR_Report at priority 50. |
The Any wire type does not constrain the payload’s shape, so the validator checks all of it:
| Rule | Rejected in the spec |
|---|---|
| The payload is a table. | "700=80" |
| At most 64 entries. | 65 entries |
Every key is a string that tonumber reads as a whole number of at least 1. |
{ VIP = 80 } |
| Every price is a number, not NaN, whole, from 1 to 1,000,000,000 (a pass on sale costs at least 1 Robux; 1,000,000,000 is the highest price a pass can have). | { ["700"] = 0 / 0 }, { ["700"] = 12.5 }, { ["700"] = 0 } |
A report that breaks any rule is rejected whole. An accepted report replaces that session’s stored quotes; it is never merged with the previous one. The quotes are dropped at Kernel.SessionEnd.
API reference
Section titled “API reference”Pricing (server)
Section titled “Pricing (server)”| Member | Description |
|---|---|
Pricing.attach(kernel, options?) -> pricing |
Starts list price reads for Passes, tracks Roblox Plus for current and future sessions, subscribes to Commerce.PurchaseGranted and Kernel.SessionEnd, connects PromptGamePassPurchaseFinished, and defines PR_Report with its validator unless Intents = false. |
pricing:hasPlus(session) -> boolean |
session.RobloxPlus == true. |
pricing:listPrice(passId) -> number? |
The pass’s list price as the server sees it, with no personal discount. Cached after the first successful read; a cache miss yields on GetProductInfoAsync. Returns nil and warns no list price for pass <id> when the call fails or the reply has no price. Failures are not cached. |
pricing:destroy() |
Disconnects the purchase event and the Bus subscriptions, removes the PR_Report validator and drops every stored quote. session.RobloxPlus stops updating. A second call does nothing. |
session.RobloxPlus |
boolean. Mirrors Player.HasRobloxSubscription from the session’s start. |
Pricing.attach(kernel, options?):
| Field | Default | Description |
|---|---|---|
Passes |
{} |
{ [name] = passId }. List prices load at attach. Every id must be a positive number; attach errors otherwise. |
Intents |
true |
Defines PR_Report and its validator. Needs a kernel with a network. |
Retries |
2 |
List price reads retried at purchase time after a failure. Added after 0.9.1 (Unreleased). |
RetrySeconds |
2 |
First retry delay in seconds, doubling per attempt. Added after 0.9.1 (Unreleased). |
Marketplace |
MarketplaceService |
Injectable for specs. |
Prices (client)
Section titled “Prices (client)”Lives at ReplicatedStorage.ChloeKernel.Prices.
| Member | Description |
|---|---|
Prices.new(options) -> prices |
Registers the names, connects the Plus property and the Plus prompt, and starts the first refresh. Errors without a local player, on an id that is not a positive whole number, and on a name used twice across Passes and Products. |
prices:refresh() |
Reads every price again as a new generation. Replies to earlier generations are dropped. Does nothing after destroy(). |
prices:get(name) -> Quote? |
The player’s quote, or nil until it has loaded. |
prices:all() -> { [string]: Quote } |
A copy of the quote table, keyed by name. The quotes are frozen. |
prices:hasPlus() -> boolean |
Whether the local player’s HasRobloxSubscription is true. |
prices:onChanged(fn) -> connection |
fn(name, quote) for each quote that loads or changes. Does not replay loaded quotes. |
prices:destroy() |
Disconnects the Plus and prompt listeners and destroys the change signal, which disconnects every onChanged handler. Lookups in flight discard their replies. |
Prices.new(options):
| Field | Default | Description |
|---|---|---|
Passes |
{} |
{ [name] = passId }. |
Products |
{} |
{ [name] = productId }. Names are shared with Passes. |
Report |
nil |
true for a NetClient of its own, or a NetClient to share. Pass prices go to Commerce.Pricing through PR_Report. Anything else errors. |
Retries |
2 |
Lookups retried after a failure. |
RetrySeconds |
2 |
First retry delay, doubling per attempt. |
Marketplace |
MarketplaceService |
Injectable for specs. |
Player |
Players.LocalPlayer |
Injectable for specs. |
The Quote fields are listed under Quotes. A Discount is { Type: string, Percent: number, Amount: number }.
Hooks and Bus topics
Section titled “Hooks and Bus topics”| Name | Kind | Payload or context | Notes |
|---|---|---|---|
Commerce.RobuxSpent |
Bus | session, kind, id, amount, source |
Once per purchase, after the buyer’s profile has loaded. kind is "Product" or "Pass"; source is "Receipt", "ClientQuote", "ListPrice" or "UnverifiedQuote". |
Commerce.PlusChanged |
Bus | session, subscribed |
session.RobloxPlus changed. Not published for the value at session start. |
Commerce.PurchaseGranted |
Bus, consumed | player, productId, purchaseId, purchaseChannel?, currencySpent? |
Published by Receipts. A numeric currencySpent becomes a "Product" amount. |
Kernel.SessionEnd |
Bus, consumed | session, ... |
Drops the session’s stored quotes and any spends held for its profile, with a warning. |
Kernel.ProfileLoaded |
Milestone, consumed | session |
Publishes the spends held while the profile loaded. |
Intent.PR_Report |
hook, fail-closed | context.Args = { report } |
Pricing’s validator at 50. Game rules at lower numbers run first. |
Gotchas and design notes
Section titled “Gotchas and design notes”Prices.newerrors on the server (Prices runs on the client: personalized prices exist only for the local player). Create it in a controller.onChangeddoes not replay. Connect it, then readall()for quotes that loaded before, as the controller above does.- The first report goes out only after every lookup has finished, including the retries of a failing one (2 s and 4 s by default). A pass bought before a report reaches the server counts
"ListPrice". - Pricing counts every pass purchase event;
Passesonly preloads list prices. A pass missing fromPassescosts aGetProductInfoAsynccall on its first purchase. Failed reads are not cached, so the next purchase reads again. destroy()removes the validator but leavesPR_Reportdefined, so the fail-closed rule rejects every later report.- With
Intents = false, or no Pricing on the server, a client withReportwaits up to 10 s for the channel on every report and then warns that the prices were not reported. - Products count only through Receipts and only when the receipt carries
CurrencySpent.