Skip to content

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.

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 += 500
end)

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

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 true
end)
if not Result.Ok then
warn("trade failed:", Result.Reason)
end

Pass 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

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 rewards
Inventory: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

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 InventoryKit

roll(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

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

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