Lua · Useful information
Animation and caching
Patterns that make scripts smooth at any FPS and cheap to run.
Same speed at any FPS
on_frame runs 60 times a second on one PC and 300 on another. Multiply movement by globals.frametime(), the seconds the last frame took.
move.lua
local x = 0
function on_frame()
x = (x + 200 * globals.frametime()) % 800
draw.circle_filled(100 + x, 200, 6, color(255, 255, 255))
endSmooth follow
To glide a value towards a target, use exponential smoothing. The number (12 here) is speed: higher is snappier. It feels the same at every FPS.
smooth.lua
local shown = 0
local function approach(cur, target, speed)
return math.lerp(cur, target, 1 - math.exp(-speed * globals.frametime()))
end
function on_frame()
local me = game.local_player()
local target = me and me.health or 0
shown = approach(shown, target, 12)
draw.rect_filled(40, 400, 2 * shown, 6, color(110, 220, 140), 3)
endTimed animation with easing
For an animation with a start and an end, keep the start time, turn the age into t from 0 to 1, and shape it with an easing function.
ease.lua
local function ease_out(t) return 1 - (1 - t) ^ 3 end
local function ease_in_out(t) return t < 0.5 and 4 * t ^ 3 or 1 - (-2 * t + 2) ^ 3 / 2 end
local started = nil
function on_kill() started = globals.time() end
function on_frame()
if not started then return end
local t = (globals.time() - started) / 0.6
if t >= 1 then started = nil return end
local y = 300 - 40 * ease_out(t)
local a = 255 * (1 - ease_in_out(t))
draw.text(40, y, "KILL", color.alpha(color(255, 255, 255), a), 24)
endTimers and cooldowns
timers.lua
local next_at = 0
function on_tick()
local now = globals.time()
if now < next_at then return end
next_at = now + 5
print("every 5 seconds")
endState that survives
| Where | Kept across | Use for |
|---|---|---|
local variable | Frames only. Reset on reload. | Animation, counters for this session. |
ui.* setting | Reloads and restarts. The user can change it. | Options the user picks. |
db | Reloads and restarts. | Small data: best score, last preset, lists. 256 KB. |
files | Reloads and restarts. | Bigger text: logs, CSV, JSON exports. 1 MB a file. |
Caching
The 8 ms budget is per callback, but every millisecond you use is a millisecond less FPS. Do slow work once and keep the result.
- Build colours, tables and fonts once at the top, not inside
on_frame. - Load images once. Every
load_imagecall makes a new texture and counts toward the 16-image limit. - Measure text once when it does not change: keep the
draw.text_sizeresult next to the text. - Do slow logic on a timer, not every frame: wallbang checks for 10 players twice a second are plenty.
- Cache network answers.
players.intelalready caches; keephttp.getresults in a variable ordb. - Avoid creating tables every frame in hot loops; reuse them. Fewer new tables means less garbage collection.
- Watch your frame time on the script's page. Under 0.3 ms is a good goal.
cache.lua
local cache = {}
local next_scan = 0
function on_frame()
local now = globals.time()
if now >= next_scan and map.ready() then
next_scan = now + 0.5
cache = {}
local me = entity.local_player()
if me and me.bones then
for _, p in ipairs(entity.players()) do
if p.enemy and p.bones then
cache[p.slot] = map.penetrate(me.bones.head, p.bones.chest).damage
end
end
end
end
for slot, dmg in pairs(cache) do
if dmg >= 1 then esp.add_text(slot, math.floor(dmg) .. " dmg") end
end
endSpread work over frames
Big jobs can be split with a coroutine that yields every few steps; resume it once a frame.
spread.lua
local job = coroutine.create(function()
local total = 0
for i = 1, 200000 do
total = total + i
if i % 5000 == 0 then coroutine.yield() end
end
print("done", total)
end)
function on_frame()
if coroutine.status(job) ~= "dead" then coroutine.resume(job) end
end