Skip to content

Commerce and trading

Recipes for developer products, game passes, per-player prices, policy-gated paid loot, journaled trades, inventories, pity loot tables, crafting stations and coin shops.

The economy splits by what changes hands. Receipts owns Robux purchases, Passes owns game pass ownership, Pricing owns what each player pays, ShopKit owns soft currency, Transactions owns player-to-player exchange, and InventoryKit is the item model under all of them. Server examples are services in src/Server/Game/Services/, with tunables in src/Server/Game/Config/. The recipes build one EconomyService, so their item ids agree.

python tools/scaffold.py data creates DataService, which attaches Receipts once and registers every grant in Data/Products.luau:

src/Server/Game/Data/Products.luau
--!strict
-- Developer product grants: [productId] = function(session, receiptInfo).
-- Grants must not yield and may only change session.Profile.Data.
return {
[1234567890] = function(session: any, _receiptInfo: any)
session.Profile.Data.Coins += 500
end,
}

Receipts acknowledges a purchase only after the grant has been saved: grant, save, then acknowledge. Acknowledging before the save is how a crash between the two gives away free products, and granting a redelivered receipt twice is how players double-dip; a ledger of purchase ids in the profile (the newest 2000) closes both. A failed save is retried in place with backoff (2 seconds, doubling up to 30) while the buyer stays. Roblox has no timed redelivery, so when the buyer leaves the receipt stays open and comes back on their next purchase or join.

A grant that cannot deliver what was paid for declines the purchase: return false (or raise) and the profile rolls back and the receipt stays open. A Loot.Refused or Inventory.Overflow for the buyer during the grant declines it the same way unless the grant returns true (after mailing the overflow, for example). A receipt that arrives while the buyer’s profile, Policy lookup or a trade on their profile is still in progress waits for it. Grants must finish without yielding and change only profile data; a schema codec must keep the ReceiptLedger field (declare it or enable extras). Register every product on one Receipts: a second Receipts.attach on the same MarketplaceService errors. Where the engine offers MarketplaceService:BindReceiptHandler, attach binds one catch-all handler and keeps ProcessReceipt as the fallback. Bus: Commerce.PurchaseGranted(player, productId, purchaseId, purchaseChannel?, currencySpent?), where currencySpent is the Robux the buyer actually paid after Roblox Plus and other discounts.

Go deeper: Receipts, Scaffolding.

Commerce.Passes looks up ownership once per session and follows purchases made in the server. It never polls:

src/Server/Game/Services/PassService.luau
--!strict
-- PassService: game pass ownership and the perks it unlocks.
local ServerScriptService = game:GetService("ServerScriptService")
local Passes = require(ServerScriptService.ChloeKernelServer.Commerce.Passes)
local PassService = {
Name = "PassService",
}
function PassService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Passes = self.Scope:add(Passes.attach(kernel, {
Passes = { VIP = 123456, DoubleCoins = 234567 },
}), "destroy")
end
function PassService.start(self: any)
-- Replays passes already owned in live sessions, then follows new ones
self.Scope:add(self.Passes:onOwned(function(session, _passId, name)
if name == "VIP" then
session.Player:SetAttribute("VIP", true)
end
end))
end
-- Other services ask at the moment it matters: false while the lookup is pending
function PassService.coinMultiplier(self: any, session: any): number
return if self.Passes:owns(session, "DoubleCoins") then 2 else 1
end
function PassService.stop(self: any)
self.Scope:destroy()
end
return PassService

The client shows the purchase prompt with MarketplaceService:PromptGamePassPurchase(Players.LocalPlayer, 234567).

When a session starts, every configured pass is looked up with UserOwnsGamePassAsync at once, so the engine batches the calls, and the answers land on session.Passes ({ [passId] = boolean }). A server-side PromptGamePassPurchaseFinished with wasPurchased marks the pass owned without another lookup, even while the join lookup is still in flight. A failed lookup retries twice with backoff; a pass bought outside the experience shows on the next join. Bus: Commerce.PassOwned(session, passId, name) once per owned pass per session, and the milestone Commerce.PassesResolved(session) when every lookup has finished (Milestones.on replays it).

Go deeper: Passes, Milestones.

Roblox Plus subscribers pay less for passes and developer products, and regional pricing and price tests change prices per player, so a hard-coded price shows some players the wrong number. Only a client call to GetProductInfoAsync returns the local player’s own price, so the shop reads it on the client:

src/Client/Game/Controllers/PriceController.luau
--!strict
-- PriceController: the local player's own price for each pass and product in the shop.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Prices = require(ReplicatedStorage.ChloeKernel.Prices)
local PriceController = {
Name = "PriceController",
}
function PriceController.init(self: any, kernel: any)
self.Kernel = kernel
end
function PriceController.start(self: any)
self.Quotes = Prices.new({
Passes = { VIP = 123456, DoubleCoins = 234567 },
Products = { Coins500 = 1234567890 },
Report = true, -- tells Commerce.Pricing what this player pays for passes (never self.Net)
})
self.Quotes:onChanged(function(name, quote)
-- Price is what this player pays; show BasePrice struck through when it is higher
local Was = if quote.Price < quote.BasePrice then quote.BasePrice else nil
self.Kernel.Bus:publish("Hud.PriceChanged", name, quote.Price, Was)
end)
end
function PriceController.stop(self: any)
if self.Quotes then
self.Quotes:destroy()
end
end
return PriceController

The server counts what each purchase cost. Add RobuxSpent = 0 to the profile’s Defaults:

src/Server/Game/Services/SpendService.luau
--!strict
-- SpendService: Roblox Plus status and the Robux each player has spent.
local ServerScriptService = game:GetService("ServerScriptService")
local Pricing = require(ServerScriptService.ChloeKernelServer.Commerce.Pricing)
local SpendService = {
Name = "SpendService",
Dependencies = { "DataService" },
}
function SpendService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
-- Passes only preloads list prices (every pass bought counts); they cap what a client's pass quote can claim
self.Pricing = self.Scope:add(Pricing.attach(kernel, {
Passes = { VIP = 123456, DoubleCoins = 234567 },
}), "destroy")
end
function SpendService.start(self: any)
self.Scope:add(self.Kernel.Bus:subscribe("Commerce.RobuxSpent", function(_topic, session, _kind, _id, amount, _source)
local Profile = session.Profile -- loaded: Pricing holds a spend until the profile is
if Profile and Profile.Active then
Profile.Data.RobuxSpent += amount
end
end))
end
function SpendService.stop(self: any)
self.Scope:destroy()
end
return SpendService

get(name) returns nil until a quote loads, so show your list price meanwhile. A quote carries Price, BasePrice, PlusPercent and the Discounts list, and every quote is read again when the player’s HasRobloxSubscription changes. On the server, session.RobloxPlus mirrors the subscription (hasPlus(session), Bus Commerce.PlusChanged(session, subscribed)), and Commerce.RobuxSpent(session, kind, id, amount, source) fires once per purchase, after the buyer’s profile has loaded. A developer product counts the receipt’s CurrencySpent (source "Receipt"). Roblox reports no paid price for a pass, so a pass counts the price the client reported through Report, capped at the list price so a client can never inflate it ("ClientQuote"), or the list price when no report arrived ("ListPrice"). If the list price stays unreadable after the retries, the reported price counts uncapped ("UnverifiedQuote"), so decide in the handler whether to trust it. A modified client can still undercount its own spend, down to 1 Robux.

Go deeper: Pricing, Receipts, Passes.

InventoryKit is a definition registry plus slotted per-player bags with stacking, equip slots and use handlers, saved in the profile. Every other recipe on this page grants these ids:

src/Server/Game/Config/Items.luau
--!strict
-- Items: every InventoryKit item definition.
return {
Sword = { Stack = 1, Kind = "Weapon", Equip = "Primary", Meta = { Damage = 25 } },
Potion = {
Stack = 10,
Kind = "Potion",
OnUse = function(session)
local Character = session.Player.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if not Humanoid or Humanoid.Health <= 0 then
return false
end
Humanoid.Health = math.min(Humanoid.Health + 25, Humanoid.MaxHealth)
return true -- consumed: one leaves the stack
end,
},
Herb = { Stack = 50 },
Water = { Stack = 20 },
Iron = { Stack = 50 },
RareRelic = { Stack = 1 },
Cat = { Stack = 1, Kind = "Pet" },
Dragon = { Stack = 1, Kind = "Pet" },
}
src/Server/Game/Services/EconomyService.luau
--!strict
-- EconomyService: the inventory, loot tables, crafting and shops.
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)
local Items = require(script.Parent.Parent.Config.Items)
local EconomyService = {
Name = "EconomyService",
Dependencies = { "DataService" },
}
function EconomyService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Inventory = self.Scope:add(InventoryKit.attach(kernel, {
Items = Items,
MaxSlots = 30,
Tools = { Sword = ServerStorage.Tools.Sword }, -- equipping hands the player this Tool
}), "destroy")
end
-- Quest rewards, drops and admin tools call this
function EconomyService.give(self: any, session: any, itemId: string, count: number): number
return self.Inventory:grant(session, itemId, count)
end
function EconomyService.stop(self: any)
self.Scope:destroy()
end
return EconomyService

grant(session, id, count) returns how many landed and publishes Inventory.Overflow(session, id, remainder) for the rest; take(session, id, count) is all or nothing. Both are server calls only: no client message can mint an item. Clients get four fail-closed intents, INV_Move, INV_Equip, INV_Unequip and INV_Use, whose validators re-derive legality from the server’s books, and the owner receives an INV_Sync state push after every change, so the inventory screen only renders what arrives. Stacking fills existing piles before empty slots, move swaps or merges (equip references follow), equipping displaces the current item through its OnUnequip, and draining a stack unequips it. space(session, id) reports how many more fit.

While an item’s OnUse runs, that slot is locked: the unit in use cannot be used twice, sold, crafted or traded, and count leaves it out. Grants made while the profile still loads merge into it when it arrives. Tools bridges equip slots to Roblox Tools: equipping clones the template into the player’s current Backpack (looked up every time, because the engine replaces the Backpack on every spawn) and has the Humanoid hold it; unequipping destroys the clone. The data lives in the profile under Inventory as { Slots, Equipped }, so leave that key out of your own Defaults. The fail-open hooks Inventory.CanEquip and Inventory.CanUse carry game rules such as tournament locks, and Persist = false keeps a bag for the visit only. Bus: Inventory.Granted, Inventory.Taken, Inventory.Equipped, Inventory.Unequipped, Inventory.Used, Inventory.Changed, Inventory.Overflow.

Go deeper: InventoryKit, Hooks.

LootKit rolls weighted tables on the server. An entry yields an item, a nested table or nothing, and PityAfter = N forces an entry after N consecutive misses, with counters saved per player:

src/Server/Game/Config/Loot.luau
--!strict
-- Loot: LootKit tables. Ids come from Config/Items.
return {
Chest = {
Rolls = 2,
Entries = {
{ Weight = 70, Id = "Herb", Count = { 2, 5 } },
{ Weight = 25, Table = "Ores" }, -- rolled in place
{ Weight = 5, Id = "RareRelic", PityAfter = 30 }, -- the 31st draw after 30 misses is forced
},
},
Ores = { Entries = { { Weight = 3, Id = "Iron", Count = 2 }, { Weight = 1, Nothing = true } } },
}
src/Server/Game/Services/EconomyService.luau
-- In init(), after the inventory
self.Loot = LootKit.attach(kernel, { Inventory = self.Inventory, Tables = require(script.Parent.Parent.Config.Loot) })
-- A chest the player opened
function EconomyService.openChest(self: any, session: any)
local Granted, Overflow = self.Loot:award(session, "Chest")
return Granted, Overflow
end

award rolls against the player’s counters and grants through InventoryKit, returning what landed and what did not fit (drop it, mail it or refund it). rollFor(session, name) rolls without granting. roll(name) rolls with no player for previews and never advances pity. Pity counts draws, not opens: with Rolls = 2 the relic arrives within 16 chests. Counters live in the profile under LootPity. Nesting stops at depth 8, and an unknown table reference fails at attach. Bus: Loot.Rolled(tableName, items, session?), Loot.Awarded(session, tableName, granted, overflow), Loot.Refused(session, tableName, reason).

Go deeper: LootKit, InventoryKit.

Roblox requires experiences that sell random items for Robux or Robux-bought currency (eggs, crates), or that let players trade paid items, to honor each player’s PolicyService rules. Attach Policy once and mark the paid tables:

src/Server/Game/Config/Loot.luau
Egg = {
Paid = true, -- opened with Robux: rolls per player, behind the Policy gate
Entries = { { Weight = 90, Id = "Cat" }, { Weight = 10, Id = "Dragon" } },
},
src/Server/Game/Services/EconomyService.luau
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local Policy = require(ServerScriptService.ChloeKernelServer.Policy)
local EggProductId = 345678
-- In init(), before the loot tables roll anything: sets kernel.Policy, which LootKit and Transactions read
self.Rules = self.Scope:add(Policy.attach(kernel), "destroy")
function EconomyService.start(self: any)
-- Show the egg shop only to players allowed to buy it
self.Scope:add(Milestones.on(self.Kernel, "Policy.Loaded", function(session)
session.Player:SetAttribute("CanBuyEggs", self.Rules:allows(session, "PaidRandomItems"))
end))
-- A refusal (PolicyRestricted) or a full bag publishes Loot.Refused or
-- Inventory.Overflow, which declines the purchase and leaves the receipt open
self.Kernel:getService("DataService").Receipts:onProduct(EggProductId, function(session, _receiptInfo)
self.Loot:award(session, "Egg")
end)
end

Each session gets one GetPolicyInfoForPlayerAsync call (at most 4 in flight, 3 retries with backoff), stored frozen on session.Policy and announced by the milestone Policy.Loaded(session, policy). Everything fails closed until it loads and after a final failure (Policy.Failed): a Paid table refuses rollFor and award with PolicyRestricted, roll() on a Paid table raises, and Transactions.atomic refuses paid trades. Rules:allows(session, feature) answers the other features too (Ads, Subscriptions, CommerceProducts, ContentSharing and more), and is false until the policy loads.

Go deeper: Policy, LootKit, Receipts.

Transactions.atomic writes a pending marker into both records, commits one durable journal decision, then settles both records. If a server dies midway, DataDriver resolves the journal before it hands the recovered profile to anyone; with lock handoff that load lands about 15 seconds after the crash. Both profiles must belong to the same DataDriver and the mutator must not yield:

src/Server/Game/Services/TradeService.luau
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Transactions = require(ServerScriptService.ChloeKernelServer.Commerce.Transactions)
local Log = Logger.scope("TradeService")
-- Called when both players have accepted; offerId is the server-issued id for this offer
function TradeService.settle(self: any, seller: any, buyer: any, offerId: string, gems: number, price: number)
local Result = Transactions.atomic(seller.Profile, buyer.Profile, function(sellerData, buyerData)
if sellerData.Gems < gems or buyerData.Coins < price then
return false -- abort: neither record changes
end
sellerData.Gems -= gems
buyerData.Gems += gems
buyerData.Coins -= price
sellerData.Coins += price
return true
end, { Id = offerId })
if not Result.Ok then
Log.info("trade refused", offerId, Result.Reason)
end
return Result
end

The mutator works on copies, so return false costs nothing. Id makes replays (a retried intent, a double-clicked accept) fail with DuplicateTransaction instead of applying twice; the newest 64 ids per participant are kept. Busy means either profile is inside another trade or has a save in flight: per-profile locks stop two trades over the same data from interleaving. PolicyRestricted means a Policy is attached and not every owner may trade paid items; pass { Paid = false } for trades that move nothing bought with Robux. PendingRecovery means the outcome needs recovery, and Result.Committed = true means the decision already committed even if settling failed. Never compensate one side by hand or retry an uncertain trade under a new id. A participant whose settle save failed is blocked and their session ends, so the next load recovers the trade. Cross-server trades and profiles from different drivers are rejected.

Server writes that land during a trade’s decision write are kept when they touched other values. Other server code that writes Profile.Data should defer while TransactionOwner is set or TransactionBlocked is true; the network, receipt and persistence paths already do.

Go deeper: Transactions, Transaction recovery.

CraftingKit is recipes over InventoryKit: every input count checks before any take, timed crafts finish on their own deadline, and station recipes check where the player crafts.

src/Server/Game/Services/EconomyService.luau
-- In init(), after the inventory
self.Crafting = self.Scope:add(CraftingKit.attach(kernel, {
Inventory = self.Inventory,
Recipes = {
HealthPotion = { Inputs = { Herb = 2, Water = 1 }, Output = { Potion = 1 }, Seconds = 3 },
IronSword = { Inputs = { Iron = 3 }, Output = { Sword = 1 }, Station = "Forge" },
},
}), "destroy")
-- In start(): the client names a station, and the server checks the player stands at one
self.Scope:add(self.Kernel.Hooks:on("Craft.CanCraft", function(context)
if context.Station ~= "Forge" then
return nil
end
local Character = context.Session.Player.Character
local Forge = workspace.Stations:FindFirstChild("Forge")
return Character ~= nil and Forge ~= nil and (Character:GetPivot().Position - Forge:GetPivot().Position).Magnitude < 12
end))

Clients send CR_Craft {recipeId, station} (an empty station string means none) and CR_Cancel through the fail-closed chain. The fail-open Craft.CanCraft hook receives Session, RecipeId and Station, and returning false vetoes. One craft runs per session. canCraft returns reasons: UnknownRecipe, MissingInputs, Busy, WrongStation, Vetoed, and Loading while the profile loads. A take that fails after a yielding hook grants back the inputs already taken. A timed craft is recorded in the profile (Crafting, renamed with Field) with its wall-clock deadline, so a leave, teleport or shutdown never loses its inputs: the next load completes it or resumes the time left. Output or a cancel refund that does not fit publishes Craft.Overflow. Bus: Craft.Started(session, recipeId, finishAt), Craft.Completed, Craft.Cancelled, Craft.Overflow(session, recipeId, remainder).

Go deeper: CraftingKit, InteractionKit.

ShopKit sells items for soft currency over InventoryKit. The default currency adapter reads and writes a profile field:

src/Server/Game/Services/EconomyService.luau
-- In init(), after the inventory
self.Shops = self.Scope:add(ShopKit.attach(kernel, {
Inventory = self.Inventory,
CurrencyField = "Coins", -- the default
Shops = {
Apothecary = {
Listings = { Herb = { Price = 5, SellPrice = 2 }, Water = { Price = 3 }, Potion = { Price = 40, Stock = 5 } },
Rotation = { Every = 3600, Size = 1, Pool = { "RareRelic", "Iron" }, Seed = 7 },
},
},
}), "destroy")

A buy checks the price, works out how many fit, charges for those, and only then grants, so the item never exists before the charge lands. Anything that stopped fitting while the charge ran is refunded through the adapter’s Give. A full bag charges nothing and rejects InventoryFull. A custom adapter is { Get(session), Take(session, amount) -> boolean, Give(session, amount) }: Take must return true on success, and anything else cancels the buy with CannotAfford. Adapters may yield, so each session runs one buy or sell at a time and an overlapping one rejects Busy.

Rotation picks come from Seed + floor(clock / Every), so every server lists the same stock without coordination. Stock = N caps buys per player per rotation window, saved in the profile so rejoining never resets it (a shop without rotation caps per lifetime). Clients send SH_Buy and SH_Sell with counts capped at 99. Bus: Shop.Bought(session, shopId, itemId, count, cost), Shop.Sold(session, shopId, itemId, count, payout), Shop.Rejected(player, shopId, itemId, reason).

Go deeper: ShopKit, InventoryKit.

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