Events ​
Event callbacks fire when the engine detects specific game events. Define them as global functions; the engine looks them up by name. You don't have to define any of them.
on_tick ​
Called every reader tick. This is your main loop for any logic that needs to run continuously.
function on_tick()
if not local_player():is_alive() then return end
-- ...
endon_kill ​
Fires when a tracked player's health drops to 0.
function on_kill(player)
if player:is_local() then
toast("we died")
end
endon_modifier_added ​
Fires when a modifier instance appears on a tracked player. Serial-based tracking detects duplicate tokens (e.g. owning AND being hit by the same item).
function on_modifier_added(player, modifier)
-- modifier fields available; see modifier table in Player reference
if not player:is_local() then return end
local caster = modifier.caster
if caster and caster:is_enemy() then
toast(modifier.name .. " from " .. caster:hero_name())
end
endThe modifier argument has the same fields as entries returned by player:get_modifiers(). See Player > Modifiers.
on_modifier_removed ​
Fires when a modifier disappears from a tracked player.
function on_modifier_removed(player, modifier)
-- modifier.name and modifier.token are populated
endon_ability_cast ​
Fires when a hero ability (slots 0-3) transitions from ready to cooldown.
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds |
function on_ability_cast(player, ability)
if player:is_enemy() then
print(player:hero_name() .. " cast slot " .. ability.slot)
end
endon_item_used ​
Fires when an active item (slots 4-7) goes on cooldown.
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "upgrade_metal_skin" |
slot | integer | 4-7 |
cooldown | number | Initial cooldown in seconds |
function on_item_used(player, item)
if item.name:find("metal_skin") and player:is_enemy() then
toast(player:hero_name() .. " popped Metal Skin!")
end
endFor self-cast items (Metal Skin, etc.) the player argument is the user. For items that target someone else, player is the target.
on_particle_create ​
Fires when a particle effect appears on a tracked player. By default, particle events only include ability-related effects (projectiles, impacts, casts) for the local player, all enemies, and all teammates. Set needs_all_particles = true at the top of your script to also receive events for cosmetic, environmental, tower, and ability-spawned particles. This increases processing overhead, so only enable it when you actually need those events.
The engine scans the game's internal particle linked list at 100Hz, detects new and removed particles via diffing, and delivers events to Lua. Particle names are full VPF paths like "particles/abilities/melee/melee_heavy_activate_charge.vpcf".
| Field | Type | Description |
|---|---|---|
name | string | Full particle path, e.g. "particles/abilities/melee/melee_heavy_activate_charge.vpcf" |
is_enemy | boolean | Particle belongs to an enemy player |
is_local | boolean | Particle belongs to the local player |
is_teammate | boolean | Particle belongs to a teammate |
owner_label | string | Human-readable owner tag, e.g. "(enemy t2)", "(local)", "(ally t3)" |
owner_pawn | integer | Raw pawn pointer of the particle owner |
owner | player or nil | Resolved player table (same shape as get_players() returns). nil if the owner couldn't be matched to a known player, happens for ability-spawned particles when needs_all_particles is enabled, since those are owned by ability entities rather than player pawns |
position | table | {x, y, z} world position from m_vSortOrigin. May be (0, 0, 0) for entity-attached particles (use control points instead) |
has_control_points | boolean | true if control point data was successfully read. Only populated for particles whose path contains "abilities/" or "melee" |
cp0 | table | {x, y, z} control point 0 (typically bounding box min) |
cp1 | table | {x, y, z} control point 1 (typically bounding box max) |
cp2 | table | {x, y, z} control point 2, effect center / AoE target position. Usually the most useful CP for gameplay logic |
cp3 | table | {x, y, z} control point 3 (adjusted center) |
function on_particle_create(ev)
if ev.is_enemy and ev.name:find("melee_heavy_activate_charge") then
toast("Enemy charging heavy melee!")
end
endNotes on control points ​
- CP2 is typically the "target center" or AoE landing position for ability particles.
- For some abilities (Celeste's Luminous Strike cast particle
unicorn_flux_strike_castfor example), the CPs are relative to the caster and do not change with the target location. The actual AoE landing particle (unicorn_daggers_target) is ability-spawned and requiresneeds_all_particles = trueto detect. - For melee particles, CPs represent the swing bounding box.
- Not all particles have meaningful CPs, check
has_control_pointsbefore reading them.
on_particle_destroy ​
Fires when a particle effect is removed from the game. Same fields as on_particle_create, but position and control points will be zeroed (the particle no longer exists in memory).
function on_particle_destroy(ev)
if ev.is_enemy and ev.name:find("melee_heavy_activate_charge") then
print("Enemy melee charge ended")
end
endCommon particle names ​
Reference list of particle paths commonly used in scripting. Enable the in-app Particle Scanner (Misc > Scripts > Dev Tools) to discover more, see Debugging > Discovering particle names.
Melee ​
| Particle | Meaning |
|---|---|
melee_heavy_activate_charge | Enemy starts charging a heavy melee (the wind-up) |
melee_heavy_activate | Heavy melee transitions from charge to swing |
melee_swing_heavy | The actual heavy melee swing |
melee_parry | A parry was activated |
melee_parry_success | A parry successfully blocked an attack |
melee_parry_debuff | Parry debuff applied to the stunned attacker |
Hero-specific melee ​
| Particle | Hero |
|---|---|
unicorn_anim_heavy_melee_start | Celeste (Unicorn) heavy melee wind-up |
unicorn_anim_heavy_melee | Celeste heavy melee swing animation |
Movement ​
| Particle | Meaning |
|---|---|
generic/sprint | Player is sprinting |
generic/slide | Player is sliding |
generic/air_dash | Player used air dash |
generic/bridge_buff | Player has the bridge zipline buff |
Combat ​
| Particle | Meaning |
|---|---|
generic/headshot | Headshot indicator |
modifiers/stunned | Player is stunned |
weapon_fx/*/muzzle_flash | Weapon muzzle flash (hero-specific subfolder) |
weapon_fx/*/tracer | Bullet tracer (hero-specific subfolder) |
Ability-spawned (requires needs_all_particles) ​
| Particle | Meaning |
|---|---|
unicorn_daggers_target | Celeste Luminous Strike AoE landing indicator |
unicorn_flux_rainbow | Celeste Luminous Strike rainbow effect |
npc/npc_melee_swing | Tower / NPC melee swing |
npc/npc_healthbar | NPC healthbar particle |
Matching modifier and item names ​
Modifier name and item name are RTTI class strings. Use string.find for substring matches because the engine reports the full name including prefixes:
if modifier.name:find("stunned") then ... end
if modifier.name:find("glitch") then ... end
if modifier.name:find("sleep") then ... endIf you want exact matching, use the token (an integer) instead:
if player:has_modifier(0x9C02E614) then ... end