Skip to content

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.

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(session, shopId, itemId, count?) checks in this order and stops at the first failure. count defaults to 1.

  1. BadCount: the count is below 1, above 99 or fractional.
  2. UnknownShop: no shop has that id.
  3. NotListed: the item is not in the shop’s static Listings or in the current rotation window.
  4. NotBuyable: the listing has no Price (a sell-only listing).
  5. OutOfStock: the listing has a Stock cap and this buy would pass it for the current window.
  6. CannotAfford: Currency.Get(session) is below Price * count. The check uses the full requested count.
  7. InventoryFull: Inventory:space(session, itemId) is 0. space counts room in existing stacks plus empty slots, the same way grant fills them.
  8. Charge. The kit calls Currency.Take(session, Price * Fits), where Fits is the smaller of the count and the space. A falsy return rejects CannotAfford and no item has been created.
  9. Grant. Inventory:grant(session, itemId, Fits) returns what landed. If the bag filled while Take yielded and fewer items landed, the difference is refunded with Currency.Give(session, Price * (Fits - Granted)). If nothing landed, the whole charge is refunded and the buy rejects InventoryFull.
  10. The stock book records the granted count and the kit publishes Shop.Bought with 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(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.

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

src/Server/Game/Config/Shops.luau
--!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
},
},
}
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.
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.
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.
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.

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

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.

src/Shared/Game/Channels.luau
return Registry.define({
ShopListings = {
Kind = "Request",
Schema = { "String" },
Response = { "Any" },
RateLimit = 4,
Validate = { [1] = { MaxLength = 40 } },
},
})
src/Server/Game/Services/EconomyService.luau
--!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()
end
end
return EconomyService

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.

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

  • Selling mints coins at a fixed rate. SellPrice is paid per item once the take succeeds. Keep SellPrice below Price for 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 others Intents = 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, send Every - Clock() % Every once; 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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.