Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

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.

Architecture: Client Intent, Server Authority

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.

Latency Compensation

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.

Hitbox Detection

Area Detection (Melee / AoE)

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 results
end

Hitbox Sizing Guidelines

Weapon TypeRange (studs)Width (studs)Height (studs)
Dagger / Fist4–545
Sword6–856
Greatsword8–1277
Spear / Polearm10–1435
AoE Slam8–10108

Raycast Detection (Ranged / Hitscan)

local function hitscanAttack(attacker: Model, aimDirection: Vector3, maxRange: number): RaycastResult?
    local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
    if not rootPart then
        return nil
    end

    local origin = rootPart.Position + Vector3.new(0, 1.5, 0) -- eye height
    local raycastParams = RaycastParams.new()
    raycastParams.FilterType = Enum.RaycastFilterType.Exclude
    raycastParams.FilterDescendantsInstances = { attacker }

    return workspace:Raycast(origin, aimDirection.Unit * maxRange, raycastParams)
end

Physical Projectiles

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

Combat State Machine

A state machine prevents invalid action combinations — attacking while stunned, blocking and dodging simultaneously — and enforces recovery windows that create readable, fair combat rhythm.
Idle --> Attacking --> Recovery --> Idle (or combo back to Attacking)
Idle --> Blocking --> Idle
Idle/Blocking --> Dodging --> Idle
Any --> Stunned --> Idle

Complete State Machine Implementation

--!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 = CombatStateMachine

local RECOVERY_DURATION = 0.4
local DODGE_DURATION = 0.5
local DODGE_COOLDOWN = 1.5
local STUN_DEFAULT_DURATION = 1.0
local ATTACK_DURATION = 0.35
local COMBO_WINDOW = 0.8
local MAX_COMBO = 4

local 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 self
end

function CombatStateMachine:getState(): CombatState
    return self.context.currentState
end

function CombatStateMachine:getComboCount(): number
    return self.context.comboCount
end

function CombatStateMachine:onStateChanged(callback: (CombatState, CombatState) -> ())
    table.insert(self._onStateChanged, callback)
end

function 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 false
end

function 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 true
end

function 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")
end

function CombatStateMachine:tryBlock(): boolean
    return self:transition("Blocking")
end

function CombatStateMachine:releaseBlock(): boolean
    if self.context.currentState ~= "Blocking" then
        return false
    end
    return self:transition("Idle")
end

function 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 false
end

function CombatStateMachine:applyStun(duration: number?)
    local stunDuration = duration or STUN_DEFAULT_DURATION
    self.context.stunEndTime = os.clock() + stunDuration
    self:transition("Stunned")
end

function 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")
    end
end

function CombatStateMachine:destroy()
    table.clear(self._onStateChanged)
end

return CombatStateMachine

State Machine Tuning Constants

ConstantDefaultEffect
ATTACK_DURATION0.35 sHow long the attack hitbox is active
RECOVERY_DURATION0.4 sWindow after attack before returning to Idle
COMBO_WINDOW0.8 sTime after an attack during which the next attack chains
MAX_COMBO4Maximum consecutive hits in a combo
DODGE_DURATION0.5 sInvulnerability / movement duration
DODGE_COOLDOWN1.5 sTime between dodge uses
STUN_DEFAULT_DURATION1.0 sDefault stun length

Damage Calculation

Formula

finalDamage = baseDamage
            × weaponMultiplier
            × (1 + totalBuffPercent)
            × (1 − defensePercent)
            × critMultiplier
            × comboMultiplier
            × typeEffectiveness

Implementation

--!strict
-- DamageCalculator.luau (ModuleScript in ServerScriptService)

export type DamageType = "Physical" | "Magical" | "Fire" | "Ice" | "Lightning"

export type WeaponStats = {
    baseDamage: number,
    weaponMultiplier: number,
    damageType: DamageType,
    critChance: number,    -- 0.0 to 1.0
    critMultiplier: number, -- e.g. 1.5 = 150% damage on crit
}

export type CombatStats = {
    attackBuff: number,   -- 0.0 to 1.0
    defense: number,      -- 0.0 to 1.0 (30% = 30% damage reduction)
    resistances: {[DamageType]: number},
}

export type DamageResult = {
    rawDamage: number,
    finalDamage: number,
    isCritical: boolean,
    damageType: DamageType,
    blocked: boolean,
}

local DamageCalculator = {}

local COMBO_MULTIPLIERS = { 1.0, 1.1, 1.25, 1.5 }
local BLOCK_REDUCTION = 0.8 -- blocking reduces damage by 80%
local MIN_DAMAGE = 1

function DamageCalculator.calculate(
    weapon: WeaponStats,
    attackerStats: CombatStats,
    defenderStats: CombatStats,
    comboHit: number,
    isBlocking: boolean
): DamageResult
    local baseDamage = weapon.baseDamage * weapon.weaponMultiplier
    local buffMultiplier = 1 + attackerStats.attackBuff
    local defenseMultiplier = 1 - math.clamp(defenderStats.defense, 0, 0.9)
    local resistance = defenderStats.resistances[weapon.damageType] or 0
    local typeMultiplier = 1 - math.clamp(resistance, 0, 0.9)
    local isCritical = math.random() < weapon.critChance
    local critMultiplier = if isCritical then weapon.critMultiplier else 1.0
    local comboIndex = math.clamp(comboHit, 1, #COMBO_MULTIPLIERS)
    local comboMultiplier = COMBO_MULTIPLIERS[comboIndex]

    local rawDamage = baseDamage * buffMultiplier * critMultiplier * comboMultiplier
    local finalDamage = rawDamage * defenseMultiplier * typeMultiplier

    local blocked = false
    if isBlocking then
        finalDamage *= (1 - BLOCK_REDUCTION)
        blocked = true
    end

    finalDamage = math.max(math.floor(finalDamage), MIN_DAMAGE)

    return {
        rawDamage = rawDamage,
        finalDamage = finalDamage,
        isCritical = isCritical,
        damageType = weapon.damageType,
        blocked = blocked,
    }
end

return DamageCalculator

Combo Escalation

Combo HitMultiplierTypical Effect
11.0×Normal swing
21.1×Faster animation
31.25×Wider hitbox
41.5×Finisher with knockback

Server-Side Cooldown Tracking

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 = CooldownManager

type CooldownMap = {[string]: number} -- abilityName -> expiry timestamp
local cooldowns: {[Player]: CooldownMap} = {}

function CooldownManager.initialize(player: Player)
    cooldowns[player] = {}
end

function CooldownManager.cleanup(player: Player)
    cooldowns[player] = nil
end

function 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() >= expiry
end

function CooldownManager.startCooldown(player: Player, abilityName: string, duration: number)
    local playerCooldowns = cooldowns[player]
    if not playerCooldowns then return end
    playerCooldowns[abilityName] = os.clock() + duration
end

function 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())
end

return CooldownManager

Melee Parry and Knockback

Parry Window

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.15

local function isParry(defenderStateMachine): boolean
    if defenderStateMachine:getState() ~= "Blocking" then
        return false
    end
    local blockDuration = os.clock() - defenderStateMachine.context.stateStartTime
    return blockDuration <= PARRY_WINDOW
end

Knockback via LinearVelocity

Use LinearVelocity (not the deprecated BodyVelocity) for combo finishers and heavy attacks:
local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number, duration: number)
    local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
    if not attachment then return end

    local linearVelocity = Instance.new("LinearVelocity")
    linearVelocity.Attachment0 = attachment
    linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
    linearVelocity.MaxForce = math.huge
    linearVelocity.VectorVelocity = direction.Unit * force
    linearVelocity.Parent = targetRootPart

    task.delay(duration, function()
        linearVelocity:Destroy()
    end)
end

WCS Framework

WCS (Weapon Combat System) is a community framework that provides skill/ability definitions, built-in cooldowns, status effects, moveset management, and client-server synchronization.
ScenarioRecommendation
Complex skill-based combat (RPG, fighting game)Use WCS — saves weeks of boilerplate
Many unique abilities with varied behaviorUse WCS — skill definition system is well designed
Simple melee/ranged with few weaponsBuild custom — WCS adds unnecessary overhead
Learning combat fundamentalsBuild custom — understand the internals first
Very custom combo/input systemsBuild custom or extend WCS — may fight the framework

RemoteEvent Architecture

Client → Server

  • AttackRemote — player pressed attack
  • BlockRemote — player started/stopped blocking
  • DodgeRemote — player pressed dodge

Server → Client

  • CombatResultRemote — damage dealt, parry, etc. for VFX
  • CooldownStarted — ability cooldown began (for UI display)

Module Placement

ModuleLocationPurpose
CombatStateMachineReplicatedStorageState machine (shared types)
DamageCalculatorServerScriptServiceDamage formula (server only)
CooldownManagerServerScriptServiceCooldown tracking (server only)
DamageDisplayReplicatedStorageFloating damage numbers (client)
MeleeCombatServerServerScriptServiceMain combat handler (server)
CombatInputStarterPlayerScriptsInput → RemoteEvent (client)

Common Mistakes

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.
-- BAD: Client reports who it hit
attackRemote:FireServer(hitTargets) -- exploiter sends all players on server

-- GOOD: Server runs its own hitbox detection
attackRemote:FireServer() -- server determines targets and damage
-- BAD: Only the client checks cooldowns
-- GOOD: Server silently drops requests that violate cooldowns
if not CooldownManager.isReady(player, "Attack") then
    return -- silently ignore
end
-- BAD: Allow attacking while stunned, dead, or in menus
-- GOOD: State machine enforces valid transitions
if not stateMachine:canTransition("Attacking") then
    return
end
-- BAD: uncapped buff stacking
totalBuff = totalBuff + newBuff -- can exceed 100%

-- GOOD: always clamp multipliers
totalBuff = math.clamp(totalBuff + newBuff, 0, 1.0)
defense = math.clamp(defense, 0, 0.9) -- cap at 90%, minimum damage always gets through

Fair PvP Design Principles

  • Telegraph every attack — give enemies a wind-up animation and sound cue before damage lands so players can react.
  • Balance risk and reward — powerful attacks need longer recovery windows or wider parry timing.
  • Provide counters — every attack should have at least one answer: dodge, block, parry, or spacing.
  • Avoid unavoidable homing — homing projectiles that are both fast and undodgeable are never fun to fight against.

Build docs developers (and LLMs) love