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: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. Most particles fire frequently; early returns keep per-event cost minimal.

Owner resolution. ev.owner is a full player table (same shape get_players() returns). Use it for :is_visible(), :get_position(), :get_view_angles(), :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 seconds elapsed since the Lua scripting engine was first initialized - specifically time_since_epoch() - g_clock_start, where g_clock_start is a static double (lua_engine.h line 79) set exactly once via if (g_clock_start == 0) at the time luaL_newstate() is called (lua_engine.h lines 5091-5093). It is NOT set at game or overlay process startup.

See also ​

Not affiliated with Valve Corporation.