Appearance
Coin Pickup Mechanic
Written: 2026-10-14
You build a server-authoritative coin pickup, validating distance on the server and stopping farm exploits with cooldowns.
Learning objectives
You spawn pickups on the server, validate collect radius there, apply anti-farm cooldowns, display state on the client, and explain why teleport and spam exploits fail.
Prerequisites
Read server authority, remotes and networking, players and characters, utility library, and annotations and inference.
Specification
You spawn up to 12 coins in a zone and grant 1 coin per pickup. You validate collect radius on the server at 8 studs. You enforce 1 second per-player cooldown and 3 seconds per-coin cooldown, and replicate display updates to clients.
Acceptance:
- Server owns spawn positions and coin values.
- Server checks distance between character root and coin before grant.
- Server rejects teleport claims beyond radius.
- Server rejects rapid claims inside cooldown windows.
- Client shows coin count and pickup sparkle only.
- Client sends claim intent only, never a grant.
- Sensitive remote uses a per-player rate limit.
Never trust the client for position or wealth; the server is the source of truth for game state. See server authority, authority techniques, and client-server boundary.
Architecture
Split into three parts. Shared config holds tuning. Service owns coins and validation. Controller renders count and effects. One RemoteEvent carries claim intent from client to server. One RemoteEvent carries count updates from server to clients. Service checks permission and context first, then type and shape, then value and rate. Controller never writes coins. This keeps the boundary clean.
Implementation
Follow these steps in order.
- Create
Shared/CoinConfigunderReplicatedStorage. - Create
Remotesfolder underReplicatedStorage. - Create
CoinServiceScript underServerScriptService. - Create
CoinDisplayLocalScript underStarterPlayerScripts. - Press Play with 1 player and walk into coins.
Container: ReplicatedStorage/Shared/CoinConfig as a ModuleScript. Behavior: you read one frozen tuning table from both sides. Expect both server and client to see the same radius and cooldowns.
luau
--!strict
export type CoinConfig = {
maxCoins: number,
collectRadius: number,
coinValue: number,
playerCooldown: number,
coinCooldown: number,
spawnSize: Vector3,
spawnCenter: Vector3,
}
local CoinConfig: CoinConfig = {
maxCoins = 12,
collectRadius = 8,
coinValue = 1,
playerCooldown = 1,
coinCooldown = 3,
spawnSize = Vector3.new(40, 1, 40),
spawnCenter = Vector3.new(0, 4, 0),
}
return table.freeze(CoinConfig)Prose walkthrough: tune count and radius here, and keep spawn center and size here. Freeze the table so neither side mutates it.
Container: ServerScriptService/CoinService as a Script. Behavior: you spawn coins, validate claims, and broadcast counts. Expect walking within 8 studs to grant 1 coin. Expect distant claims to grant nothing.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local CoinConfig = require(Shared:WaitForChild("CoinConfig"))
local Workspace = game:GetService("Workspace")
type CoinRecord = {
part: Part,
lastTaken: number,
}
local CoinConfigModule = require(Shared:WaitForChild("CoinConfig")) :: CoinConfig
local collectRadius: number = CoinConfigModule.collectRadius
local coinValue: number = CoinConfigModule.coinValue
local maxCoins: number = CoinConfigModule.maxCoins
local playerCooldown: number = CoinConfigModule.playerCooldown
local coinCooldown: number = CoinConfigModule.coinCooldown
local spawnCenter: Vector3 = CoinConfigModule.spawnCenter
local spawnSize: Vector3 = CoinConfigModule.spawnSize
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local claimEvent: RemoteEvent = Remotes:WaitForChild("ClaimCoin") :: RemoteEvent
local countEvent: RemoteEvent = Remotes:WaitForChild("CoinCount") :: RemoteEvent
local coins: { [string]: CoinRecord } = {}
local balances: { [number]: number } = {}
local playerLastClaim: { [number]: number } = {}
local playerLastSeen: { [number]: number } = {}
local function makeCoin(name: string, position: Vector3): ()
local part = Instance.new("Part")
part.Name = name
part.Shape = Enum.PartType.Cylinder
part.Size = Vector3.new(1, 2, 2)
part.Anchored = true
part.CanCollide = false
part.Position = position
part.Parent = Workspace
coins[name] = { part = part :: Part, lastTaken = 0 }
end
local function spawnAll(): ()
local index: number = 1
while index <= maxCoins do
local offsetX: number = math.random(-math.floor(spawnSize.X * 0.5), math.floor(spawnSize.X * 0.5))
local offsetZ: number = math.random(-math.floor(spawnSize.Z * 0.5), math.floor(spawnSize.Z * 0.5))
local position = Vector3.new(spawnCenter.X + offsetX, spawnCenter.Y, spawnCenter.Z + offsetZ)
makeCoin(`Coin{index}`, position)
index += 1
end
end
local function getRoot(player: Player): BasePart?
local character: Model? = player.Character
if character == nil then
return nil
end
local root: BasePart? = (character :: Model):FindFirstChild("HumanoidRootPart") :: BasePart?
return root
end
local function onClaim(player: Player, coinName: unknown): ()
if player == nil then
return
end
if typeof(coinName) ~= "string" then
return
end
local name: string = coinName :: string
local now: number = os.clock()
local last: number? = playerLastSeen[player.UserId]
if last ~= nil and now - last < 0.1 then
return
end
playerLastSeen[player.UserId] = now
local personal: number? = playerLastClaim[player.UserId]
if personal ~= nil and now - personal < playerCooldown then
return
end
local record: CoinRecord? = coins[name]
if record == nil then
return
end
local entry: CoinRecord = record :: CoinRecord
if now - entry.lastTaken < coinCooldown then
return
end
local root: BasePart? = getRoot(player)
if root == nil then
return
end
local distance: number = ((entry.part :: Part).Position - (root :: BasePart).Position).Magnitude
if distance > collectRadius then
return
end
entry.lastTaken = now
playerLastClaim[player.UserId] = now
local total: number? = balances[player.UserId]
local nextTotal: number = coinValue
if total ~= nil then
nextTotal = (total :: number) + coinValue
end
balances[player.UserId] = nextTotal
(entry.part :: Part).Position = Vector3.new(spawnCenter.X + math.random(-20, 20), spawnCenter.Y, spawnCenter.Z + math.random(-20, 20))
countEvent:FireClient(player, nextTotal)
end
local function onAdded(player: Player): ()
balances[player.UserId] = 0
playerLastClaim[player.UserId] = 0
playerLastSeen[player.UserId] = 0
countEvent:FireClient(player, 0)
end
local function onRemoving(player: Player): ()
balances[player.UserId] = nil
playerLastClaim[player.UserId] = nil
playerLastSeen[player.UserId] = nil
end
claimEvent.OnServerEvent:Connect(onClaim)
Players.PlayerAdded:Connect(onAdded)
Players.PlayerRemoving:Connect(onRemoving)
for _, existing in Players:GetPlayers() do
onAdded(existing)
end
spawnAll()Prose walkthrough: spawn parts with Parent assigned last. Store each coin with its last-taken time. Check claim shape first with typeof. Apply a 0.1 second spam gate per player. Apply a 1 second grant gate per player. Apply a 3 second gate per coin. Measure server-side distance. Move the coin after a valid grant. Send the new total to that player only. Remote use follows remote events.
Container: StarterPlayerScripts/CoinDisplay as a LocalScript. Behavior: you send claim intent on touch and show the server total. Expect the label to change only after the server fires.
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local claimEvent: RemoteEvent = Remotes:WaitForChild("ClaimCoin") :: RemoteEvent
local countEvent: RemoteEvent = Remotes:WaitForChild("CoinCount") :: RemoteEvent
local player = Players.LocalPlayer
if player == nil then
return
end
local screen = Instance.new("ScreenGui")
screen.Name = "CoinHud"
screen.ResetOnSpawn = false
screen.Parent = player:WaitForChild("PlayerGui")
local label = Instance.new("TextLabel")
label.Name = "CoinLabel"
label.Size = UDim2.new(0, 200, 0, 50)
label.Position = UDim2.new(0, 16, 0, 16)
label.Text = "Coins: 0"
label.Parent = screen
local function setCount(total: unknown): ()
if typeof(total) ~= "number" then
return
end
label.Text = `Coins: {math.floor(total :: number)}`
end
countEvent.OnClientEvent:Connect(setCount)
local function watchTouch(part: BasePart): ()
if string.sub(part.Name, 1, 4) ~= "Coin" then
return
end
part.Touched:Connect(function(): ()
claimEvent:FireServer(part.Name)
end)
end
for _, instance in Workspace:GetDescendants() do
if instance:IsA("BasePart") then
watchTouch(instance)
end
end
Workspace.DescendantAdded:Connect(function(instance: Instance): ()
if instance:IsA("BasePart") then
watchTouch(instance)
end
end)Prose walkthrough: build HUD with Parent last. Listen for server counts. Fire only the coin name. Never send a position or a total. Let the server decide.
Studio setup: create a Folder named Remotes under ReplicatedStorage with two RemoteEvent instances named ClaimCoin and CoinCount inside it. Create a Folder named Shared under ReplicatedStorage. Place CoinConfig inside Shared.
Testing it
- Press Play with 1 player.
- Walk into a coin part.
- Confirm Output shows no error and HUD shows
Coins: 1. - Teleport your character far away with the Move tool, then fire
ClaimCoinwith a coin name from the command bar on the client. - Confirm HUD stays unchanged because distance fails.
- Fire
ClaimCoinfive times in under a second. - Confirm you gain at most 1 coin because cooldowns reject repeats.
- Leave Play, press Play again, confirm coins respawn and HUD resets to 0.
Exploit attempts to try:
- Teleport collect: move your root 100 studs away, then fire the claim. It fails because the server measures distance with its own coin and root positions.
- Rapid fire: loop FireServer 20 times. It fails because per-player and per-coin timestamps reject repeats.
- Fake coin name: send
Coin999. It fails because the server looks up the name in its own table.
Common mistakes and failures
You trust Touched on the client. Move validation to the server. You forget per-coin cooldown and one coin farms forever. Add both gates. You grant before distance check. Order checks as identity then shape then rate then value. You update leaderstats on the client. Keep balances on the server and replicate display only. You create Instance with parent argument. Construct first and set Parent last.
Exercises and extensions
- Add a magnet radius in config that pulls display coins without granting.
- Add a despawn timer that moves stale coins after 30 seconds.
- Add a server log that counts rejected claims by reason.
- Add a team bonus that grants 2 coins inside a goal zone you validate on the server.
- Reuse utility library Result in the claim handler return path.
Solution 1
Add magnetRadius to Shared/CoinConfig. The client pulls coin billboards toward the player inside that radius for display only; the server grant radius stays at collectRadius.
ModuleScript in ReplicatedStorage/Shared named CoinConfigMagnet
luau
--!strict
local CoinConfigMagnet = {
maxCoins = 12,
collectRadius = 8,
magnetRadius = 14,
coinValue = 1,
playerCooldown = 1,
}
table.freeze(CoinConfigMagnet)
return CoinConfigMagnetExpected behavior: coins visibly drift toward nearby players, while grants still require the server 8-stud check so display movement never mints coins.
Solution 2
Track spawn time per coin on the server and relocate stale ones.
Script in ServerScriptService named CoinDespawn
luau
--!strict
local Workspace = game:GetService("Workspace")
local CoinDespawn = {}
local ages: { [BasePart]: number } = {}
local staleAfter: number = 30
function CoinDespawn.watch(coin: BasePart): ()
ages[coin] = os.clock()
end
function CoinDespawn.sweep(center: Vector3): ()
local now: number = os.clock()
for coin: BasePart, born: number in ages do
if now - born >= staleAfter then
if coin.Parent == Workspace then
coin.Position = center + Vector3.new(math.random(-20, 20), 0, math.random(-20, 20))
end
ages[coin] = now
end
end
end
return CoinDespawnExpected behavior: coins untouched for 30 seconds teleport to a fresh spot and their timer restarts, while claimed coins stop being tracked.
Solution 3
Count rejections by reason on the server so exploit patterns show up in Output.
Script in ServerScriptService named ClaimAudit
luau
--!strict
local ClaimAudit = {}
local counts: { [string]: number } = {}
function ClaimAudit.reject(reason: string): ()
if #reason < 2 then
return
end
counts[reason] = (counts[reason] or 0) + 1
end
function ClaimAudit.report(): ()
for reason: string, total: number in counts do
print(`{reason}: {total}`)
end
end
return ClaimAuditExpected behavior: dead, far, malformed, and spamming callers each increment their own reason, and report prints one line per reason.
Solution 4
Grant 2 coins only when the server confirms the player stands inside the goal zone.
Script in ServerScriptService named GoalBonus
luau
--!strict
local Workspace = game:GetService("Workspace")
local GoalBonus = {}
local goal = Workspace:WaitForChild("GoalZone") :: BasePart
local goalRadius: number = 10
function GoalBonus.grant(balance: number, player: Player): number
if not math.isfinite(balance) then
return math.floor(0)
end
local character = player.Character
if character == nil then
return math.floor(balance)
end
local root = character:FindFirstChild("HumanoidRootPart")
if root == nil or not root:IsA("BasePart") then
return math.floor(balance)
end
local distance: number = (root.Position - goal.Position).Magnitude
if not math.isfinite(distance) then
return math.floor(balance)
end
if distance > goalRadius then
return math.floor(balance)
end
return math.floor(balance) + 2
end
return GoalBonusExpected behavior: players inside the zone gain 2 coins per claim, while dead, missing, NaN, and distant callers keep their balance.
Solution 5
Return a Result from the claim validator so the handler branches once.
Script in ServerScriptService named ClaimResult
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Result = require(Shared:WaitForChild("Result"))
local function checkClaim(distance: number, cooldownLeft: number): { ok: boolean, value: boolean?, error: string? }
if not math.isfinite(distance) then
return Result.err("bad-distance")
end
if distance > 8 then
return Result.err("too-far")
end
if cooldownLeft > 0 then
return Result.err("cooling-down")
end
return Result.ok(true)
end
assert(Result.isOk(checkClaim(4, 0)), "near claim ok")
assert(not Result.isOk(checkClaim(40, 0)), "far claim rejected")
assert(not Result.isOk(checkClaim(4, 1)), "cooldown rejected")
print("ClaimResult passed")Expected behavior: Output prints ClaimResult passed because near off-cooldown claims validate and far or cooling claims reject through Results.
Summary
You spawned coins on the server and validated radius and cooldowns. Next build service controller system.