---
url: /projects/client-server-protocol.md
description: Design a validated shop protocol with idempotency and server reconciliation.
---

# Validated Shop Protocol

*Written: {{ $frontmatter.date }}*

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](../roblox-fundamentals/server-authority.md), [remotes and networking](../roblox-fundamentals/remotes-and-networking.md), [state and data](../intermediate/state-and-data.md), [utility library](utility-library.md), and [narrowing and guards](../strict-typing/narrowing-and-guards.md).

## 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:

* `itemId` is a string matching config.
* `key` is 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](https://create.roblox.com/docs/en-us/projects/server-authority), [client-server boundary](https://create.roblox.com/docs/en-us/scripting/security/client-server-boundary), and [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote).

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

1. Create `Shared/ShopConfig` under `ReplicatedStorage`.
2. Create `Remotes` folder with `BuyRequest` and `ShopUpdate`.
3. Create `ShopService` Script under `ServerScriptService`.
4. Create `ShopClient` LocalScript under `StarterPlayerScripts`.
5. 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)
end
```

Prose 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

1. Press Play with 1 player.
2. Confirm wallet shows `Coins: 200`.
3. Click HealthPotion once.
4. Confirm button shows `Wait...` then wallet shows `Coins: 175` and button shows `Owned`.
5. Click the same potion again.
6. Confirm server answers `owned` and state stays unchanged.
7. Double-click IronShield fast twice.
8. Confirm only one charge occurs and the second key dedupes or cooldowns.
9. Open a second client in a Local Server with 2 players.
10. 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

1. Add a consumable count for HealthPotion with server-side uses.
2. Add a stock limit per item in config enforced on the server.
3. Add a gift action that moves an owned item between two players with server checks.
4. Add a refund path with its own idempotency key.
5. Feed purchases into [data system](data-system.md) saves through the service only.

::: details 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 PotionUses
```

Expected behavior: living players with uses left heal 25 and lose one use, while empty, dead, and missing callers change nothing.
:::

::: details 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 ShopStock
```

**Script 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.
:::

::: details 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 GiftItem
```

Expected behavior: owned items move between live players, while unknown items, empty bags, offline receivers, and self-gifts change nothing.
:::

::: details 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 RefundKeys
```

Expected behavior: first buys and first refunds pass, while replays, refunds without buys, and short keys fail.
:::

::: details 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](data-system.md).
