Script Structure
A Lua script is a .lua file in the scripts folder. No fields are strictly required. An empty file is a valid script. Define only what you need.
Your most basic script will probably look 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 (the rate at which TSUKI reads memory) and is what 90%+ of scripts will use as their main loop. It runs as a coroutine, return to start fresh next tick, or call coroutine.yield() to resume from the same line preserving all local state.
Event-only scripts
on_tick isn't required. If your script only needs to react to game events (kills, modifiers, ability casts), you can skip it entirely. This is a complete event-only script:
name = "Kill Logger"
function on_kill(player)
toast(player:hero_name() .. " died!", 2)
endThe full list of available event callbacks is in the 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 |
needs_internal unlocks p:get_stats, p:get_ability_value, p:get_property, p:get_hit_events, p:get_hit_stats, entities.row:get, trace.line and trace.get. Without it those return nil and log once. Nothing else changes.
The two styles are not interchangeable
Only name is read from both places. Everything else lives in exactly one place. id and settings are read only as top-level globals. Every other field is read only from inside script_info. Putting needs_all_particles = true at the top level, or settings inside script_info, is silently ignored. You get no error. 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 environment is torn down: a reload, a re-enable, or app close. Not on a plain disable |
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) | An ability (slots 0-3) enters its cast-delay / windup phase |
on_ability_cast(player, info) | An ability (slots 0-3) went on cooldown |
on_ability_channel(player, info) | A channeled ability (slots 0-3) began its channel phase |
on_item_used(player, info) | An active item (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 is paused at its current coroutine.yield(), not killed, and resumes with its local state intact when it returns true. 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 200 ms later. The Reload button does the same; toggling one script off and on rebuilds only that one. All three 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 only stops it ticking. on_unload does not run until you toggle it back on.
Only the top level of the scripts folder is scanned. A library you import() must sit there, not in a subfolder, and its top-level code runs at load even while 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 answersnil