ShopKit
Runs soft-currency shops over InventoryKit, with pluggable currency adapters, charge-before-grant buys and rotating stock that every server computes the same way.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .ShopKit - Since
- v0.5.0
- Source
- Kits/ShopKit.luau
ShopKit runs soft-currency shops on top of InventoryKit: listings with buy and sell prices,
per-player stock caps, and rotating stock that every server lists identically without talking to the others. A buy
charges before it grants. ShopKit measures how many items fit, takes the price of exactly that many, and only then
creates the items. If the charge fails, no item exists yet, so there is nothing to claw back. Items that stopped
fitting while a yielding Take ran are refunded through the adapter’s Give.
ShopKit owns coins, meaning soft currency stored in player data. Robux purchases belong to Receipts, which saves before it acknowledges. The two meet only where a receipt grant adds the coins that ShopKit later spends. Player-to-player exchange belongs to Transactions.
Mental model
Section titled “Mental model”Every buy and sell runs inside a per-session lock. Currency adapters may yield (a guild bank on a DataStore, an
Open Cloud call), and a second transaction for the same player must not interleave with the first. While one buy or
sell is in flight, another one for that session rejects Busy. An error inside the transaction releases the lock and
is rethrown to the caller.
Buy order
Section titled “Buy order”buy(session, shopId, itemId, count?) checks in this order and stops at the first failure. count defaults to 1.
BadCount: the count is below 1, above 99 or fractional.UnknownShop: no shop has that id.NotListed: the item is not in the shop’s staticListingsor in the current rotation window.NotBuyable: the listing has noPrice(a sell-only listing).OutOfStock: the listing has aStockcap and this buy would pass it for the current window.CannotAfford:Currency.Get(session)is belowPrice * count. The check uses the full requested count.InventoryFull:Inventory:space(session, itemId)is 0.spacecounts room in existing stacks plus empty slots, the same waygrantfills them.- Charge. The kit calls
Currency.Take(session, Price * Fits), whereFitsis the smaller of the count and the space. A falsy return rejectsCannotAffordand no item has been created. - Grant.
Inventory:grant(session, itemId, Fits)returns what landed. If the bag filled whileTakeyielded and fewer items landed, the difference is refunded withCurrency.Give(session, Price * (Fits - Granted)). If nothing landed, the whole charge is refunded and the buy rejectsInventoryFull. - The stock book records the granted count and the kit publishes
Shop.Boughtwith the granted count and the price actually paid.
A partial fit is a success: asking for 5 with room for 3 charges for 3 and grants 3.
Sell order
Section titled “Sell order”sell(session, shopId, itemId, count?) checks BadCount, UnknownShop, NotListed and NotSellable (no
SellPrice), then calls Inventory:take(session, itemId, count). That call is all or nothing, so a player holding fewer
than count gets NotOwned. A slot whose OnUse handler is still running holds back one unit, so an item being used
cannot also be sold. After the take, the kit pays Currency.Give(session, SellPrice * count) and publishes
Shop.Sold. Items leave before coins arrive, so a yielding Give cannot pay twice for the same items.
Every refusal returns false, reason and publishes Shop.Rejected.
Currency adapters
Section titled “Currency adapters”A currency is a table of three functions:
{ Get = function(session): number, Take = function(session, amount): boolean, -- true on success; anything falsy cancels the buy Give = function(session, amount),}Take must return true when it took the amount. A falsy return rejects the buy with CannotAfford before any item
exists. Give pays sell proceeds and refunds the part of a buy that stopped fitting. All three may yield; the
per-session lock keeps a yielding adapter from running two transactions for one player at once.
The default adapter reads and writes one field, CurrencyField (default "Coins"), in session.Profile.Data, or in
session.Data when the session has no profile. A missing field counts as 0. With a
DataDriver attached the balance persists with the profile, and Receipts grants can add to the same
field.
A custom adapter handles anything else, such as a second currency:
local Gems = { Get = function(session: any): number return session.Profile.Data.Gems or 0 end, Take = function(session: any, amount: number): boolean local Data = session.Profile.Data if (Data.Gems or 0) < amount then return false end Data.Gems -= amount return true end, Give = function(session: any, amount: number) local Data = session.Profile.Data Data.Gems = (Data.Gems or 0) + amount end,}
local GemShop = ShopKit.attach(kernel, { Shops = GemShops, Inventory = Inventory, Currency = Gems, Intents = false, -- SH_Buy and SH_Sell already belong to the coin shop})One kit handles one currency. A second currency needs a second kit with Intents = false (see
design notes).
Defining shops
Section titled “Defining shops”--!strict-- Shops: every storefront, keyed by id.
return { Apothecary = { Listings = { Herb = { Price = 5, SellPrice = 2 }, Cauldron = { Price = 250 }, OldBoot = { SellPrice = 1 }, -- sell-only 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 }, -- every rotated item gets this listing }, },}Listing fields
Section titled “Listing fields”| Field | Type | Default | Description |
|---|---|---|---|
Price |
number? |
nil |
Buy price per item. nil means the item cannot be bought. |
SellPrice |
number? |
nil |
Payout per item sold. nil means the item cannot be sold. |
Stock |
number? |
nil |
Most buys per player per rotation window. nil means no cap. |
Shop fields
Section titled “Shop fields”| Field | Type | Description |
|---|---|---|
Listings |
{ [itemId]: Listing }? |
Static listings, available in every window. |
Rotation |
table? |
Windowed stock, described 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. |
Size |
number |
required | Items listed per window, capped at the pool size. |
Pool |
{ string } |
required | Item ids eligible for rotation. |
Seed |
number? |
0 |
Pick seed. Give different shops different seeds or they rotate in step. |
Listing |
Listing? |
{ Price = 100 } |
The listing 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 bought items land and sold items come from. |
Currency |
adapter | field adapter | See currency adapters. |
CurrencyField |
string? |
"Coins" |
Field the default adapter uses. Ignored when Currency is set. |
Clock |
() -> number |
os.time |
Rotation clock. Wall time is what makes windows agree across servers. |
Intents |
boolean? |
true |
Wire SH_Buy and SH_Sell. Needs a kernel with networking. |
Deterministic rotation
Section titled “Deterministic rotation”Every server computes the same rotating stock from the same inputs, with no MemoryStore, messaging or elected leader:
- The window index is
math.floor(Clock() / Every). Withos.time, every server computes the same index at the same moment. - The picks come from
Random.new(Seed + windowIndex), drawingSizeitems fromPoolwithout replacement.
listings(shopId) returns static Listings merged with this window’s picks. It recomputes on every call, so a new
window takes effect on the next call and the kit runs no timer.
for ItemId, Listing in Shops: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 counter lives in a ShopStock table in the profile (or
session.Data without one), keyed shopId:itemId:windowIndex. A shop without Rotation uses window 0 forever, so its
cap applies per player for the life of the profile. On a buy of a capped listing in a rotating shop, the kit first
removes that shop’s counters from other windows, so the table does not grow as windows roll over. OutOfStock charges
nothing, and a partial fit counts only the granted items against the cap.
ShopKit is an attach kit: the owning game service calls it from init, with the shop table in Game/Config. The kit
pushes no storefront state, so this example adds a ShopListings request channel that clients call when a shop opens.
return Registry.define({ ShopListings = { Kind = "Request", Schema = { "String" }, Response = { "Any" }, RateLimit = 4, Validate = { [1] = { MaxLength = 40 } }, },})--!strict-- EconomyService: the inventory and the shops.
local ServerScriptService = game:GetService("ServerScriptService")
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)local Items = require(script.Parent.Parent.Config.Items)local ShopKit = require(ServerScriptService.ChloeKernelServer.Kits.ShopKit)local Shops = require(script.Parent.Parent.Config.Shops)
local EconomyService = { Name = "EconomyService", Dependencies = { "DataService" },}
function EconomyService.init(self: any, kernel: any) self.Kernel = kernel self.Inventory = InventoryKit.attach(kernel, { Items = Items }) self.Shops = ShopKit.attach(kernel, { Shops = Shops, Inventory = self.Inventory })end
function EconomyService.start(self: any) -- Unknown shop ids return an empty table self.Net.ShopListings.handle(function(_session: any, shopId: string) return self.Shops:listings(shopId) end) self.Rejected = self.Kernel.Bus:subscribe("Shop.Rejected", function(_topic, player, shopId, itemId, reason) if reason == "CannotAfford" and player then self.Kernel.Bus:publish("Economy.SuggestCoinStore", player, shopId, itemId) end end)end
function EconomyService.stop(self: any) if self.Rejected then self.Rejected:disconnect() end if self.Shops then self.Shops:destroy() endend
return EconomyService--!strict-- ShopController: storefront reads and the buy and sell intents.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local ShopController = { Name = "ShopController",}
function ShopController.init(self: any, _kernel: any) -- Kit channels are not in Game.Channels. These schemas must match ShopKit's. local KitNet = NetClient.new() self.Buy = KitNet:intent("SH_Buy", { "String", "String", "NumberU8" }) self.Sell = KitNet:intent("SH_Sell", { "String", "String", "NumberU8" })end
-- Yields for the round trip; nil when the request was rejected or timed outfunction ShopController.listings(self: any, shopId: string): { [string]: any }? return self.Net.ShopListings.invoke(shopId)end
-- No local change: items arrive through INV_Sync (InventoryClient) and coins through the game's own displayfunction ShopController.buy(self: any, shopId: string, itemId: string, count: number) self.Buy.fire(shopId, itemId, count)end
function ShopController.sell(self: any, shopId: string, itemId: string, count: number) self.Sell.fire(shopId, itemId, count)end
return ShopControllerClient intents
Section titled “Client intents”Both intents are fail-closed. The kit’s validators run at priority 50 on the Hooks chain:
| Intent | Schema | Rate limit | Validator (priority 50) |
|---|---|---|---|
SH_Buy |
String, String, NumberU8 (shopId, itemId, count) |
10/s | Count 1-99, the shop exists, the item is listed in the current window. |
SH_Sell |
String, String, NumberU8 |
10/s | The same checks. |
A message carries at most 99 items, so a bulk buy costs rate-limited messages. The handlers call buy and sell,
which run the full checks again: balance, stock and ownership come from server state at execution time. Put economy
rules (level requirements, region locks) in extra validators on Intent.SH_Buy and Intent.SH_Sell.
API reference
Section titled “API reference”| Member | Description |
|---|---|
ShopKit.attach(kernel, options) -> kit |
Asserts Shops and Inventory. Wires the intents when Intents ~= false and the kernel has networking. |
kit:buy(session, shopId, itemId, count?) -> (boolean, string?) |
The buy order. Reasons: BadCount, UnknownShop, NotListed, NotBuyable, OutOfStock, CannotAfford, InventoryFull, Busy. |
kit:sell(session, shopId, itemId, count?) -> (boolean, string?) |
The sell order. Reasons: BadCount, UnknownShop, NotListed, NotSellable, NotOwned, Busy. |
kit:listings(shopId) -> { [itemId]: Listing } |
Static listings merged with this window’s picks. Empty for an unknown shop. Computed on every call. |
kit:destroy() |
Unhooks the kit’s intent validators, makes the intent handlers inert and clears the per-session locks. Direct buy and sell calls still work. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Shop.Bought |
session, shopId, itemId, count, cost |
After a buy. count is the granted amount and cost the price paid for it, both smaller than requested on a partial fit. |
Shop.Sold |
session, shopId, itemId, count, payout |
After a sell. |
Shop.Rejected |
player, shopId, itemId, reason |
Every refusal, from a direct call or an intent. The first argument is the Player, not the session, and is nil for a session without one. |
Shop.Bought and Shop.Sold feed economy analytics and QuestKit objectives.
Design notes
Section titled “Design notes”- Selling mints coins at a fixed rate.
SellPriceis paid per item once the take succeeds. KeepSellPricebelowPricefor anything players can also buy or earn elsewhere, or a buy-and-sell loop against your own shop prints money. - Channel names are fixed (
SH_Buy,SH_Sell), so a second kit with intents on would collide on the definitions. Wire intents on one kit, give the othersIntents = false, and call them from your own validated channels. - There is no
SH_Sync. Storefronts are read when opened, as in the usage example. For a “rotation changes in 3:12” timer, sendEvery - Clock() % Everyonce; wall clocks agree, so the client can count down locally. - A buy that arrives just after a window changes is checked against the new window. The item may have rotated out
(
NotListed) or changed price. Sending the window index with the storefront and refetching when it changes keeps the UI in step.