anthonysaade.dev

Hurtboxes: One Part Per Character for Every Hit and Query

How Bard’s Fighting Arena gives every character a single invisible hurtbox, so combat, abilities, and character sync all find characters the same way.

By · · 4 min read · Intermediate

A fighting game asks one question constantly: which characters are here? Melee hitboxes ask it every frame of an attack. The Timestop bubble asks it every frame it is active. The character sync from my Parallel Luau post asks it to decide who to animate.

Asking that question of the characters themselves is messy. A character is dozens of parts, plus accessories, weapons, and effects. A spatial query would return many parts per character, and every caller would have to filter them and remove duplicates.

Bard’s Fighting Arena answers it differently: every character gets exactly one hurtbox, and every query in the game looks only at hurtboxes.

The hurtbox

Each hurtbox is cloned from a single reference part, set up once:

local HurtboxReference do --// PlayerAdded - Character Init \\--
	HurtboxReference = Instance.new("Part") do
		HurtboxReference.CanCollide = false
		HurtboxReference.Transparency = 1
		HurtboxReference.Massless = true
		HurtboxReference.CanTouch = false
		HurtboxReference.Locked = true

When a character spawns, it gets its own copy:

		const Hurtbox = Instance.fromExisting(HurtboxReference) do --// Hurtbox
			Hurtbox.Locked = true
			Hurtbox.Archivable = false
			Hurtbox.Anchored = true
			Hurtbox.Name = Character.Name.."_Hurtbox"
			GarbageCollector[#GarbageCollector+1] = Hurtbox
			const Weld = Instance.new("Weld", Hurtbox) do
				Weld.Enabled = false
				Weld.Part0 = Hurtbox
				Weld.Part1 = Root
				Weld:GetPropertyChangedSignal("C0"):Connect(function()
					Weld.C0 = CFrame.identity
				end)
				Weld:GetPropertyChangedSignal("C1"):Connect(function()
					Weld.C1 = CFrame.identity
				end)
			end

It is invisible, massless, and has collision and touch turned off, so it never interacts with physics or gameplay on its own. It is sized to cover the whole body (the root’s size times 1 × 3 × 2), and all hurtboxes live together in one Hurtboxes folder.

One filter for every query

Because the hurtboxes share a folder, one OverlapParams can include only that folder:

local HitboxOParams = OverlapParams.new() do
	HitboxOParams.FilterType = Enum.RaycastFilterType.Include
	HitboxOParams.FilterDescendantsInstances = {workspace:WaitForChild("Hurtboxes")}
	Globals.HitboxOParams = HitboxOParams
end

Every spatial query in the game uses it. A query can only ever return hurtboxes, and each character has exactly one, so results never need de-duplicating or filtering out stray parts.

A weld used as a pointer

Each hurtbox holds a Weld that is never enabled. Its Part1 is the character’s root, and its offsets are locked to identity so nothing can change them. It does not hold anything in place. It is a link from the hurtbox back to its character that replicates automatically and can be read from any script.

Here is how the melee hitbox check uses it:

			local Hurtboxes = workspace:GetPartsInPart(Hitbox, Globals.HitboxOParams)
			for Index, Hurtbox in next, Hurtboxes do
				Hurtboxes[Index] = nil
				
				local Weld = Hurtbox:FindFirstChild("Weld") :: Weld
				if not Weld then
					continue
				end
				local HurtboxAdornee = Weld.Part1
				local Victim : Globals.CharacterType = HurtboxAdornee and HurtboxAdornee.Parent
				if Victim == HitboxOwner or table.find(Hit, Victim) then
					continue
				end

The same lookup, Hurtbox.Weld.Part1.Parent, appears in the Timestop bubble and the parallel character sync. One pattern, used everywhere.

Not welded: predicted

Hurtboxes are not welded to their characters. A welded hurtbox would sit wherever the server currently sees the character, which lags behind where that player sees themselves by their ping. Attacks aimed at what the attacker saw would miss.

Instead, the hurtbox is anchored and moved every frame to a predicted position. The server looks ahead along the character’s velocity by an amount scaled to that player’s latency, so the hurtbox lines up with where the player actually is on their own screen:

						const CompensationPing = math.clamp((Player:GetAttribute("NetworkPing") or Player:GetNetworkPing())*2000, 75, 180)/75

						local Walkspeed : number = StateProfile.PredictionSpeed and States:FindFirstChild("PredictionSpeed")
						if Walkspeed then
							Walkspeed = Walkspeed.Value
						else
							BodyPosition = Root:FindFirstChildOfClass("BodyPosition") or false
							if BodyPosition then
								Walkspeed = (BodyPosition.Position-Root.Position).Magnitude
							else
								BodyVelocity = (StateProfile.Dashing and Character.Torso:FindFirstChildOfClass("BodyVelocity") or Root:FindFirstChildOfClass("BodyVelocity")) or false
								if BodyVelocity then
									Walkspeed = BodyVelocity.Velocity.Magnitude
								else
									Walkspeed = Globals.GetWalkspeed(Character, Humanoid)
								end
							end
						end
						if Walkspeed < 1 then
							Walkspeed = 1
						end

						local Product do
							const Delta = DeltaTime*CompensationPing*6
							local Direction = Root.AssemblyLinearVelocity*Delta do
								const Interval = Walkspeed*Delta
								const Magnitude = Direction ~= Vector3.zero and Direction.Magnitude
								if Magnitude and Magnitude <= Interval then
									Product = CFrame.new(Root.Position+Direction)*Root.CFrame.Rotation
								else
									if Magnitude then
										--warn(Direction, "|", Magnitude, "|", Interval, "|", Interval/Magnitude)
										Direction *= Interval/Magnitude
									end
									Product = Root.CFrame+Direction
								end
							end
							
							local MoveDelta = Product.Position-Root.Position
							if MoveDelta.Magnitude >= 1024/2 then
								MoveDelta = MoveDelta.Unit*(1024/2-1)
							end
							if MoveDelta.Magnitude > 1e-4 then
								const CastOrigin = Root.Position-MoveDelta
								const Blockcast = workspace:Blockcast(CFrame.new(CastOrigin)*Product.Rotation, Character.Hurtbox.Value.Size, MoveDelta*2, CharacterRCParams)
								if Blockcast then
									Product = CFrame.new(CastOrigin+MoveDelta.Unit*Blockcast.Distance)*Product.Rotation
								end
							end
						end
						if Player and Character.CameraLocked.Value and not Globals.CantRotate(Character) then
							local _, CameraY = Character.CameraCFrame.Value.Rotation:ToOrientation()
							local X, _, Z = Product.Rotation:ToOrientation()
							Product = CFrame.new(Product.Position)*CFrame.Angles(X, CameraY, Z)
						end
						ClientCFrame.Value = Product

Here is the prediction running, with the hurtboxes made visible. The small, fully opaque box is where the server sees me. The big, transparent box is my hurtbox, placed with latency prediction.

At 28–31 ms ping.
At 158–162 ms ping.

At higher ping the hurtbox leads further ahead, because it has more latency to make up.

Exploit-resistant by speed limit

The prediction can never outrun the character. Before it moves anything, the server works out the fastest the character is allowed to go right now: an ability’s set speed, an active BodyPosition or BodyVelocity, or the walk speed with every buff and debuff applied. The look-ahead is capped at that speed, so a client reporting impossible movement cannot push its hurtbox further than its real top speed allows.

It also cannot pass through walls. A Blockcast the size of the hurtbox checks the predicted move, and stops it at anything in the way.

Moved in one batch

Every hurtbox’s predicted position is collected during the loop:

			const Hurtbox = Character.Hurtbox.Value
			if Hurtbox then
				local Area : CFrame = ClientCFrame.Value do
					if Area == CFrame.identity then
						Area = Root.CFrame
					end
				end
				const Index = #HurtboxParts+1 do
					HurtboxParts[Index] = Hurtbox
					HurtboxCFrames[Index] = Area
				end

Then all of them move at once with a single workspace:BulkMoveTo(HurtboxParts, HurtboxCFrames, FireCFrameChanged) call, which is much cheaper than setting each part’s CFrame separately.

Seeing what you hit

The predicted position is also saved as ClientCFrame, which replicates to everyone. Players can turn on the Interpolation Prediction setting to render other players at that predicted position instead of their usual replicated one. With it on, the character you see is where their hurtbox is, so what you aim at is what you hit.

Turning Interpolation Prediction on and off.

The adjustment lives in the parallel character sync. For every other player, it eases their root joint toward their predicted position:

				if not IsPlayerCharacter and not Root.Anchored and not StateProfile.Ragdoll then
					local ObjectSpace = RootCFrame:ToObjectSpace(Character.ClientCFrame.Value)
					local Position; if IntepolationPrediction > 0 then
						local Alpha = DeltaTime*IntepolationPrediction*10 do
							if CharacterVelocityMagnitude <= 1 then
								Alpha = LerpDT
							end
						end

						local Magnitude = (ObjectSpace.Position-RootJoint.C0.Position).Magnitude*IntepolationPrediction+1
						Alpha *= Magnitude; if Alpha > 1 then
							Alpha = 1
						end
						Position = RootJoint.C0.Position:Lerp(ObjectSpace.Position, Alpha) do
							local Origin, Offset = RootCFrame.Position, Root.PivotOffset.Position + Vector3.yAxis*0.1
							local Raycast = workspace:Raycast(Origin+Vector3.yAxis*Position.Y, Offset, Globals.MapRParams)
							if Raycast then
								Position += Vector3.yAxis*(((Raycast.Position-Offset)-Origin).Y-Position.Y)
							end
						end
					end
					local Angle do
						local _, RotationY = ObjectSpace.Rotation:ToOrientation()
						Angle = CFrame.Angles(0, 0, RotationY)
					end
					if Position then
						RJointC0 = CFrame.new(Position)*RJointC0.Rotation*Angle
					else
						RJointC0 *= Angle
					end
				end

It only applies to other players, and only while they are not anchored or ragdolled. The blend speed scales with the setting, and grows with how far the character is from its predicted spot, so large gaps close quickly while small ones stay smooth. When the character is barely moving, it falls back to the normal smoothing rate. A raycast keeps the adjusted position on the ground.

Why it works well