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))
end

Smooth 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)
end

Timed 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)
end

Timers 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")
end

State that survives

WhereKept acrossUse for
local variableFrames only. Reset on reload.Animation, counters for this session.
ui.* settingReloads and restarts. The user can change it.Options the user picks.
dbReloads and restarts.Small data: best score, last preset, lists. 256 KB.
filesReloads 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_image call makes a new texture and counts toward the 16-image limit.
  • Measure text once when it does not change: keep the draw.text_size result 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.intel already caches; keep http.get results in a variable or db.
  • 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
end

Spread 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