Appearance
Validated Shop Protocol
Written: 2026-10-14
You build a shop where the client predicts and the server decides. You validate every buy and stop double-fire with idempotency keys.
Learning objectives
You define a buy request schema, layer server validation, enforce rate limits, dedupe retries with idempotency keys, and reconcile client prediction with server truth.
Prerequisites
Read server authority, remotes and networking, state and data, utility library, and narrowing and guards.
Specification
You sell three items: WoodSword for 50, IronShield for 120, HealthPotion for 25. Each buy sends item id plus idempotency key. Server validates in layers. Client shows pending state then confirms or reverts.
Buy schema:
itemIdis a string matching config.keyis a non-empty string made by the client per click.- No price travels from the client.
Acceptance:
- Server rejects unknown item, missing key, duplicate key, poor funds, and fast spam.
- Server owns balances and inventories.
- Server processes each key once per player.
- Client predicts pending ownership then reconciles on server answer.
- Sensitive remote uses a token bucket per player.
You validate permission and context first, then type and shape, then value and rate. The server never trusts price or ownership from the client. See server authority, client-server boundary, and remote events.
Architecture
Keep three parts. Shared shop config holds ids and prices. Service holds money and ownership. Controller holds buttons and pending display. Two RemoteEvents cross the boundary: BuyRequest from client to server, ShopUpdate from server to client. Service keeps per-player last-buy times and seen keys. Controller keeps pending keys until reconcile. This gives fast feedback with safe truth.
Implementation
Follow these steps in order.
- Create
Shared/ShopConfigunderReplicatedStorage. - Create
Remotesfolder withBuyRequestandShopUpdate. - Create
ShopServiceScript underServerScriptService. - Create
ShopClientLocalScript underStarterPlayerScripts. - Press Play and buy each item once.
Container: ReplicatedStorage/Shared/ShopConfig as a ModuleScript. Behavior: you share item ids and prices. Expect both sides to list the same three items.
luau
--!strict
export type ShopItem = {
id: string,
price: number,
label: string,
}
local items: { ShopItem } = {
{ id = "WoodSword", price = 50, label = "Wood Sword" },
{ id = "IronShield", price = 120, label = "Iron Shield" },
{ id = "HealthPotion", price = 25, label = "Health Potion" },
}
local ShopConfig = {}
ShopConfig.items = items
ShopConfig.startCoins = 200
ShopConfig.buyCooldown = 0.5
function ShopConfig.find(itemId: string): ShopItem?
for _, item in items do
if item.id == itemId then
return item
end
end
return nil
end
return table.freeze(ShopConfig)Prose walkthrough: list ids once. Keep prices on both sides for display, but the server decides the charge. Expose find for safe lookup.
Container: ServerScriptService/ShopService as a Script. Behavior: you validate buys and you broadcast results. Expect valid buys to reduce coins and add inventory. Expect duplicate keys to change nothing.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local ShopConfig = require(Shared:WaitForChild("ShopConfig"))
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local buyEvent: RemoteEvent = Remotes:WaitForChild("BuyRequest") :: RemoteEvent
local updateEvent: RemoteEvent = Remotes:WaitForChild("ShopUpdate") :: RemoteEvent
local startCoins: number = (ShopConfig :: any).startCoins
local buyCooldown: number = (ShopConfig :: any).buyCooldown
local findItem: (string) -> any = (ShopConfig :: any).find
local coins: { [number]: number } = {}
local owned: { [number]: { [string]: boolean } } = {}
local seenKeys: { [number]: { [string]: boolean } } = {}
local tokens: { [number]: number } = {}
local refills: { [number]: number } = {}
local bucketSize = 3
local function getCoins(player: Player): number
local total: number? = coins[player.UserId]
if total == nil then
return 0
end
return total :: number
end
local function sendState(player: Player, lastKey: string, ok: boolean, message: string): ()
local list: { string } = {}
local bag: { [string]: boolean }? = owned[player.UserId]
if bag ~= nil then
for id: string, has: boolean in bag :: { [string]: boolean } do
if has then
table.insert(list, id)
end
end
end
updateEvent:FireClient(player, getCoins(player), list, lastKey, ok, message)
end
local function onBuy(player: Player, itemId: unknown, key: unknown): ()
if player == nil then
return
end
if typeof(itemId) ~= "string" then
return
end
if typeof(key) ~= "string" then
return
end
local id: string = itemId :: string
local idemKey: string = key :: string
if #idemKey < 4 then
return
end
local now: number = os.clock()
local stored: number = tokens[player.UserId] or bucketSize
local refilledAt: number = refills[player.UserId] or now
local gained: number = math.floor((now - refilledAt) / buyCooldown)
if gained > 0 then
stored = math.min(bucketSize, stored + gained)
refilledAt += gained * buyCooldown
end
if stored < 1 then
sendState(player, idemKey, false, "cooldown")
return
end
tokens[player.UserId] = stored - 1
refills[player.UserId] = refilledAt
local seen: { [string]: boolean }? = seenKeys[player.UserId]
if seen == nil then
seen = {}
seenKeys[player.UserId] = seen
end
local box: { [string]: boolean } = seen :: { [string]: boolean }
if box[idemKey] then
sendState(player, idemKey, true, "duplicate")
return
end
box[idemKey] = true
local item: any = findItem(id)
if item == nil then
sendState(player, idemKey, false, "unknown item")
return
end
local bag: { [string]: boolean }? = owned[player.UserId]
if bag == nil then
bag = {}
owned[player.UserId] = bag
end
local case: { [string]: boolean } = bag :: { [string]: boolean }
if case[id] then
sendState(player, idemKey, false, "owned")
return
end
local price: unknown = item.price
if typeof(price) ~= "number" then
sendState(player, idemKey, false, "funds")
return
end
if not math.isfinite(price :: number) then
sendState(player, idemKey, false, "funds")
return
end
if getCoins(player) < (price :: number) then
sendState(player, idemKey, false, "funds")
return
end
coins[player.UserId] = getCoins(player) - (price :: number)
case[id] = true
sendState(player, idemKey, true, "bought")
end
local function onAdded(player: Player): ()
coins[player.UserId] = startCoins
owned[player.UserId] = {}
seenKeys[player.UserId] = {}
tokens[player.UserId] = bucketSize
refills[player.UserId] = os.clock()
sendState(player, "init", true, "ready")
end
local function onRemoving(player: Player): ()
coins[player.UserId] = nil
owned[player.UserId] = nil
seenKeys[player.UserId] = nil
tokens[player.UserId] = nil
refills[player.UserId] = nil
end
buyEvent.OnServerEvent:Connect(onBuy)
Players.PlayerAdded:Connect(onAdded)
Players.PlayerRemoving:Connect(onRemoving)
for _, existing in Players:GetPlayers() do
onAdded(existing)
endProse walkthrough: check shape before value. Enforce cooldown fast to drop spam. Record each key before validation so retries dedupe. Look up the item by id. Check owned then funds. Mutate coins and inventory only after all checks. Send full reconcile state with the same key.
Container: StarterPlayerScripts/ShopClient as a LocalScript. Behavior: you render buttons, you predict pending buys, you reconcile on server state. Expect a button to show ... until the server answers, then Owned or the price again.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local ShopConfig = require(Shared:WaitForChild("ShopConfig"))
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local buyEvent: RemoteEvent = Remotes:WaitForChild("BuyRequest") :: RemoteEvent
local updateEvent: RemoteEvent = Remotes:WaitForChild("ShopUpdate") :: RemoteEvent
local items: { any } = (ShopConfig :: any).items
local player = Players.LocalPlayer
if player == nil then
return
end
local clickCount: number = 0
local pending: { [string]: string } = {}
local labels: { [string]: TextButton } = {}
local screen = Instance.new("ScreenGui")
screen.Name = "ShopHud"
screen.ResetOnSpawn = false
screen.Parent = player:WaitForChild("PlayerGui")
local wallet = Instance.new("TextLabel")
wallet.Name = "Wallet"
wallet.Size = UDim2.new(0, 220, 0, 40)
wallet.Position = UDim2.new(0, 16, 0, 16)
wallet.Text = "Coins: ?"
wallet.Parent = screen
local y: number = 70
for _, entry in items do
local record: any = entry
local button = Instance.new("TextButton")
button.Name = record.id
button.Size = UDim2.new(0, 220, 0, 40)
button.Position = UDim2.new(0, 16, 0, y)
button.Text = `{record.label}: {record.price}`
button.Parent = screen
labels[record.id] = button
y += 48
local id: string = record.id
button.MouseButton1Click:Connect(function(): ()
clickCount += 1
local key: string = `{id}-{clickCount}-{math.floor(os.clock() * 1000)}`
pending[key] = id
button.Text = "Wait..."
(button :: TextButton).AutoButtonColor = false
buyEvent:FireServer(id, key)
end)
end
local function onUpdate(total: unknown, list: unknown, key: unknown, ok: unknown, message: unknown): ()
if typeof(total) ~= "number" then
return
end
if typeof(list) ~= "table" then
return
end
if typeof(key) ~= "string" then
return
end
wallet.Text = `Coins: {math.floor(total :: number)}`
local have: { [string]: boolean } = {}
for _, id in list :: { string } do
if typeof(id) == "string" then
have[id :: string] = true
end
end
for id: string, button: TextButton in labels do
local item: any = (ShopConfig :: any).find(id)
if have[id] then
button.Text = "Owned"
elseif pending[(key :: string)] == id and not (ok :: boolean) and (message :: string) ~= "duplicate" then
if item ~= nil then
button.Text = `{item.label}: {item.price}`
end
elseif have[id] == nil and pending[(key :: string)] ~= id then
if item ~= nil and button.Text == "Wait..." then
button.Text = `{item.label}: {item.price}`
end
end
button.AutoButtonColor = true
end
pending[key :: string] = nil
end
updateEvent.OnClientEvent:Connect(onUpdate)Prose walkthrough: build one button per item with Parent last. Make a unique key per click. Show Wait... at once. Fire only id and key. Update wallet on every server message. Mark owned on confirm. Restore price on reject. Clear the pending key.
Testing it
- Press Play with 1 player.
- Confirm wallet shows
Coins: 200. - Click HealthPotion once.
- Confirm button shows
Wait...then wallet showsCoins: 175and button showsOwned. - Click the same potion again.
- Confirm server answers
ownedand state stays unchanged. - Double-click IronShield fast twice.
- Confirm only one charge occurs and the second key dedupes or cooldowns.
- Open a second client in a Local Server with 2 players.
- Confirm player 1 buys do not change player 2 wallet.
Common mistakes and failures
You send price from the client. Read price from config on the server. You grant before funds check. Order checks as shape then duplicate then item then owned then funds. You forget idempotency and double-click charges twice. Store seen keys per player. You leave pending buttons stuck. Clear pending on every reconcile. You trust owned flags on the client. Rebuild display from server list each time.
Exercises and extensions
- Add a consumable count for HealthPotion with server-side uses.
- Add a stock limit per item in config enforced on the server.
- Add a gift action that moves an owned item between two players with server checks.
- Add a refund path with its own idempotency key.
- Feed purchases into data system saves through the service only.
Solution 1
Track potion uses on the server; the client only sends drink intent.
Script in ServerScriptService named PotionUses
luau
--!strict
local PotionUses = {}
local uses: { [number]: number } = {}
function PotionUses.set(userId: number, count: number): ()
if not math.isfinite(count) then
return
end
uses[userId] = math.floor(count)
end
function PotionUses.drink(player: Player): boolean
local left: number = uses[player.UserId] or 0
if left <= 0 then
return false
end
local character = player.Character
if character == nil then
return false
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil or humanoid.Health <= 0 then
return false
end
uses[player.UserId] = left - 1
humanoid.Health = math.min(humanoid.MaxHealth, humanoid.Health + 25)
return true
end
return PotionUsesExpected behavior: living players with uses left heal 25 and lose one use, while empty, dead, and missing callers change nothing.
Solution 2
Cap stock in config and decrement on the server per sale.
ModuleScript in ReplicatedStorage/Shared named ShopStock
luau
--!strict
local ShopStock = {
WoodSword = 10,
IronShield = 5,
HealthPotion = 99,
}
table.freeze(ShopStock)
return ShopStockScript in ServerScriptService named StockSeller
luau
--!strict
local stock: { [string]: number } = { WoodSword = 10, IronShield = 5, HealthPotion = 99 }
local function sell(itemId: unknown): boolean
if typeof(itemId) ~= "string" then
return false
end
local left: number? = stock[itemId]
if left == nil or left <= 0 then
return false
end
stock[itemId] = (left :: number) - 1
return true
end
assert(sell("WoodSword") == true, "first sale ok")
assert(stock.WoodSword == 9, "stock drops")
print("StockSeller passed")Expected behavior: Output prints StockSeller passed because sales decrement server stock and unknown or empty items sell nothing.
Solution 3
Move items between owners only after server checks on both players.
Script in ServerScriptService named GiftItem
luau
--!strict
local Players = game:GetService("Players")
local GiftItem = {}
local bags: { [number]: { [string]: number } } = {}
function GiftItem.give(from: Player, toUserId: number, itemId: unknown): boolean
if typeof(itemId) ~= "string" then
return false
end
if #itemId < 2 then
return false
end
local receiver = Players:GetPlayerByUserId(toUserId)
if receiver == nil then
return false
end
if receiver == from then
return false
end
local bag: { [string]: number } = bags[from.UserId] or {}
if (bag[itemId] or 0) <= 0 then
return false
end
bag[itemId] = bag[itemId] - 1
bags[from.UserId] = bag
local take: { [string]: number } = bags[toUserId] or {}
take[itemId] = (take[itemId] or 0) + 1
bags[toUserId] = take
return true
end
return GiftItemExpected behavior: owned items move between live players, while unknown items, empty bags, offline receivers, and self-gifts change nothing.
Solution 4
Give refunds their own key set so a buy key never refunds twice.
Script in ServerScriptService named RefundKeys
luau
--!strict
local RefundKeys = {}
local seenBuys: { [string]: boolean } = {}
local seenRefunds: { [string]: boolean } = {}
function RefundKeys.buy(key: unknown): boolean
if typeof(key) ~= "string" or #key < 4 then
return false
end
if seenBuys[key] then
return false
end
seenBuys[key] = true
return true
end
function RefundKeys.refund(buyKey: unknown): boolean
if typeof(buyKey) ~= "string" or #buyKey < 4 then
return false
end
if not seenBuys[buyKey] then
return false
end
local refundKey: string = `refund:{buyKey}`
if seenRefunds[refundKey] then
return false
end
seenRefunds[refundKey] = true
return true
end
return RefundKeysExpected behavior: first buys and first refunds pass, while replays, refunds without buys, and short keys fail.
Solution 5
Call the data service from the shop service; the client never touches saves.
Script in ServerScriptService named ShopSaveBridge
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local DataService = require(ServerScriptService:WaitForChild("Data"):WaitForChild("DataService"))
local function checkout(player: Player, total: number): boolean
if not math.isfinite(total) or total <= 0 then
return false
end
return DataService.spendCoins(player, math.floor(total))
end
local _ = checkout
print("ShopSaveBridge ready")Expected behavior: valid totals spend through the data service and persist on its autosave, while NaN and free totals never reach the store.
Summary
You validated schema, funds, ownership, rate, and keys. Next build data system.