Combat systems are at the heart of many successful Roblox games, from sword-fighting adventures to shooter experiences. If you’ve mastered the basics of scripting and are ready to tackle something more complex, building a damage system is the perfect next step. In this tutorial, we’ll create a robust, server-authoritative combat system that’s both secure and easy to expand.
Why Server-Side Combat Matters
Before we dive into code, let’s talk about where your combat logic should run. You might be tempted to handle everything on the client for instant feedback, but this opens your game to exploiters who can manipulate damage values or bypass cooldowns.
The golden rule: never trust the client with important game logic. All damage calculations, health changes, and hit detection should happen on the server. The client can provide visual feedback and send input to the server, but the server makes the final decisions.
Setting Up Your Humanoid Health System
Roblox characters come with a built-in Humanoid object that manages health. Let’s start by understanding how to work with it properly.
The Humanoid has several important properties:
Health– Current health valueMaxHealth– Maximum health capacityDied– Event that fires when health reaches zero
Here’s a basic script to modify a player’s health when they join:
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local humanoid = character:WaitForChild("Humanoid")
-- Set custom max health
humanoid.MaxHealth = 150
humanoid.Health = 150
-- Listen for death
humanoid.Died:Connect(function()
print(player.Name .. " has died!")
end)
end)
end)
This script runs on the server and customizes each player’s health when their character spawns. Notice we’re using WaitForChild to ensure the Humanoid exists before we try to access it—this prevents race condition errors.
Creating a Melee Weapon with Debounce
Now let’s build a sword that deals damage on contact. The key challenge here is debounce—preventing the weapon from dealing damage multiple times per second when touching the same player.
First, create a Tool in ReplicatedStorage with a Handle part. Inside the Tool, add a Script (this will run on the server when the tool is equipped):
local tool = script.Parent
local handle = tool:WaitForChild("Handle")
local DAMAGE = 20
local COOLDOWN = 1
local canDamage = true
local hitPlayers = {}
tool.Activated:Connect(function()
if not canDamage then return end
canDamage = false
hitPlayers = {} -- Reset hit tracking
-- Play swing animation here
-- Enable damage for a brief window
task.wait(0.1) -- Small delay before damage starts
local connection
connection = handle.Touched:Connect(function(hit)
local humanoid = hit.Parent:FindFirstChild("Humanoid")
local player = game.Players:GetPlayerFromCharacter(hit.Parent)
if humanoid and player then
-- Check if we haven't hit this player yet
if not hitPlayers[player.UserId] then
hitPlayers[player.UserId] = true
humanoid:TakeDamage(DAMAGE)
print("Dealt " .. DAMAGE .. " damage to " .. player.Name)
end
end
end)
-- Disable damage window after a short time
task.wait(0.3)
connection:Disconnect()
-- Wait for cooldown
task.wait(COOLDOWN - 0.4)
canDamage = true
end)
Let’s break down what’s happening:
- We use a
canDamageflag to implement cooldown between swings - The
hitPlayerstable tracks which players we’ve already hit this swing, preventing multiple damage instances - We create a temporary
Touchedconnection that only exists during the damage window TakeDamage()is the proper way to reduce Humanoid health—it respects ForceFields and other protective effects
Building a Ranged Combat System
For projectile-based combat like guns or magic spells, we need a different approach. We’ll use RemoteEvents to let clients request shots, then validate and process them on the server.
Create a RemoteEvent in ReplicatedStorage called “FireWeapon”. Here’s the server script:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local fireWeaponEvent = ReplicatedStorage:WaitForChild("FireWeapon")
local DAMAGE = 15
local MAX_RANGE = 200
local COOLDOWN = 0.5
local lastFireTime = {}
fireWeaponEvent.OnServerEvent:Connect(function(player, targetPosition)
-- Validate cooldown
local currentTime = tick()
if lastFireTime[player.UserId] and currentTime - lastFireTime[player.UserId] < COOLDOWN then
return -- Too soon, ignore
end
lastFireTime[player.UserId] = currentTime
local character = player.Character
if not character then return end
local humanoidRootPart = character:FindFirstChild("HumanoidRootPart")
if not humanoidRootPart then return end
-- Validate range
local distance = (targetPosition - humanoidRootPart.Position).Magnitude
if distance > MAX_RANGE then
return -- Too far, possible exploit
end
-- Perform raycast
local rayOrigin = humanoidRootPart.Position + Vector3.new(0, 2, 0)
local rayDirection = (targetPosition - rayOrigin).Unit * MAX_RANGE
local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = {character}
raycastParams.FilterType = Enum.RaycastFilterType.Blacklist
local raycastResult = workspace:Raycast(rayOrigin, rayDirection, raycastParams)
if raycastResult then
local hitPart = raycastResult.Instance
local hitHumanoid = hitPart.Parent:FindFirstChild("Humanoid")
if hitHumanoid and hitHumanoid.Health > 0 then
hitHumanoid:TakeDamage(DAMAGE)
print(player.Name .. " hit " .. hitPart.Parent.Name)
end
end
end)
This system includes several important validations:
- Cooldown checking prevents rapid-fire exploits
- Range validation ensures players can’t shoot across the entire map
- Server-side raycasting determines what was actually hit, not trusting client input
The client script would simply fire the RemoteEvent with the target position when the player clicks.
Adding Visual Feedback
Combat feels unsatisfying without proper feedback. While damage is processed on the server, we can add client-side effects that make hits feel impactful.
Consider adding:
- Sound effects that play on hit
- Particle effects or visual indicators
- Camera shake for the victim
- Damage number GUIs that float upward
These can be triggered by RemoteEvents fired from the server after damage is dealt, or predicted on the client for instant feedback (then corrected if needed).
Handling Edge Cases
A robust combat system needs to handle several edge cases:
Respawning characters: Always disconnect events and clear timers when a character dies. Store references carefully and check if objects still exist before accessing them.
Team systems: Check if players are on the same team before dealing damage. You can access this via player.Team and player.Neutral properties.
Invincibility periods: Consider adding brief invincibility after respawning using ForceFields or a custom invincibility flag.
-- Example team check
local attacker = game.Players:GetPlayerFromCharacter(tool.Parent)
local victim = game.Players:GetPlayerFromCharacter(hit.Parent)
if attacker and victim then
if attacker.Team == victim.Team and not attacker.Neutral then
return -- Don't damage teammates
end
end
Recap and Next Steps
You’ve now learned how to build a complete combat system with both melee and ranged weapons, proper server-side validation, debounce mechanics, and health management. Remember these key principles:
- Always process damage on the server
- Use debounce to prevent multi-hit exploits
- Validate all client input (range, cooldowns, etc.)
- Use
TakeDamage()instead of directly modifying health - Handle edge cases like teams and respawning
To expand your combat system further, consider learning about:
- Animation systems for attack sequences
- Status effects (poison, burning, slowness)
- Weapon upgrade and progression systems
- Advanced hit detection with hitboxes instead of part collision
With this foundation, you’re well-equipped to create engaging combat experiences that are both fun and secure. Happy developing!