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 - the main loop for continuous logic.
function on_tick()
if not local_player():is_alive() then return end
-- ...
endis_enabled
Called before anything else each tick. Return false to skip the event callbacks, on_tick and the aim lock for that tick. A script that does not define it is enabled.
function is_enabled()
return not is_in_menu()
endWARNING
An error inside is_enabled marks the script as errored and it is skipped until reloaded. Keep it cheap.
Returning false pauses a running on_tick coroutine rather than killing it. See Script Structure.
on_unload
Called before the script's Lua environment is torn down. Fires on reload, app shutdown, and env reset (re-enable). Use it to clean up any global or main-config state the script set. Takes no arguments.
It does not run when you toggle a script off. It runs when you toggle it back on, immediately before the fresh copy starts.
function on_unload()
-- reset any state set outside this script's env
endon_kill
Fires when a tracked player dies. There are two cases:
- Health drops to 0 while the player is still in the snapshot -
playeris a full player object. - Player disappears from the snapshot while previously alive -
playeris a plain table{hero_name, team, is_alive=false}(the live object is gone; only the cached fields are available).
Always check before calling player methods:
function on_kill(player)
if type(player) == "table" then
-- player left the snapshot before we could read them
toast(player.hero_name .. " died (left snapshot)")
return
end
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 is a reduced shape - it does not include expires_at. If you need an expiry time, compute it as modifier.remaining + sim_time(). All other fields from player:get_modifiers() are present. See Player > Modifiers.
on_modifier_removed
Fires when a modifier disappears from a tracked player. Only token and name are populated - all other fields are absent because the modifier is no longer in memory. name may be an empty string and token may be 0 if the data was not cached before removal.
function on_modifier_removed(player, modifier)
-- only modifier.name (may be "") and modifier.token (may be 0) are present
if modifier.name ~= "" then
print(player:hero_name() .. " lost modifier: " .. modifier.name)
end
endon_ability_start
Fires when a hero ability (slots 0-3) begins its cast-delay / windup phase. This is the earliest possible reaction point - before the cooldown starts and before the cast commits. info.phase is always "start".
Use this for parry or dodge reactions that need maximum reaction time.
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds (may be 0 before commit) |
phase | string | Always "start" |
cast_delay_start | number? | sim_time() when cast-delay began (nil if absent) |
channel_start | number? | sim_time() when channel began (nil if not yet started) |
cooldown_start | number? | sim_time() when cooldown began (nil if not yet started) |
cooldown_end | number? | sim_time() when cooldown ends (nil if not yet started) |
cast_completed | number? | sim_time() the cast finished (nil if not yet completed) |
charges | integer? | Remaining charges at event time (nil if ability has no charges) |
upgrade_bits | integer | Bitmask of purchased upgrade tiers |
function on_ability_start(player, info)
if player:is_enemy() then
print(player:hero_name() .. " started cast on slot " .. info.slot)
end
endon_ability_cast
Fires when a hero ability (slots 0-3) commits - the cooldown rises above 0.1 seconds (ab.cooldown > 0.1f). Abilities whose cooldown lands at or below 0.1 s do not trigger this event. info.phase is always "cast".
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds |
phase | string | Always "cast" |
cast_delay_start | number? | sim_time() when cast-delay began (nil if absent) |
channel_start | number? | sim_time() when channel began (nil if not channeled) |
cooldown_start | number? | sim_time() when cooldown began (nil if absent) |
cooldown_end | number? | sim_time() when cooldown ends (nil if absent) |
cast_completed | number? | sim_time() the cast finished (nil if absent) |
charges | integer? | Remaining charges at event time (nil if ability has no charges) |
upgrade_bits | integer | Bitmask of purchased upgrade tiers |
function on_ability_cast(player, info)
if player:is_enemy() then
print(player:hero_name() .. " cast slot " .. info.slot)
end
endon_ability_channel
Fires when a hero ability (slots 0-3) begins its channel phase. Only fires for abilities that actually channel; not every ability has this phase. info.phase is always "channel".
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds |
phase | string | Always "channel" |
cast_delay_start | number? | sim_time() when cast-delay began (nil if absent) |
channel_start | number | sim_time() when channel began (always present for this event) |
cooldown_start | number? | sim_time() when cooldown began (nil if not yet on cooldown) |
cooldown_end | number? | sim_time() when cooldown ends (nil if not yet on cooldown) |
cast_completed | number? | sim_time() the cast finished (nil if not yet completed) |
charges | integer? | Remaining charges at event time (nil if ability has no charges) |
upgrade_bits | integer | Bitmask of purchased upgrade tiers |
function on_ability_channel(player, info)
if player:is_enemy() then
print(player:hero_name() .. " channeling slot " .. info.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
endThe player argument is always the owner/user of the item - the player whose item slot went on cooldown. There is no targeting information in this event.
on_shot
Fires once per trigger pull by the local player, with the player first and the event table second.
| Field | Type | Description |
|---|---|---|
cmd | integer? | Command number the shot was attributed to. Absent unless the button feed is being read |
sim_time | number | The weapon's own last-attack time, not a call to sim_time() |
clip_before | integer | Rounds in the clip before the shot |
clip_after | integer | Rounds in the clip after it |
shot_number | integer | The weapon's monotonic shot counter |
One event per trigger pull. A shotgun blast is one event. shot_number only advances when a bullet actually left; a tap on an empty or reloading clip fires nothing.
cmd is present only while some script is reading input.buttons(). Otherwise the field is absent.
clip_after can equal clip_before at a reload boundary or in an infinite ammo state. input.diag().clip_unread counts those.
function on_shot(me, info)
shots = shots + 1
if info.clip_after == 0 then input.tap("reload") end
endon_shot says a shot left the gun. For what it hit, see Hit Events.
on_entity_added
Fires when an entity appears in a class you are watching.
One argument, no player
Takes a single row table and no player argument, unlike the modifier, ability and shot callbacks.
The argument is the same row table entities.by_class returns, with the same fields and the same row:get(name) method.
- Only for classes you are polling. Call
entities.by_class(class)every tick; events stop 2 seconds after the last call. - The first answer seeds the set silently. Added means since you started watching, not spawned.
- The queue holds 512 events and drops the oldest past that.
function on_tick()
entities.by_class("npc_trooper") -- keep asking, or the events stop
end
function on_entity_added(row)
print("trooper " .. row.index .. " at " .. string.format("%.0fm", row.distance_m))
endon_entity_removed
Fires when an entity disappears from a class you are watching. Same single row argument and the same 2 second ask window as on_entity_added.
The row holds the last values the entity had, not a fresh read, so its position and health are as of the tick before it went away.
function on_entity_removed(row)
print("lost " .. row.class .. " " .. row.index)
endon_particle_create
Fires when a particle effect appears on a tracked player. By default only ability effects (projectiles, impacts, casts) on the local player, enemies and teammates are delivered. needs_all_particles = true adds cosmetic, environmental, tower and ability-spawned particles at a processing cost, so leave it off unless you need them.
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
endon_sound_event
Fires for each game sound seen this tick: footsteps, ability casts, item procs. Requires needs_sound_events = true in your script_info. Without it the sound stream is never parsed and the callback never runs.
One argument, no player
Unlike the modifier and ability callbacks, this takes a single event table and no player argument.
| Field | Type | Description |
|---|---|---|
name | string | Sound event name, e.g. "Haze.Footstep" |
position | table | World position as {x, y, z}. It is a plain table, not a vec3, so construct one with vec3(e.position.x, e.position.y, e.position.z) before doing vector math |
positional | boolean | false for 2D sounds (UI, music) that have no world position |
is_enemy | boolean | The attributed owner is an enemy |
is_local | boolean | The attributed owner is you |
is_teammate | boolean | The attributed owner is an ally |
owner_label | string | Hero name of the attributed owner, or "" when nobody was close enough to attribute |
owner_distance | number | Distance in game units from the sound to that player |
guid | integer | Engine handle for the sound instance |
The engine infers ownership from how close the sound played to each player. In a crowded fight it can pick the wrong hero, and owner_label is "" when nothing was close enough. Check owner_label ~= "" before trusting is_enemy.
script_info = {
name = "Footstep Alert",
needs_sound_events = true,
}
function on_sound_event(e)
if e.is_enemy and e.name:find("Footstep") then
local p = vec3(e.position.x, e.position.y, e.position.z)
if is_position_visible(p) then
notify(e.owner_label .. " nearby", 1)
end
end
endAn error thrown inside this callback is swallowed so one bad script cannot stall the event loop for the others. Nothing visible marks the failure, so check the console.
Common particle names
Reference list of particle paths commonly used in scripting. The Particle Scanner (labelled 'Particle Logger' in the UI) is in Dev Studio > Tools, opened from the bug icon on the top navbar. The old Scripts → Dev Tools sub-tab no longer exists. 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