How to validate combat hits on the server in Roblox
If the client reports hits and the server just applies damage, hits can land from anywhere. How to check range, cooldowns and ranged shots on the server.
What it looks like
Combat where the client reports a hit and the server applies the damage. The server may check the weapon and a cooldown, but not whether the hit could have happened: how far away the target was, where a shot started, or whether anything was in the way.
Luau
remote.OnServerEvent:Connect(function(player, victim) victim.Humanoid:TakeDamage(20) end)
How to detect it
- Find handlers that call
TakeDamage, changeHealth, apply knockback or award kills, where the target, the hit position or the ray's starting point comes from the client. - For each one, list what the server checks: that the weapon is in the attacker's character, a cooldown kept on the server, that the target is a living character other than the attacker, and that the target is within the weapon's range of the attacker's character.
- For ranged weapons, check where the server's raycast starts. A ray cast on the server from an origin the client sent is still the client's shot. The origin should come from the attacker's own character.
- A hit with no range or origin check is a finding even when everything else is validated.
What an exploiter could do
They can hit players they couldn't reach or see: from across the map, through walls or from behind cover, at the fastest rate the server allows. Fights stop being winnable for anyone else in the server.
How to fix it
- Decide on the server whether the hit was possible. Check that the weapon is in the attacker's character, keep the cooldown on the server, check that the target is alive, and check the distance between the two characters'
HumanoidRootPartagainst the weapon's range plus a small margin for latency. - For ranged weapons, raycast on the server from the attacker's own character, such as the head, and use only the direction from the client.
- Characters are moved by their own client, so these checks stop hits from places the attacker isn't, not a client that moves its character first. Movement checks belong to anti-exploit.
Vulnerable example
Vulnerable
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local damage = 20 local cooldown = 0.6 local lastSwing: { [Player]: number } = {} ReplicatedStorage.Remotes.SwordHit.OnServerEvent:Connect(function(player: Player, victim: unknown) if typeof(victim) ~= "Instance" or not victim:IsA("Model") then return end local character = player.Character local humanoid = victim:FindFirstChildOfClass("Humanoid") if not character or victim == character or not humanoid or not character:FindFirstChild("Sword") then return end local now = os.clock() if now - (lastSwing[player] or 0) < cooldown then return end lastSwing[player] = now humanoid:TakeDamage(damage) end) Players.PlayerRemoving:Connect(function(player) lastSwing[player] = nil end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local damage = 20 local cooldown = 0.6 local reach = 8 local lastSwing: { [Player]: number } = {} ReplicatedStorage.Remotes.SwordHit.OnServerEvent:Connect(function(player: Player, victim: unknown) if typeof(victim) ~= "Instance" or not victim:IsA("Model") then return end local character = player.Character local humanoid = victim:FindFirstChildOfClass("Humanoid") if not character or victim == character or not humanoid or humanoid.Health <= 0 or not character:FindFirstChild("Sword") then return end local root = character:FindFirstChild("HumanoidRootPart") local targetRoot = victim:FindFirstChild("HumanoidRootPart") if not root or not root:IsA("BasePart") or not targetRoot or not targetRoot:IsA("BasePart") then return end if (root.Position - targetRoot.Position).Magnitude > reach then return end local now = os.clock() if now - (lastSwing[player] or 0) < cooldown then return end lastSwing[player] = now humanoid:TakeDamage(damage) end) Players.PlayerRemoving:Connect(function(player) lastSwing[player] = nil end)
Common false positives
- The client choosing where to aim is normal. The server's job is to check the shot could come from where the attacker is, not to second-guess the aim.
- Damage worked out entirely on the server, such as a
Touchedhandler on a server-owned hitbox or a server raycast from the attacker's character, isn't client-reported. - Damage dealt only to non-player targets that give nothing, such as practice dummies, can't harm anyone. A dummy that awards XP or currency still counts.