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:

TablePurpose
vellumEvents, logging, key queries, persistent storage
uiMenu tabs, panels, and widgets (checkbox, slider, combo, keybind, etc.)
hudDraggable HUD elements drawn every frame
drawText, lines, rectangles, circles
gamePlayer snapshots, map name, LOS queries, ConVar reads
httpAsync HTTP(S) requests (off by default)
inputSynthesized keyboard and mouse input (off by default)
soundPlay .wav files from C:\vellum\sounds
Note Everything a script persists (widget values, HUD positions, 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:

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:

AvailableRemoved
base (no dofile / loadfile)io (entire library)
table, string, math, utf8package / require
coroutinedebug (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.

Important Vellum never writes to the game. Everything here is read-only from the game's memory. The 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):

EventWhen it fires
on_loadOnce, right after the script finishes loading
on_unloadOnce, right before the script is unloaded
on_tickEvery frame, before features tick. Logic only. Drawing is not valid here.
on_renderEvery frame, inside the render frame. draw.* is valid here.
on_config_loadAfter 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.

vellum.log(message, level?)
ParameterTypeDefault
messagestring(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

vellum.set_callback(event, fn)
vellum.unset_callback(event, fn?)

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

vellum.script_name() → string
vellum.time() → number
vellum.frame_time() → number

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

vellum.is_key_down(key) → bool

Returns true while the given key is physically held. The same codes work in vellum.is_key_down, ui.keybind, and input.*.

KeyCode
Mouse Left653
Mouse Right654
Mouse Middle655
Mouse X1656
Mouse X2657
A546
B547
C548
D549
E550
F551
G552
H553
I554
J555
K556
L557
M558
N559
O560
P561
Q562
R563
S564
T565
U566
V567
W568
X569
Y570
Z571
0536
1537
2538
3539
4540
5541
6542
7543
8544
9545
F1572
F2573
F3574
F4575
F5576
F6577
F7578
F8579
F9580
F10581
F11582
F12583
Tab512
Space524
Enter525
Escape526
Backspace523
Delete522
Insert521
Home519
End520
Page Up517
Page Down518
Menu535
Left Arrow513
Right Arrow514
Up Arrow515
Down Arrow516
Left Shift528
Right Shift532
Left Ctrl527
Right Ctrl531
Left Alt529
Right Alt533
Left Super530
Right Super534
Caps Lock607
Scroll Lock608
Num Lock609
Print Screen610
Pause611
'596
,597
-598
.599
/600
;601
=602
[603
\604
]605
`606
Keypad 0612
Keypad 1613
Keypad 2614
Keypad 3615
Keypad 4616
Keypad 5617
Keypad 6618
Keypad 7619
Keypad 8620
Keypad 9621
Keypad .622
Keypad /623
Keypad *624
Keypad -625
Keypad +626
Keypad Enter627
if vellum.is_key_down(550) then     -- E key
    -- peek logic
end

vellum.set_data / vellum.get_data

vellum.set_data(key, value|nil)
vellum.get_data(key, default?) → string

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

ui.tab(label, icon_utf8?) → handle

Creates a new top-level tab in the menu. The optional icon is a UTF-8 glyph (the built-in tabs use FontAwesome).

ui.panel(tab_handle, title, span?) → handle

Adds a panel inside your tab. span is 1 (half-width, default) or 2 (full-width).

ui.attach(builtin_tab, builtin_panel) → handle

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

ui.checkbox(panel, label, default?) → handle
local enabled = ui.checkbox(panel, "Enable ESP", true)

ui.slider_int / ui.slider_float

ui.slider_int(panel, label, default, min, max, fmt?) → handle
ui.slider_float(panel, label, default, min, max, fmt?) → handle
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

ui.combo(panel, label, {items}, default_1based?) → handle
ui.multi_combo(panel, label, {items}, mask?) → handle

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

ui.button(panel, label, fn) → handle
ui.label(panel, text) → handle

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

ui.color_picker(panel, label, r?, g?, b?, a?) → handle
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

ui.keybind(panel, label, show_in_list?) → handle

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

ui.get(handle) → value(s)
Widget kindReturns
checkboxbool
slider_int / combonumber (combo is 1-based)
slider_floatnumber
multi_combonumber (bitmask)
color_pickerr, g, b, a (four numbers, 0-255)
keybindbool (true when held or toggled on)
button / labelstring (the current text)
ui.set(handle, value(s)...)

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.

ui.set_visible(handle, bool)

Hides or shows a widget row in the menu without destroying it. Useful for conditionally exposing options based on a checkbox.

ui.is_active(handle) → bool

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

hud.element(name, w, h, fn, chrome?) → handle

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

hud.set_size(handle, w, h)
hud.set_visible(handle, bool)
hud.set_chrome(handle, bool)
hud.position(handle) → x, y

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.

draw.screen_size() → w, h

Returns the game screen dimensions in pixels.

draw.text(x, y, r, g, b, a, text)
draw.line(x1, y1, x2, y2, r, g, b, a, thickness?)
draw.rect(x1, y1, x2, y2, r, g, b, a, thickness?, rounding?)
draw.rect_filled(x1, y1, x2, y2, r, g, b, a, rounding?)
draw.circle(x, y, radius, r, g, b, a, thickness?)
draw.circle_filled(x, y, radius, r, g, b, a)
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

game.map_name() → string
game.world_to_screen(x, y, z) → sx, sy | nil
game.is_visible(x1,y1,z1, x2,y2,z2) → bool
game.local_player() → player | nil
game.players() → { player, ... }

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.

FieldType
namestring
steam_idnumber
pingnumber
moneynumber
teamnumber
alivebool
is_localbool
is_enemybool
healthnumber

These fields only exist while the player is alive:

FieldType
pos{x,y,z}
eye_pos{x,y,z}
pitch, yawnumber
scopedbool
defusingbool
flash_durationnumber
has_defuserbool
has_helmetbool
weaponstring
weapon_clipnumber
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

game.cvar(name) → value | nil

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 typeLua return
boolboolean
int (any width)integer
float / doublenumber
vector3 / qanglethree 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

http.enabled() → bool
http.get(url, callback) → bool
http.post(url, body, callback, content_type?) → bool

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

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

input.enabled() → bool
input.key_down(key) → bool
input.key_up(key) → bool
input.key_press(key) → bool
input.mouse_move(dx, dy) → bool

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:

Important This sends input to whatever window is in the foreground via Windows 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

sound.play(filename, volume?) → bool
sound.list() → { filename, ... }

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)