starlit

Examples

Complete scripts: an enemy overlay, hit markers with a kill feed, a small resolver, and five scripts that combine the rage, settings, movement, visuals, 3D drawing and menu modules.

Copy a script into your scripts folder under the name in its first line, then load it from the scripts page. Every call in them is documented in the reference.

How we tested them

The test suite of this site runs each script against a stand-in for the host. The stand-in follows the rules of this reference: argument types and ranges, the caps, the fixed error texts, draw calls only inside on_paint, and handles that go stale after one frame. The helpers vec3, color, bit and the math extras run as they run in the host. A script that breaks one of the rules fails the test.

The first three scripts have run against a simulated match, not in a live game. The numbers below come from that simulation and say nothing about a real server.

The five scripts after them, from Peek helper on, combine the newer modules. The test suite parses each of them as Lua 5.4. They are written against the reference pages and call only functions that exist, but they have not run against the stand-in host.

Enemy overlay

Boxes, names, health bars and distances for enemy players. All of it is switched from the scripts page. The Gate group has a keybind: with no key chosen the overlay is always on, with a key it shows only while the key is held.

How it works:

  • entity.players(true) returns the living enemies. The handles are used inside the same frame and never stored.
  • screen_box projects the feet and a point 6 units above the eyes with game.to_screen, and makes the box 0.45 times as wide as it is tall. It returns nothing when either point is behind the camera.
  • The health bar colour comes from color(...):lerp(...). The result is passed to draw.rect_filled as a colour object.
  • vec3:dist measures the distance to the local player.

With five enemies on the screen the script records 25 draw commands per frame: two outlines, two bar rectangles and one name for each. The cap is 2000.

-- enemy_overlay.lua
-- Boxes, names, health bars and distances for enemy players.

local look = ui.group("Look")
local use_box = look:checkbox("boxes", true)
local use_name = look:checkbox("names", true)
local use_health = look:checkbox("health bars", true)
local use_dist = look:checkbox("distance", false)
local tint = look:color("box colour", 190, 170, 255)
local max_dist = look:slider("max distance", 300, 5000, 3000)

local gate = ui.group("Gate")
local only_key = gate:keybind("show only while held", "hold")
gate:label("leave the key unbound to always show")

-- with no key chosen the overlay is always on; with a key it follows the key
local function wanted()
  local active, key = only_key:get()
  return key == 0 or active
end

-- the screen rectangle of a standing player: x, y, w, h, or nil when he is not on the screen
local function screen_box(ent, fx, fy, fz)
  local hx, hy, hz = ent:eye_position()
  if not hx then return nil end
  local bx, by = game.to_screen(fx, fy, fz)
  local tx, ty = game.to_screen(hx, hy, hz + 6)
  if not bx or not tx then return nil end
  local h = by - ty
  if h < 6 then return nil end
  local w = h * 0.45
  return tx - w / 2, ty, w, h
end

events.on_paint(function()
  if not wanted() then return end
  local me = entity.local_player()
  if not me then return end
  local mx, my, mz = me:origin()
  if not mx then return end
  local here = vec3(mx, my, mz)
  local r, g, b = tint:get()
  local limit = max_dist:get()

  for _, ent in ipairs(entity.players(true)) do
    local fx, fy, fz = ent:origin()
    if fx then
      local dist = here:dist(vec3(fx, fy, fz))
      local x, y, w, h = screen_box(ent, fx, fy, fz)
      if x and dist <= limit then
        if use_box:get() then
          draw.rect(x - 1, y - 1, w + 2, h + 2, 0, 0, 0, 170)
          draw.rect(x, y, w, h, r, g, b, 255)
        end
        if use_health:get() then
          local frac = math.clamp((ent:get("health") or 0) / 100, 0, 1)
          local fill = color(255, 70, 70):lerp(color(90, 255, 120), frac)
          draw.rect_filled(x - 6, y, 3, h, 0, 0, 0, 170)
          draw.rect_filled(x - 6, y + h * (1 - frac), 3, h * frac, fill)
        end
        if use_name:get() then
          local name = ent:name() or "?"
          local tw = draw.measure(name)
          draw.text(x + w / 2 - tw / 2, y - 15, name, 255, 255, 255, 255, "ui", "shadow")
        end
        if use_dist:get() then
          draw.text(x, y + h + 2, string.format("%.0f", dist), r, g, b, 255, "small")
        end
      end
    end
  end
end)

Hit markers and kill feed

A fading cross in the middle of the screen with the damage you dealt, red for a headshot, and a feed of kills in the top right corner. The handlers only store what happened. on_paint draws it.

How it works:

  • events.on_player_hurt reacts only when the attacker is the local player and the victim is somebody else.
  • Ages are measured with time.now(), which counts real seconds. The marker lasts one second. Feed lines last as long as the seconds shown slider says.
  • entity.get(slot) turns the slots of an event into names. Slot 0 is printed as world, a slot that is no longer in the match as ?.
  • A kill through a wall (e.penetrated > 0) gets [wall], a headshot [head].
  • The feed panel is a rounded rectangle, and its text is drawn inside draw.clip, so a long name cannot leave the panel.
  • A new round and a new level clear both lists.
-- hit_feed.lua
-- A hit marker with the damage number, and a small kill feed.

local group = ui.group("Hit feedback")
local use_marker = group:checkbox("hit marker", true)
local use_feed = group:checkbox("kill feed", true)
local feed_lines = group:slider("feed lines", 2, 8, 5)
local feed_secs = group:slider("seconds shown", 3, 15, 8)

local marks = {} -- { born, damage, head }
local feed = {}  -- { born, text }

local function name_of(slot)
  if slot == 0 then return "world" end
  local ent = entity.get(slot)
  return ent and ent:name() or "?"
end

events.on_player_hurt(function(e)
  local me = entity.local_player()
  if not me or e.attacker ~= me:index() or e.userid == e.attacker then return end
  marks[#marks + 1] = { born = time.now(), damage = e.damage, head = e.hitgroup == 1 }
  if #marks > 6 then table.remove(marks, 1) end
end)

events.on_player_death(function(e)
  local how = e.headshot and " [head] " or " > "
  if e.penetrated > 0 then how = how .. "[wall] " end
  feed[#feed + 1] = { born = time.now(), text = name_of(e.attacker) .. how .. name_of(e.userid) }
  if #feed > 8 then table.remove(feed, 1) end
end)

local function reset() marks, feed = {}, {} end
events.on_round_start(reset)
events.on_level(reset)

events.on_paint(function()
  local now = time.now()
  local sw, sh = screen.size()

  if use_marker:get() then
    for i = #marks, 1, -1 do
      local m = marks[i]
      local age = now - m.born
      if age > 1 then
        table.remove(marks, i)
      else
        local a = math.floor(255 * (1 - age))
        local cx, cy = sw / 2, sh / 2
        local c = m.head and color(255, 80, 80, a) or color(255, 255, 255, a)
        draw.line(cx - 9, cy - 9, cx - 4, cy - 4, c, 2)
        draw.line(cx + 9, cy - 9, cx + 4, cy - 4, c, 2)
        draw.line(cx - 9, cy + 9, cx - 4, cy + 4, c, 2)
        draw.line(cx + 9, cy + 9, cx + 4, cy + 4, c, 2)
        draw.text(cx + 14, cy - 22 - age * 14, "-" .. m.damage, c, "bold", "shadow")
      end
    end
  end

  if use_feed:get() then
    local keep = feed_secs:get()
    while #feed > 0 and now - feed[1].born > keep do table.remove(feed, 1) end
    local first = math.max(1, #feed - feed_lines:get() + 1)
    local shown = #feed - first + 1
    if shown > 0 then
      local w, line = 260, 16
      local x, y = sw - w - 12, 60
      draw.rect_rounded_filled(x, y, w, shown * line + 8, 4, 20, 20, 28, 200)
      draw.clip(x, y, w, shown * line + 8)
      for i = first, #feed do
        draw.text(x + 8, y + 4 + (i - first) * line, feed[i].text, 235, 235, 240, 255, "ui", "shadow")
      end
      draw.unclip()
    end
  end
end)

A small resolver

One yaw hint for every enemy. The script keeps a list of candidates, 0, 58, -58, 29, -29, and starts every enemy on the first. When three shots in a row at one enemy miss, it moves that enemy to the next candidate. A hit keeps the candidate and resets the count.

How it works:

  • It holds a hint of 0 and does not clear it. The ragebot reports shots only for enemies that carry a hint, and the misses at 0 are the evidence the script needs. See Results.
  • on_paint sets the hint of every living enemy each frame. A hint dies two seconds after the last call, so the call has to repeat.
  • events.on_shot ignores results whose hint differs from the candidate the script holds now, because a result arrives some frames after the shot.
  • An enemy that dies loses the hint but not the state. When he comes back he starts from what was learned. A new name in the slot starts from the first candidate.
  • events.on_level and events.on_unload release the hints.
-- small_resolver.lua
-- One yaw hint for every enemy. It starts at 0 and moves to the next candidate
-- only after several misses in a row, judged by the results of the shots.

local enabled = ui.checkbox("resolver", true)
local show = ui.checkbox("show state", false)

local STEPS = { 0, 58, -58, 29, -29 } -- the candidates, in the order we try them
local MISSES = 3                      -- misses in a row before we try the next one

local enemies = {} -- [slot] = { name, step, run, shots, hits }

local function state_of(ent)
  local slot, name = ent:index(), ent:name()
  local s = enemies[slot]
  if not s or s.name ~= name then -- another player in this slot starts from the first candidate
    s = { name = name, step = 1, run = 0, shots = 0, hits = 0 }
    enemies[slot] = s
  end
  return s
end

events.on_shot(function(shot)
  local s = enemies[shot.target]
  -- a result of an older hint, or of an enemy we do not track, says nothing about the current one
  if not s or math.abs(shot.hint - STEPS[s.step]) > 0.5 then return end
  s.shots = s.shots + 1
  if shot.hit then
    s.hits, s.run = s.hits + 1, 0
  else
    s.run = s.run + 1
    if s.run >= MISSES then
      s.step, s.run = s.step % #STEPS + 1, 0
    end
  end
end)

local function release()
  for slot in pairs(enemies) do resolver.clear(slot) end
end

events.on_paint(function()
  if not enabled:get() then
    release()
    return
  end

  -- a hint of 0 is still a hint: the ragebot reports shots only for hinted enemies
  local alive = {}
  for _, ent in ipairs(entity.players(true)) do
    alive[ent:index()] = true
    resolver.set_yaw(ent:index(), STEPS[state_of(ent).step])
  end
  for slot in pairs(enemies) do
    if not alive[slot] then resolver.clear(slot) end -- dead or gone: drop the hint, keep what was learned
  end

  if show:get() then
    local slots = {}
    for slot in pairs(enemies) do slots[#slots + 1] = slot end
    table.sort(slots)
    for i, slot in ipairs(slots) do
      local s = enemies[slot]
      local text = string.format("%d  %s  hint %d  %d of %d", slot, s.name, STEPS[s.step], s.hits, s.shots)
      draw.text(20, 300 + (i - 1) * 13, text, 255, 255, 255, 255, "small")
    end
  end
end)

events.on_level(function()
  release()
  enemies = {}
end)
events.on_unload(release)

What the simulation shows

The stand-in ragebot fires at every hinted enemy every 8 frames and reports the result 12 frames later. It scores a shot as a head hit when the hint is within 10 degrees of the real pose, as a body hit within 25 degrees, and as a miss otherwise. Each enemy gets 80 shots.

real pose turned byhint it ends onsettled at shothead hits in 80 shots
+58+58576
-29-291764
00180
-58-58972
+45+58never0

"Settled at shot" is the number of the first head hit after the last miss. The enemy that needs no correction stays on 0 and loses no shot. The enemy turned by +45 degrees ends on +58, which is 13 degrees away: every shot is a body hit and none is a head hit.

The script is small on purpose. It cannot tell a wrong hint from bad luck: three misses in a row for any reason move the hint. Raise MISSES to make it slower to move. A pose that lies between two candidates is a case it does not solve.

Peek helper

Fire discipline for a peek: a group on the rage page with a peek key, a fire gate and a lower hit chance in the air. While the key is held the player steps ahead, and the ragebot's shot is held back until the player is slow enough.

How it works:

  • ui.group with page = "rage" puts the controls on the rage page of the menu, in the right column.
  • me:get("on_ground") and me:get("speed") come from the frame snapshot and cost nothing extra.
  • rage.override_hitchance(nil) gives the user's number back, so the call is safe to make every frame.
  • rage.hold_fire(true) is called only while the player is faster than the limit. The call lives 500 ms, so a frame without it releases the hold.
  • movement.intent is called with a short ttl, so the step ends as soon as the key is released.
-- peek_helper.lua
-- A peek key that steps ahead, holds the ragebot's shot until you are slow, and relaxes the hit chance in the air.

local grp   = ui.group("Peek helper", { page = "rage", column = "right", order = 5 })
local key   = grp:keybind("peek key", "hold")
local gate  = grp:checkbox("hold fire until slow", true)
local limit = grp:slider("speed limit", 5, 250, 40)
local air   = grp:slider("hit chance in the air", 1, 100, 45)

events.on_paint(function()
  local me = entity.local_player()
  if not me or not me:is_alive() then return end

  local airborne = not me:get("on_ground")
  rage.override_hitchance(airborne and air:get() or nil)

  if gate:get() and (me:get("speed") or 0) > limit:get() then
    rage.hold_fire(true)
  end

  local active, bound = key:get()
  if bound ~= 0 and active then
    movement.intent{ forward = 1, ttl = 2 }
  end
end)

Weather

A breathing fog and an optional sky tint that follow the clock. The script reads the user's own fog density with settings.base and swings around it, so the fog still looks like the user's choice.

How it works:

  • time.now() times the animation, and the speed slider scales it.
  • The fog colour is a blend of two colours from the menu, made with color(...):lerp(...). The result is passed to settings.override as a colour object.
  • The density swings around settings.base("world.fog.density") and is clamped to 0 to 1.
  • All overrides are repeated every frame. When the checkbox is switched off the script stops calling, and within 500 ms the fog is the user's again.
-- weather.lua
-- A breathing fog and an optional sky tint, driven by the clock.

local grp   = ui.group("Weather", { page = "visuals", column = "right", order = 20 })
local on    = grp:checkbox("animate fog", true)
local sky   = grp:checkbox("tint the sky", false)
local speed = grp:slider("speed", 0.1, 3.0, 0.6)
local swing = grp:slider("density swing", 0.0, 0.5, 0.15)
local tint  = grp:color("fog tint", 150, 170, 255)
local alt   = grp:color("second tint", 255, 190, 150)

events.on_paint(function()
  if not on:get() then return end

  local t = time.now() * speed:get()
  local k = 0.5 + 0.5 * math.sin(t)
  local mix = color(tint:get()):lerp(color(alt:get()), k)

  local density = settings.base("world.fog.density") + swing:get() * math.sin(t * 0.5)
  settings.override("world.fog.enabled", true)
  settings.override("world.fog.color", mix)
  settings.override("world.fog.density", math.clamp(density, 0, 1))

  if sky:get() then
    settings.override("world.sky.color_enabled", true)
    settings.override("world.sky.color", mix)
  end
end)

ESP by health

The enemy box blends from one colour to another as health drops, the health bar takes the same two colours, a low-health enemy gets an hp line above the name, and every enemy gets an armor bar on the left.

How it works:

  • visuals.player(slot) gives a handle for each living enemy. The handle is used in the same frame.
  • c:lerp(o, t) blends the colours and c:unpack() expands to r, g, b, a, which is what v:color takes. In the last position of a call it expands to all four numbers.
  • v:color("health_full", ...) and v:color("health_low", ...) set the two ends of the health bar.
  • v:bar("left", fraction, ...) adds a thin bar beside the box. fraction is 0 to 1.
  • Nothing is stored. When the script stops, the ESP is the user's own within two frames.
-- esp_health.lua
-- Box and bar colours follow the enemy's health, with an hp line and an armor bar.

local grp   = ui.group("ESP by health", { page = "visuals", column = "right", order = 21 })
local boxes = grp:checkbox("colours follow health", true)
local text  = grp:checkbox("hp line when hurt", true)
local armor = grp:checkbox("armor bar", true)
local full  = grp:color("full health", 120, 255, 140)
local low   = grp:color("low health", 255, 70, 70)

events.on_paint(function()
  local c_full, c_low = color(full:get()), color(low:get())
  for _, p in ipairs(entity.players(true)) do
    local v = visuals.player(p:index())
    if v then
      local hp = math.clamp((p:get("health") or 100) / 100, 0, 1)
      if boxes:get() then
        v:color("box", c_low:lerp(c_full, hp):unpack())
        v:color("health_full", c_full:unpack())
        v:color("health_low", c_low:unpack())
      end
      if text:get() and hp < 0.5 then
        v:text("top", math.floor(hp * 100) .. " hp", c_low:unpack())
      end
      if armor:get() then
        v:bar("left", (p:get("armor") or 0) / 100, 90, 160, 255)
      end
    end
  end
end)

Damage graph tab

A tab called stats with a live panel. Every hit the local player lands becomes a bar, red for a headshot and blue for the body. Moving the pointer over a bar prints the damage.

How it works:

  • ui.tab("stats") adds the tab after configs. tab:group fills its left column with controls and ui.surface puts the panel in the right column.
  • events.on_player_hurt stores the hits. Only hits where the attacker is the local player and the victim is somebody else are kept.
  • panel:on_draw(function(w, h, m) ... end) draws in the panel's own coordinates. m is nil unless the pointer is over the panel, and m.mx is the pointer's x in panel pixels.
  • The clear button runs its function on the next frame, like any button.
-- damage_graph.lua
-- A tab with a live bar graph of the damage you deal.

local hits = {}

local tab  = ui.tab("stats")
local opts = tab:group("graph", { column = "left", order = 1 })
local keep = opts:slider("hits shown", 5, 40, 20)
local head = opts:color("head hits", 255, 90, 90)
local body = opts:color("body hits", 120, 200, 255)
opts:button("clear", function() hits = {} end)

events.on_player_hurt(function(e)
  local me = entity.local_player()
  if not me or e.attacker ~= me:index() or e.userid == e.attacker then return end
  hits[#hits + 1] = { damage = e.damage, head = e.hitgroup == 1 }
  while #hits > 40 do table.remove(hits, 1) end
end)

local panel = ui.surface("damage", { tab = tab, column = "right", order = 1, w = 300, h = 180 })
panel:on_draw(function(w, h, m)
  draw.rect_filled(0, 0, w, h, 14, 14, 18, 255)
  local n = math.min(#hits, keep:get())
  if n == 0 then
    draw.text(8, 8, "no hits yet", 160, 160, 170, 255, "small")
    return
  end

  local bw = w / keep:get()
  local hover
  for i = 1, n do
    local hit = hits[#hits - n + i]
    local x = (i - 1) * bw
    local bh = (h - 24) * math.min(hit.damage, 100) / 100
    local r, g, b = (hit.head and head or body):get()
    draw.rect_filled(x + 1, h - bh, bw - 2, bh, r, g, b, 255)
    if m and m.mx >= x and m.mx < x + bw then hover = hit end
  end
  if hover then
    draw.text(8, 6, hover.damage .. " damage" .. (hover.head and ", head" or ""), 255, 255, 255, 255, "small")
  end
end)

World markers

Shapes in the world: a ring on the floor around the player, a line to every enemy, and the head hitbox of every enemy in red. The ring radius is in metres.

How it works:

  • draw.circle3d, draw.line3d and draw.hitbox take world coordinates. Starlit projects them with the game's view, so they stay glued to the world while the camera moves.
  • 1 metre is 1 / 0.0254 game units, so the radius slider is divided by 0.0254.
  • A circle of 48 segments, one line per enemy and one hitbox (16 segments) per enemy stay far below the 1024 segments a script may project in a frame.
  • Shapes are an overlay and are not hidden by walls.
-- world_markers.lua
-- A ring around you, lines to enemies and their head hitboxes, drawn in the world.

local grp    = ui.group("World markers", { page = "visuals", column = "left", order = 30 })
local ring   = grp:checkbox("range ring", true)
local radius = grp:slider("ring radius (m)", 5, 40, 15)
local lines  = grp:checkbox("lines to enemies", true)
local heads  = grp:checkbox("enemy heads", true)
local tint   = grp:color("color", 255, 200, 0)

events.on_paint(function()
  local me = entity.local_player()
  if not me or not me:is_alive() then return end
  local mx, my, mz = me:origin()
  local r, g, b, a = tint:get()

  if ring:get() then
    draw.circle3d(mx, my, mz + 2, radius:get() / 0.0254, r, g, b, a, 48, 2)
  end

  for _, p in ipairs(entity.players(true)) do
    if lines:get() then
      local x, y, z = p:origin()
      if x then draw.line3d(mx, my, mz + 2, x, y, z + 2, r, g, b, 90) end
    end
    if heads:get() then
      draw.hitbox(p:index(), 0, 255, 70, 70, 255, 2)
    end
  end
end)

previous: vec3, color, bit, math · next: For developers