Module 11 · Roblox UI systems
Mini-project: A secure shop interface
Build a responsive item list whose client requests and server decisions stay separate.
What you will learn
- Create responsive shop structure
- Send only an item ID from the client
- Validate price, ownership, and balance on the server
- shop catalog
- Server-owned item definitions including IDs and prices.
- feedback state
- Visible pending, success, or error information in the UI.
Loading saved progress…
Understand
Read the idea and follow a worked example.
Why this matters
A shop connects responsive UI to consequential inventory and currency changes, so the authority boundary must remain visible in every file.
The mental model
The ScreenGui contains a layout-managed list. The LocalScript sends a stable item ID and shows pending state. The server looks up its own catalog, checks ownership and balance, then returns a small result code.
The client never sends the trusted price or final inventory state.
New words and symbols
--!strict asks Studio to check your code for type mistakes. It is an instruction for the editor and produces no Output message.
Matching quotes such as "hello" surround a string: a piece of text. The quotation marks tell Luau where the text starts and ends.
local is read as “local”: it creates a name that is available only in the current code scope.
+= is read as “plus equals”: it reads the current value, adds, then stores the result back.
== is read as “equals equals”: it compares two values and produces a boolean.
.. is read as “concatenate”: it joins text values into a new string.
if is read as “if”: it starts a decision by evaluating a condition.
then is read as “then”: it marks the body that can run when the condition is truthy.
A tiny example
--!strictlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local purchaseRequest = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("PurchaseRequest")assert(purchaseRequest:IsA("RemoteEvent"), "PurchaseRequest missing")local button = script.Parent.ShopList.WoodenKey.BuyButtonlocal status = script.Parent.Statusassert(button:IsA("TextButton") and status:IsA("TextLabel"), "Shop UI is incomplete")local pending = falselocal attempt = 0local messages: { [string]: string } = { purchased = "Purchased Wooden Key!", already_owned = "You already own this key.", insufficient = "Not enough coins.", unknown_item = "That item is unavailable.", slow_down = "Please wait a moment and try again.",}purchaseRequest.OnClientEvent:Connect(function(code: string, balance: number) pending = false button.Active = true status.Text = (messages[code] or "Purchase not completed.") .. " Coins: " .. tostring(balance)end)button.Activated:Connect(function() if pending then return end pending = true attempt += 1 local thisAttempt = attempt button.Active = false status.Text = "Checking purchase..." purchaseRequest:FireServer("wooden-key") task.delay(5, function() if pending and attempt == thisAttempt then pending = false button.Active = true status.Text = "No reply yet. Check the server Script and Output, then retry." end end)end)Line by line
- 4
Wait for the explicitly created shared RemoteEvent to reach the client.
- 7
Controls come from the exact hierarchy created in setup.
- 11
A local pending flag prevents overlapping button requests.
- 21
The server reply restores the button and translates a safe result code into visible text.
- 27
Activated begins a request; it does not approve a purchase.
- 34
Only the stable item ID is sent, never a claimed price or balance.
- 35
A bounded timeout offers recovery if the server script is missing or fails.
Read the code aloud
Read one statement at a time: name the value being read, what the operation does, and where its result goes. Keep identifiers exactly as written so you can find them again in the code.
Pause at calls, conditions, and loops. Explain when the body runs before moving to the next statement; use the line notes above when a symbol is unfamiliar.
Execution trace
Start with the statements outside function bodies. A function definition creates a callable value; its body runs when a call, event, or scheduled task reaches it.
Trace the arguments at each call and the branch or loop inside that call. Return to the caller when the function returns. For an event, use the action named in Try it to trigger the callback.
Line 4: Wait for the explicitly created shared RemoteEvent to reach the client.
Line 7: Controls come from the exact hierarchy created in setup.
Line 11: A local pending flag prevents overlapping button requests.
Line 21: The server reply restores the button and translates a safe result code into visible text.
Line 27: Activated begins a request; it does not approve a purchase.
Line 34: Only the stable item ID is sent, never a claimed price or balance.
Line 35: A bounded timeout offers recovery if the server script is missing or fails.
State before and after
Before running, identify the starting values and any existing Studio objects this example needs. List what you expect to change.
After your practice change, check this result: The UI remains usable at narrow sizes and only server-approved purchases change state.
Reading or printing a value leaves it unchanged. Look for assignments, calls that change state, and property updates to explain each change you observe.
Server purchase decision
--!strictlocal Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")local purchaseRequest = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("PurchaseRequest")assert(purchaseRequest:IsA("RemoteEvent"), "PurchaseRequest missing")local STARTING_TEST_COINS = 50local catalog: { [string]: number } = { ["wooden-key"] = 25 }local coins: { [Player]: number } = {}local owned: { [Player]: { [string]: boolean } } = {}local lastRequest: { [Player]: number } = {}purchaseRequest.OnServerEvent:Connect(function(player: Player, itemId: unknown) local balance = coins[player] or STARTING_TEST_COINS local now = os.clock() if now - (lastRequest[player] or -math.huge) < 0.5 then purchaseRequest:FireClient(player, "slow_down", balance) return end lastRequest[player] = now if typeof(itemId) ~= "string" or catalog[itemId] == nil then purchaseRequest:FireClient(player, "unknown_item", balance) return end local price = catalog[itemId] local playerOwned = owned[player] or {} if playerOwned[itemId] then purchaseRequest:FireClient(player, "already_owned", balance) return end if balance < price then purchaseRequest:FireClient(player, "insufficient", balance) return end coins[player] = balance - price playerOwned[itemId] = true owned[player] = playerOwned purchaseRequest:FireClient(player, "purchased", coins[player])end)Players.PlayerRemoving:Connect(function(player: Player) coins[player] = nil owned[player] = nil lastRequest[player] = nilend)Predict before running
Which value must the server ignore if a client tries to supply it?
Try it
Mark each step after you have tried it.
Common mistake
Sending the button's displayed price and subtracting it on the client makes the catalog untrusted. The server owns both price and resulting balance.
Checkpoint
Submit once you have considered every question. Retries are unlimited.
Recap
- Responsive layout and security are separate responsibilities.
- Clients request by stable selector.
- Servers own catalogs, balances, and inventory updates.
Next: Module 12 organizes these systems into modules, adds careful persistence, and finishes Coin Quest.
Studio challenge
A secure shop interface
Checks are formative feedback, not cheat-proof certification. You can finish this lesson without the plugin.
- Target
- StarterGui/LearningLuau/ShopGui
- Check mode
- source structure
Challenge state: idle
Studio Companion
Not connected
Connect once. Compatible challenges will prepare automatically as you reach them.