Vellum Scripting
A sandboxed Lua 5.4 runtime that runs alongside Vellum. Scripts can read game state, draw on screen, add menu widgets, play sounds, and synthesize input. All from plain .lua files dropped into C:\vellum\scripts.
Overview
Every script gets its own Lua state so a crash in one never touches another. The runtime exposes seven global tables that cover every interaction surface:
| Table | Purpose |
|---|---|
vellum | Events, logging, key queries, persistent storage |
ui | Menu tabs, panels, and widgets (checkbox, slider, combo, keybind, etc.) |
hud | Draggable HUD elements drawn every frame |
draw | Text, lines, rectangles, circles |
game | Player snapshots, map name, LOS queries, ConVar reads |
http | Async HTTP(S) requests (off by default) |
input | Synthesized keyboard and mouse input (off by default) |
sound | Play .wav files from C:\vellum\sounds |
vellum.set_data storage, and autoload flags) lives in a single file: C:\vellum\scripts.dat. The scripts directory itself only ever holds .lua files.
Setup & loading
Create .lua files in C:\vellum\scripts. Vellum auto-discovers them. Open the Scripts tab in the menu to load, unload, or reload scripts. Tick Autoload on a script and it loads automatically every time Vellum starts.
The Scripts tab exposes two feature gates that both default to off:
- Script HTTP access enables
http.getandhttp.post - Allow input control enables
input.*(key presses, mouse moves)
Turn these on before a script that uses them will work. A script that calls http.get(...) or input.key_press(...) will get false back while the gate is off. It won't crash.
Sandbox rules
Each script is a text-only chunk (binary bytecode is rejected). The standard library is heavily pruned:
| Available | Removed |
|---|---|
| base (no dofile / loadfile) | io (entire library) |
| table, string, math, utf8 | package / require |
| coroutine | debug (entire library) |
| os (clock, date, time, difftime only) | os.execute, os.exit, os.remove, etc. |
An instruction budget caps every callback. If a script enters an infinite loop or does too much work in one tick, the runtime kills it and pushes an error notification. This keeps a runaway script from freezing Vellum.
input.* table sends keystrokes to the foreground window via Windows SendInput. It does not write to game memory.
Event lifecycle
Scripts hook into five events via vellum.set_callback(name, fn):
| Event | When it fires |
|---|---|
on_load | Once, right after the script finishes loading |
on_unload | Once, right before the script is unloaded |
on_tick | Every frame, before features tick. Logic only. Drawing is not valid here. |
on_render | Every frame, inside the render frame. draw.* is valid here. |
on_config_load | After a config is applied (cloud download, file load). |
Use on_tick for game-state polling and on_render for drawing. Keep heavy work in on_tick so the render path stays fast.
vellum.log
Pushes a notification into Vellum's notification feed.
| Parameter | Type | Default |
|---|---|---|
| message | string | (required) |
| level | "info" | "success" | "warning" | "error" | "info" |
vellum.log("enemy spotted!", "warning")
vellum.log("round started", "info")
print("hello") -- same as vellum.log with "info" level
vellum.set_callback / vellum.unset_callback
Register a function for one of the five lifecycle events. Calling unset_callback without a function argument clears all handlers for that event. With a function, it removes only that specific handler.
vellum.set_callback("on_load", function()
print("I'm alive!")
end)
vellum.set_callback("on_tick", function()
-- poll game state here
end)
vellum.set_callback("on_render", function()
-- draw stuff here
end)
vellum.script_name / vellum.time / vellum.frame_time
script_name() returns the file stem (no extension). time() is a monotonic clock in seconds. frame_time() is the previous frame's delta (1 / frame_time gives instantaneous FPS).
vellum.is_key_down
Returns true while the given key is physically held. The same codes work in vellum.is_key_down, ui.keybind, and input.*.
| Key | Code |
|---|---|
| Mouse Left | 653 |
| Mouse Right | 654 |
| Mouse Middle | 655 |
| Mouse X1 | 656 |
| Mouse X2 | 657 |
| A | 546 |
| B | 547 |
| C | 548 |
| D | 549 |
| E | 550 |
| F | 551 |
| G | 552 |
| H | 553 |
| I | 554 |
| J | 555 |
| K | 556 |
| L | 557 |
| M | 558 |
| N | 559 |
| O | 560 |
| P | 561 |
| Q | 562 |
| R | 563 |
| S | 564 |
| T | 565 |
| U | 566 |
| V | 567 |
| W | 568 |
| X | 569 |
| Y | 570 |
| Z | 571 |
| 0 | 536 |
| 1 | 537 |
| 2 | 538 |
| 3 | 539 |
| 4 | 540 |
| 5 | 541 |
| 6 | 542 |
| 7 | 543 |
| 8 | 544 |
| 9 | 545 |
| F1 | 572 |
| F2 | 573 |
| F3 | 574 |
| F4 | 575 |
| F5 | 576 |
| F6 | 577 |
| F7 | 578 |
| F8 | 579 |
| F9 | 580 |
| F10 | 581 |
| F11 | 582 |
| F12 | 583 |
| Tab | 512 |
| Space | 524 |
| Enter | 525 |
| Escape | 526 |
| Backspace | 523 |
| Delete | 522 |
| Insert | 521 |
| Home | 519 |
| End | 520 |
| Page Up | 517 |
| Page Down | 518 |
| Menu | 535 |
| Left Arrow | 513 |
| Right Arrow | 514 |
| Up Arrow | 515 |
| Down Arrow | 516 |
| Left Shift | 528 |
| Right Shift | 532 |
| Left Ctrl | 527 |
| Right Ctrl | 531 |
| Left Alt | 529 |
| Right Alt | 533 |
| Left Super | 530 |
| Right Super | 534 |
| Caps Lock | 607 |
| Scroll Lock | 608 |
| Num Lock | 609 |
| Print Screen | 610 |
| Pause | 611 |
| ' | 596 |
| , | 597 |
| - | 598 |
| . | 599 |
| / | 600 |
| ; | 601 |
| = | 602 |
| [ | 603 |
| \ | 604 |
| ] | 605 |
| ` | 606 |
| Keypad 0 | 612 |
| Keypad 1 | 613 |
| Keypad 2 | 614 |
| Keypad 3 | 615 |
| Keypad 4 | 616 |
| Keypad 5 | 617 |
| Keypad 6 | 618 |
| Keypad 7 | 619 |
| Keypad 8 | 620 |
| Keypad 9 | 621 |
| Keypad . | 622 |
| Keypad / | 623 |
| Keypad * | 624 |
| Keypad - | 625 |
| Keypad + | 626 |
| Keypad Enter | 627 |
if vellum.is_key_down(550) then -- E key
-- peek logic
end
vellum.set_data / vellum.get_data
Per-script persistent key/value store. Values are stored and returned as strings. Passing nil to set_data removes the key. This is great for counters, timestamps, or any state that should survive reloads.
local runs = tonumber(vellum.get_data("runs", "0")) + 1
vellum.set_data("runs", tostring(runs))
print("loaded " .. runs .. " times")
ui.tab / ui.panel / ui.attach
All three return an integer handle that you pass to widget constructors. They are meant to be called once at script load time (top level or inside on_load).
Creates a new top-level tab in the menu. The optional icon is a UTF-8 glyph (the built-in tabs use FontAwesome).
Adds a panel inside your tab. span is 1 (half-width, default) or 2 (full-width).
Appends widgets to an existing built-in panel instead of creating a new tab. For example, ui.attach("Misc", "Misc") injects widgets at the bottom of the Misc → Misc panel.
local my_tab = ui.tab("My Script")
local left_panel = ui.panel(my_tab, "Settings", 1)
local right_pnl = ui.panel(my_tab, "Visuals", 1)
-- inject into a built-in panel
local misc = ui.attach("Misc", "Misc")
Widgets
Every widget constructor takes a panel handle as the first argument and returns a widget handle. Widget values persist across restarts automatically. You don't need to manually save anything.
ui.checkbox
local enabled = ui.checkbox(panel, "Enable ESP", true)
ui.slider_int / ui.slider_float
local fov = ui.slider_float(panel, "FOV", 2.5, 0.5, 30.0, "%.1f deg")
local max_hp = ui.slider_int(panel, "Max HP", 100, 1, 200, "%.0f hp")
ui.combo / ui.multi_combo
Combo returns a 1-based index from ui.get(). Multi-combo stores a bitmask where bit 0 = item 1, bit 1 = item 2, etc.
local mode = ui.combo(panel, "Mode", { "Boxes", "Corners", "Off" }, 1)
local flags = ui.multi_combo(panel, "Show", { "Scoped", "Flashed", "Defusing" }, 0)
-- reading
local idx = ui.get(mode) -- 1, 2, or 3
local mask = ui.get(flags)
local scoped = mask & 1 ~= 0
local flashed = mask & 2 ~= 0
ui.button / ui.label
Buttons fire the callback when clicked. Labels are read-only text rows. Use ui.set(handle, new_text) to update a label dynamically.
local status = ui.label(panel, "status: idle")
ui.button(panel, "Click me", function()
ui.set(status, "status: clicked!")
end)
ui.color_picker
local box_col = ui.color_picker(panel, "Box color", 255, 64, 64, 255)
-- reading
local r, g, b, a = ui.get(box_col)
draw.rect(x1, y1, x2, y2, r, g, b, a, 1.5)
ui.keybind
A keybind widget. Click it in the menu to capture a key, right-click to cycle between hold and toggle mode. When show_in_list is true (the default), the bound key shows up in the built-in Keybinds HUD window with an accent highlight when the bind is active.
An unbound keybind (no key set) always returns true from ui.is_active(). This is the same convention the built-in feature keybinds use. Bind a key to gate the feature behind it.
local bind = ui.keybind(panel, "ESP key")
if not ui.is_active(bind) then return end -- skip when not held/toggled
ui.get / ui.set / ui.set_visible / ui.is_active
| Widget kind | Returns |
|---|---|
| checkbox | bool |
| slider_int / combo | number (combo is 1-based) |
| slider_float | number |
| multi_combo | number (bitmask) |
| color_picker | r, g, b, a (four numbers, 0-255) |
| keybind | bool (true when held or toggled on) |
| button / label | string (the current text) |
Sets a widget's value from Lua. Sliders are clamped to their min/max. Combos clamp to the item count. Color takes r, g, b, a. Labels/buttons take a string.
Hides or shows a widget row in the menu without destroying it. Useful for conditionally exposing options based on a checkbox.
For keybinds: true when held or toggled on (and true when unbound). For checkboxes: returns the checked state. For anything else: false.
local master = ui.checkbox(panel, "Enable", true)
local fov = ui.slider_float(panel, "FOV", 2.0, 0.5, 10.0)
local bind = ui.keybind(panel, "Hold key")
vellum.set_callback("on_tick", function()
-- hide FOV slider when master is off
ui.set_visible(fov, ui.get(master))
-- reset FOV on config load
ui.set(fov, 2.0)
end)
vellum.set_callback("on_render", function()
if not ui.get(master) then return end
if not ui.is_active(bind) then return end
-- feature code here
end)
hud.element
Creates a draggable HUD window drawn every frame. Drag it while the menu is open; the position persists. The callback receives (x, y, w, h) describing the content area. Coordinates are screen-absolute. Inside the callback, draw.* targets this element's window automatically.
By default (chrome=true), the element gets the same titled window chrome as the built-in Keybinds / Radar / Now Playing windows: a rounded header bar with the element's name, an accent divider line, and a glow. Passing chrome=false gives you a bare transparent canvas (you draw the background yourself).
local info = hud.element("My HUD", 200, 50, function(x, y, w, h)
draw.rect_filled(x, y, x + w, y + h, 14, 14, 18, 210, 5)
draw.text(x + 10, y + 10, 235, 235, 245, 255, game.map_name())
end)
HUD controls
All are safe to call every frame. After a set_size or set_chrome, the callback receives the new dimensions on the next render.
hud.set_visible(info, ui.get(show_hud_checkbox))
local x, y = hud.position(info)
Drawing primitives
Available inside on_render callbacks and hud.element draw functions. Every colour is four numbers: r, g, b, a (0-255). The optional arguments have sensible defaults.
Returns the game screen dimensions in pixels.
vellum.set_callback("on_render", function()
local w, h = draw.screen_size()
-- crosshair dot
draw.circle_filled(w * 0.5, h * 0.5, 3.0, 255, 255, 255, 200)
-- box around every enemy
for _, p in ipairs(game.players()) do
if p.alive and p.is_enemy and p.bbox then
draw.rect(p.bbox.x1, p.bbox.y1, p.bbox.x2, p.bbox.y2, 255, 60, 60, 255, 1.5)
end
end
end)
State reads
world_to_screen converts a 3D world position to screen pixel coordinates. Returns nil when the point is behind the camera or the view matrix isn't ready.
is_visible tests line-of-sight against the map's physics collision mesh. This is the same test the built-in ESP visibility check uses. Returns false while no map is loaded.
Player snapshot
game.local_player() and game.players() return plain Lua tables: snapshots, not live objects. No stale reads can happen because there's no lingering pointer.
| Field | Type |
|---|---|
| name | string |
| steam_id | number |
| ping | number |
| money | number |
| team | number |
| alive | bool |
| is_local | bool |
| is_enemy | bool |
| health | number |
These fields only exist while the player is alive:
| Field | Type |
|---|---|
| pos | {x,y,z} |
| eye_pos | {x,y,z} |
| pitch, yaw | number |
| scoped | bool |
| defusing | bool |
| flash_duration | number |
| has_defuser | bool |
| has_helmet | bool |
| weapon | string |
| weapon_clip | number |
| bbox | {x1,y1,x2,y2} |
local me = game.local_player()
if me and me.alive then
print(("hp %d | %s"):format(me.health, me.weapon or "?"))
end
for _, p in ipairs(game.players()) do
if p.alive and p.is_enemy then
print(p.name .. " is alive at " .. p.pos.x .. ", " .. p.pos.y)
end
end
game.cvar
Reads a typed value from the game's ConVar list. Returns nil when the CVar system isn't ready or the named ConVar doesn't exist. The return type depends on what the ConVar holds:
| ConVar type | Lua return |
|---|---|
| bool | boolean |
| int (any width) | integer |
| float / double | number |
| vector3 / qangle | three numbers (x, y, z) |
| string (or unknown) | string |
local gravity = game.cvar("sv_gravity")
print("gravity: " .. gravity) -- e.g. 800
local pos = { game.cvar("mp_restartgame") }
print("mp_restartgame = " .. table.concat(pos, ", "))
Async HTTP
Both functions return true when the request was queued, false when the feature gate is off or the queue is full. The callback runs on the main thread as fn(status, body):
- status 1xx to 5xx: HTTP response code; body is the response text
- status 0: transport error; body is the error message
Requests are async and won't block Vellum. If the script is reloaded before a request completes, the stale callback is silently dropped.
if not http.enabled() then
print("turn on Script HTTP access first")
return
end
http.get("https://example.com", function(code, body)
if code == 200 then
print("got: " .. body)
else
print("failed: " .. code .. " " .. body)
end
end)
http.post("https://example.com/data", '{"key":"val"}', function(code, body)
print(code)
end, "application/json")
Synthesized input
All functions return false while the Allow input control toggle is off. Keys use the same codes as vellum.is_key_down. Mouse button codes automatically route to mouse events:
- key_down holds a key (no release). Pair with
key_up. - key_up releases a previously-held key.
- key_press is a convenience call: down then up in one shot.
- mouse_move does relative mouse movement in screen pixels.
SendInput. Make sure CS2 has focus. Holding keys across multiple frames needs explicit key_down / key_up pairing. key_press is a single tap.
-- single tap
input.key_press(524) -- tap Space
-- hold and release across frames
local crouching = false
vellum.set_callback("on_tick", function()
if should_crouch() and not crouching then
input.key_down(527) -- Left Ctrl (hold)
crouching = true
elseif not should_crouch() and crouching then
input.key_up(527) -- Left Ctrl (release)
crouching = false
end
end)
-- relative mouse movement
input.mouse_move(0, -10) -- nudge up
Audio playback
Plays .wav files from C:\vellum\sounds. filename is a bare file name with no path separators or directory traversal. The .wav extension is optional. volume is 0 to 100 (default 100). Returns false when the file doesn't exist.
Playback is async on a background thread. Starting a new sound stops any currently-playing sound from this feature (same behaviour as the built-in hitsound).
-- list available files
for _, f in ipairs(sound.list()) do
print(f)
end
-- play one
sound.play("click", 80) -- C:\vellum\sounds\click.wav at 80% volume
sound.play("headshot.wav") -- extension optional
-- random sound
local files = sound.list()
if #files > 0 then
sound.play(files[math.random(#files)])
end
Cookbook: Basic ESP
Draws a coloured box around every enemy player, gated behind a keybind and a visibility check.
local tab = ui.tab("My ESP")
local panel = ui.panel(tab, "Settings", 1)
local master = ui.checkbox(panel, "Enable", true)
local key = ui.keybind(panel, "Active key")
local col = ui.color_picker(panel, "Box color", 255, 64, 64, 255)
local thick = ui.slider_float(panel, "Thickness", 1.5, 0.5, 5.0, "%.1f px")
local visonly = ui.checkbox(panel, "Visible only", false)
vellum.set_callback("on_render", function()
if not ui.get(master) then return end
if not ui.is_active(key) then return end
local r, g, b, a = ui.get(col)
local t = ui.get(thick)
local me = game.local_player()
if not me or not me.alive then return end
for _, p in ipairs(game.players()) do
if not (p.alive and p.is_enemy and p.bbox) then goto continue end
if ui.get(visonly) then
local vis = game.is_visible(
me.eye_pos.x, me.eye_pos.y, me.eye_pos.z,
p.eye_pos.x, p.eye_pos.y, p.eye_pos.z)
if not vis then goto continue end
end
draw.rect(p.bbox.x1, p.bbox.y1, p.bbox.x2, p.bbox.y2, r, g, b, a, t)
::continue::
end
end)
Cookbook: Custom HUD
A draggable HUD that shows HP, weapon, and map name.
local misc = ui.attach("Misc", "Misc")
local show = ui.checkbox(misc, "My HUD", true)
local hud_el = hud.element("My HUD", 220, 48, function(x, y, w, h)
draw.rect_filled(x, y, x + w, y + h, 14, 14, 18, 200, 5)
draw.rect(x, y, x + w, y + h, 95, 95, 115, 200, 1, 5)
local line1 = game.map_name()
draw.text(x + 8, y + 6, 150, 150, 165, 255, line1)
local me = game.local_player()
local line2 = "not in game"
if me and me.alive then
line2 = ("hp %d | %s"):format(me.health, me.weapon or "?")
end
draw.text(x + 8, y + 26, 235, 235, 245, 255, line2)
end)
vellum.set_callback("on_tick", function()
hud.set_visible(hud_el, ui.get(show))
end)
Cookbook: Keybinds & toggles
Keybind widgets automatically appear in the Keybinds HUD window once a key is bound. The highlight colour tracks active/inactive state.
local panel = ui.panel(ui.tab("Binds Demo"), "Keys", 1)
local hold_bind = ui.keybind(panel, "Hold for ESP")
local tog_bind = ui.keybind(panel, "Toggle aim")
-- Right-click the keybind in the menu to switch between hold/toggle mode.
vellum.set_callback("on_render", function()
-- hold_bind: only active while the key is held
if ui.is_active(hold_bind) then
draw.circle_filled(100, 100, 8, 0, 255, 0, 200)
end
-- tog_bind: toggles on/off each time the key is pressed
if ui.is_active(tog_bind) then
draw.circle_filled(140, 100, 8, 255, 0, 0, 200)
end
end)
Cookbook: HTTPS requests
Fetch JSON from an API and parse it. Lua doesn't have a built-in JSON parser, so use pattern matching for simple responses or bring your own.
if not http.enabled() then
print("Enable Script HTTP access in the Scripts tab first")
return
end
http.get("https://example.com", function(code, body)
if code == 200 then
print("got: " .. body)
else
print("status " .. code .. ": " .. body)
end
end)