Lua scripting
Write your own overlays in Lua: a few lines that draw on screen, react to the match and keep their own settings in the menu. Everything a script can do is on this page.
Get started
- Open the menu and go to Scripts.
- Type a name into New script and press Create. GriefBot writes a short starter script, switches it on and opens it in your editor.
- Change it and save. A script that is switched on reloads every time its file is saved, so you see each change in game straight away.
- Anything you
print, and the full error when a script stops, shows in Output under the scripts.
Every script is one .lua file in your cfg\scripts folder (Open folder
on the Scripts tab takes you there), and its first line must be:
-- GriefBot script This is the whole starter script. It adds two settings to its card in the menu and draws one line of text every frame:
-- GriefBot script
local show = ui.toggle("Show", true)
local color = ui.color("Colour", "#5aa5ff")
events.on("paint", function()
if not show:get() then return end
local w = client.screen()
render.text(w / 2, 48, "Hello from Lua", color:get(), 16, "c")
end) Scripts use Lua 5.4. If you have never written Lua, the first chapters of Programming in Lua cover everything these pages use.
How a script runs
When a script is switched on, its file runs once from top to bottom. That is where it declares its settings
and registers its callbacks with events.on. After that, GriefBot calls the callbacks:
"paint"once every frame. Draw here; drawing anywhere else does nothing.- A game event such as
"player_death"when the game fires it, just before the next paint. "unload"once when the script is switched off, reloaded or the overlay unloads.
| Call | What it does |
|---|---|
events.on(name, function) | Calls function for name: "paint", "unload" or a game event. Call it as often as you like; every function registered for a name runs, in the order they were added. A game event's function receives one table with the event's fields. |
Settings can only be declared while the file runs the first time, not from inside a callback. Variables at the top of the file keep their values between callbacks, and are reset when the script reloads; use storage for anything that must survive that.
Everything a script does happens on the overlay's own frame, so a slow script costs frames. The card on the Scripts tab shows how many milliseconds each script takes a frame.
Settings
A script's settings appear in its card's cog on the Scripts tab, drawn like every other setting in the menu,
and are saved for you. Each call returns a handle; read it with :get() whenever you need the
current value, because the user can change it at any time.
| Declare | What it does |
|---|---|
ui.toggle(label, default) | An on/off switch. default is false if left out. :get() returns a boolean. |
ui.slider(label, min, max, default, format) | A slider for a number. default is min if left out. format is how the value is printed, with one number in it: "%.1f" (the default), "%.0f px", "%.2f s". |
ui.slider_int(label, min, max, default) | A slider for a whole number. :get() returns an integer. |
ui.combo(label, options, default) | A dropdown. options is a list of 1 to 16 strings and default the position of the selected one, starting at 1. :get() returns that position and :item() its text. |
ui.color(label, default) | A colour picker with transparency. :get() returns a colour. |
ui.key(label, default) | A key the user binds, with a mode: hold, toggle, hold to turn off, or always. default is a key or nothing. :get() returns true while the key has it active. |
| Handle | What it does |
|---|---|
handle:get() | The current value. |
handle:set(value) | Changes the value from the script, as if the user had. Not for ui.key: the user binds keys. |
handle:item() | For ui.combo only: the text of the selected option. |
A label is 1 to 47 characters with no ##, = or line break, and is unique within its
script. A script has at most 32 settings. Values are remembered by label, so renaming a setting starts it
from its default.
local size = ui.slider_int("Text size", 10, 24, 14)
local side = ui.combo("Side", { "Left", "Right" }, 1)
local key = ui.key("Hold to hide", "mouse4")
events.on("paint", function()
if key:get() then return end
local w = client.screen()
local x = side:get() == 2 and w - 200 or 40
render.text(x, 300, "Side: " .. side:item(), "#ffffff", size:get())
end)Drawing
Draw from "paint". Positions are screen pixels with 0, 0 at the top left;
client.screen() gives the size. Shapes are drawn over the game and under the menu, in the order
you draw them. Arguments shown after the colour can be left out.
| Call | What it does |
|---|---|
render.line(x1, y1, x2, y2, color, thickness) | A line. thickness is 1 if left out. |
render.rect(x, y, w, h, color, rounding, thickness) | A rectangle outline. rounding is the corner radius. |
render.rect_filled(x, y, w, h, color, rounding) | A filled rectangle. |
render.rect_gradient(x, y, w, h, top_left, top_right, bottom_right, bottom_left) | A filled rectangle blending between four corner colours. Left out, top_right is top_left, bottom_right is top_right and bottom_left is top_left, so two colours make a left-to-right fade. |
render.circle(x, y, radius, color, thickness, segments) | A circle outline. segments is worked out from the size if left out. |
render.circle_filled(x, y, radius, color, segments) | A filled circle. |
render.triangle_filled(x1, y1, x2, y2, x3, y3, color) | A filled triangle. |
render.polyline(points, color, thickness, closed) | Lines through points, a flat list { x1, y1, x2, y2, ... } of 2 to 256 points. closed joins the last point back to the first. |
render.polygon(points, color) | A filled shape through 3 to 256 points. The shape must be convex. |
render.text(x, y, text, color, size, flags) | Text with a dark outline. size is in pixels, 6 to 96, 14 if left out. flags is a string of letters: c centres it on x, r ends it at x, m centres it on y and p draws it plain, without the outline. |
render.text_size(text, size) | Returns the width and height the text takes up. |
render.world_to_screen(x, y, z) | Where a point in the game world is on screen: returns x, y, or nil when the point is behind the camera or off screen. Also takes a table with x, y and z, such as a player's origin. |
Colours
A colour is either a hex string, "#ff8800" or with transparency "#ff8800cc", or the
number render.color and ui.color return. Treat that number as a value to pass on,
not one to do arithmetic with.
| Call | What it does |
|---|---|
render.color(r, g, b, a) | A colour from 0 to 255 channels. a is 255 (solid) if left out. |
A script can draw up to 20,000 shapes a frame.
Players and the bomb
| Call | What it does |
|---|---|
entities.players() | Every player in the match, as a list of player tables. Calling it again in the same frame returns the same list, so it costs nothing to call from several places. |
entities.get(index) | The player with this index, or nil. Game events name players by index. |
entities.local_player() | You, or nil when you are not in a match. |
entities.bomb() | The planted bomb, or nil when no bomb is planted. |
A player
| Field | What it does |
|---|---|
index | The player's number in this match. Stays the same while they are connected. |
name, steamid | Their name, and their SteamID64 as a string (empty for bots). |
team | 2 for Terrorists, 3 for Counter-Terrorists. |
enemy, is_local | Whether they are on the other team (deathmatch counts everyone), and whether this is you. |
alive, health, armor, helmet | Alive or not, health 0 to 100, armour 0 to 100, and whether they have a helmet. |
money | Their cash. |
weapon, weapon_id, clip | The weapon in their hands by name ("ak47", "awp"), its item number, and the rounds in its magazine. |
scoped, defusing, planting, reloading, walking | What they are doing right now. |
has_bomb, has_defuser, flashed | Carrying the bomb, carrying a defuse kit, blinded by a flashbang. |
dormant | The game has stopped sending this player's position, so the values are their last known ones. |
place | The callout where they are standing, such as "BombsiteA". |
origin, eye, head, velocity | Tables with x, y, z: where their feet are, their eye position, their head, and how fast they are moving in units a second. |
The bomb
| Field | What it does |
|---|---|
site | "A" or "B". |
ticking, time_left | Whether the timer is running, and the seconds until it explodes. |
defusing, defuse_left, defused | Whether someone is defusing it, the seconds until they finish, and whether it has been defused. |
origin | Where it is, as a table with x, y, z. Missing when the position is not known. |
Game events
Register a function with events.on and it receives a table with the event's
name and the fields below. A field that names a player holds that player's index
(look them up with entities.get), or nil when the player cannot be found, such as a
bot that just left.
events.on("player_death", function(e)
local me = entities.local_player()
if me and e.attacker == me.index and e.headshot then
print("Headshot with the " .. e.weapon)
end
end) | Event | What it does |
|---|---|
player_hurt | userid, attacker (players), health and armor left, dmg_health, dmg_armor, hitgroup (1 head, 2 chest, 3 stomach, 4 and 5 arms, 6 and 7 legs), weapon. |
player_death | userid, attacker, assister (players), weapon, headshot, noscope, thrusmoke, attackerblind, penetrated (walls the bullet went through), distance. |
weapon_fire | userid, weapon, silenced. |
bullet_impact | userid, and where the bullet hit: x, y, z. |
player_blind | userid, attacker, blind_duration in seconds. |
item_purchase | userid, team, weapon. |
round_start | timelimit, fraglimit, objective. |
round_freeze_end | The buy time is over and players can move. No fields. |
round_end | winner (team), reason, message. |
round_mvp | userid, reason. |
bomb_planted | userid, site (the game's own number for the site; entities.bomb().site has the letter). |
bomb_begindefuse | userid, haskit. |
bomb_defused, bomb_exploded | userid, site. |
smokegrenade_detonate, molotov_detonate | userid, and where it went off: x, y, z. |
inferno_expire | A fire burnt out at x, y, z. |
vote_cast | userid, vote_option, team. |
player_disconnect | name, reason. |
game_end | winner (team). |
cs_win_panel_match | The match is over and the scoreboard is up. No fields. |
Numbers are numbers, flags such as headshot are booleans, and weapon is the
game's own name for the weapon.
Client and keys
| Call | What it does |
|---|---|
client.screen() | The screen's width and height in pixels. |
client.in_game() | true while you are in a match. |
client.map() | The map's name, such as "de_mirage", or "" outside a match. |
client.menu_open() | true while the GriefBot menu is open. |
client.ping() | Your ping in milliseconds. |
client.time() | Seconds on a clock that never jumps, for timing things: subtract two readings. |
client.curtime() | The game's own clock, in seconds. |
client.frametime() | How long the last frame took, in seconds. |
Keys
| Call | What it does |
|---|---|
input.key_down(key) | true while the key is held. |
input.key_pressed(key) | true on the one frame the key went down. |
input.cursor() | The mouse position on screen: x, y. |
A key is a name or a Windows virtual-key code from 1 to 255. Names are a letter "a" to
"z", a digit "0" to "9", "f1" to "f24", or
one of: mouse1 to mouse5, shift, ctrl, alt,
lshift, rshift, lctrl, rctrl, lalt,
ralt, space, enter, tab, escape,
backspace, capslock, insert, delete, home,
end, pageup, pagedown, up, down,
left, right.
Keys read as up while the menu is open or CS2 is not the window in front, so a script never sees what you type into the menu or another program.
Storage
Values a script wants to keep between reloads and restarts: a counter, a position, a last-seen time. They are saved for you a moment after they change.
| Call | What it does |
|---|---|
storage.get(key, default) | The stored value, or default (or nil) when there is none. |
storage.set(key, value) | Stores a number, boolean or string. Setting nil removes the key. |
A key is 1 to 48 characters with no = or line break. A script keeps up to 64 values, and a string
can be up to 512 bytes. To keep a table, keep its parts under their own keys.
local kills = storage.get("kills", 0)
events.on("player_death", function(e)
local me = entities.local_player()
if me and e.attacker == me.index and e.userid ~= me.index then
kills = kills + 1
storage.set("kills", kills)
end
end)The sandbox
Scripts can draw and read the game. They cannot touch your files, start programs, reach the network, or change what the game or the rest of GriefBot does. That is what makes it safe to run a script somebody else wrote.
What Lua gives you
string,table,math,utf8andcoroutine, complete exceptstring.dump.- The basic functions:
print,pairs,ipairs,type,tostring,tonumber,select,pcall,error,setmetatableand the rest, withoutdofile,loadfileandrequire.loadtakes text only. os.time(),os.date(format, time)andos.clock()(seconds since the script started). No other part ofos, and noioordebug.collectgarbagewith"count","collect"or"step".print(...)writes to the Output panel, the values separated by two spaces.
Limits
| Limit | Value |
|---|---|
| Memory per script | 64 MB |
| Work when the file first runs | about 30 million Lua instructions |
| Work per callback | about 5 million instructions for each paint and each event |
| Shapes per frame | 20,000 per script |
| Settings | 32 per script |
| Stored values | 64 per script, strings up to 512 bytes |
| Scripts | 64 in the folder |
A script that goes past a limit is stopped and says which one. pcall cannot catch running out of
time: an endless loop is stopped even inside it.
When a script fails
A script that hits an error stops. Its card turns red and shows the line and the message, such as Line 12: attempt to index a nil value, and the Output panel has the full message with the chain of calls that led to it. The other scripts keep running.
Fix the file and save it: a script that is switched on starts again by itself. Reload in the card's cog runs it again without a change.
The most common error is reading a field of something that is nil, for example
entities.local_player().health outside a match. Check first:
local me = entities.local_player()
if not me then return endSharing scripts
The Library on the Config tab has a Scripts section with scripts other members published. Get downloads one into your scripts folder, switched off, under a name that does not replace anything you have. Read it first if you like, then switch it on from the Scripts tab.
To publish your own, open its card's cog and choose Publish. You name it and describe it, and
the file is published as it is saved. Your account name is shown with it. A published script must start with
-- GriefBot script and be at most 256 KB.
Signed-in members can also browse the scripts on the website, in the library.
Examples
Each one is a complete script: paste it into a new file and save.
Speed
-- GriefBot script
local size = ui.slider_int("Size", 12, 32, 18)
events.on("paint", function()
local me = entities.local_player()
if not me or not me.alive then return end
local v = me.velocity
local speed = math.floor(math.sqrt(v.x * v.x + v.y * v.y))
local w, h = client.screen()
render.text(w / 2, h - 160, speed .. " u/s", speed > 250 and "#7ee08a" or "#ffffff", size:get(), "c")
end)
Bomb timer
-- GriefBot script
events.on("paint", function()
local b = entities.bomb()
if not b or not b.ticking then return end
local text = string.format("%s %.1f s", b.site, b.time_left)
if b.defusing then
text = text .. string.format(" - defused in %.1f s", b.defuse_left)
end
local w = client.screen()
render.text(w / 2, 90, text, b.time_left < 10 and "#ff6b71" or "#ffffff", 20, "c")
end)
Hit log
-- GriefBot script
local hold = ui.slider("Show for", 1, 10, 4, "%.0f s")
local groups = { "head", "chest", "stomach", "left arm", "right arm", "left leg", "right leg" }
local lines = {}
events.on("player_hurt", function(e)
local me = entities.local_player()
if not me or e.attacker ~= me.index then return end
local victim = entities.get(e.userid)
table.insert(lines, {
text = string.format("Hit %s in the %s for %d (%d left)",
victim and victim.name or "someone", groups[e.hitgroup] or "body", e.dmg_health, e.health),
at = client.time(),
})
end)
events.on("paint", function()
local now = client.time()
for i = #lines, 1, -1 do
if now - lines[i].at > hold:get() then table.remove(lines, i) end
end
local y = 200
for _, line in ipairs(lines) do
render.text(24, y, line.text, "#e0e6f0", 14)
y = y + 18
end
end)
Health bars
-- GriefBot script
local color = ui.color("Bar", "#7ee08a")
events.on("paint", function()
for _, p in ipairs(entities.players()) do
if p.enemy and p.alive and not p.dormant then
local tx, ty = render.world_to_screen(p.head.x, p.head.y, p.head.z + 10)
local bx, by = render.world_to_screen(p.origin)
if tx and bx then
local h = by - ty
local x = tx - h * 0.3 - 6
local filled = h * p.health / 100
render.rect_filled(x, ty, 3, h, "#00000099")
render.rect_filled(x, ty + h - filled, 3, filled, color:get())
end
end
end
end)