Verification: Static review complete; Studio/device tests unverifiedLast verified: 2026-10-03Prerequisites: Collect coins and track a score, The client-server boundary

10 Remotes and validation

Before you start and what you will build

Continue the collectibles place. Remove the temporary boundary experiment from lesson 09. Keep Collectibles, CoinHud, and the obby scripts. You will add a free speed boost: collect at least three coins, approach a green station, and press a button. The server grants five seconds at WalkSpeed 28, with twenty seconds between accepted activations. Coins are an eligibility threshold and are not spent.

A RemoteEvent carries asynchronous messages. FireServer reaches OnServerEvent; Roblox supplies the sending Player as the callback's first argument. FireClient sends feedback to one player. The client must not provide a player identity to impersonate another user. See Remote events and callbacks.

Exact setup

Stop the test before editing. The obby no longer has a Baseplate, so first build a connected side platform rather than putting the station over a gap:

  1. Inside Workspace.Obby, add an ordinary Part named BoostPlatform. Set Size to 12, 1, 12, Position to 0, 1, 12, Anchored to true, and CanCollide to true. Its near edge at Z=6 meets Platform0's far edge at Z=6, and both surfaces are Y=1.5.
  2. Add an ordinary Part directly under Workspace named BoostStation. Set Size to 6, 1, 6, Position to 0, 2, 12, Anchored to true, CanCollide to true, and Color to green. It sits on BoostPlatform, clear of the Start checkpoint and the course's coins.
  3. In Play, walk from Start toward positive Z onto BoostPlatform before collecting coins. Then collect three different coins and return across the same course platforms to Platform0 and the side platform. A reset uses your latest checkpoint, so return by traversing the course; do not assume resetting sends you to Start.

Distance is measured from the character root to BoostStation's center. Keep the exact positions for the first pass, then change the layout only after both approach and return routes work.

Add one Script BoostServer and one LocalScript BoostClient:

Workspace
  Obby
    BoostPlatform            new anchored Part beside Platform0
  BoostStation               Part on BoostPlatform
ServerScriptService
  Collectibles               existing Script
  BoostServer                new Script
StarterPlayer
  StarterPlayerScripts
    CoinHud                  existing LocalScript
    BoostClient              new LocalScript

The server creates these during Play; do not create a second copy manually:

ReplicatedStorage
  BoostRemotes               Folder
    RequestBoost             RemoteEvent
    BoostFeedback            RemoteEvent

Complete server implementation

Paste this into BoostServer. There is no yielding between the final checks and updating the activation state, preventing a second handler from passing the same cooldown window while the first waits.

Download 10-boost-server.server.luau

-- Script: ServerScriptService > BoostServer
-- Lesson 11 replaces this script's contents; do not run both versions.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Config = {
    RequiredCoins = 3,
    Radius = 12,
    Speed = 28,
    Duration = 5,
    Cooldown = 20,
    RequestInterval = 0.5,
}

local station = workspace:WaitForChild("BoostStation", 10)
assert(station and station:IsA("BasePart"), "Create Workspace.BoostStation Part")
assert(not ReplicatedStorage:FindFirstChild("BoostRemotes"), "Run only one BoostServer")
local remotes = Instance.new("Folder")
remotes.Name = "BoostRemotes"
local request = Instance.new("RemoteEvent")
request.Name = "RequestBoost"
request.Parent = remotes
local feedback = Instance.new("RemoteEvent")
feedback.Name = "BoostFeedback"
feedback.Parent = remotes
remotes.Parent = ReplicatedStorage

local lastRequest = {}
local nextAllowed = {}
local active = {}
local function reply(player, requestId, ok, message)
    feedback:FireClient(player, requestId, ok, message)
end

request.OnServerEvent:Connect(function(player, action, requestId)
    local now = os.clock()
    if now - (lastRequest[player] or -math.huge) < Config.RequestInterval then
        return -- Do not multiply a flood by sending a reply for every rejected request.
    end
    lastRequest[player] = now
    if typeof(requestId) ~= "number" or requestId % 1 ~= 0
        or requestId < 1 or requestId > 1000000000 then
        return -- Correlation metadata must also be bounded and well-formed.
    end
    if typeof(action) ~= "string" or action ~= "speed" then
        reply(player, requestId, false, "Unknown request")
        return
    end

    local character = player.Character
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    local root = character and character:FindFirstChild("HumanoidRootPart")
    if not humanoid or humanoid.Health <= 0 or not root or not root:IsA("BasePart") then
        reply(player, requestId, false, "Respawn before requesting a boost")
        return
    end
    local distance = (root.Position - station.Position).Magnitude
    if not (distance <= Config.Radius) then -- Also rejects NaN distances.
        reply(player, requestId, false, "Move closer to the green boost station")
        return
    end
    local leaderstats = player:FindFirstChild("leaderstats")
    local coins = leaderstats and leaderstats:FindFirstChild("Coins")
    if not coins or not coins:IsA("IntValue") or coins.Value < Config.RequiredCoins then
        reply(player, requestId, false, "Collect " .. Config.RequiredCoins .. " coins first")
        return
    end
    local remaining = (nextAllowed[player] or 0) - now
    if remaining > 0 or active[player] then
        reply(player, requestId, false, "Boost cooling down; try again shortly")
        return
    end

    -- No yielding between final validation and committing the state change.
    nextAllowed[player] = now + Config.Cooldown
    local token = {}
    active[player] = token
    local previousSpeed = humanoid.WalkSpeed
    humanoid.WalkSpeed = Config.Speed
    reply(player, requestId, true, "Boost active for " .. Config.Duration .. " seconds")

    task.delay(Config.Duration, function()
        if active[player] ~= token then return end
        active[player] = nil
        if player.Character == character and humanoid.Parent
            and humanoid.WalkSpeed == Config.Speed then
            humanoid.WalkSpeed = previousSpeed
        end
        if player.Parent == Players then
            reply(player, requestId, true, "Boost ended; wait for the cooldown before reusing")
        end
    end)
end)

Players.PlayerRemoving:Connect(function(player)
    lastRequest[player] = nil
    nextAllowed[player] = nil
    active[player] = nil
end)

Complete client implementation

Paste this into BoostClient. The generated button uses Activated so the same handler supports click and touch, plus gamepad activation when selected. Gamepad navigation and screen fit still require testing; the event alone does not create a good control layout. See GuiButton.

Download 10-boost-client.client.luau

-- LocalScript: StarterPlayer > StarterPlayerScripts > BoostClient
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local player = Players.LocalPlayer
local remotes = ReplicatedStorage:WaitForChild("BoostRemotes")
local request = remotes:WaitForChild("RequestBoost")
local feedback = remotes:WaitForChild("BoostFeedback")

local gui = Instance.new("ScreenGui")
gui.Name = "BoostGui"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")

local button = Instance.new("TextButton")
button.Name = "RequestButton"
button.AnchorPoint = Vector2.new(1, 1)
button.Position = UDim2.new(1, -20, 1, -100)
button.Size = UDim2.fromOffset(220, 56)
button.BackgroundColor3 = Color3.fromRGB(30, 100, 60)
button.TextColor3 = Color3.new(1, 1, 1)
button.TextSize = 22
button.Text = "Request boost"
button.Parent = gui

local status = Instance.new("TextLabel")
status.Name = "StatusLabel"
status.AnchorPoint = Vector2.new(1, 1)
status.Position = UDim2.new(1, -20, 1, -164)
status.Size = UDim2.fromOffset(260, 76)
status.BackgroundColor3 = Color3.fromRGB(25, 30, 40)
status.TextColor3 = Color3.new(1, 1, 1)
status.TextSize = 18
status.TextWrapped = true
status.Text = "Stand near the green station and request a boost"
status.Parent = gui

local REQUEST_TIMEOUT = 4
local lastClick = -math.huge
local latestRequestId = 0
local pending = false
button.Activated:Connect(function()
    if pending or os.clock() - lastClick < 0.6 then return end
    lastClick = os.clock()
    latestRequestId += 1
    local requestId = latestRequestId
    pending = true
    button.Text = "Waiting..."
    status.Text = "Requesting..."
    request:FireServer("speed", requestId)
    task.delay(REQUEST_TIMEOUT, function()
        if pending and latestRequestId == requestId then
            pending = false
            button.Text = "Request boost"
            status.Text = "No reply yet. Check your status, then tap to retry."
            status.TextColor3 = Color3.fromRGB(255, 205, 160)
        end
    end)
end)
feedback.OnClientEvent:Connect(function(requestId, ok, message)
    if requestId ~= latestRequestId then return end
    pending = false
    button.Text = "Request boost"
    status.Text = message
    status.TextColor3 = ok and Color3.fromRGB(180, 255, 190)
        or Color3.fromRGB(255, 205, 160)
end)

Read the security boundary

The gameplay request is the string speed, plus a bounded integer request ID used only to match feedback. Speed, duration, score threshold, and target player all come from server-owned decisions. The server rate limiter rejects excessive attempts before doing more work. The client limiter makes normal clicks pleasant; it is not a security control because a modified client can ignore it. The client allows one pending request and shows a manual retry hint after four seconds without matching feedback. It never automatically resends. A timeout does not prove the server rejected the request. Correlation IDs prevent an older reply or timer from overwriting the newest request's status.

Next, the handler checks a live character, station proximity, the server's coin balance, and cooldown. The active token identifies this particular activation so a delayed callback cannot undo a later activation. On player removal, per-player tables are cleared. On character replacement, the callback does not apply an old character's speed to a new humanoid.

This is an authorization example, not a complete anti-cheat system. A player's movement can be client-controlled. A distance check does not prove they reached the station legitimately, and setting WalkSpeed on the server does not stop a malicious client from changing its movement. Before adding valuable rewards or competitive rankings, study Roblox's movement validation guidance and validate plausible progress. Avoid promising that a single remote validation function makes a game “exploit-proof.”

Acceptance tests

  1. With zero coins, walk from Start along positive Z onto BoostPlatform without touching a coin, then click. Expect the collect-coins message and unchanged speed.
  2. Collect three different coins. Far from the station, click. Expect the proximity message. Return over the course to Platform0, then walk onto BoostPlatform; confirm the side platform is reachable both before and after collection.
  3. Approach and click. Expect active feedback and faster walking. Coins should remain unchanged.
  4. Click again after one second. Expect cooldown feedback, not another activation.
  5. After five seconds, speed returns to its previous value. After twenty seconds from activation, another boost is allowed.
  6. Reset during the boost. The new character should use its normal spawn speed.
  7. With two clients, activate on one. The other's eligibility, cooldown, and speed should remain independent.

For a negative-input test, temporarily change FireServer("speed", requestId) to FireServer(123, requestId). Expect “Unknown request” and no gameplay change, then restore it. To test the timeout in a disposable test copy, temporarily comment out the client's FireServer line, click once, and wait four seconds. Expect the manual retry hint and no automatic requests; restore the line afterward. Do not add arbitrary privileged debug remotes. Code was statically reviewed, not Studio-executed for this manual.

Troubleshooting and exercises

  • No button: the client may be waiting for BoostRemotes because BoostServer failed. Read server Output first.
  • Duplicate-server assertion: delete the duplicate BoostServer rather than removing the guard. Remotes should have one authoritative handler.
  • Boost never ends: check Output, duplicate scripts, and other code writing WalkSpeed. The restore deliberately avoids overwriting a different speed another system set meanwhile.
  • Always too far away: inspect the station's real Position and character root in the server view.

Exercises: change the threshold to two coins, then test both sides of that boundary. Add a separate cosmetic accepted-effect on the client without changing score or speed there. Before supporting multiple overlapping movement effects, design one server movement controller instead of stacking independent delayed resets.

Sources and navigation

Last checked 2026-10-03: remotes, securing the boundary, Humanoid WalkSpeed, GuiButton.

Previous: 09 Client-server boundary · Next: 11 Modules and configuration

Related: 13 Cross-device controls · 14 Testing multiplayer · 16 Debugging and performance

Progress and help notes

Nothing is sent automatically. Copying happens only when you press the button.

Sources