Events
Define any of these as global functions and TSUKI calls them by name. All of them are optional.
on_tick
Called every tick. This is the main loop for continuous logic.
function on_tick()
local me = local_player()
if not me or not me: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. Your timers wait too and fire on the next tick that runs. Your events.on handlers and raw_watch callbacks are skipped too, and what they miss is not sent later. They run at the start of the next tick, so they follow what is_enabled returned on the tick before. On a tick where it returns false, keys your script holds are released and its queued taps are dropped. 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.
When a callback errors
An error inside a callback does not stop your script. This applies to the on_* events below, events.on handlers, raw_watch, entities.watch, cmd.on_change, timer and http callbacks. The error is shown in the Lua console and written to the log, tagged with your script's name. The same callback reports at most once every 5 seconds. The next report says how many errors it skipped.
on_tick and is_enabled are the exception. An error there stops the script until you reload it.
Time limit
Every callback has 5 seconds to finish. This is the same limit on_tick has between two coroutine.yield() calls. It applies to everything listed above, plus is_enabled, on_disable, on_unload, ui.button callbacks, and your script's top-level code when it loads. A callback still running after 5 seconds is stopped with a callback timeout error. The error is shown and logged, and your script stops until you reload it, the same as an error in on_tick. The keys it holds, its commands and its subscriptions are released. A pcall inside your callback does not catch the timeout.
When is_enabled returns false, a running on_tick coroutine pauses and resumes on the next tick that runs. See Script Structure.
on_unload
Called before your script is unloaded. That happens on reload, when the app closes, and when the script is turned back on. Use it to undo global or main-config changes your script made. 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. A reload and app close run it for every loaded script, including scripts that are turned off.
function on_unload()
-- reset any state set outside this script's env
endon_disable
function on_disable()
-- the script was just turned off in the menu
endCalled when your script is turned off. It is not called when your script errors.
You do not need it to clean up. When a script is turned off or errors, TSUKI releases everything it owned: keys it was holding, cmd.* clears and holds, glows, raw_watch watches, entities.watch watches, events.on handlers, cmd.on_change, entity.set_rate tiers, timers, and any internal.set_rate it set. Use on_disable for your own state.
on_disable runs as your script. config.set_* saves into your own config, and print is tagged with your script's name.
on_kill
Fires when a tracked player dies. There are two cases:
- Health drops to 0 while the player is still in the player list.
playeris a full player object. - Player disappears from the player list after being alive.
playeris a plain table{hero_name, team, is_alive=false}. The live object is gone, so only the cached fields are available.
Always check before calling player methods:
function on_kill(player)
if type(player) == "table" then
-- player left the player list before we could read them
toast(player.hero_name .. " died (left the player list)")
return
end
if player:is_local() then
toast("we died")
end
endon_modifier_added
Fires when a modifier instance appears on a tracked player. Each instance is tracked on its own, so duplicate tokens are detected (for example, owning and being hit by the same item).
function on_modifier_added(player, modifier)
-- for the modifier fields, see the modifier table in the 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 every field from player:get_modifiers() except expires_at, stacks and max_stacks. For the stack count, find the entry with the same serial in player:get_modifiers(). If you need an expiry time, compute it as game_time() + modifier.remaining and compare it with game_time() later. Both stop during pauses, so the result stays right. See Player > Modifiers.
on_modifier_removed
Fires when a modifier disappears from a tracked player. Only token and name are set. All other fields are absent. name may be an empty string and token may be 0.
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 or windup phase. This is the earliest point you can react, before the cooldown starts and before the cast commits. info.phase is always "start". The time fields of this event and of on_ability_cast and on_ability_channel use the game clock, which stops during pauses. "Now" on that clock is game.server_time(), not game_time() or sim_time(). See Player > Timestamps.
Your own abilities only
on_ability_start, on_ability_cast, on_ability_channel and on_item_used only fire for you. The game doesn't send other players' abilities or items to your client, not even your teammates'. For enemy casts, try on_sound_event or on_particle_create. Not every cast makes a particle. Some only make a sound, and a sound's owner is a guess based on distance. p:get_ability_cooldowns() gives every player's cooldowns (estimated for other players), and p:is_ult_ready() works on every player.
| Field | Type | Description |
|---|---|---|
name | string | Ability 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? | Game clock time when cast-delay began (nil if absent) |
channel_start | number? | Game clock time when channel began (nil if not yet started) |
cooldown_start | number? | Game clock time when cooldown began (nil if not yet started) |
cooldown_end | number? | Game clock time when cooldown ends (nil if not yet started) |
cast_completed | number? | Game clock 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 | The packed upgrade state, the same value as get_ability(slot).upgrade_bits |
function on_ability_start(player, info)
print("windup started on slot " .. info.slot)
endon_ability_cast
Fires when a hero ability (slots 0-3) commits, which is when its cooldown rises above 0.1 seconds. Abilities whose cooldown is 0.1 s or lower do not trigger this event. info.phase is always "cast".
| Field | Type | Description |
|---|---|---|
name | string | Ability name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds |
phase | string | Always "cast" |
cast_delay_start | number? | Game clock time when cast-delay began (nil if absent) |
channel_start | number? | Game clock time when channel began (nil if not channeled) |
cooldown_start | number? | Game clock time when cooldown began (nil if absent) |
cooldown_end | number? | Game clock time when cooldown ends (nil if absent) |
cast_completed | number? | Game clock time the cast finished (nil if absent) |
charges | integer? | Remaining charges at event time (nil if ability has no charges) |
upgrade_bits | integer | The packed upgrade state, the same value as get_ability(slot).upgrade_bits |
function on_ability_cast(player, info)
print(info.name .. " cast, back in " .. string.format("%.1f", info.cooldown) .. " s")
endon_ability_channel
Fires when a hero ability (slots 0-3) begins its channel phase. It only fires for abilities that channel. info.phase is always "channel".
| Field | Type | Description |
|---|---|---|
name | string | Ability name, e.g. "ability_vampirebat_batswarm" |
slot | integer | 0-3 |
cooldown | number | Initial cooldown in seconds |
phase | string | Always "channel" |
cast_delay_start | number? | Game clock time when cast-delay began (nil if absent) |
channel_start | number | Game clock time when channel began (always present for this event) |
cooldown_start | number? | Game clock time when cooldown began (nil if not yet on cooldown) |
cooldown_end | number? | Game clock time when cooldown ends (nil if not yet on cooldown) |
cast_completed | number? | Game clock 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 | The packed upgrade state, the same value as get_ability(slot).upgrade_bits |
function on_ability_channel(player, info)
print("channeling slot " .. info.slot)
endon_item_used
Fires when one of your active items (slots 4-7) goes on cooldown. It only fires for you.
| Field | Type | Description |
|---|---|---|
name | string | Item 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") then
toast("Metal Skin used, back in " .. string.format("%.0f", item.cooldown) .. " s")
end
endThe player argument is always you. The event has no targeting information.
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 a script is reading the buttons |
sim_time | number | When the weapon last fired, the same value as p:get_last_attack_time(). It is a game clock time like cooldown_end, so compare it with game.server_time(), not 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 shot counter. It only goes up |
A shotgun blast is one event. shot_number only goes up when a bullet left the gun. A tap on an empty or reloading clip fires nothing.
cmd is present only while some script is reading input.buttons().
clip_after can equal clip_before at a reload boundary or in an infinite ammo state. input.diag().clip_unread counts those.
local shots = 0
function on_shot(me, info)
shots = shots + 1
if info.clip_after == 0 then input.tap("reload") end
endon_shot tells you a shot left the gun. To find out 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, or watch the class withentities.watch. Events stop 2 seconds after the last call. - Entities that already exist do not fire. "Added" means the entity appeared after you started watching, not that it just 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. It takes the same single row argument as on_entity_added and has the same 2 second polling window.
The row holds the last values the entity had. Its position and health are from 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 you only get ability effects (projectiles, impacts, casts) on the local player, enemies and teammates. needs_all_particles = true in script_info adds cosmetic, environmental, tower and ability-spawned particles. This costs extra processing, so leave it off unless you need them.
The engine checks for new and removed particles at 100Hz. Particle names are full 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)". While Guardian Parry is on you also get particles on nearby guardians, tagged "(enemy guardian)" or "(ally guardian)", with is_enemy or is_teammate set and no owner |
owner_pawn | integer | Raw pawn pointer of the particle owner |
owner | player or nil | Player table, the same shape get_players() returns. nil if the owner couldn't be matched to a known player. This happens for ability-spawned particles when needs_all_particles is enabled, because ability entities own those, not player pawns |
position | table | {x, y, z} world position. May be (0, 0, 0) for entity-attached particles. Use the control points for those |
has_control_points | boolean | true if the control point data was read. Only set 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, the effect center or 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, the CPs are relative to the caster and do not change with the target location. One example is
unicorn_flux_strike_cast, the cast particle of Celeste's Luminous Strike. Its AoE landing particle (unicorn_daggers_target) is ability-spawned and needsneeds_all_particles = trueto detect. - For melee particles, the CPs are 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. It has the same fields as on_particle_create, except that position is (0, 0, 0) and there are no control points. While any enabled script has needs_all_particles = true, destroy events carry no owner. is_enemy, is_local and is_teammate are false, owner_label is "" and there is no owner.
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, such as footsteps, ability casts and item procs. It requires needs_sound_events = true in your script_info. Without it, the callback only runs while another script or feature has sound events turned on, so always set it.
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 build 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. "" for your own sounds and when nobody was close enough |
owner_distance | number | Distance in game units from the sound to that player |
guid | integer | Engine handle for the sound instance |
The engine guesses the owner from how close the sound played to each player. In a crowded fight it can pick the wrong hero. When no player was within 120 game units, owner_label is "" and is_enemy, is_local and is_teammate are all false.
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
endCommon particle names
These are particle paths that scripts often use. The Particle Logger is in Dev Studio > Particles. Open Dev Studio from the bug icon on the top navbar. 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 heavy melee swing itself |
melee_parry | A parry was activated |
melee_parry_success | A parry 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 the game's full names, prefixes included. Use string.find to match part of one:
if modifier.name:find("stunned") then ... end
if modifier.name:find("glitch") then ... end
if modifier.name:find("sleep") then ... endFor an exact match, use the token (an integer) instead:
if player:has_modifier(0x9C02E614) then ... end