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.
On this page
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_boxprojects the feet and a point 6 units above the eyes withgame.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 todraw.rect_filledas a colour object. vec3:distmeasures 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_hurtreacts 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 theseconds shownslider says. entity.get(slot)turns the slots of an event into names. Slot 0 is printed asworld, 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_paintsets 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_shotignores results whosehintdiffers 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_levelandevents.on_unloadrelease 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 by | hint it ends on | settled at shot | head hits in 80 shots |
|---|---|---|---|
| +58 | +58 | 5 | 76 |
| -29 | -29 | 17 | 64 |
| 0 | 0 | 1 | 80 |
| -58 | -58 | 9 | 72 |
| +45 | +58 | never | 0 |
"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.groupwithpage = "rage"puts the controls on the rage page of the menu, in the right column.me:get("on_ground")andme: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.intentis called with a shortttl, 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 tosettings.overrideas 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 andc:unpack()expands tor, g, b, a, which is whatv:colortakes. In the last position of a call it expands to all four numbers.v:color("health_full", ...)andv:color("health_low", ...)set the two ends of the health bar.v:bar("left", fraction, ...)adds a thin bar beside the box.fractionis 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 afterconfigs.tab:groupfills its left column with controls andui.surfaceputs the panel in the right column.events.on_player_hurtstores 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.misnilunless the pointer is over the panel, andm.mxis the pointer's x in panel pixels.- The
clearbutton 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.line3danddraw.hitboxtake 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)