Use this file to discover all available pages before exploring further.
Combat is one of the most exploited systems in any Roblox game. Every design decision must begin with a single principle: the server is the sole authority on damage, health, and combat state. The client’s job is to send intent (“I pressed attack”), display responsive animations and VFX, and render server-confirmed results. Giving the client any power to decide who takes damage or how much is an open invitation for exploiters to break your game for everyone else on the server.
The fundamental combat loop flows in one direction — the client fires a RemoteEvent expressing intent, and the server validates, calculates, and applies everything.
Client Server------ ------Player presses attack | +--> FireServer("Attack") | +--> Validate cooldown +--> Validate state (not stunned, not dead) +--> Run hitbox detection +--> Calculate damage +--> Apply damage to targets +--> Update attacker state/cooldowns | <-- FireClient(results) ------+ | +--> Play hit VFX/SFX +--> Show damage numbers
Exploiters can modify LocalScripts, fire RemoteEvents with fabricated data, and teleport their character. If the client decides who gets hit or how much damage to deal, a single cheater can ruin the entire server.
Pure server-side hitboxes can feel unresponsive in fast-paced combat. Two practical strategies:
Predictive VFX
The client plays the swing animation and VFX immediately on input. The server confirms the hit separately. If the server says “miss,” no damage numbers appear — but players perceive responsiveness from the animation even if server confirmation takes 50–100 ms.
Client Hint + Server Validation
The client sends its estimated hit targets along with the attack request. The server re-runs the hitbox check using server-side positions. If both agree, damage applies. If not, the client hint is discarded — catching teleport exploits while tolerating minor positional lag.
Use workspace:GetPartBoundsInBox() for volume-based melee detection. The box is positioned in front of the attacker’s HumanoidRootPart, offset by half the range so it starts at the character and extends forward.
local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model} local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2) local size = Vector3.new(width, height, range) local overlapParams = OverlapParams.new() overlapParams.FilterType = Enum.RaycastFilterType.Exclude overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent } local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams) local hitCharacters: {[Model]: true} = {} local results: {Model} = {} for _, part in parts do local model = part:FindFirstAncestorWhichIsA("Model") if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then hitCharacters[model] = true table.insert(results, model) end end return resultsend
For visible, dodgeable projectiles (arrows, fireballs), create a part that moves each frame via a RunService.Heartbeat loop. Cast a short ray each tick to check for collision:
local RunService = game:GetService("RunService")local function fireProjectile(origin: CFrame, speed: number, maxDistance: number, gravity: number, onHit: (RaycastResult) -> ()) local projectile = Instance.new("Part") projectile.Size = Vector3.new(0.3, 0.3, 1) projectile.CFrame = origin projectile.Anchored = true projectile.CanCollide = false projectile.Parent = workspace local velocity = origin.LookVector * speed local distanceTraveled = 0 local raycastParams = RaycastParams.new() raycastParams.FilterType = Enum.RaycastFilterType.Exclude raycastParams.FilterDescendantsInstances = { projectile } local connection: RBXScriptConnection connection = RunService.Heartbeat:Connect(function(dt: number) velocity += Vector3.new(0, -gravity * dt, 0) local displacement = velocity * dt local rayResult = workspace:Raycast(projectile.Position, displacement, raycastParams) if rayResult then connection:Disconnect() onHit(rayResult) projectile:Destroy() return end projectile.CFrame = CFrame.new(projectile.Position + displacement, projectile.Position + displacement + velocity) distanceTraveled += displacement.Magnitude if distanceTraveled >= maxDistance then connection:Disconnect() projectile:Destroy() end end)end
A state machine prevents invalid action combinations — attacking while stunned, blocking and dodging simultaneously — and enforces recovery windows that create readable, fair combat rhythm.
--!strict-- CombatStateMachine.luau (ModuleScript in ReplicatedStorage)export type CombatState = "Idle" | "Attacking" | "Recovery" | "Blocking" | "Dodging" | "Stunned"export type StateTransition = { from: {CombatState}, to: CombatState, condition: ((context: StateMachineContext) -> boolean)?,}export type StateMachineContext = { player: Player, currentState: CombatState, stateStartTime: number, lastAttackTime: number, comboCount: number, stunEndTime: number, dodgeCooldownEnd: number,}local CombatStateMachine = {}CombatStateMachine.__index = CombatStateMachinelocal RECOVERY_DURATION = 0.4local DODGE_DURATION = 0.5local DODGE_COOLDOWN = 1.5local STUN_DEFAULT_DURATION = 1.0local ATTACK_DURATION = 0.35local COMBO_WINDOW = 0.8local MAX_COMBO = 4local VALID_TRANSITIONS: {StateTransition} = { { from = { "Idle" }, to = "Attacking" }, { from = { "Attacking" }, to = "Recovery" }, { from = { "Recovery" }, to = "Idle" }, { from = { "Recovery" }, to = "Attacking" }, -- combo: attack during recovery window { from = { "Idle" }, to = "Blocking" }, { from = { "Blocking" }, to = "Idle" }, { from = { "Idle", "Blocking" }, to = "Dodging" }, { from = { "Dodging" }, to = "Idle" }, { from = { "Idle", "Attacking", "Recovery", "Blocking", "Dodging" }, to = "Stunned" }, { from = { "Stunned" }, to = "Idle" },}function CombatStateMachine.new(player: Player) local self = setmetatable({}, CombatStateMachine) self.context = { player = player, currentState = "Idle" :: CombatState, stateStartTime = os.clock(), lastAttackTime = 0, comboCount = 0, stunEndTime = 0, dodgeCooldownEnd = 0, } :: StateMachineContext self._onStateChanged = {} :: {(CombatState, CombatState) -> ()} return selfendfunction CombatStateMachine:getState(): CombatState return self.context.currentStateendfunction CombatStateMachine:getComboCount(): number return self.context.comboCountendfunction CombatStateMachine:onStateChanged(callback: (CombatState, CombatState) -> ()) table.insert(self._onStateChanged, callback)endfunction CombatStateMachine:canTransition(to: CombatState): boolean local from = self.context.currentState for _, transition in VALID_TRANSITIONS do if transition.to == to and table.find(transition.from, from) then if transition.condition and not transition.condition(self.context) then continue end return true end end return falseendfunction CombatStateMachine:transition(to: CombatState): boolean if not self:canTransition(to) then return false end local from = self.context.currentState self.context.currentState = to self.context.stateStartTime = os.clock() for _, callback in self._onStateChanged do callback(from, to) end return trueendfunction CombatStateMachine:tryAttack(): boolean local now = os.clock() local ctx = self.context if ctx.currentState == "Recovery" then local timeSinceAttack = now - ctx.lastAttackTime if timeSinceAttack <= COMBO_WINDOW and ctx.comboCount < MAX_COMBO then ctx.comboCount += 1 ctx.lastAttackTime = now return self:transition("Attacking") end return false end if ctx.currentState ~= "Idle" then return false end ctx.comboCount = 1 ctx.lastAttackTime = now return self:transition("Attacking")endfunction CombatStateMachine:tryBlock(): boolean return self:transition("Blocking")endfunction CombatStateMachine:releaseBlock(): boolean if self.context.currentState ~= "Blocking" then return false end return self:transition("Idle")endfunction CombatStateMachine:tryDodge(): boolean local now = os.clock() if now < self.context.dodgeCooldownEnd then return false end if self:transition("Dodging") then self.context.dodgeCooldownEnd = now + DODGE_COOLDOWN return true end return falseendfunction CombatStateMachine:applyStun(duration: number?) local stunDuration = duration or STUN_DEFAULT_DURATION self.context.stunEndTime = os.clock() + stunDuration self:transition("Stunned")endfunction CombatStateMachine:update() local now = os.clock() local ctx = self.context local elapsed = now - ctx.stateStartTime if ctx.currentState == "Attacking" and elapsed >= ATTACK_DURATION then self:transition("Recovery") elseif ctx.currentState == "Recovery" and elapsed >= RECOVERY_DURATION then ctx.comboCount = 0 self:transition("Idle") elseif ctx.currentState == "Dodging" and elapsed >= DODGE_DURATION then self:transition("Idle") elseif ctx.currentState == "Stunned" and now >= ctx.stunEndTime then self:transition("Idle") endendfunction CombatStateMachine:destroy() table.clear(self._onStateChanged)endreturn CombatStateMachine
All cooldowns must be enforced on the server. The client can display a timer, but the server silently rejects any action that violates a cooldown.
--!strict-- CooldownManager.luau (ModuleScript in ServerScriptService)local CooldownManager = {}CooldownManager.__index = CooldownManagertype CooldownMap = {[string]: number} -- abilityName -> expiry timestamplocal cooldowns: {[Player]: CooldownMap} = {}function CooldownManager.initialize(player: Player) cooldowns[player] = {}endfunction CooldownManager.cleanup(player: Player) cooldowns[player] = nilendfunction CooldownManager.isReady(player: Player, abilityName: string): boolean local playerCooldowns = cooldowns[player] if not playerCooldowns then return false end local expiry = playerCooldowns[abilityName] if not expiry then return true end return os.clock() >= expiryendfunction CooldownManager.startCooldown(player: Player, abilityName: string, duration: number) local playerCooldowns = cooldowns[player] if not playerCooldowns then return end playerCooldowns[abilityName] = os.clock() + durationendfunction CooldownManager.getRemainingTime(player: Player, abilityName: string): number local playerCooldowns = cooldowns[player] if not playerCooldowns then return 0 end local expiry = playerCooldowns[abilityName] if not expiry then return 0 end return math.max(0, expiry - os.clock())endreturn CooldownManager
A parry negates all damage and stuns the attacker when the defender enters Blocking state within a tight window before the hit lands:
local PARRY_WINDOW = 0.15local function isParry(defenderStateMachine): boolean if defenderStateMachine:getState() ~= "Blocking" then return false end local blockDuration = os.clock() - defenderStateMachine.context.stateStartTime return blockDuration <= PARRY_WINDOWend
WCS (Weapon Combat System) is a community framework that provides skill/ability definitions, built-in cooldowns, status effects, moveset management, and client-server synchronization.
When to Use WCS
Basic WCS Skill
Scenario
Recommendation
Complex skill-based combat (RPG, fighting game)
Use WCS — saves weeks of boilerplate
Many unique abilities with varied behavior
Use WCS — skill definition system is well designed
Simple melee/ranged with few weapons
Build custom — WCS adds unnecessary overhead
Learning combat fundamentals
Build custom — understand the internals first
Very custom combo/input systems
Build custom or extend WCS — may fight the framework
local ReplicatedStorage = game:GetService("ReplicatedStorage")local WCS = require(ReplicatedStorage.Packages.WCS)local Fireball = WCS.RegisterSkill("Fireball")Fireball.CooldownTime = 3Fireball.MaxHoldTime = 2function Fireball:OnStartServer() -- Server-side fireball logic local character = self.Character -- spawn projectile, deal damage, etc.endfunction Fireball:OnStartClient() -- Client-side VFX -- play casting animation, spawn particle emitterend
Install via Wally: wcs = "digest/wcs@latest" or grab the model from the Toolbox.
Never apply damage on the client.Humanoid:TakeDamage() must only ever be called from a server Script. Any client-side damage call can be exploited.
Trusting client hitbox results
-- BAD: Client reports who it hitattackRemote:FireServer(hitTargets) -- exploiter sends all players on server-- GOOD: Server runs its own hitbox detectionattackRemote:FireServer() -- server determines targets and damage
No cooldown enforcement on server
-- BAD: Only the client checks cooldowns-- GOOD: Server silently drops requests that violate cooldownsif not CooldownManager.isReady(player, "Attack") then return -- silently ignoreend
No state validation
-- BAD: Allow attacking while stunned, dead, or in menus-- GOOD: State machine enforces valid transitionsif not stateMachine:canTransition("Attacking") then returnend
Uncapped stat stacking
-- BAD: uncapped buff stackingtotalBuff = totalBuff + newBuff -- can exceed 100%-- GOOD: always clamp multiplierstotalBuff = math.clamp(totalBuff + newBuff, 0, 1.0)defense = math.clamp(defense, 0, 0.9) -- cap at 90%, minimum damage always gets through