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.
-- 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
endKey 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 ​
- Events > on_particle_create, full event field reference
- Events > Common particle names, curated list of particle paths
- Debugging > Discovering particle names. How to find paths for other abilities