Why server anti-cheat shouldn't ban on one reading

Lag, falls, flings and the game's own teleports trip movement checks. How to move players back instead of kicking, and allow for your own movement.

warningLuaudit check: instant-movement-bans

What it looks like

A server-side speed, fly or teleport check kicks or bans a player the first time one measurement looks wrong. It doesn't allow for lag, the game's own teleports, vehicles, launch pads, respawns or being flung by physics.

Luau
if distance > humanoid.WalkSpeed * delta then
	player:Kick("Exploiting")
end

How to detect it

  • Find server code that measures a character's movement: distance between position samples, AssemblyLinearVelocity, time in the air, or height above the floor.
  • Look at what happens when the check fails. Kick, BanAsync or a saved ban after a single failed reading is a finding. Moving the player back, or acting only after several failures in a short window, is the safer shape.
  • Check that the game's own movement is allowed for: server teleports, respawns, seats and vehicles, speed boosts and launch pads, WalkSpeed changes, ragdolls and explosions. Each one should reset the last position or raise the limit.
  • Check the limit has headroom. Roblox's network ownership guide notes position updates are subject to latency and need averaging over time, so a lagging player can appear to cover several seconds of distance in one sample.
  • Roblox's server-side detection guide recommends rubber-banding a speeding player back rather than kicking them, and acting only once several different signals agree.
  • Client-side movement checks are Anti-cheat that only runs on the client. Punishment triggered by other players' reports is Players punished on another client's word.

What an exploiter could do

Exploiters lose little from this; the cost falls on honest players. A lag spike, a laggy connection, a fling from another player's physics or one of the game's own pads gets innocent players kicked or banned. Players who can push or fling others can get them removed on purpose.

How to fix it

  • Respond to a failed reading by moving the player back to their last good position, not by kicking.
  • Keep a count of failures over a short window and act, if at all, only when it stays high, and log it for staff to review before any ban.
  • Reset the last good position whenever the game moves the player itself, and allow for every speed source the game has.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local allowance = 1.5
local lastPosition: { [Player]: Vector3 } = {}

RunService.Heartbeat:Connect(function(delta)
	for _, player in Players:GetPlayers() do
		local character = player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if root and root:IsA("BasePart") and humanoid then
			local position = root.Position * Vector3.new(1, 0, 1)
			local previous = lastPosition[player]
			lastPosition[player] = position
			if previous and (position - previous).Magnitude > humanoid.WalkSpeed * allowance * delta then
				player:Kick("Speed exploit detected.")
			end
		end
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastPosition[player] = nil
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local allowance = 1.5
local interval = 0.5
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")
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if root and root:IsA("BasePart") and humanoid then
			local position = root.Position * Vector3.new(1, 0, 1)
			local previous = lastPosition[player]
			if previous and (position - previous).Magnitude > humanoid.WalkSpeed * allowance * elapsed then
				root.CFrame = CFrame.new(previous.X, root.Position.Y, previous.Z)
			else
				lastPosition[player] = position
			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

  • Checks that only move the player back, freeze them briefly or flag them for staff review don't punish innocent players for one bad reading.
  • A kick after many failures across a long window, with the game's own movement allowed for, is a judgement call. Mention the risk rather than reporting it.
  • Kicks for things no honest player can do at all, such as being in a sealed area that the server can prove they never entered, aren't measurement noise. Judge them on their own evidence.