Skip to content

Script Structure ​

A Lua script is a .lua file in the scripts folder. No fields are required, and an empty file is a valid script. Define only what you need.

A basic script looks something like this:

lua
name = "Aim Assist"

function on_tick()
    if is_in_menu() then return end
    if not input.is_key_held(VK.XBUTTON2) then return end

    local target = targeting.find_closest_by_fov(15, 50)
    if target and target:is_alive() then
        snap_to_target(target, { bone = bone.chest })
    end
end

on_tick runs every ~4ms, and most scripts use it as their main loop. It runs as a coroutine. Return to start fresh on the next tick, or call coroutine.yield() to resume from the same line with all local state kept.

on_tick must return or yield within 5 seconds, and every other callback must return within 5 seconds, or the script is stopped. See Time limit.

Event-only scripts ​

on_tick isn't required. If your script only reacts to game events (kills, modifiers, ability casts), you can leave it out. This is a complete event-only script:

lua
name = "Kill Logger"

function on_kill(player)
    -- player is a plain table once they have left the player list
    local hero = type(player) == "table" and player.hero_name or player:hero_name()
    toast(hero .. " died!", 2)
end

All event callbacks are listed in the Optional callbacks table below.

Optional globals ​

FieldDefaultDeclare wherePurpose
namefilename (without .lua)eitherDisplay name in menu
idhero_id.any (all heroes)top-level onlyHero filter: hero_id.haze, etc.
settingsnonetop-level onlyConfig table rendered in the menu
description""script_info onlyTagline shown in the menu
version""script_info onlyVersion string shown on the script record
author""script_info onlyAuthor name shown on the script record
needs_all_particlesfalsescript_info onlyReceive all particle events, not just ability-related ones
needs_sound_eventsfalsescript_info onlyEnables the on_sound_event callback
needs_internalfalsescript_info onlyPermission to call the internal feeds. Without it those calls return nil
icon""script_info onlyArt for the sidebar row of each tab your script makes with ui.new_tab: a hero name such as "Haze", or a path to the game's art (see Game Icons). A hero name shows only after TSUKI has seen that hero in a match. Until then, or without icon, the row shows a code icon

needs_internal gives a script the internal feeds, such as p:get_stats, p:get_ability_value, p:get_shot_damage, p:get_property, p:get_hit_events, entities.row:get and trace.line. The full list is under What the flag gates. Without it those return nil and log once. The flag has no other effect.

The two styles are not interchangeable

Only name is read from both places. id and settings are read only as top-level globals. Every other field is read only from inside script_info. If you put needs_all_particles = true at the top level, or settings inside script_info, it is ignored with no error and the feature never turns on.

A typical header uses both:

lua
id = hero_id.haze                -- top-level only
settings = { ... }               -- top-level only

script_info = {                  -- everything else
    name        = "Aim Assist",
    description = "Locks onto the closest enemy in FOV",
    version     = "1.2",
    author      = "you",
    needs_internal = true,       -- only if the script calls an internal feed
}

Optional callbacks ​

Define only the callbacks you need.

CallbackWhen it fires
on_tick()Every ~4ms, runs as a coroutine
is_enabled()Checked before every tick. Return false to skip this tick
on_unload()Before the script is unloaded: a reload, a re-enable, or app close. Not on a plain disable
on_disable()The script was turned off in the menu. Not called when it errors
on_kill(player)A player dies
on_modifier_added(player, mod)A modifier is applied to any player
on_modifier_removed(player, mod)A modifier is removed from any player
on_ability_start(player, info)One of your abilities (slots 0-3) enters its cast-delay / windup phase
on_ability_cast(player, info)One of your abilities (slots 0-3) went on cooldown
on_ability_channel(player, info)One of your channeled abilities (slots 0-3) began its channel phase
on_item_used(player, info)One of your active items (slots 4-7) went on cooldown
on_shot(player, info)The local player fired. One event per trigger pull, so a shotgun blast is one event
on_entity_added(row)An entity appeared in a class you are polling with entities.by_class. One argument, no player
on_entity_removed(row)An entity disappeared from a class you are polling. One argument, no player
on_particle_create(event)A particle system spawned
on_particle_destroy(event)A particle system destroyed
on_sound_event(event)A game sound was played. Requires needs_sound_events = true. Takes a single event table and no player argument

Parameter details for each callback are in the Events reference.

If is_enabled() returns false, the on_tick coroutine pauses at its current coroutine.yield(). It resumes with its local state intact when is_enabled() returns true again. Your events.on handlers and raw_watch callbacks are skipped during the pause, and what they miss is not sent later. Wall-clock time keeps running while paused, so a helper like fire_and_hold may find its hold window already elapsed.

Reloading ​

Saving a .lua file reloads every script about 300 ms later. Each new save restarts that wait, so a burst of saves reloads once. The Reload button reloads straight away.

To stop saves from reloading, press the pause button in the header of the Scripts page. Right-click it to change the wait (0 to 5000 ms). Saves made while it is paused are not reloaded when you turn it back on, so press Reload.

Toggling one script off and on rebuilds only that script. A save, Reload and a re-enable all rebuild the Lua state, so on_unload runs first and top-level local variables reset. Use Storage for state that should survive.

Toggling a script off runs on_disable and stops it ticking. on_unload runs later, when you toggle it back on or at the next reload or app close.

Only the top level of the scripts folder is scanned, so a library you import() must be there and not in a subfolder. Its top-level code runs at load, even while the library is disabled.

Next steps ​

  • Sandbox: which standard libraries are available and which aren't
  • Script Settings: declarative settings and the config namespace
  • Events: full reference for each callback
  • Globals: top-level functions like clock, toast, print
  • Player: everything you can ask about a player
  • Internal API Reference: what needs_internal gives you and when a feed returns nil