Commerce & Trading
Complete, copyable recipes for the economy: real-money receipts, dupe-proof trades, and the item layer they sit on. Kernel is the booted server kernel unless stated otherwise. The division of labor is deliberate — Receipts owns Robux, ShopKit owns soft currency, Transactions owns player-to-player exchange, and InventoryKit is the item model underneath all of them.
Sell a developer product safely
Section titled “Sell a developer product safely”Receipts are acknowledged only after the grant has persisted — grant, persist, then acknowledge:
local Shop = Receipts.attach(Kernel, { GetProfile = function(player) local Session = Kernel:getSession(player) return Session and Session.Profile end,})
Shop:onProduct(1234567890, function(session, receiptInfo) session.Profile.Data.Coins += 500end)Duplicate receipts are detected via a ledger in the profile; a failed save defers the acknowledgement so Roblox retries, and the retry grants exactly once. The ordering is the whole recipe: acknowledging before the save persists is how a crash between the two mints free product, and granting on the retry of an already-granted receipt is how players double-dip — the ledger closes both.
Go deeper: Receipts · DataDriver
Trade items between players
Section titled “Trade items between players”Transactions.atomic is the escrow: mutate deep copies, abort freely, commit only when both profiles save:
local Result = Transactions.atomic(SellerProfile, BuyerProfile, function(seller, buyer) local Index = table.find(seller.Inventory, ItemId) if not Index or buyer.Coins < Price then return false -- abort: both untouched end table.remove(seller.Inventory, Index) table.insert(buyer.Inventory, ItemId) buyer.Coins -= Price seller.Coins += Price return trueend)
if not Result.Ok then warn("trade failed:", Result.Reason)endPass a transaction id and identical replays (retried intents, double-clicked accepts) are rejected instead of applied twice:
Transactions.atomic(SellerProfile, BuyerProfile, mutator, { Id = tradeOfferId })-- Reason = "DuplicateTransaction" on replay, "Busy" if either profile is-- already inside another atomic (per-profile locks: concurrent trades over-- the same inventory cannot interleave — the classic same-server dupe)The mutator works on deep copies, so return false costs nothing — neither profile changes until both saves succeed. If the second save fails the first is compensated back. Cross-server trades are rejected by design — that’s how dupes are born.
Go deeper: Transactions · DataDriver
Give players an inventory
Section titled “Give players an inventory”InventoryKit is the item model everything else was missing — a definition registry, slotted per-player bags with stacking, equip slots, and use handlers, persisted through the profile:
local Inventory = InventoryKit.attach(Kernel, { MaxSlots = 30, Items = { Sword = { Stack = 1, Equip = "Primary", Meta = { Damage = 25 }, OnEquip = function(session, item) giveTool(session, "Sword") end }, Potion = { Stack = 10, OnUse = function(session) healCharacter(session, 25) return true -- consumed: one leaves the stack end }, },})Inventory:grant(session, "Potion", 3) -- loot drops, shop purchases, quest rewardsInventory:take(session, "Potion", 1) -- crafting costs, trade escrow (all-or-nothing)Grants and takes are server-API-only — no client message can mint an item. Clients get four fail-closed intents (INV_Move/INV_Equip/INV_Unequip/INV_Use) whose validators re-derive legality from the server’s books, and the owner receives an INV_Sync state push after every mutation — inventory UI is purely a render problem. Stacking tops up piles before opening slots, move is the drag-and-drop primitive (swap or merge, equip references follow), equipping displaces the incumbent through its OnUnequip, and draining a stack auto-unequips. Hook points Inventory.CanEquip/Inventory.CanUse (fail-open) carry game rules like tournament locks. Persist = false keeps a bag session-only (battle-royale loadouts). Bus: Inventory.Granted/Taken/Equipped/Unequipped/Used/Changed.
Go deeper: InventoryKit · Hooks
Roll loot with pity timers
Section titled “Roll loot with pity timers”LootKit rolls weighted tables server-side. Entries yield items, nested tables, or nothing; PityAfter = N force-hits an entry on the Nth consecutive miss, with counters persisted per player in the profile:
local Loot = LootKit.attach(Kernel, { Inventory = Inventory, Tables = { Chest = { Rolls = 2, Entries = { { Weight = 70, Id = "Coin", Count = { 5, 20 } }, { Weight = 25, Table = "Herbs" }, -- nested table rolled in place { Weight = 5, Id = "RareRelic", PityAfter = 30 }, -- guaranteed within 31 opens } }, Herbs = { Entries = { { Weight = 1, Id = "Sage" }, { Weight = 1, Id = "Nightshade" } } }, },})local Granted, Overflow = Loot:award(session, "Chest") -- rolls + grants through InventoryKitroll(name) rolls with no player (display, previews) and never advances pity. rollFor(session, name) rolls against the player’s persisted counters. Nesting caps at depth 8 and unknown table references fail at attach. award returns what landed and what didn’t fit — overflow is yours to drop, mail, or refund. Bus: Loot.Rolled(tableName, items, session?), Loot.Awarded(session, tableName, granted, overflow).
Go deeper: LootKit · InventoryKit
Craft items at stations
Section titled “Craft items at stations”CraftingKit is recipes over InventoryKit — inputs check atomically before any take, timed crafts queue with cancel refunds, and station recipes gate on where the player crafts:
local Crafting = CraftingKit.attach(Kernel, { Inventory = Inventory, Recipes = { HealthPotion = { Inputs = { Herb = 2, Water = 1 }, Output = { Potion = 1 }, Seconds = 3 }, IronSword = { Inputs = { Iron = 3 }, Output = { Sword = 1 }, Station = "Forge" }, },})Clients ride CR_Craft {recipeId, station} and CR_Cancel through the fail-closed chain; the claimed station verifies in the Craft.CanCraft hook (check proximity to the tagged forge there — InteractionKit’s zone or a distance check). One active craft per session; canCraft returns reasons (MissingInputs, Busy, WrongStation, Vetoed). Output that cannot fit publishes Craft.Overflow. Bus: Craft.Started/Completed/Cancelled/Overflow.
Go deeper: CraftingKit · InventoryKit · InteractionKit
Sell things for coins
Section titled “Sell things for coins”ShopKit is soft-currency shops over InventoryKit (Receipts owns Robux; this owns coins). The default currency adapter reads and writes a profile field; custom adapters plug in for anything else:
local Shop = ShopKit.attach(Kernel, { Inventory = Inventory, CurrencyField = "Coins", Shops = { Apothecary = { Listings = { Herb = { Price = 5, SellPrice = 2 }, Cauldron = { Price = 250 } }, Rotation = { Every = 3600, Size = 3, Pool = { "RareSeed", "Moonstone", "Vial", "Charm" }, Seed = 7 }, }, },})Rotation picks derive from Seed + floor(clock / Every) — every server lists the same rotating stock with zero coordination. Stock = N caps buys per player per window. Buys grant first and charge for what fit (a partial grant charges the partial price; a full inventory charges nothing). Clients ride SH_Buy/SH_Sell fail-closed with counts capped at 99. Bus: Shop.Bought/Sold/Rejected.
Go deeper: ShopKit · InventoryKit · Receipts