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.

warningLuaudit check: client-side-anticheat

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 on WalkSpeed, JumpPower, JumpHeight, HipHeight or 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 Humanoid properties don't replicate, so compare movement over time rather than reading WalkSpeed.
  • 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.