Skip to content

Globals ​

Any script can call the top-level functions on this page and the game, tsuki, storage, json and timer namespaces it covers.

entities has its own page, Entities. aim, trace and internal are in the Internal API Reference.

Player Access ​

FunctionReturnsDescription
local_player()player | nilThe local player object. nil when there is no local player, for example in the main menu
get_players()tableEnemy players. Teammates are in get_teammates()
get_all_players()tableAll valid players except the local player
get_teammates()tableTeammate players only
get_dead_players()tablePlayers who are dead right now, with their respawn timer. See below
get_dormant_players()tableLiving players your client is not receiving updates for, with where and when they were last seen. See below
lua
local me = local_player()
for _, p in ipairs(get_players()) do
    if p:is_enemy() and p:is_alive() then
        -- ...
    end
end

Dead and dormant players ​

get_players() and the other lists only hold living players your client is receiving. Two more lists cover the rest. Both return an array of plain tables (not player objects), and do not include you.

FieldBoth listsDescription
hero_name, hero_idyesThe hero
team, is_enemyyesTeam number, and whether it is the other team
player_name, steam_idyesThe player
scoreyesThe scoreboard table, when it has been read
respawn_indead onlySeconds until the player respawns. Absent when unknown
last_positiondormant onlyvec3 where the player was last seen. Absent if they were never seen this match
last_seendormant onlySeconds since then. Absent when last_position is
health, max_healthdormant onlyThe last health the game sent

A dormant player is one the game stopped sending you, usually because nobody on your team can see them. Their position is not updated, which is why they are not in get_players().

lua
for _, d in ipairs(get_dead_players()) do
    if d.is_enemy and d.respawn_in then
        print(string.format("%s respawns in %.0f s", d.hero_name, d.respawn_in))
    end
end

World Entities ​

FunctionReturnsDescription
get_world_entities([type])WorldEntity[]All tracked world entities. Optional type string filters by coarse type name (e.g. "sinner", "tower") or exact engine class name (e.g. "citadel_breakableprop")

Each WorldEntity has:

FieldTypeDescription
typestringCoarse type name ("sinner", "tower", "trooper_boss", "unknown", ...)
class_namestringExact engine class name
x, y, znumberWorld position
health, max_healthnumberCurrent and maximum health
teamintegerTeam id
distancenumberDistance from the local player, in metres (already converted)
subclassintegerSeparates subtypes that share one class_name, e.g. golden statue vs soul crate
currency_valueintegerSoul value of the pickup. 0 for non-soul rows
activebooleanWhether the pickup is available (false while on respawn cooldown)
pickup_namenilAlways nil. Tell pickups apart by type and subclass, as Powerup ESP does
rejuv_innumberrejuv rows only: seconds until it can be taken. It goes to 0 and below once it can be taken, and is -1 when unknown. -1 on every other row
rejuv_takenbooleanRejuvenator rows only: true once it has been taken. false on every other row
rejuv_hitsintegerrejuv rows only: punches the crystal has left (3 to 0), or -1 when unknown. Other rows always read 3, so check e.type == "rejuv" first
lua
-- Print every Sinner's Sacrifice
for _, e in ipairs(get_world_entities("sinner")) do
    print(string.format("sinner at %.0f,%.0f hp=%d/%d", e.x, e.y, e.health, e.max_health))
end

-- Check for breakable props regardless of sub-type
for _, e in ipairs(get_world_entities("citadel_breakableprop")) do
    print(e.class_name, e.subclass)
end

Time ​

FunctionReturnsDescription
clock()numberHigh-resolution time in seconds. Use for deltatime and debouncing
game_time()numberMatch clock time in seconds (pause-aware HUD timer)
get_match_time()numberAlias for game_time()
is_paused()booleanTrue while the match clock is frozen (a team pause, or the 3s unpause countdown)
sim_time()numberRaw client SimTime. Keeps advancing through pauses and pre-game
game.time()numberSame as game_time()
game.sim_time()numberSame as sim_time()
game.tick_count()integer or nilThe game's current tick number. nil, never 0, until the game clock is running
game.tick_interval()number or nilSeconds per game tick: 0.015625 on a 64 tick server. nil until it has been checked. Keeps its value through a pause
game.latency()number or nilYour network latency in milliseconds. nil until it has been read
game.start_time()number or nilSimTime the match clock started. nil before the horn
game.frame_dt()numberSeconds since the previous tick, capped at 1. Use it to drive animation
game.server_time()number"Now" on the clock the game's time stamps use. Stops during pauses. See below
game.state()integer, stringThe match phase: 7, "in_progress" while the match runs. See below
game.match_id()integer | nilThe match id. nil outside a match and in bot matches

clock() counts real seconds since the engine started and never pauses. game_time() and its alias get_match_time() follow the match clock and freeze while is_paused() is true. sim_time() is the raw client SimTime and keeps running through pauses and pre-game.

Do not diff absolute timestamps against sim_time()

The game's absolute timestamps run on the match clock. These are cast_delay_start, cooldown_start / cooldown_end, channel_start, cast_completed, and a modifier's expires_at. They freeze during pauses. If you subtract them from sim_time(), the result is too large by all the pause and pre-game time so far.

For "how much is left", use the pre-computed fields ability.cooldown and modifier.remaining. Both already handle pauses. Use sim_time() only for deltas between two sim_time() samples.

If you do need "now" on the same clock as those timestamps, use game.server_time():

lua
local ab = local_player():get_ability(slot.ability1)
if ab and ab.cast_delay_start then
    local since_cast = game.server_time() - ab.cast_delay_start
end
lua
-- Correct: read the pre-computed remaining time (already pause-aware)
local ab = local_player():get_ability(slot.ability1)
if ab and ab.cooldown > 0 then
    print(string.format("Ability 1: %.1fs left", ab.cooldown))
end

-- Correct: sim_time is fine for a delta between two sim_time samples
local started = sim_time()
-- ... some ticks later ...
local elapsed = sim_time() - started

game.state() returns the phase as a number and a name: 0 "invalid", 1 "init", 2 "waiting_for_players", 3 "hero_selection", 4 "match_intro", 5 "loading_map", 6 "pregame", 7 "in_progress", 8 "post_game", 9 "play_of_the_game", 10 "abandoned", 11 "end". Any other number comes with the name "unknown".

game.tick_count() is the game's tick number. It is nil for about the first second of a match and while the match is paused, never 0. Game ticks are not the same rate as on_tick.

game.tick_interval() is the length of one game tick in seconds. Use it instead of writing 1/64 into your script. It is nil when it cannot be confirmed, keeps its value through a pause, and is nil again on a new map until it has been checked.

Output ​

FunctionReturnsDescription
print(...)-Writes to the app console, the Windows console, and the client's tsuki.log. Lines are prefixed [script_name] in the app console and the log file, and [Lua:script_name] in the Windows console. Arguments are converted to strings and joined with tabs, as in standard Lua. The log file keeps at most 30 lines per script every 10 seconds (see below)
toast(text, [duration], [style])-Centered HUD toast. Optional per-call styling. See below
notify(text, [duration], [color])-Notification in the top-left corner, Standard rendering modes only. Default duration 3.0, color white. See below
play_sound(filename)-Play a WAV file from assets\sounds\. Pass the filename only, no path separators
lua
toast("ready")
notify("warning", 5.0)
play_sound("alert.wav")   -- plays assets\sounds\alert.wav

Log lines look like [2026-08-23 21:04:11.882] [LUA] [my_script] ready. They stay after the session ends, so the log tells a script that never ran from one that ran quietly.

The log file keeps only the first 30 lines each script prints in every 10 seconds, so a script that prints every tick cannot fill it. The next time the script prints after those 10 seconds, the log gets one line saying how many were left out, for example [my_script] (212 more print lines in the last 10 s were not logged). The app console still shows every line.

toast(text, [duration], [style]) ​

A centered HUD toast notification.

  • duration: seconds to display. Leave it out or pass 0 to use the global default from the menu settings.
  • style: optional table of per-call overrides. Any key you leave out uses the global default set in the menu:
KeyTypeNotes
bg{r, g, b, a}Background fill
text{r, g, b, a}Text color
outline{r, g, b, a}Outline / drop-shadow color
scalenumberSize multiplier (1.0 = default)

Toasts are deduplicated by text only. If the same string is already showing or queued, the call is dropped, even with a different style table, so toast("X") called every tick in on_tick shows once.

lua
toast("Hit", 2, {bg = {r = 255, g = 0, b = 0}})  -- queued
toast("Hit", 2, {bg = {r = 0, g = 255, b = 0}})  -- dropped (same text)
toast("Hit!", 2)                                   -- different text: replaces "Hit"

The style colors must be {r, g, b, a} tables. A packed draw.color() number is ignored and the global default is used instead.

Only one toast shows at a time. A toast with different text replaces the one on screen straight away, and when several arrive before the overlay's next frame, only the newest shows. TSUKI's own parry toasts use the same spot.

lua
toast("Target locked", 2, {
    bg = {r = 40, g = 15, b = 50, a = 220},
    text = {r = 200, g = 140, b = 255},
    scale = 1.2
})

A demo script where each button shows a different style preset:

lua
-- Toast Style Demo
-- Click buttons to see different toast styles
script_info = {
    name        = "Toast Style Demo",
    description = "Showcases custom toast styling",
}

ui.new_tab("toast_demo", "Toast Demo")

ui.button("toast_demo", "styles", "Default Toast", function()
    toast("Default style, uses your global settings", 3)
end)

ui.button("toast_demo", "styles", "Success (Green)", function()
    toast("Parry successful!", 3, {
        bg   = {r=20, g=60, b=20, a=220},
        text = {r=100, g=255, b=100},
        outline = {r=80, g=200, b=80, a=120},
    })
end)

ui.button("toast_demo", "styles", "Danger (Red)", function()
    toast("LOW HP, retreat!", 4, {
        bg   = {r=80, g=10, b=10, a=230},
        text = {r=255, g=80, b=80},
        outline = {r=255, g=60, b=60, a=150},
        scale = 1.3,
    })
end)

ui.button("toast_demo", "styles", "Info (Blue)", function()
    toast("Cooldown ready", 2, {
        bg   = {r=15, g=25, b=60, a=220},
        text = {r=120, g=180, b=255},
        outline = {r=80, g=140, b=220, a=100},
    })
end)

ui.button("toast_demo", "styles", "Big Gold", function()
    toast("ULTRA KILL!", 5, {
        bg   = {r=40, g=30, b=5, a=240},
        text = {r=255, g=215, b=0},
        outline = {r=255, g=180, b=0, a=160},
        scale = 1.8,
    })
end)

notify(text, [duration], [color]) ​

A notification in the top-left corner of the screen. Default duration 3.0, color white. It only shows when Rendering Mode in the TSUKI menu is Standard (GPU) or Standard (CPU). In the other modes it draws nothing, so use toast for a message every player sees. Unlike toast, the same text is not merged. At most 10 show at once, and an 11th removes the oldest.

  • color must be a packed integer, the value that draw.color(r, g, b, [a]) returns.
  • Unlike toast, notify does not accept an {r, g, b, a} table. A table, or a float such as 3.0, is ignored without an error. The notification falls back to white.
lua
notify("boss up", 5.0, draw.color(255, 80, 80))   -- correct
notify("boss up", 5.0, { r = 255, g = 80, b = 80 })  -- ignored, shows white

Input shortcuts ​

FunctionReturnsDescription
slot_to_key(slot)integerVK code for an ability, item, jump, slide or weapon_melee slot. Returns 0, not nil, for any other slot. See Game Keybinds
item_slot_to_key(config_slot)integerVK code for a 0-based item config slot (0-3). Equivalent to slot_to_key(config_slot + 4)
left_click()-Simulate a left mouse click. The button is held for 32 ms (two game ticks) so the game always sees it
right_click()-Simulate a right mouse click, held the same way

A click sent while the previous one is still held waits for it to finish. Only one click waits at a time, so more clicks sent in the meantime are merged into it. For other buttons or a longer hold use input.click. For finer-grained input control (key down, key up, mouse movement), see the Input namespace.

Game state ​

is_position_visible(pos) ​

Casts a ray from the camera to a world-space position. Returns true if nothing blocks the path. The map and the chess pieces a hero can place block it. Players do not. It also returns true when TSUKI has no collision data for the map yet.

Pass a second position, is_position_visible(pos, from), to check the line between two points instead of from the camera.

lua
local pos = some_player:get_position()
if is_position_visible(pos) then
    -- position has line-of-sight from camera
end

This checks a point in the world. To check whether a player is visible, use player:is_visible() instead.

is_in_menu() ​

Returns true when the game cursor is visible, meaning the player is in a menu, shop, scoreboard, or settings screen.

lua
if is_in_menu() then return end

All input actions from Lua scripts (key presses, mouse movement) are blocked automatically while the player is in a menu, so you don't need to check is_in_menu() for safety. Use it to skip expensive logic like targeting when the player isn't actively playing.

is_game_focused() ​

Returns true when the game window is in the foreground (active and focused).

lua
if not is_game_focused() then return end

Use this to skip logic when the player has alt-tabbed or is in another window.

Spectators ​

These list the dead players who are spectating you. They update every 2 seconds, and only while the Spectator Panel is on in the TSUKI menu.

FunctionReturnsDescription
game.get_spectators()tableHero names of the players spectating you, for example {"Haze", "Abrams"}
game.get_spectator_count()integerNumber of spectators, without building the name table
lua
if game.get_spectator_count() > 0 then
    local specs = game.get_spectators()
    print(#specs .. " spectators: " .. table.concat(specs, ", "))
end

Item data ​

FunctionReturnsDescription
game.item_info(name)table | nilAn item's tier, shop slot, price override and base values, by name

name is the item's name with or without the upgrade_ prefix, for example "extra_spirit" or "upgrade_extra_spirit". Case does not matter. It returns nil for a name that is not an item, and when the game's item data cannot be read yet. The table has:

FieldTypeDescription
namestringThe full name, with upgrade_ in front
tierinteger1 to 5
slotstring | nilThe shop category: "weapon", "vitality", "spirit", and a few special ones. nil when unknown
price_overrideintegerA special price for this item, or 0 when it costs the normal price for its tier
propertiestableThe item's base values by property name, as {[name] = value}, in the game's own units

The properties are the values from the game files, before a player's items or upgrades change them, the same kind of numbers p:get_ability_properties gives for an ability.

lua
local info = game.item_info("extra_spirit")
if info then
    print(info.name, "tier", info.tier, info.slot)
    for k, v in pairs(info.properties) do print(k, v) end
end

Tokens ​

A modifier's token, an item's token and a damage message's ability_id are numbers that stand for a name. These two functions convert between the number and the name.

FunctionReturnsDescription
game.token(name)integerThe token for a name. Case does not matter
game.token_name(token)string | nilThe name behind a token: any hero's ability, any item, and most other game data. nil when it is unknown
lua
for _, m in ipairs(local_player():get_modifiers()) do
    print(m.name ~= "" and m.name or game.token_name(m.token) or "?")
end

Tsuki Config ​

Read/write TSUKI's own settings (ESP toggles, aimbot FOV, etc.).

FunctionReturnsDescription
tsuki.get_bool(key, default)booleanRead a boolean setting
tsuki.get_int(key, default)integerRead an integer setting
tsuki.get_float(key, default)numberRead a float setting
tsuki.set_bool(key, value)-Write a boolean setting
tsuki.set_int(key, value)-Write an integer setting
tsuki.set_float(key, value)-Write a float setting
tsuki.get_color(key, default)ColorRead a color {r,g,b,a} (0-255)
tsuki.set_color(key, value)-Write a color {r,g,b,a} (0-255)
lua
-- React to the user's configured aimbot FOV
local fov = tsuki.get_float("aimbot_fov", 3.0)
local target = targeting.find_closest_by_fov(fov, 30.0)

-- Temporarily override a setting and restore it
local prev = tsuki.get_bool("show_teammate_esp", false)
tsuki.set_bool("show_teammate_esp", false)
-- ... do something ...
tsuki.set_bool("show_teammate_esp", prev)

Colors are stored as [r,g,b,a] arrays, each value 0-255. get_color and set_color use a matching {r,g,b,a} table:

lua
-- Tint the enemy box fill red while a condition holds, restore otherwise
local KEY  = "enemy_box_fill_vis_color"
local base = tsuki.get_color(KEY, {r=255, g=255, b=255, a=40})  -- cache the real color ONCE

if condition then
    tsuki.set_color(KEY, {r=255, g=0, b=0, a=90})
else
    tsuki.set_color(KEY, base)                                  -- restore
end

set_color writes the LIVE setting

set_color changes the user's real saved value, and the change is written to their config. For a temporary or per-modifier tint, cache the original with get_color and restore it when the condition clears. Restore it in on_disable and on_unload too, so turning the script off or reloading it does not leave the tint behind. A tint that is still on when the game or TSUKI closes is saved.

Setting keys match the IDs TSUKI uses internally. For new script-defined settings, prefer the UI namespace. It creates a labeled widget and saves its value automatically.

The API registry ​

Every registered name, in a table your script can read.

FunctionReturnsDescription
tsuki.api()tableAn array of {name, kind, host, needs_internal, since}, one entry per registered name
tsuki.api_info(name)table or nilOne entry, or nil when nothing matches exactly

The fields on an entry:

FieldNotes
nameNamespaced functions are ns.fn, player methods are p:method, callbacks and globals are bare
kindOne of function, method, global, callback or table
hostexternal or internal
needs_internalWhether your script_info has to declare the flag
sinceThe build the name arrived in

tsuki.api_info is the reliable way to check whether a call exists on this build.

The name must match exactly, including the prefix: "p:get_stats", not "get_stats". tsuki.api() builds a new table on each call, so do not call it every tick.

lua
local i = tsuki.api_info("p:get_stats")
if i then
    print(i.name, i.host, i.needs_internal)   -- p:get_stats  internal  true
end

local n_internal = 0
for _, e in ipairs(tsuki.api()) do
    if e.needs_internal then n_internal = n_internal + 1 end
end
print(n_internal .. " calls need needs_internal = true")

The Lua helpers further down this page (sleep, debounce, press_ability, snap_to_target and the rest) and print are not in the table, but they still work.

For what the host and needs_internal tags mean, see the Internal API Reference.

Cross-Script Modules ​

Scripts can share code with export and import. A library script calls export at the top level to register a table of functions under a name. Consumer scripts call import from on_tick to fetch it.

FunctionReturnsDescription
export(name, module_table)-Register a table under name so other scripts can import it. Call at top level
import(name)table | nilFetch a module exported by another loaded script. Returns nil if the library is not loaded

WARNING

The sandbox removes eight base-library globals: require, dofile, loadfile, rawget, rawset, rawlen, rawequal, and collectgarbage. Calling any of them fails with attempt to call a nil value. export/import is the only supported way to share code between scripts.

The sandbox loads only _G (base), math, string, table, coroutine and utf8. There is no os, io or debug. Library code ported from elsewhere that calls rawget, rawset or collectgarbage("count") must drop those calls before it runs here.

lua
-- mylib.lua: the library script
script_info = {
    name        = "mylib",
    description = "Shared utilities",
}

export("mylib", {
    clamp = function(v, lo, hi) return math.max(lo, math.min(hi, v)) end,
    sign  = function(v) return v >= 0 and 1 or -1 end,
})
lua
-- consumer.lua
script_info = {
    name        = "consumer",
    description = "Uses mylib",
}

local lib = nil

function on_tick()
    if not lib then lib = import("mylib") end
    if not lib then return end  -- library not loaded yet

    local clamped = lib.clamp(150, 0, 100)   -- 100
end

Storage ​

Persistent key-value storage, separate for each script. Values survive script reloads, game restarts, and PC restarts. Each script has its own file at scripts\storage\<script_name>.json, and one script cannot see another script's keys.

FunctionReturnsDescription
storage.set(key, value)-Save a value under a key. Passing nil as the value deletes the key.
storage.get(key)any | nilLoad a saved value, or nil if not set
storage.keys()tableList of saved keys for this script

Values can be strings, numbers or booleans. nil deletes the key. Any other type, or a missing value (storage.set("k")), raises storage.set: unsupported value type. Tables are not supported. Store each field under its own key, or encode the table as a string with json.encode.

Numeric strings do not round-trip

Any string that Lua can convert to a number is stored as a number. storage.set("k", "42") stores the number 42.0, and storage.get("k") returns 42.0, not "42". The same happens with "3.14", "1e3" and "0x1F". If a value must stay a string, give it a non-numeric prefix.

Writes are cached in memory and flushed to disk about every 5 seconds while the engine is ticking. One more flush runs when the Lua VM shuts down (app exit or a script reload). storage.set() does not flush to disk right away, and disabling a script does not flush.

Don't write storage from on_unload

The shutdown flush runs before on_unload is called, and the cache is discarded right after. Anything on_unload saves never reaches disk. Save state as it changes instead.

lua
-- Track cumulative kills across game sessions
local function in_dead_list(t)
    for _, d in ipairs(get_dead_players()) do
        if d.hero_name == t.hero_name and d.team == t.team then return true end
    end
    return false
end

function on_kill(player)
    local me = local_player()
    if type(player) == "table" then   -- left the player list: dead, or only out of sight
        if not me or player.team == me:get_team() or not in_dead_list(player) then return end
    elseif not player:is_enemy() then return end
    local total = (storage.get("total_kills") or 0) + 1
    storage.set("total_kills", total)
    print("Total kills: " .. total)
end

-- Store structured data as individual keys...
storage.set("loadout_slot1", "burst_fire")
storage.set("loadout_slot2", "trooper_bounty")
local slot1 = storage.get("loadout_slot1")

-- ...or as one JSON string (see JSON below)
storage.set("loadout", json.encode({ "burst_fire", "trooper_bounty" }))
local loadout = json.decode(storage.get("loadout") or "[]")

-- Delete a key
storage.set("old_data", nil)

JSON ​

json.encode and json.decode turn Lua values into JSON text and back. Use them for HTTP bodies, or to store a table with storage.set.

FunctionReturnsDescription
json.encode(value, [pretty])stringJSON text. Pass true as pretty for indented output. Raises an error for a value JSON cannot hold (a function, NaN, or tables nested deeper than 64, which also catches a table that contains itself)
json.decode(text)value | nil, errorThe decoded value, or nil and a short reason when the text is not valid JSON. It does not raise on bad text, so it is safe on whatever a server sends back. Passing nil or a table still raises an error
json.nullvalueStands for JSON null

How values map:

  • A table whose keys are exactly 1 to n is an array. An empty table {} encodes as []. Any other table is an object, and number keys become strings.
  • To force an object, for example an empty {}, use setmetatable(t, { __jsontype = "object" }).
  • Integers stay integers.
  • JSON null decodes to json.null, not nil, so arrays keep their positions. Test it with v == json.null.
  • json.decode refuses text over 16 MB or nested deeper than 256, and returns nil and the reason.
  • In a string, bytes that are not valid UTF-8 are encoded as the replacement character, so binary data does not survive a round trip.
lua
local body = json.encode({ name = "tsuki", hits = 3, tags = { "a", "b" } })
-- {"hits":3,"name":"tsuki","tags":["a","b"]}

local data, err = json.decode(body)
if data then print(data.name, #data.tags) else print("bad json:", err) end

Timers ​

timer runs a function later, or again and again, without a coroutine.

FunctionReturnsDescription
timer.after(seconds, fn)integerCalls fn once after seconds. Returns the timer's id
timer.every(seconds, fn)integerCalls fn every seconds until it is removed. Returns the timer's id
timer.remove(id)booleanCancels one of your script's timers. true when it was still pending

seconds can be 0 (the script's next tick) up to 1e9. Anything else raises an error. Adding a timer also raises an error when 4096 timers are already waiting across all scripts.

Timers count real time, so they keep counting through a match pause. A timer belongs to its script and fires on that script's tick, right before its events and on_tick, so it never fires faster than the script ticks. While the script is paused (is_enabled() returns false, or you are playing another hero) its timers wait and fire on its next tick. They are removed when the script is turned off, errors or reloads. An error inside a timer is reported like any other callback error, and a timer.every keeps running.

lua
local beat = timer.every(1, function() print("one second") end)
timer.after(10, function() timer.remove(beat) end)

Helpers ​

These coroutine-friendly helpers for sleeping, debouncing, key management, and aim control are available in every script. sleep, wait_*, hold_key, hold_ability, aim_at_target and fire_and_hold yield, so call them only from on_tick. In an event callback or a timer they raise an error.

Timing and debugging ​

FunctionReturnsDescription
sleep(ms)-Yield for approximately ms milliseconds
debounce(key, cooldown_ms)booleanReturn true at most once per cooldown_ms window for a given key. Keys are shared by all scripts, so make yours unique, for example by prefixing the script name
dump(value, [depth])stringAny value as readable text. Tables come out one key per line, sorted, nested up to depth levels (default 6). A table that contains itself is marked instead of looping
lua
print(dump(local_player():get_scoreboard()))
lua
function on_tick()
    if not debounce("my_feature", 500) then return end
    -- runs at most twice per second
end

Key and Ability Shortcuts ​

FunctionReturnsDescription
press_ability(slot)-Press (down + up) the key bound to the given ability slot
hold_key(key, duration?)-Hold a VK key for duration ms then release. Default 500 ms
hold_ability(slot, duration?)-Hold the key bound to an ability slot. Default 500 ms
lua
press_ability(slot.ability1)     -- tap ability 1
hold_key(VK.SPACE, 200)          -- hold Space for 200 ms
hold_ability(slot.ability3, 300) -- hold ability 3 for 300 ms

Aim Helpers ​

FunctionReturnsDescription
snap_to_target(target, opts?)booleanMove aim toward target with input.snap_to. Each call moves part of the way, so call it every tick. Returns false if target is invalid or dead
aim_at_target(target, opts?)booleanSmooth aim at target until within threshold FOV or timeout. Returns true on success, and false at the timeout or as soon as target:is_targetable() is false, for example behind a wall
lock_aim(target, opts?)-Aim at target every tick, after on_tick, until unlock_aim() or the target dies. All scripts share one lock. A lock_aim or unlock_aim from any script replaces it, and it moves the mouse once after each running script's on_tick. Turning your script off does not clear it, so call unlock_aim() in on_disable
unlock_aim()-Cancel any active aim lock
fire_and_hold(target, slot, opts?)-Press ability then continuously aim at target for hold_time ms

opts table for aim helpers:

KeyTypeDefaultNotes
bonestring | integerspine_1 (index 4) for snap_to_target, aim_at_target and lock_aim. bone.chest for fire_and_holdBone to aim at
projectile_velocitynumber0Projectile speed for lead compensation
hold_timenumber200fire_and_hold only. Milliseconds to keep aiming after pressing
speednumber0.5aim_at_target and lock_aim. Smoothing speed passed to input.move_mouse
thresholdnumber0.5aim_at_target only. Success FOV in degrees
timeoutnumber2000aim_at_target only. Maximum ms before returning false

To soften a continuous lock, set speed on lock_aim instead of calling aim_at_target in a loop:

lua
lock_aim(target, { bone = bone.head, speed = 0.2 })   -- slow, sticky tracking

snap_to_target and fire_and_hold ignore speed. They aim through input.snap_to, which does its own smoothing.

lua
-- Aim, and fire once the crosshair is on the head
if snap_to_target(target, { bone = bone.head })
    and targeting.get_fov(target, bone.head) < 1.0 then
    press_ability(slot.ability1)
end

-- Smooth aim with timeout
if aim_at_target(target, { speed = 0.3, threshold = 1.5, timeout = 500 }) then
    left_click()
end

-- Fire then hold aim on the target
fire_and_hold(target, slot.ability2, { hold_time = 300, bone = bone.chest })

-- Continuous tracking per tick
lock_aim(target, { bone = bone.chest })
-- later, when done:
unlock_aim()

Wait Helpers ​

All wait_* helpers yield the coroutine until a condition is met, then return true. They return false if the timeout elapses first (default 3000 ms).

FunctionReturnsDescription
wait_for_cast_delay(slot, timeout?)booleanYield until the ability is out of its cast delay. Returns true at once if it is not in one, so give a press time to reach the game first
wait_for_cooling_down(slot, timeout?)booleanYield until the ability has STARTED cooling down (is_cooling_down becomes true). Returns true at that point, not when the cooldown finishes
wait_until_in_range(target, distance, timeout?)booleanYield until target is within distance metres. Returns false early if the target dies
lua
function on_tick()
    press_ability(slot.ability1)
    sleep(100)                                                       -- give the press time to reach the game
    if not wait_for_cast_delay(slot.ability1, 1000) then return end  -- wait up to 1s
    -- cast delay finished, ability is active
    if not wait_for_cooling_down(slot.ability1, 5000) then return end -- wait for CD
    -- the cooldown has started
end

Targeting Helper ​

FunctionReturnsDescription
find_closest_by_predicate(predicate, max_dist)player | nilFind the closest enemy within max_dist metres that satisfies predicate(player) == true
lua
-- Find the closest enemy that has a specific item
local target = find_closest_by_predicate(function(p)
    return p:has_item("Phantom Strike") and p:is_alive()
end, 30.0)