PROJECT 02

2 / 11

Build Server-Validated Melee Hit Detection in Roblox Studio

Let the server validate cooldown, range, facing, and line of sight, then deal 25 damage exactly once per sword swing.

Published Updated
The server accepting one sword attack and dealing 25 damage during ACTIVE

Extend the swing from project step 1 so it changes a TrainingDummy from 100 HP to 75 HP. The client will only request an attack. The server decides whether the request is allowed, which targets are valid, and how much damage they receive.

Server-owned state sequence for one 25-damage melee hit

1. Build the test map and request channel

Start with a small map that can compare a valid hit and a blocked hit at the same distance. Add these four objects under Workspace. If the place already has a floor or spawn, rename it and match these values:

  • ArenaFloorPart with Position = 0, 0, 0, Size = 82, 1, 56, and Anchored = true.
  • CombatSpawnSpawnLocation with Position = 0, 1.5, 14, Size = 7, 0.4, 7, Anchored = true, and Neutral = true.
  • TrainingDummy > HumanoidRootPartPosition = 0, 3, 7 and Anchored = true.
  • TestWallPart with Position = 0, 3.5, 10.5, Size = 5, 6, 1, Anchored = true, CanCollide = true, and CanQuery = true.

Add ArenaFloor and TestWall from Home > Part, then rename them in Explorer. Use the beside Workspace to add CombatSpawn. A SpawnLocation determines where a character enters Play. Set the wall’s Material to Brick and give it a color that stands apart from the floor.

Tip — Position values use X, Y, Z order

X moves left and right, Y controls height, and Z moves forward and backward. This preset places the wall at Z = 10.5, halfway between the spawn at Z = 14 and the dummy at Z = 7.

Top-down combat-02 test map with the spawn, wall, and TrainingDummy on one line

For the valid-hit test, change only the wall’s Position.X to 7. This parks it beside the attack line. For the blocked test, restore X = 0. Keep Z = 10.5 in both cases. At the start of Play, move to the center line and face the dummy so both tests use the same distance of about 7 studs.

Use Avatar > Character > Generate Rig to add an R15 block rig. Rename it TrainingDummy, set its Humanoid.MaxHealth and Humanoid.Health to 100, anchor its HumanoidRootPart, and place it in front of the spawn. This is the Character button described in Roblox’s official Rig Generator guide.

Add a RemoteEvent directly under ReplicatedStorage and name it SwordAttack.

Playtest setup with the sword equipped and a 100 HP test target in front of the player

Tip — Send a request, not a combat result

A modified client could lie about a target or damage number. This event sends no arguments. The server owns the fixed 25 damage, the query size, and the 0.95-second cooldown.

In SwordAnimationClient, get the event near the top and call it immediately after setting busy = true:

local attackEvent = game:GetService("ReplicatedStorage"):WaitForChild("SwordAttack")

-- Inside tool.Activated
attackEvent:FireServer()

2. Own attack timing on the server

Add a Script named SwordCombatServer to ServerScriptService. Use the following code:

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local attackEvent = ReplicatedStorage:WaitForChild("SwordAttack")
local COOLDOWN = 0.95
local WINDUP = 0.28
local ACTIVE_DURATION = 0.12
local DAMAGE = 25
local HITBOX_SIZE = Vector3.new(6, 5, 7)
local MAX_DISTANCE = 8.5
local MIN_FACING_DOT = 0.2
local lastAttackAt = {}

local function hasLineOfSight(attacker, origin, target, targetPosition)
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {attacker}
	local hit = workspace:Raycast(origin, targetPosition - origin, params)
	return hit == nil or hit.Instance:IsDescendantOf(target)
end

local function findCandidates(character)
	local root = character:FindFirstChild("HumanoidRootPart")
	if not root then return {} end

	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {character}
	local box = root.CFrame * CFrame.new(0, 1.5, -4)
	local parts = workspace:GetPartBoundsInBox(box, HITBOX_SIZE, params)
	local result, seen = {}, {}

	for _, part in parts do
		local model = part:FindFirstAncestorOfClass("Model")
		local humanoid = model and model:FindFirstChildOfClass("Humanoid")
		if humanoid and humanoid.Health > 0 and not seen[humanoid] then
			seen[humanoid] = true
			table.insert(result, humanoid)
		end
	end
	return result
end

local function isValidTarget(character, targetHumanoid)
	local root = character:FindFirstChild("HumanoidRootPart")
	local target = targetHumanoid.Parent
	local targetRoot = target and target:FindFirstChild("HumanoidRootPart")
	if not root or not targetRoot then return false end

	local offset = targetRoot.Position - root.Position
	if offset.Magnitude == 0 or offset.Magnitude > MAX_DISTANCE then
		return false
	end
	if root.CFrame.LookVector:Dot(offset.Unit) < MIN_FACING_DOT then
		return false
	end
	return hasLineOfSight(
		character,
		root.Position + Vector3.new(0, 1.8, 0),
		target,
		targetRoot.Position
	)
end

local function runAttack(player)
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	local tool = character and character:FindFirstChild("OriginalSword")
	if not character or not humanoid or humanoid.Health <= 0 or not tool then
		return
	end

	local now = os.clock()
	if now - (lastAttackAt[player] or -math.huge) < COOLDOWN then
		return
	end
	lastAttackAt[player] = now

	player:SetAttribute("CombatPhase", "WINDUP")
	task.wait(WINDUP)
	if player.Character ~= character or humanoid.Health <= 0 then return end

	player:SetAttribute("CombatPhase", "ACTIVE")
	local hitThisSwing = {}
	local startedAt = os.clock()
	repeat
		for _, targetHumanoid in findCandidates(character) do
			if not hitThisSwing[targetHumanoid]
				and isValidTarget(character, targetHumanoid) then
				hitThisSwing[targetHumanoid] = true
				targetHumanoid:TakeDamage(DAMAGE)
			end
		end
		task.wait()
	until os.clock() - startedAt >= ACTIVE_DURATION

	player:SetAttribute("CombatPhase", "RECOVERY")
	task.wait(math.max(0, COOLDOWN - WINDUP - ACTIVE_DURATION))
	if player.Parent then player:SetAttribute("CombatPhase", "READY") end
end

attackEvent.OnServerEvent:Connect(function(player)
	task.spawn(runAttack, player)
end)

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

The event carries no client-controlled values, so there are no target or damage arguments to type-check. The server still validates request frequency, a living character, the equipped Tool, target health, range, facing, and line of sight.

Tip — A hitbox is the space checked during the active interval

GetPartBoundsInBox() queries a 6 × 5 × 7-stud box placed in front of the character. It does not create a permanent invisible Part. The server repeats the query only during the 0.12-second active window.

hitThisSwing is a fresh set for each accepted attack. A rig may put its arm, torso, and leg inside the box at the same time, but their shared Humanoid can enter this set only once.

Revalidate after the request arrives

Roblox supplies the player argument to OnServerEvent, so a client cannot impersonate another Player in that position. The Player can still reset, die, or unequip between the request and the hit. runAttack() checks the current Character, living Humanoid, and equipped OriginalSword before accepting the request. After the 0.28-second windup, it verifies that the same Character is still alive.

Cooldown time comes from the server’s os.clock(). The client never reports how long it has waited. Sending the event many times or changing a local clock cannot enter a second attack before the server-owned 0.95 seconds expire.

The CombatPhase attribute is also server-owned. It advances through WINDUP, ACTIVE, RECOVERY, and READY independently of the visual animation markers. A modified marker can change what that client sees, but it cannot expand the server’s 0.12-second active query.

Tip — Cooldown is the wait before another request can begin

This attack always occupies 0.95 seconds. Recovery is the remainder after subtracting the 0.28-second windup and 0.12-second active window.

Server-owned flow from READY through ACTIVE to exactly one 25-damage result

3. Reject invalid targets

Being inside the box is not enough. isValidTarget() also requires:

  • no more than 8.5 studs between roots;
  • a facing dot product of at least 0.2;
  • no wall hit before the target;
  • living attacker and target;
  • OriginalSword equipped by the attacker.

Tip — The dot product measures facing

LookVector:Dot(offset.Unit) is near 1 in front, 0 to the side, and -1 behind. Rejecting values below 0.2 prevents a target behind the player from connecting even if one of its parts touches the query box.

A wall blocking the test target while its health remains at 100 out of 100

While Play is stopped, set TestWall.Position to 7, 3.5, 10.5. Start Play and attack from directly in front of the dummy; its health should reach 75. Stop again and restore the wall to 0, 3.5, 10.5. Attack from the same position and facing. The overhead display should stay at 100 / 100.

If the blocked attack still deals damage, confirm that TestWall.CanQuery is enabled and that the player, wall, and dummy form the straight line shown in the map. CanQuery controls whether spatial queries such as Raycasts include the Part.

4. Verify 100 HP to 75 HP

Start Play, equip the sword, face the nearby dummy, and attack once.

Actual Play view of one forward sword attack changing the test target from 100 HP to 75 HP

The test target stopped at 75 HP after one valid swing

Run these checks one at a time:

  1. One close, forward-facing attack changes 100 to 75.
  2. Repeated input during the same swing leaves the value at 75.
  3. A target beyond 8.5 studs takes no damage.
  4. A target behind the character takes no damage.
  5. A wall between both roots prevents damage.

Stop and restore the dummy to 100 HP between checks. If one attack removes 50 or more health, make sure hitThisSwing is created inside runAttack() and that only one copy of SwordCombatServer exists.

For a short diagnostic, place print(targetHumanoid.Parent.Name, targetHumanoid.Health) immediately before TakeDamage(). The valid front attack should print one line; distant, rear, and wall cases should print none. Remove the print afterward. If no request begins, confirm that the equipped Tool is under the Character, SwordAttack is directly under ReplicatedStorage, and the server Script is under ServerScriptService. A test wall must keep CanQuery enabled so the Raycast can detect it.

After all five checks, restore the dummy to its front starting position with Health = 100, then return TestWall.Position to 7, 3.5, 10.5. Save the Place with the center lane open. Run one final Play check, confirm that a forward swing stops at 75 HP, and then stop. Runtime damage does not change the saved edit-state health, so project step 3 starts with a 100-HP dummy and the same wall parked to the side.

The server is now the combat authority. Next, you will turn the dummy into AshKnight, detect a living player within 40 studs, and pathfind until the enemy is 6 studs away.

Official references

Expanded image