---
url: /projects/gameplay-mechanic.md
description: >-
  Ship a server-authoritative coin pickup that resists teleport and spam
  exploits.
---

# Coin Pickup Mechanic

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

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](../roblox-fundamentals/server-authority.md), [remotes and networking](../roblox-fundamentals/remotes-and-networking.md), [players and characters](../roblox-fundamentals/players-and-characters.md), [utility library](utility-library.md), and [annotations and inference](../strict-typing/annotations-and-inference.md).

## 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](https://create.roblox.com/docs/en-us/projects/server-authority), [authority techniques](https://create.roblox.com/docs/en-us/projects/server-authority/techniques), and [client-server boundary](https://create.roblox.com/docs/en-us/scripting/security/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.

1. Create `Shared/CoinConfig` under `ReplicatedStorage`.
2. Create `Remotes` folder under `ReplicatedStorage`.
3. Create `CoinService` Script under `ServerScriptService`.
4. Create `CoinDisplay` LocalScript under `StarterPlayerScripts`.
5. 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](https://create.roblox.com/docs/en-us/scripting/events/remote).

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

1. Press Play with 1 player.
2. Walk into a coin part.
3. Confirm Output shows no error and HUD shows `Coins: 1`.
4. Teleport your character far away with the Move tool, then fire `ClaimCoin` with a coin name from the command bar on the client.
5. Confirm HUD stays unchanged because distance fails.
6. Fire `ClaimCoin` five times in under a second.
7. Confirm you gain at most 1 coin because cooldowns reject repeats.
8. 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

1. Add a magnet radius in config that pulls display coins without granting.
2. Add a despawn timer that moves stale coins after 30 seconds.
3. Add a server log that counts rejected claims by reason.
4. Add a team bonus that grants 2 coins inside a goal zone you validate on the server.
5. Reuse [utility library](utility-library.md) Result in the claim handler return path.

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

Expected behavior: coins visibly drift toward nearby players, while grants still require the server 8-stud check so display movement never mints coins.
:::

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

Expected behavior: coins untouched for 30 seconds teleport to a fresh spot and their timer restarts, while claimed coins stop being tracked.
:::

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

Expected behavior: dead, far, malformed, and spamming callers each increment their own reason, and `report` prints one line per reason.
:::

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

Expected behavior: players inside the zone gain 2 coins per claim, while dead, missing, NaN, and distant callers keep their balance.
:::

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