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:
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
endon_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:
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)
endAll event callbacks are listed in the Optional callbacks table below.
Optional globals
| Field | Default | Declare where | Purpose |
|---|---|---|---|
name | filename (without .lua) | either | Display name in menu |
id | hero_id.any (all heroes) | top-level only | Hero filter: hero_id.haze, etc. |
settings | none | top-level only | Config table rendered in the menu |
description | "" | script_info only | Tagline shown in the menu |
version | "" | script_info only | Version string shown on the script record |
author | "" | script_info only | Author name shown on the script record |
needs_all_particles | false | script_info only | Receive all particle events, not just ability-related ones |
needs_sound_events | false | script_info only | Enables the on_sound_event callback |
needs_internal | false | script_info only | Permission to call the internal feeds. Without it those calls return nil |
icon | "" | script_info only | Art 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:
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.
| Callback | When 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_internalgives you and when a feed returnsnil