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.
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 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.
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
- Cheap queries. Spatial queries only look at one part per character instead of every body part and accessory.
- One source of truth. Combat, abilities, and animation all agree on where a character is and who it is.
- Fair under latency. Hits land where players see each other, and the speed cap keeps that from being abused.
- Easy to extend. A new ability that needs to find characters only needs a region and
HitboxOParams.