Tutorial · Gameplay

How to make a combat system with hitboxes in Roblox Studio

Battlegrounds games are built on one loop: press a button, swing, and hit whoever is in front of you. The hit is decided by a hitbox, an invisible box in front of the attacker. This guide builds a punch combo where the client only asks to punch and the server owns everything that matters: the cooldown, the combo count, the hitbox and the damage.

Updated 23 October 20266 min readIntermediateLuau

In short

A LocalScript binds a Punch action and fires a RemoteEvent with no arguments. A Script in ServerScriptService checks the attacker is alive and off cooldown, counts the combo, then calls workspace:GetPartBoundsInBox with a box a few studs in front of the attacker's root part, excluding their own character. Each Humanoid found takes damage once.

  • The client sends no target and no damage. The server works both out, so a cheater cannot hit across the map.
  • Collect Humanoids in a table so a target with 15 body parts in the box takes damage once, not 15 times.
  • Prefer a box query to Touched on a fist: Touched misses fast swings and fires many times.

Get the script

Punch combo and hitbox server script

A Script in ServerScriptService: a 3-hit punch combo with a server cooldown, a box hitbox in front of the attacker, no friendly fire and a KOs stat.

Script in ServerScriptService named CombatService
-- Script in ServerScriptService named CombatService.
-- Punch combos: the client asks to punch, the server checks the cooldown, finds
-- targets in a box in front of the attacker and deals the damage.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local DAMAGE = 10 -- per punch
local FINISHER_DAMAGE = 20 -- the last punch of a combo
local COMBO_LENGTH = 3
local PUNCH_COOLDOWN = 0.35 -- seconds between punches
local COMBO_RESET = 1 -- seconds without a punch before the combo starts over
local HITBOX_SIZE = Vector3.new(5, 5, 5)
local HITBOX_DISTANCE = 3.5 -- studs in front of the attacker's root

local punchEvent = Instance.new("RemoteEvent")
punchEvent.Name = "Punch"
punchEvent.Parent = ReplicatedStorage

type State = { last: number, combo: number }
local states: { [Player]: State } = {}

local function addKO(player: Player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local kos = leaderstats and leaderstats:FindFirstChild("KOs")
	if kos and kos:IsA("IntValue") then
		kos.Value += 1
	end
end

punchEvent.OnServerEvent:Connect(function(player: Player)
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	local root = humanoid and humanoid.RootPart
	if not (character and humanoid and root) or humanoid.Health <= 0 then
		return
	end

	-- The server keeps its own clock, so a cheater cannot punch faster by firing more often
	local now = os.clock()
	local state: State = states[player] or { last = -math.huge, combo = 0 }
	states[player] = state
	if now - state.last < PUNCH_COOLDOWN then
		return
	end
	state.combo = if now - state.last > COMBO_RESET then 1 else state.combo % COMBO_LENGTH + 1
	state.last = now
	local damage = if state.combo == COMBO_LENGTH then FINISHER_DAMAGE else DAMAGE

	-- The hitbox: a box in front of where the server sees the attacker
	local boxCFrame = root.CFrame * CFrame.new(0, 0, -HITBOX_DISTANCE)
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = { character }

	local hitHumanoids: { [Humanoid]: boolean } = {}
	for _, part in workspace:GetPartBoundsInBox(boxCFrame, HITBOX_SIZE, params) do
		local model = part:FindFirstAncestorOfClass("Model")
		local target = model and model:FindFirstChildOfClass("Humanoid")
		local targetPlayer = if model then Players:GetPlayerFromCharacter(model) else nil
		-- No friendly fire: skip players on the attacker's team
		local sameTeam = targetPlayer and not player.Neutral and targetPlayer.Team == player.Team
		-- One hit per Humanoid, however many of its parts are in the box
		if target and target.Health > 0 and not sameTeam and not hitHumanoids[target] then
			hitHumanoids[target] = true
			target:TakeDamage(damage)
			if target.Health <= 0 then
				addKO(player)
			end
		end
	end
end)

Players.PlayerAdded:Connect(function(player: Player)
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"
	local kos = Instance.new("IntValue")
	kos.Name = "KOs"
	kos.Parent = leaderstats
	leaderstats.Parent = player
end)

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

RoCode builds it in the place you have open in Roblox Studio, on Windows or macOS with a desktop browser; on a phone the button sends the prompt and the link to your computer. Free to start, with free credits every day. More Roblox Studio scripts

Where to put it

Explorer: the server script and the input script
ServerScriptService
  CombatService Script new
StarterPlayer
  StarterPlayerScripts
    CombatClient LocalScript new

Settings you can change

DAMAGEIn the script
Damage of the first punches of a combo. Default: 10.
FINISHER_DAMAGEIn the script
Damage of the last punch of a combo. Default: 20.
PUNCH_COOLDOWNIn the script
Seconds between punches, kept by the server. Default: 0.35.

Use it with the CombatClient LocalScript from Step 2, which sends the punch on a click, a gamepad trigger or a phone button.

What a cheater can and cannot doThe server keeps the cooldown and builds the hitbox from where it sees the attacker, so firing the RemoteEvent faster or from far away does not help a cheater. A cheater who moves their character close to someone can still punch them, as any player can.

Type-checked with luau-lsp strict against Roblox's API definitions on 23 October 2026. Not playtested in Studio yet: press Play and watch the Output window before you publish.

Prompt for RoCode

Add a punch combat system: a LocalScript that fires a Punch RemoteEvent on click, gamepad R2 or a phone button, and a server Script with a 0.35 second cooldown, a 3-hit combo where the last hit does 20 damage, a 5 stud hitbox in front of the attacker using GetPartBoundsInBox, no friendly fire, and a KOs leaderstat.

On this page
  1. Get the script
  2. How it works
  3. 1. Server
  4. 2. Input
  5. 3. Game feel
  6. Common errors
  7. With RoCode
  8. Questions
  9. Sources

How a server-side hitbox works

When the player presses Punch, their device sends a RemoteEvent with nothing in it. The server looks at its own copy of the attacker: are they alive, has the cooldown passed, which hit of the combo is this. Then it asks the physics engine which parts are inside a box in front of the attacker's root part. Any Humanoid those parts belong to, other than the attacker and their teammates, takes damage.

Who decides what
DecisionMade byWhy
When to punchClientInput lives on the player's device.
Cooldown and comboServerA client could fire the event 100 times a second.
Who is hitServerA client could name any player as the target.
DamageServerA client could send any number.

Step 1: The server script

Insert a Script in ServerScriptService named CombatService:

Script in ServerScriptService named CombatService
-- Script in ServerScriptService named CombatService.
-- Punch combos: the client asks to punch, the server checks the cooldown, finds
-- targets in a box in front of the attacker and deals the damage.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local DAMAGE = 10 -- per punch
local FINISHER_DAMAGE = 20 -- the last punch of a combo
local COMBO_LENGTH = 3
local PUNCH_COOLDOWN = 0.35 -- seconds between punches
local COMBO_RESET = 1 -- seconds without a punch before the combo starts over
local HITBOX_SIZE = Vector3.new(5, 5, 5)
local HITBOX_DISTANCE = 3.5 -- studs in front of the attacker's root

local punchEvent = Instance.new("RemoteEvent")
punchEvent.Name = "Punch"
punchEvent.Parent = ReplicatedStorage

type State = { last: number, combo: number }
local states: { [Player]: State } = {}

local function addKO(player: Player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local kos = leaderstats and leaderstats:FindFirstChild("KOs")
	if kos and kos:IsA("IntValue") then
		kos.Value += 1
	end
end

punchEvent.OnServerEvent:Connect(function(player: Player)
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	local root = humanoid and humanoid.RootPart
	if not (character and humanoid and root) or humanoid.Health <= 0 then
		return
	end

	-- The server keeps its own clock, so a cheater cannot punch faster by firing more often
	local now = os.clock()
	local state: State = states[player] or { last = -math.huge, combo = 0 }
	states[player] = state
	if now - state.last < PUNCH_COOLDOWN then
		return
	end
	state.combo = if now - state.last > COMBO_RESET then 1 else state.combo % COMBO_LENGTH + 1
	state.last = now
	local damage = if state.combo == COMBO_LENGTH then FINISHER_DAMAGE else DAMAGE

	-- The hitbox: a box in front of where the server sees the attacker
	local boxCFrame = root.CFrame * CFrame.new(0, 0, -HITBOX_DISTANCE)
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = { character }

	local hitHumanoids: { [Humanoid]: boolean } = {}
	for _, part in workspace:GetPartBoundsInBox(boxCFrame, HITBOX_SIZE, params) do
		local model = part:FindFirstAncestorOfClass("Model")
		local target = model and model:FindFirstChildOfClass("Humanoid")
		local targetPlayer = if model then Players:GetPlayerFromCharacter(model) else nil
		-- No friendly fire: skip players on the attacker's team
		local sameTeam = targetPlayer and not player.Neutral and targetPlayer.Team == player.Team
		-- One hit per Humanoid, however many of its parts are in the box
		if target and target.Health > 0 and not sameTeam and not hitHumanoids[target] then
			hitHumanoids[target] = true
			target:TakeDamage(damage)
			if target.Health <= 0 then
				addKO(player)
			end
		end
	end
end)

Players.PlayerAdded:Connect(function(player: Player)
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"
	local kos = Instance.new("IntValue")
	kos.Name = "KOs"
	kos.Parent = leaderstats
	leaderstats.Parent = player
end)

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

The combo counts 1, 2, 3, 1, 2, 3 while punches come within COMBO_RESET seconds of each other, and the third does FINISHER_DAMAGE. The hitbox is a HITBOX_SIZE box whose centre is HITBOX_DISTANCE studs in front of the root part: root.CFrame * CFrame.new(0, 0, -3.5) moves forward, because a part's front is its minus Z direction.

OverlapParams with Exclude and the attacker's character keeps the attacker out of their own hitbox. NPCs with a Humanoid can be hit too. When a hit takes a player to 0 health, the attacker gets a KO in the player list.

Step 2: The punch input

Insert a LocalScript in StarterPlayer > StarterPlayerScripts named CombatClient:

LocalScript in StarterPlayerScripts named CombatClient
-- LocalScript in StarterPlayerScripts named CombatClient.
-- Click, tap the Punch button or press R2 on a gamepad to punch.
local ContextActionService = game:GetService("ContextActionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local punchEvent = ReplicatedStorage:WaitForChild("Punch") :: RemoteEvent

local function onPunch(_name: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
	if state == Enum.UserInputState.Begin then
		punchEvent:FireServer()
	end
	-- Pass, so clicks still reach buttons and tools
	return Enum.ContextActionResult.Pass
end

ContextActionService:BindAction("Punch", onPunch, true, Enum.UserInputType.MouseButton1, Enum.KeyCode.ButtonR2)
ContextActionService:SetTitle("Punch", "Punch")

ContextActionService:BindAction binds one action to the left mouse button and the gamepad's R2, and its third argument, true, adds a Punch button on touch screens. It returns Pass so clicks still reach tools and buttons.

Step 3: Make it feel good

  • Animations: play a different punch animation for each combo hit on the client, with the Animator in the character's Humanoid. The animation guide shows how to load and play one.
  • Hit effects: have the server fire a second RemoteEvent to all clients with the hit position, and play a sound and a small particle burst there.
  • Knockback: a target's character is simulated by the target's own device, so a push set by the server may be overwritten. Games usually ask the target's client to apply it, or use a short-lived constraint, and test it with two players.
  • Blocking: add a Block action that sets an attribute on the player while held, and have the server halve damage when the target has it.

Test it

  1. Insert a dummy to punch: on the Avatar tab, open the Rig Builder and add a rig.
  2. Press Play, walk up to the dummy and click three times. Its health goes down by 10, 10, then 20.
  3. Click as fast as you can: the punches land at most every 0.35 seconds.
  4. Start a 2-player test and punch the other player until they fall: KOs goes to 1.

Common errors and fixes

If punches do nothing, check the Output window on both the Client and Server views.

Infinite yield possible on 'ReplicatedStorage:WaitForChild("Punch")'

CombatService did not run, so the RemoteEvent was never created. It must be a Script in ServerScriptService.

Punching does nothing to the dummy

Stand closer: the box reaches about 6 studs in front of you. Also check the dummy has a Humanoid with health above 0.

Teammates take no damage

That is the friendly fire check. Players on no team can always hit each other.

Clicking a button in my menu also punches

The Punch action is bound to the mouse button, which also clicks menus. Bind it to a key such as F instead, or call ContextActionService:UnbindAction("Punch") while a menu is open and bind it again when it closes.

Build it with RoCode

If you would rather not set this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin creates the scripts in your Explorer.

You type

Add battlegrounds-style combat: a 3-punch combo on click with a server hitbox in front of the player, a stronger third hit, a block on F that halves damage, and a KOs leaderboard.

RoCode does
  • Writes the server combat script, the input LocalScript and the RemoteEvents
  • Reads your place first to reuse your leaderstats and team setup
  • Is told to compile-check each script with Check Script, and sets undo points around each batch

After a build, RoCode can run its test robot: it plays your game in Studio, clicks, presses keys and equips tools, and reports PASS or FAIL. It plays as one player and checks only what it chose to test, so test hits on a second player yourself. RoCode cannot make punch animations: create them in the Animation Editor. How RoCode connects to Studio.

Questions

How do I make a hitbox in Roblox?

Use workspace:GetPartBoundsInBox(cframe, size, params) on the server with a box in front of the attacker, and damage each Humanoid found once. Step 1 has the full script.

Should hitboxes be on the client or the server?

On the server for anything that deals damage. Some games detect hits on the client for responsiveness, but then the server must check every claimed hit, which is more work than this guide's approach.

Why does my punch hit the same player many times?

Each body part in the box is a separate result. Keep a table of Humanoids already hit in this punch, as the script does.

How do I count kills?

When a hit takes a Humanoid to 0 health, add 1 to the attacker's KOs value, as CombatService does.

Sources and further reading

How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 23 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.