Globals
Top-level functions and small namespaces (storage) callable from any script. These don't need a prefix beyond the namespace name.
Four more namespaces live elsewhere: entities in Entities, and aim, trace and internal in the Internal API Reference.
Player Access
| Function | Returns | Description |
|---|---|---|
local_player() | player | The local player object |
get_players() | table | All enemy players. Teammates are always available via get_teammates() |
get_all_players() | table | All valid players except the local player |
get_teammates() | table | Teammate players only |
local me = local_player()
for _, p in ipairs(get_players()) do
if p:is_enemy() and p:is_alive() then
-- ...
end
endWorld Entities
| Function | Returns | Description |
|---|---|---|
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:
| Field | Type | Description |
|---|---|---|
type | string | Coarse type name ("sinner", "tower", "trooper_boss", "unknown", ...) |
class_name | string | Exact engine class name |
x, y, z | number | World position |
health, max_health | number | Current and maximum health |
team | integer | Team id |
distance | number | Distance from the local player, in metres (already converted) |
subclass | integer | m_nSubclassID. Separates subtypes that share one class_name, e.g. golden statue vs soul crate |
currency_value | integer | Soul value of the pickup; 0 for non-soul rows |
active | boolean | Whether the pickup is available (false while on respawn cooldown) |
pickup_name | string | Name of the powerup. Key is absent (nil) when empty, so test if e.pickup_name then |
-- Print all sinners (neutral soul-denying enemies)
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)
endTime
| Function | Returns | Description |
|---|---|---|
clock() | number | High-resolution time in seconds. Use for deltatime and debouncing |
game_time() | number | Match clock time in seconds (pause-aware HUD timer) |
get_match_time() | number | Alias for game_time() |
is_paused() | boolean | Match clock is frozen (a team pause, or the 3s unpause countdown) |
sim_time() | number | Raw client SimTime. Keeps advancing through pauses and pre-game |
game.tick_count() | integer or nil | The game's current tick number. nil, never 0, until the game clock is running |
game.latency() | number or nil | Network latency in milliseconds. nil until a source serves it |
clock() is wall-clock monotonic seconds since the engine started. game_time() (and its alias get_match_time()) advances with the match clock and freezes when the game is paused.
sim_time() is distinct from both. It is the raw client SimTime and keeps running through pauses and pre-game. game_time() freezes whenever is_paused() is true.
Do not diff absolute timestamps against sim_time()
The game's absolute timestamps are in the match-clock domain: cast_delay_start, cooldown_start / cooldown_end, channel_start, cast_completed, and buff expires_at. They freeze during pauses, so subtracting them from sim_time() over-counts by all the accumulated pause and pre-game time. In a real paused match that error measured about 126 seconds.
For "how much is left", use the pre-computed fields ability.cooldown and buff.remaining. Both are already pause-correct. Use sim_time() only for deltas between two sim_time() samples.
-- 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() - startedgame.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.latency() returns nil until a source serves it, and none does yet.
Output
| Function | Returns | Description |
|---|---|---|
print(...) | - | Output to the app console, the Windows console, and the client's tsuki.log. Prefixed [script_name] in the app console and the log file; [Lua:script_name] on the Windows console. Arguments are stringified and tab-joined, as in stock Lua |
toast(text, [duration], [style]) | - | Centered HUD toast. Optional per-call styling. See below |
notify(text, [duration], [color]) | - | Persistent notification. Default duration 3.0, color white |
play_sound(filename) | - | Play a WAV file from assets\sounds\. Pass the filename only, no path separators |
toast("ready")
notify("warning", 5.0)
play_sound("alert.wav") -- plays assets\sounds\alert.wavprint also reaches the log file, so script output survives the session. After the fact you can tell "the script isn't running" from "the script is running quietly". Log lines look like [2026-08-23 21:04:11.882] [LUA] [my_script] ready.
toast(text, [duration], [style])
A centered HUD toast notification.
duration, seconds to display. Pass0to use the global default from menu settings.style, optional table with per-call overrides. Any key omitted falls back to the global defaults configured in the menu:
| Key | Type | Notes |
|---|---|---|
bg | {r, g, b, a} | Background fill |
text | {r, g, b, a} | Text color |
outline | {r, g, b, a} | Outline / drop-shadow color |
scale | number | Size multiplier (1.0 = default) |
Toasts deduplicate by text only. If the same string is currently showing or already queued, the call is dropped on the floor, even if you passed a different style table. This means it's safe to fire toast("X") inside on_tick without flooding the screen; a tick-rate spam of the same message renders the original once.
toast("Hit", 2, {bg = draw.color(255,0,0)}) -- shows
toast("Hit", 2, {bg = draw.color(0,255,0)}) -- DEDUPED (same text)
toast("Hit!", 2) -- shows (different text)Different toasts with different text aren't deduplicated against each other, only repeats of the same string are suppressed, so a stream of "Hit X", "Hit Y", "Hit Z" all appear. The queue holds at most 5 pending toasts at a time.
toast("Target locked", 2, {
bg = {r = 40, g = 15, b = 50, a = 220},
text = {r = 200, g = 140, b = 255},
scale = 1.2
})Demo script - each button triggers a different style preset:
-- Toast Style Demo
-- Click buttons to see different toast styles
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 persistent notification. Default duration 3.0, color white.
colormust be a packed integer - whatdraw.color(r, g, b, [a])returns.- Unlike
toast,notifydoes not accept an{r, g, b, a}table. A table, or a float such as3.0, is silently ignored: the notification falls back to white, and nothing is raised.
notify("boss up", 5.0, draw.color(255, 80, 80)) -- correct
notify("boss up", 5.0, { r = 255, g = 80, b = 80 }) -- ignored, shows whiteInput shortcuts
| Function | Returns | Description |
|---|---|---|
slot_to_key(slot) | integer | VK 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) | integer | VK 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 |
right_click() | - | Simulate a right mouse click |
For finer-grained input control (key down, key up, mouse movement), see the Input namespace.
Game state
is_position_visible(pos)
Performs a BVH raycast from the camera to a world-space position. Returns true if the path is unobstructed (the point is visible from the camera).
local pos = some_player:get_position()
if is_position_visible(pos) then
-- position has line-of-sight from camera
endThis is a point-in-world check, not a player-visibility check. For per-player visibility 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.
if is_in_menu() then return endAll input actions (key presses, mouse movement) from Lua scripts are automatically blocked while in menu. You don't need to check is_in_menu() for safety, it's handled internally. Use this 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).
if not is_game_focused() then return endUse this to skip logic when the player has alt-tabbed or is in another window. Unlike is_in_menu(), this detects when the game itself isn't the active window.
Spectators
The game namespace exposes who is currently spectating the match.
| Function | Returns | Description |
|---|---|---|
game.get_spectators() | table | Array of spectator name strings currently watching the match |
game.get_spectator_count() | integer | Number of spectators, without building the name table |
if game.get_spectator_count() > 0 then
local specs = game.get_spectators()
print(#specs .. " spectators: " .. table.concat(specs, ", "))
endTsuki Config
Read/write TSUKI's own settings (ESP toggles, aimbot FOV, etc.).
| Function | Returns | Description |
|---|---|---|
tsuki.get_bool(key, default) | boolean | Read a boolean setting |
tsuki.get_int(key, default) | integer | Read an integer setting |
tsuki.get_float(key, default) | number | Read 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) | Color | Read a color {r,g,b,a} (0-255) |
tsuki.set_color(key, value) | - | Write a color {r,g,b,a} (0-255) |
-- 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 0-255); get_color/set_color use a matching {r,g,b,a} table:
-- 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
endset_color writes the LIVE setting
set_color changes the user's actual saved value and it persists to their config. For a temporary or per-modifier tint, cache the original with get_color and restore it when the condition clears - otherwise the tint can be saved permanently if the game closes while it is active.
Setting keys match the IDs TSUKI uses internally. For new script-defined settings, prefer the UI namespace - it creates a labeled widget and persists its value automatically.
The API registry
Every registered name, in a table your script can read.
| Function | Returns | Description |
|---|---|---|
tsuki.api() | table | An array of {name, kind, host, needs_internal, since}, one entry per registered name |
tsuki.api_info(name) | table or nil | One entry, or nil when nothing matches exactly |
tsuki.reader_diag() | table or nil | {tick_avg_ms, tick_max_ms, ticks} for TSUKI's own loop. nil before the first measurement |
The fields on an entry:
| Field | Notes |
|---|---|
name | Namespaced functions are ns.fn, player methods are p:method, callbacks and globals are bare |
kind | One of function, method, global, callback or table |
host | external or internal |
needs_internal | Whether your script_info has to declare the flag |
since | The build the name arrived in |
The table is built from the live registrations, so tsuki.api_info is the reliable way to check whether a call exists on this build.
The name must match exactly, prefix included: "p:get_stats", not "get_stats". tsuki.api() builds a fresh table each call; do not call it every tick.
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. They still work.
tsuki.reader_diag() reports TSUKI's own tick timing. A script that pushes the average up is doing too much per tick.
For what the host and needs_internal tags mean, see the Internal API Reference.
Cross-Script Modules
Scripts can share code by using 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.
| Function | Returns | Description |
|---|---|---|
export(name, module_table) | - | Register a table under name so other scripts can import it. Call at top level |
import(name) | table | nil | Fetch 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 mechanism for sharing code between scripts.
The sandbox loads six standard libraries: _G (base), math, string, table, coroutine, utf8. There is no os, io, or debug. Some ported library code reaches for rawget/rawset for metatable-safe access, or for collectgarbage("count") to measure memory. Edit those calls out before it will run here.
-- mylib.lua - the library script
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,
})-- consumer.lua
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(some_value, 0, 100)
endStorage
Persistent key-value storage scoped per script. Values survive script reloads, game restarts, and PC restarts. Each script gets its own isolated storage at scripts\storage\<script_name>.json; keys from script A are invisible to script B.
| Function | Returns | Description |
|---|---|---|
storage.set(key, value) | - | Save a value under a key. Passing nil as the value deletes the key. |
storage.get(key) | any | nil | Load a saved value, or nil if not set |
storage.keys() | table | List 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 fields individually or encode them as a string.
Numeric strings do not round-trip
The type check asks "is this a number?" before "is this a string?". In Lua that is true for any string convertible to a number. storage.set("k", "42") stores the number 42, and storage.get("k") returns 42, not "42" (same for "3.14", "1e3", "0x1F"). If a value must stay a string, give it a non-numeric prefix.
Writes are cached in memory and flushed to disk every ~5 seconds while the engine is ticking. One more flush runs when the Lua VM shuts down (app exit or a script reload). There is no immediate flush on each storage.set() call, 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. While it sits in the cache it is filed under whichever script ticked last, not yours. Persist state as it changes instead.
-- Track cumulative kills across game sessions
function on_kill(player)
if 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 (no json global exists)
storage.set("loadout_slot1", "burst_fire")
storage.set("loadout_slot2", "trooper_bounty")
local slot1 = storage.get("loadout_slot1")
-- Delete a key
storage.set("old_data", nil)Helpers
Coroutine-friendly helpers available in every script covering common patterns: sleeping, debouncing, key management, and aim control. All timing helpers (sleep, wait_*) yield the coroutine and are only usable inside coroutine callbacks (e.g. on_tick).
Timing
| Function | Returns | Description |
|---|---|---|
sleep(ms) | - | Yield for approximately ms milliseconds |
debounce(key, cooldown_ms) | boolean | Return true at most once per cooldown_ms window for a given key |
function on_tick()
if not debounce("my_feature", 500) then return end
-- runs at most twice per second
endKey and Ability Shortcuts
| Function | Returns | Description |
|---|---|---|
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 |
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 msAim Helpers
| Function | Returns | Description |
|---|---|---|
snap_to_target(target, opts?) | boolean | Flick aim to target using input.snap_to. Returns false if target is invalid or dead |
aim_at_target(target, opts?) | boolean | Smooth aim at target until within threshold FOV or timeout. Returns true on success |
lock_aim(target, opts?) | - | Start continuously aiming at target every tick (processed after on_tick) |
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:
| Key | Type | Default | Notes |
|---|---|---|---|
bone | string | integer | spine_1 (index 4) for snap_to_target/aim_at_target; bone.chest for fire_and_hold | Bone to aim at |
projectile_velocity | number | 0 | Projectile speed for lead compensation |
hold_time | number | 200 | fire_and_hold only - ms to keep aiming after pressing |
speed | number | 0.5 | aim_at_target and lock_aim - smoothing speed handed to input.move_mouse |
threshold | number | 0.5 | aim_at_target only - success FOV in degrees |
timeout | number | 2000 | aim_at_target only - max ms before returning false |
lock_aim reads the same speed key with the same 0.5 default. Soften a continuous lock directly; you do not need to hand-roll aim_at_target in a loop:
lock_aim(target, { bone = bone.head, speed = 0.2 }) -- slow, sticky trackingsnap_to_target and fire_and_hold ignore speed; they flick through input.snap_to, which does its own smoothing.
-- Flick and fire
if snap_to_target(target, { bone = bone.head }) 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).
| Function | Returns | Description |
|---|---|---|
wait_for_cast_delay(slot, timeout?) | boolean | Yield until the ability's cast delay finishes |
wait_for_cooling_down(slot, timeout?) | boolean | Yield 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?) | boolean | Yield until target is within distance metres |
function on_tick()
press_ability(slot.ability1)
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
-- ready again
endTargeting Helper
| Function | Returns | Description |
|---|---|---|
find_closest_by_predicate(predicate, max_dist) | player | nil | Find the closest enemy within max_dist metres that satisfies predicate(player) == true |
-- 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)