Appearance
Capstone Briefs
Written: 2026-10-14
You design three builds on your own from goals and done criteria. You write the code.
Learning objectives
You plan a lobby loop, a two-player trade, and an admin console, stating done criteria before you code and reusing services, controllers, and shared config.
Prerequisites
Read utility library, gameplay mechanic, service controller system, validated shop protocol, data system, and server authority.
Specification
You build three independent briefs in any order, with no shared state between briefs. Each brief lists goal, constraints, architecture hints, done criteria, and extensions. Brief implementations are yours to decide; worked solutions for the extension exercises appear below.
Architecture
Reuse the same split in each brief. Services own truth and physics. Controllers render and send intent. Shared holds tuning and ids. Comm owns remotes. DataService owns persistence. Keep one feature per module. The server validates permission and context first, then type and shape, then value and rate. See server authority, authority techniques, client-server boundary, remote events, and data stores.
Implementation
Follow this decision sequence for each brief.
- List the server-owned state.
- List the client intents and display needs.
- Define shared tuning and remote names.
- Define validation order and cooldowns.
- Define init order and leave cleanup.
- Build service first, then controller, then wiring.
- Play-test against the done list.
Brief 1: round-based minigame lobby.
Goal: you run lobby, countdown, round, and winner display for 2 to 8 players, resetting the map each round.
Constraints: server owns phase and timer. Clients request join only. Round starts only with 2 or more joined players. Timer ticks on the server. Teleport and force-start from clients must fail.
Suggested architecture: one LobbyService with phase state machine. One LobbyController for timer labels and join buttons. One shared LobbyConfig for durations and spawn points. Two remotes: JoinIntent client to server, PhaseUpdate server to clients. Persist wins through data system only.
Definition of done: solo join waits in lobby. Two joins start countdown. Round ends with one winner label on all clients. Leave mid-round keeps round valid for others. Rapid join spam changes nothing after first join. Map resets before next lobby.
Extension ideas: add team split, add sudden-death timer, add rematch vote with idempotency keys.
Brief 2: trading bench between two players.
Goal: you trade one item for one item between two nearby players, with both confirming before swap. Either can cancel.
Constraints: server owns both inventories and trade state. Clients propose and confirm only. Trade opens only within 10 studs validated on the server. Items lock during trade. Double-confirm swaps once. Disconnect cancels and unlocks.
Suggested architecture: one TradeService with per-pair state. One TradeController for offer list and confirm buttons. One shared TradeConfig for radius and timeout. Three remotes: OfferIntent, ConfirmIntent, TradeUpdate. Validate distance on each step. Use idempotency keys from validated shop protocol.
Definition of done: near players open trade. Far players get rejected. One confirm waits. Two confirms swap exactly once. Cancel unlocks items. Spam confirm does not dupe. Leave during trade restores both inventories.
Extension ideas: add coin top-up to offers, add 30 second expiry, add trade log per server.
Brief 3: admin command console with permission tiers.
Goal: you run chat-free console commands with three tiers: mod, admin, owner. Mods kick. Admins ban and give coins. Owner grants admin.
Constraints: server owns tier list and every effect. Clients send command text only. Server parses and validates tier per command. All commands rate limited per player. All effects logged with actor and target. Tier list persists across sessions.
Suggested architecture: one AdminService with tier table and handlers. One AdminController for input box and result feed. One shared AdminConfig for command names and cooldowns. Two remotes: CommandIntent client to server, CommandResult server to sender. Store tiers in DataStore wrapper from data system.
Definition of done: mod kick works and mod ban fails. Admin give-coins validates amount range. Owner promote works and persists after rejoin. Unknown command returns safe error. Spam commands throttle. Client never sees tier file or ban logic.
Extension ideas: add temp-ban expiry, add reason strings with length caps, add audit view for owners.
Testing it
- Pick one brief and read its done list aloud.
- Press Play with 2 players in a Local Server.
- Walk through each done bullet and mark pass or fail.
- Try one exploit per brief: teleport trade, fake round start, fake admin tier.
- Confirm each exploit fails on the server.
- Stop Play, fix failures, repeat until all bullets pass.
- Repeat for the next brief with a fresh checklist.
Common mistakes and failures
You trust distance from the client in trade. Measure on the server each step. You start rounds on client timers. Tick phase on the server. You store tiers on the client. Store tiers on the server and persist them. You swap before double confirm. Swap only after two locked confirms. You skip idempotency and double confirm dupes. Dedupe keys per pair. You mix briefs in one service. Keep one service per brief.
Exercises and extensions
- Combine lobby wins with shop coins through DataService only.
- Add trade timeout with server-driven cancel.
- Add admin audit search by actor id.
- Time your lobby loop for 8 players and note slow steps.
- Review your builds against service controller system init and cleanup rules.
Solution 1
Pay lobby wins through the data service; the lobby never writes coins itself.
Script in ServerScriptService named LobbyPayout
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local DataService = require(ServerScriptService:WaitForChild("Data"):WaitForChild("DataService"))
local function payWin(player: Player, wins: number): boolean
if player.Parent == nil then
return false
end
if not math.isfinite(wins) or wins < 1 or wins > 10 then
return false
end
return DataService.addCoins(player, math.floor(wins))
end
local _ = payWin
print("LobbyPayout ready")Expected behavior: winners gain coins through the single persistence owner, while left players and out-of-range win counts pay nothing.
Solution 2
Expire trades on the server clock and unlock both sides.
Script in ServerScriptService named TradeTimeout
luau
--!strict
local TradeTimeout = {}
local expires: { [string]: number } = {}
local timeout: number = 30
function TradeTimeout.open(pairId: string): ()
expires[pairId] = os.clock() + timeout
end
function TradeTimeout.cancel(pairId: string): boolean
if expires[pairId] == nil then
return false
end
expires[pairId] = nil
return true
end
function TradeTimeout.sweep(onExpire: (string) -> ()): ()
local now: number = os.clock()
for pairId: string, deadline: number in expires do
if now >= deadline then
expires[pairId] = nil
onExpire(pairId)
end
end
end
return TradeTimeoutExpected behavior: idle pairs expire after 30 seconds with items unlocked, while confirmed, cancelled, and unknown pairs never fire the callback.
Solution 3
Index audit rows by actor so owners search one key.
Script in ServerScriptService named AuditSearch
luau
--!strict
export type AuditRow = {
actor: number,
action: string,
target: number?,
}
local AuditSearch = {}
local byActor: { [number]: { AuditRow } } = {}
function AuditSearch.log(row: AuditRow): ()
if row.actor <= 0 or #row.action < 2 then
return
end
local rows: { AuditRow }? = byActor[row.actor]
if rows == nil then
rows = {}
byActor[row.actor] = rows
end
table.insert(rows :: { AuditRow }, { actor = row.actor, action = row.action, target = row.target })
end
function AuditSearch.find(actorId: number): { AuditRow }
local rows: { AuditRow }? = byActor[actorId]
if rows == nil then
return {}
end
return table.clone(rows :: { AuditRow })
end
return AuditSearchExpected behavior: each actor's commands list in order, mutating a result never leaks into the log, and unknown actors return empty.
Solution 4
Time one full lobby tick with 8 joined players and write the slow steps down.
txt
phase tick: 0.4ms for 8 players
teleport-all: 2.1ms, slowest step
label broadcast: 0.3ms
verdict: split teleport across two framesExpected behavior: a written table names the slowest step with a number, and the fix targets that step instead of the whole loop.
Solution 5
Check init order and cleanup against the service/controller rules before you call a brief done.
txt
DataService starts before LobbyService: yes
controller start yields: no
PlayerRemoving clears lobby seat and trade lock: yes
remotes owned by Comm signals only: yesExpected behavior: a written checklist passes all four lines, or the failing line names the refactor before release.
Summary
You planned three systems from criteria and built them with server truth. Next read glossary.