Skip to content

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.

buy(session, shopId, itemId, count) walks a refusal ladder, then commits in the one order that cannot strand money:

  1. BadCount — count below 1, above 99, or not an integer.
  2. UnknownShop — no shop with that id.
  3. NotListed — the item is neither in static Listings nor in this window’s rotation.
  4. NotBuyable — listed, but no Price (a sell-only listing).
  5. OutOfStock — this purchase would exceed the listing’s per-player Stock for the current window.
  6. CannotAfford — balance below Price * Count.
  7. Grant. Inventory:grant returns what fit. Zero returns InventoryFull — nothing charged.
  8. 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 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 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.

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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
},
},
},
})
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.
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.
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.
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.

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). With os.time, every server on Earth computes the same index at the same moment.
  • The picks come from Random.new(Seed + windowIndex) drawing Size items from Pool without 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)
end

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.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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

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.

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.
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.

  • Sells create coins from a fixed faucet. SellPrice is paid per item unconditionally once ownership checks pass. Keep SellPrice below Price on 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 second attach with intents enabled would collide on the channel definitions. Attach one kit with intents; give additional kits (other currencies) Intents = false and 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, compute Every - Clock() % Every server-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.