ShopKit
ShopKit is soft-currency shops over InventoryKit: listings with buy and sell prices, per-player stock caps, and rotating stock that agrees across every server with zero coordination. The design decision that defines it: buys grant first and charge for what fit. The price check happens up front, but money moves only after the inventory says how much actually landed — a partial grant charges the partial price, and a full inventory charges nothing. The failure mode where a player pays for items that never arrived is structurally impossible.
Mental model
Section titled “Mental model”Buy order
Section titled “Buy order”buy(session, shopId, itemId, count) walks a refusal ladder, then commits in the one order that cannot strand money:
BadCount— count below 1, above 99, or not an integer.UnknownShop— no shop with that id.NotListed— the item is neither in staticListingsnor in this window’s rotation.NotBuyable— listed, but noPrice(a sell-only listing).OutOfStock— this purchase would exceed the listing’s per-playerStockfor the current window.CannotAfford— balance belowPrice * Count.- Grant.
Inventory:grantreturns what fit. Zero returnsInventoryFull— nothing charged. - Charge
Price * Granted. The balance check passed on this thread, so the take cannot fail. Stock consumption records only the granted amount.
Every refusal returns (false, reason) and publishes Shop.Rejected — the reason strings are your storefront’s error toasts.
Sell order
Section titled “Sell order”sell mirrors it: BadCount, UnknownShop, NotListed, NotSellable (no SellPrice), then Inventory:take — all-or-nothing, so NotOwned fires when the player holds fewer than count — then Give(SellPrice * Count) and Shop.Sold.
Currency adapters
Section titled “Currency adapters”Currency is an adapter with three functions:
{ Get = function(session) -> number, Take = function(session, amount) -> boolean, -- false when it cannot cover Give = function(session, amount),}The default adapter reads and writes a single profile field — CurrencyField, default "Coins" — in session.Profile.Data (or session.Data when the session has no profile), treating a missing field as 0. That makes the zero-config path: coins persist with the profile, Receipts handlers add to the same field, done.
A custom adapter plugs in anything else — multiple currencies, a guild bank, an Effects-buffed earn rate on Give:
local Gems = { Get = function(session) return session.Profile.Data.Gems or 0 end, Take = function(session, amount) local Data = session.Profile.Data if (Data.Gems or 0) < amount then return false end Data.Gems -= amount return true end, Give = function(session, amount) session.Profile.Data.Gems = (session.Profile.Data.Gems or 0) + amount end,}local GemShop = ShopKit.attach(Kernel, { Shops = GemShops, Inventory = Inventory, Currency = Gems, Intents = false, -- SH_Buy/SH_Sell already belong to the coin shop})One kit, one currency: attach a second ShopKit (with Intents = false on one of them — see design notes) for a second currency.
Defining shops
Section titled “Defining shops”local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal ShopKit = require(Root.Kits.ShopKit)
local Shop = ShopKit.attach(Kernel, { Inventory = Inventory, CurrencyField = "Coins", Shops = { Apothecary = { Listings = { Herb = { Price = 5, SellPrice = 2 }, -- buy and sell Cauldron = { Price = 250 }, -- buy-only OldBoot = { SellPrice = 1 }, -- sell-only (junk sink) Moonwater = { Price = 40, Stock = 3 }, -- 3 per player per window }, Rotation = { Every = 3600, -- one-hour windows Size = 3, Pool = { "RareSeed", "Moonstone", "Vial", "Charm", "Talisman" }, Seed = 7, Listing = { Price = 120 }, -- template applied to rotated items }, }, },})Listing fields
Section titled “Listing fields”| Field | Type | Default | Description |
|---|---|---|---|
Price |
number? |
nil |
Buy price per item. nil means not buyable. |
SellPrice |
number? |
nil |
Sell payout per item. nil means not sellable. |
Stock |
number? |
nil |
Max buys per player per rotation window. nil means unlimited. |
Shop fields
Section titled “Shop fields”| Field | Type | Description |
|---|---|---|
Listings |
{ [itemId]: Listing }? |
Static stock, available in every window. |
Rotation |
table? | Windowed stock — see below. A shop can have either, or both. |
Rotation fields
Section titled “Rotation fields”| Field | Type | Default | Description |
|---|---|---|---|
Every |
number |
required | Window length in seconds of the kit clock (os.time by default). |
Size |
number |
required | Items listed per window (capped at the pool size). |
Pool |
{ string } |
required | Item ids eligible for rotation. |
Seed |
number? |
0 |
Shared pick seed. Distinct shops should use distinct seeds or they rotate in lockstep. |
Listing |
Listing? |
{ Price = 100 } |
Listing template every rotated item gets for the window. |
attach options
Section titled “attach options”| Option | Type | Default | Description |
|---|---|---|---|
Shops |
{ [string]: Shop } |
required | Every shop, keyed by id. |
Inventory |
InventoryKit | required | Where buys land and sells come from. |
Currency |
adapter | field adapter | See currency adapters. |
CurrencyField |
string? |
"Coins" |
Field the default adapter uses. Ignored when Currency is passed. |
Clock |
() -> number |
os.time |
Rotation clock. os.time is wall time, which is what makes windows agree across servers. |
Intents |
boolean? |
true |
Wire SH_Buy/SH_Sell. Requires the kernel to have networking. |
Deterministic rotation
Section titled “Deterministic rotation”Rotation gives every server identical rotating stock with zero cross-server coordination — no MemoryStore, no messaging, no elected leader:
- The window index is
floor(clock / Every). Withos.time, every server on Earth computes the same index at the same moment. - The picks come from
Random.new(Seed + windowIndex)drawingSizeitems fromPoolwithout replacement.
Same seed, same window, same RNG, same picks — determinism does the coordination. listings(shopId) returns the merged view: static Listings plus this window’s picks (each carrying the Listing template). It computes on every call, so window boundaries take effect on the next call with no ticking loop anywhere in the kit.
for ItemId, Listing in Shop:listings("Apothecary") do print(ItemId, Listing.Price)endStock caps
Section titled “Stock caps”Stock = N caps buys at N per player per rotation window. The bookkeeping is a counter keyed shopId:itemId:windowIndex in session.Data.ShopStock; non-rotating shops use a constant window of 0, making the cap per server session. A refused purchase (OutOfStock) charges nothing; a partial grant consumes only the granted amount of stock.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal InventoryKit = require(Root.Kits.InventoryKit)local ShopKit = require(Root.Kits.ShopKit)
return function(kernel) local Inventory = InventoryKit.attach(kernel, { Items = { Herb = { Stack = 20 }, Cauldron = { Stack = 1 }, }, })
local Shop = ShopKit.attach(kernel, { Inventory = Inventory, Shops = { Apothecary = { Listings = { Herb = { Price = 5, SellPrice = 2 }, Cauldron = { Price = 250 } }, }, }, })
-- ShopKit pushes no storefront state; serve listings on request. local Net = kernel:net() Net:defineRequest("ShopListings", { "String" }, { "Any" }, { Handler = function(session, shopId) return Shop:listings(shopId) end, }) kernel.Hooks:on("Request.ShopListings", function(context) return Shop.Shops[context.Args[1]] ~= nil -- fail-closed: requests need a validator end, 10)
kernel.Bus:subscribe("Shop.Rejected", function(_, player, shopId, itemId, reason) print(player, "rejected at", shopId, itemId, reason) end)end-- src/Client/Bootstrap.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal NetClient = require(ChloeKernel.Net.Client)
return function(_kernel) local Net = NetClient.new() local Buy = Net:intent("SH_Buy", { "String", "String", "NumberU8" }) local Sell = Net:intent("SH_Sell", { "String", "String", "NumberU8" }) local GetListings = Net:request("ShopListings", { "String" }, { "Any" })
onShopOpened(function(shopId) local Listings = GetListings.invoke(shopId) -- yields; nil on rejection if Listings then renderStorefront(shopId, Listings) end end) onBuyClicked(function(shopId, itemId, count) Buy.fire(shopId, itemId, count) -- No local mutation: the purchase shows up as an INV_Sync push -- (items) and your currency display's own state feed (coins). end) onSellClicked(function(shopId, itemId, count) Sell.fire(shopId, itemId, count) end)endClient intents
Section titled “Client intents”Both intents ride the fail-closed chain with kit validators at priority 50:
| Intent | Schema | Rate limit | The priority-50 validator |
|---|---|---|---|
SH_Buy |
String, String, NumberU8 (shopId, itemId, count) |
10/s | Count in 1–99, shop exists, item listed in the current window. |
SH_Sell |
String, String, NumberU8 |
10/s | Same checks. |
Counts cap at 99 per message (BadCount beyond it, and the NumberU8 wire type cannot carry past 255 anyway) — a bulk-buy exploit has to spend rate-limited messages, not one giant count. The handlers call buy/sell, which re-run the full ladder; affordability, stock, and ownership are checked against server books at execution time, never trusted from the message. Add economy rules (purchase level requirements, region locks) as extra validators on Intent.SH_Buy / Intent.SH_Sell.
API reference
Section titled “API reference”| Member | Description |
|---|---|
ShopKit.attach(kernel, options) -> kit |
Asserts Shops and Inventory; wires intents when networking exists. |
kit:buy(session, shopId, itemId, count?) -> (boolean, string?) |
The grant-then-charge ladder. Reasons: BadCount, UnknownShop, NotListed, NotBuyable, OutOfStock, CannotAfford, InventoryFull. |
kit:sell(session, shopId, itemId, count?) -> (boolean, string?) |
Take-then-pay. Reasons: BadCount, UnknownShop, NotListed, NotSellable, NotOwned. |
kit:listings(shopId) -> { [itemId]: Listing } |
Static listings merged with this window’s rotation picks. Empty table for unknown shops. Computed per call. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Shop.Bought |
session, shopId, itemId, count, cost |
After a successful buy. count is the granted amount, cost what was actually charged — on a partial fit both are smaller than requested. |
Shop.Sold |
session, shopId, itemId, count, payout |
After a successful sell. |
Shop.Rejected |
player, shopId, itemId, reason |
Every refusal, server-call or intent. Note the first argument is the Player, not the session — it can be nil for sessions without one (tests). |
Shop.Bought/Shop.Sold are the feed for economy analytics and QuestKit trade objectives; a Shop.Rejected subscriber watching for repeated CannotAfford in a row is a decent “show the coin store” trigger.
Design notes
Section titled “Design notes”- Sells create coins from a fixed faucet.
SellPriceis paid per item unconditionally once ownership checks pass. KeepSellPricebelowPriceon anything players can also buy, or grant through any other channel — a buy-low/sell-high loop against your own shop is an infinite money printer with no exploit required. - One intent wiring per server. Channel names are fixed (
SH_Buy/SH_Sell), so a secondattachwith intents enabled would collide on the channel definitions. Attach one kit with intents; give additional kits (other currencies)Intents = falseand call them from your own validated intents. - No storefront push. The kit deliberately ships no
SH_Sync: storefronts are read on open (the request pattern above), not streamed. If you want live “rotation changes in 3:12” timers, computeEvery - Clock() % Everyserver-side and send it once — the client can count down locally because wall clocks agree. - Prices are per-window facts. A buy that arrives just after a window flip is validated against the new window — the item may have rotated out (
NotListed) or changed price. That is correct behavior at the boundary; sending the storefront with a window index and re-fetching on flip keeps the UI honest. - Trading between players is not a shop. Player-to-player exchange needs escrow and replay protection — that is Transactions.