Why client-side anti-cheat isn't enough in Roblox
An exploiter controls their own client, so checks that only run there can be switched off. How to move movement checks to the server.
What it looks like
A LocalScript watches for cheating, such as a changed WalkSpeed, a character moving too fast or an unexpected tool, and kicks the player or reports them. Nothing on the server checks the same thing.
Luau
humanoid:GetPropertyChangedSignal("WalkSpeed"):Connect(function()
if humanoid.WalkSpeed > 16 then
player:Kick()
end
end)How to detect it
- Look in LocalScripts and client ModuleScripts for
Kick, for checks onWalkSpeed,JumpPower,JumpHeight,HipHeightor the character's position or velocity, and for scans of the player's own tools, GUIs or scripts. - For each one, look for a server script that checks the same thing. A client check with no server equivalent is a finding.
- Client code that fires a remote to report itself, such as "I detected cheating", only works if the exploiter lets it. Treat it the same way.
What an exploiter could do
Their executor runs on the same client as the check, so they can delete the LocalScript, stop it running, block the kick or never send the report. The check catches only players who aren't trying to hide, and gives a false sense that the game is protected.
How to fix it
- Move the check to the server. The server can see where a character is and how fast it moves, but changes a client makes to its own
Humanoidproperties don't replicate, so compare movement over time rather than readingWalkSpeed. - Move the player back rather than kicking them. Lag makes honest players jump now and then, and a kick for that feels unfair.
- Reset the check whenever the server moves a character itself, such as on respawn or a teleport, and allow for anything that legitimately moves faster, like vehicles.
- Keep client checks only as an extra layer, never as the only one, and don't rely on the client reporting itself.
Vulnerable example
Vulnerable
local Players = game:GetService("Players") local player = Players.LocalPlayer local maxSpeed = 16 local function watch(character: Model) local humanoid = character:WaitForChild("Humanoid") :: Humanoid humanoid:GetPropertyChangedSignal("WalkSpeed"):Connect(function() if humanoid.WalkSpeed > maxSpeed then player:Kick("Speed hacking is not allowed.") end end) end if player.Character then watch(player.Character) end player.CharacterAdded:Connect(watch)
Fixed example
Fixed
local Players = game:GetService("Players") local RunService = game:GetService("RunService") local maxSpeed = 16 local allowance = 1.5 local interval = 1 local lastPosition: { [Player]: Vector3 } = {} local elapsed = 0 RunService.Heartbeat:Connect(function(delta) elapsed += delta if elapsed < interval then return end for _, player in Players:GetPlayers() do local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") if root and root:IsA("BasePart") then local previous = lastPosition[player] local flat = Vector3.new(root.Position.X, 0, root.Position.Z) if previous and (flat - previous).Magnitude > maxSpeed * allowance * elapsed then root.CFrame = CFrame.new(previous + Vector3.new(0, root.Position.Y, 0)) else lastPosition[player] = flat end end end elapsed = 0 end) Players.PlayerAdded:Connect(function(player) player.CharacterAdded:Connect(function() lastPosition[player] = nil end) end) Players.PlayerRemoving:Connect(function(player) lastPosition[player] = nil end)
Common false positives
- Client checks that sit alongside a server check doing the same job are fine, and worth a mention in "What you did well" only for the server part.
- Client code that limits the player's own input for feel, like a cooldown on a button, isn't anti-cheat. Server-side limits are covered by other checks.