Skip to content

Particle Melee Parry ​

Auto-parries enemy heavy melee attacks using particle detection. Demonstrates on_particle_create, owner resolution, distance/visibility/facing gating, and clock()-based debouncing.

lua
-- Particle Melee Parry
-- Detects enemy heavy melee charge via particles and auto-parries.

name        = "Particle Melee Parry"
description = "Auto-parry enemy heavy melee via particle detection"

settings = {
    { key = "enabled",        label = "Enable",             type = "bool",  default = true },
    { key = "max_dist",       label = "Max Distance (m)",   type = "float", default = 12.0, min = 3.0, max = 25.0 },
    { key = "require_vis",    label = "Require Visible",    type = "bool",  default = true },
    { key = "require_facing", label = "Require Facing Us",  type = "bool",  default = true },
    { key = "facing_angle",   label = "Facing Angle (deg)", type = "float", default = 90.0, min = 30.0, max = 180.0 },
    { key = "show_toast",     label = "Show Toast",         type = "bool",  default = true },
    { key = "cooldown_ms",    label = "Cooldown (ms)",      type = "float", default = 400.0, min = 100.0, max = 2000.0 },
}

local last_parry_time = 0

function on_particle_create(ev)
    if not ev.is_enemy then return end
    if not config.get_bool("enabled", true) then return end
    if not ev.name:find("melee_heavy_activate_charge") then return end

    local owner = ev.owner
    if not owner then return end

    local me = local_player()
    if not me or not me:is_alive() then return end

    -- Visibility
    if config.get_bool("require_vis", true) and not owner:is_visible() then return end

    -- Distance (game units are in inches; divide by 39.37 for meters)
    local my_pos = me:get_position()
    local e_pos = owner:get_position()
    local dx, dy, dz = my_pos.x - e_pos.x, my_pos.y - e_pos.y, my_pos.z - e_pos.z
    local dist = math.sqrt(dx*dx + dy*dy + dz*dz) / 39.37
    if dist > config.get_float("max_dist", 12.0) then return end

    -- Facing check: is the enemy looking toward us?
    if config.get_bool("require_facing", true) then
        local angles = owner:get_view_angles()
        if angles then
            local len2d = math.sqrt(dx*dx + dy*dy)
            if len2d > 1.0 then
                local yaw = math.rad(angles.y)
                local dot = (math.cos(yaw) * dx + math.sin(yaw) * dy) / len2d
                local threshold = math.cos(math.rad(config.get_float("facing_angle", 90.0) / 2))
                if dot < threshold then return end
            end
        end
    end

    -- Cooldown, prevents double-fire across scan frames on the same melee
    local now = clock()
    if (now - last_parry_time) < config.get_float("cooldown_ms", 400.0) / 1000.0 then return end
    last_parry_time = now

    -- Fire parry
    input.press_key(keybinds.parry_key())

    if config.get_bool("show_toast", true) then
        toast(string.format("Parried %s (%.0fm)", owner:get_hero_name() or "?", dist))
    end
end

Key patterns ​

Early filtering. Check ev.is_enemy and ev.name:find(...) before doing any expensive work. Most particles aren't relevant, return immediately for non-matches. This keeps the handler cheap even when hundreds of events flow through per second.

Owner resolution. ev.owner is a full player table (same shape get_players() returns). Use it for :is_visible(), :get_position(), :get_view_angles(), :get_hero_name(), etc. It can be nil if the owner pawn doesn't match any known player, always nil-check before using.

Distance in meters. Game units are in inches. Divide by 39.37 to convert to meters for human-readable settings.

Facing check. Compute the dot product between the enemy's forward vector (derived from their view-angle yaw) and the direction from them to us. A positive dot means they're facing us. The threshold is derived from the configured cone angle: cos(angle / 2).

Debounce via clock(). Prevents the parry from firing multiple times on the same melee charge. The particle can be detected across several 10ms scan frames. clock() returns wall-clock seconds since engine startup.

See also ​

Not affiliated with Valve Corporation.