Skip to content

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.

lua
function on_tick()
    local me = local_player()
    if not me or not me:is_alive() then return end
    -- ...
end

is_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.

lua
function is_enabled()
    return not is_in_menu()
end

WARNING

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.

lua
function on_unload()
    -- reset any state set outside this script's env
end

on_disable ​

lua
function on_disable()
    -- the script was just turned off in the menu
end

Called 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. player is a full player object.
  • Player disappears from the player list after being alive. player is 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:

lua
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
end

on_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).

lua
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
end

The 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.

lua
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
end

on_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.

FieldTypeDescription
namestringAbility name, e.g. "ability_vampirebat_batswarm"
slotinteger0-3
cooldownnumberInitial cooldown in seconds (may be 0 before commit)
phasestringAlways "start"
cast_delay_startnumber?Game clock time when cast-delay began (nil if absent)
channel_startnumber?Game clock time when channel began (nil if not yet started)
cooldown_startnumber?Game clock time when cooldown began (nil if not yet started)
cooldown_endnumber?Game clock time when cooldown ends (nil if not yet started)
cast_completednumber?Game clock time the cast finished (nil if not yet completed)
chargesinteger?Remaining charges at event time (nil if ability has no charges)
upgrade_bitsintegerThe packed upgrade state, the same value as get_ability(slot).upgrade_bits
lua
function on_ability_start(player, info)
    print("windup started on slot " .. info.slot)
end

on_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".

FieldTypeDescription
namestringAbility name, e.g. "ability_vampirebat_batswarm"
slotinteger0-3
cooldownnumberInitial cooldown in seconds
phasestringAlways "cast"
cast_delay_startnumber?Game clock time when cast-delay began (nil if absent)
channel_startnumber?Game clock time when channel began (nil if not channeled)
cooldown_startnumber?Game clock time when cooldown began (nil if absent)
cooldown_endnumber?Game clock time when cooldown ends (nil if absent)
cast_completednumber?Game clock time the cast finished (nil if absent)
chargesinteger?Remaining charges at event time (nil if ability has no charges)
upgrade_bitsintegerThe packed upgrade state, the same value as get_ability(slot).upgrade_bits
lua
function on_ability_cast(player, info)
    print(info.name .. " cast, back in " .. string.format("%.1f", info.cooldown) .. " s")
end

on_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".

FieldTypeDescription
namestringAbility name, e.g. "ability_vampirebat_batswarm"
slotinteger0-3
cooldownnumberInitial cooldown in seconds
phasestringAlways "channel"
cast_delay_startnumber?Game clock time when cast-delay began (nil if absent)
channel_startnumberGame clock time when channel began (always present for this event)
cooldown_startnumber?Game clock time when cooldown began (nil if not yet on cooldown)
cooldown_endnumber?Game clock time when cooldown ends (nil if not yet on cooldown)
cast_completednumber?Game clock time the cast finished (nil if not yet completed)
chargesinteger?Remaining charges at event time (nil if ability has no charges)
upgrade_bitsintegerThe packed upgrade state, the same value as get_ability(slot).upgrade_bits
lua
function on_ability_channel(player, info)
    print("channeling slot " .. info.slot)
end

on_item_used ​

Fires when one of your active items (slots 4-7) goes on cooldown. It only fires for you.

FieldTypeDescription
namestringItem name, e.g. "upgrade_metal_skin"
slotinteger4-7
cooldownnumberInitial cooldown in seconds
lua
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
end

The 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.

FieldTypeDescription
cmdinteger?Command number the shot was attributed to. Absent unless a script is reading the buttons
sim_timenumberWhen 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_beforeintegerRounds in the clip before the shot
clip_afterintegerRounds in the clip after it
shot_numberintegerThe 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.

lua
local shots = 0

function on_shot(me, info)
    shots = shots + 1
    if info.clip_after == 0 then input.tap("reload") end
end

on_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 with entities.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.
lua
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))
end

on_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.

lua
function on_entity_removed(row)
    print("lost " .. row.class .. " " .. row.index)
end

on_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".

FieldTypeDescription
namestringFull particle path, e.g. "particles/abilities/melee/melee_heavy_activate_charge.vpcf"
is_enemybooleanParticle belongs to an enemy player
is_localbooleanParticle belongs to the local player
is_teammatebooleanParticle belongs to a teammate
owner_labelstringHuman-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_pawnintegerRaw pawn pointer of the particle owner
ownerplayer or nilPlayer 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
positiontable{x, y, z} world position. May be (0, 0, 0) for entity-attached particles. Use the control points for those
has_control_pointsbooleantrue if the control point data was read. Only set for particles whose path contains "abilities/" or "melee"
cp0table{x, y, z} control point 0 (typically bounding box min)
cp1table{x, y, z} control point 1 (typically bounding box max)
cp2table{x, y, z} control point 2, the effect center or AoE target position. Usually the most useful CP for gameplay logic
cp3table{x, y, z} control point 3 (adjusted center)
lua
function on_particle_create(ev)
    if ev.is_enemy and ev.name:find("melee_heavy_activate_charge") then
        toast("Enemy charging heavy melee!")
    end
end

Notes 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 needs needs_all_particles = true to detect.
  • For melee particles, the CPs are the swing bounding box.
  • Not all particles have meaningful CPs. Check has_control_points before 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.

lua
function on_particle_destroy(ev)
    if ev.is_enemy and ev.name:find("melee_heavy_activate_charge") then
        print("Enemy melee charge ended")
    end
end

on_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.

FieldTypeDescription
namestringSound event name, e.g. "Haze.Footstep"
positiontableWorld 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
positionalbooleanfalse for 2D sounds (UI, music) that have no world position
is_enemybooleanThe attributed owner is an enemy
is_localbooleanThe attributed owner is you
is_teammatebooleanThe attributed owner is an ally
owner_labelstringHero name of the attributed owner. "" for your own sounds and when nobody was close enough
owner_distancenumberDistance in game units from the sound to that player
guidintegerEngine 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.

lua
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
end

Common 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 ​

ParticleMeaning
melee_heavy_activate_chargeEnemy starts charging a heavy melee (the wind-up)
melee_heavy_activateHeavy melee transitions from charge to swing
melee_swing_heavyThe heavy melee swing itself
melee_parryA parry was activated
melee_parry_successA parry blocked an attack
melee_parry_debuffParry debuff applied to the stunned attacker

Hero-specific melee ​

ParticleHero
unicorn_anim_heavy_melee_startCeleste (Unicorn) heavy melee wind-up
unicorn_anim_heavy_meleeCeleste heavy melee swing animation

Movement ​

ParticleMeaning
generic/sprintPlayer is sprinting
generic/slidePlayer is sliding
generic/air_dashPlayer used air dash
generic/bridge_buffPlayer has the bridge zipline buff

Combat ​

ParticleMeaning
generic/headshotHeadshot indicator
modifiers/stunnedPlayer is stunned
weapon_fx/*/muzzle_flashWeapon muzzle flash (hero-specific subfolder)
weapon_fx/*/tracerBullet tracer (hero-specific subfolder)

Ability-spawned (requires needs_all_particles) ​

ParticleMeaning
unicorn_daggers_targetCeleste Luminous Strike AoE landing indicator
unicorn_flux_rainbowCeleste Luminous Strike rainbow effect
npc/npc_melee_swingTower / NPC melee swing
npc/npc_healthbarNPC 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:

lua
if modifier.name:find("stunned") then ... end
if modifier.name:find("glitch") then ... end
if modifier.name:find("sleep") then ... end

For an exact match, use the token (an integer) instead:

lua
if player:has_modifier(0x9C02E614) then ... end