Draw
2D and 3D drawing primitives. Draw calls accumulate in a per-tick buffer. That buffer is cleared at the top of every tick and handed to the overlay at the end of it. Whatever you draw lives for exactly one tick. To keep something on screen, draw it again every tick from on_tick.
The buffer holds 1024 shapes and 128 text draws per tick, shared by every loaded script. A call past that budget draws nothing and raises nothing. draw.box3d costs 12 of the shape slots and draw.sphere3d costs three per segment, so 36 at its default segment count.
Event callbacks (on_kill, on_ability_cast, ...) run inside the same tick as on_tick, so drawing from one works. The drawing flashes for that single tick unless you record the state and re-draw it from on_tick. ui.button callbacks are the exception. They run before the buffer is cleared, so draw calls made there are discarded.
Colors
Colors are IM_COL32 packed uint32 in 0xAABBGGRR format (alpha in the high byte, red in the low byte).
local WHITE = 0xFFFFFFFF
local RED = 0xFF0000FF -- red in the low byte, FF alpha
local GREEN = 0xFF00FF00
local BLUE = 0xFFFF0000
local YELLOW = 0xFF00FFFFUse draw.color to build colors from RGBA components without hand-packing:
local muted = draw.color(180, 180, 180) -- alpha defaults to 255
local fade = draw.color(255, 255, 255, 80)draw.color also accepts a single {r, g, b, a} table as its first argument, so the result of a color picker can be passed directly:
local col = draw.color(ui.get(my_color_picker))Byte order gotcha
This format is ABGR, not ARGB. 0xFFFF0000 is solid blue, not red, most other engines and CSS use 0xAARRGGBB. If you're copying a hex color from elsewhere, the red and blue channels need to swap. draw.color(r, g, b, a) handles the packing for you.
2D primitives
| Function | Description |
|---|---|
draw.line(x1, y1, x2, y2, color, [thickness], [glow]) | Line between two points. glow = true draws a soft glow under the line |
draw.rect(x, y, w, h, color, [thickness], [rounding], [glow]) | Outlined rectangle - w/h are width and height, not corner coords. glow = true draws a soft glow under the outline |
draw.rect_filled(x, y, w, h, color, [rounding], [glow]) | Filled rectangle - w/h are width and height, not corner coords. glow = true draws a soft glow underneath |
draw.circle(x, y, radius, color, [thickness], [segments], [glow]) | Outlined circle. glow = true draws a soft glow underneath |
draw.circle_filled(x, y, radius, color, [segments], [glow]) | Filled circle. glow = true draws a soft glow underneath |
draw.rect_gradient(x, y, w, h, color_a, color_b, [vertical]) | Rectangle filled with a two-color gradient. w/h are width and height |
The optional glow boolean exists only on the first five 2D primitives. draw.rect_gradient, the 3D primitives and all text functions ignore any extra argument.
draw.rect_gradient requires both colors, unlike most optional-looking pairs in this namespace. A truthy vertical puts color_a along the top edge and color_b along the bottom. Omitted or false runs color_a on the left to color_b on the right. There is no rounding argument.
Text
Text functions accept an optional font name OR scale parameter. Pass a string for a font name, or a number for scale on the default font.
| Function | Description |
|---|---|
draw.text(x, y, text, color, [font_or_size], [size]) | Text with shadow |
draw.text_raw(x, y, text, color, [font_or_size], [size]) | Text without shadow |
draw.text3d(pos, text, color, [font_or_size], [size]) | Text anchored to a world position (vec3) |
draw.text(100, 100, "Hello", 0xFFFFFFFF) -- default font, scale 1.0
draw.text(100, 120, "Hello", 0xFFFFFFFF, 1.5) -- default font, scale 1.5
draw.text(100, 140, "Hello", 0xFFFFFFFF, "hud") -- custom font, scale 1.0
draw.text(100, 160, "Hello", 0xFFFFFFFF, "hud", 1.5) -- custom font, scale 1.5All text functions return nothing.
draw.text, draw.text_raw and draw.text3d cut the string at 47 bytes. A longer label truncates with no warning and no error, so build long readouts as two lines rather than one.
Fonts
Scripts can use custom fonts. Either pre-load fonts via fonts.lua or let users pick from system fonts via the font picker setting.
| Function | Returns | Description |
|---|---|---|
draw.set_font(name) | - | Set the per-script default font for subsequent draw.text calls. Pass nil, "", or "default" to reset to the overlay default |
draw.get_font() | string | Current per-script default font name |
draw.get_fonts() | table | Array of all loaded custom font names |
Font state is per-script. Calling draw.set_font("Arial") in Script A doesn't affect Script B.
If the font name isn't loaded yet, draw.set_font triggers dynamic loading. The engine searches in this order:
C:\TSUKI\Deadlock\assets\fonts\(custom fonts you add)C:\Windows\Fonts\(installed system fonts)
The first frame may render with the default font while the atlas rebuilds.
fonts.lua (optional pre-loading)
Pre-load fonts at specific sizes into the atlas at startup. Drop a fonts.lua next to your scripts. Not required; the font picker dynamically loads system fonts on demand.
-- C:\TSUKI\Deadlock\scripts\fonts.lua
return {
hud = { file = "roboto.ttf", size = 16 },
small = { file = "consolas.ttf", size = 10 },
big = { file = "inter.ttf", size = 24 },
}.ttf and .otf files go in C:\TSUKI\Deadlock\assets\fonts\. The keys (hud, small, etc.) become the names you pass to draw.set_font.
Font priority
Highest precedence wins:
- Explicit font passed to
draw.text(..., font_name) - Per-script default set via
draw.set_font(name) - Overlay default font
Images
An image is loaded once into a handle, then drawn into a screen rectangle as often as you like. Both loaders return an integer handle, or nil on failure.
| Function | Returns | Description |
|---|---|---|
draw.load_image(file_name) | integer | nil | Load an image by bare file name from the scripts image folder |
draw.load_image_data(base64) | integer | nil | Load an image from base64 bytes embedded in the script |
draw.image(handle, x, y, w, h, [alpha]) | - | Draw a loaded image into a screen rectangle |
draw.image_size(handle) | width, height | nil | Pixel size of a loaded image |
draw.load_image takes a bare file name under C:\TSUKI\Deadlock\scripts\images\. Slashes, colons and .. are rejected.
draw.load_image_data takes the file's bytes as base64, at most 256 KB decoded. Identical bytes share one handle.
PNG and JPG, up to 4096 pixels a side, at most 64 images across all scripts. A second draw.load_image for the same name returns the same handle. A failing name logs once.
Load from on_tick. At script load time the call usually returns nil. Keep the handle in a local and retry until it lands.
Argument order gotcha
draw.image takes the handle first. Arguments 4 and 5 are width and height, not a second corner. alpha is 0 to 1. A stale handle draws nothing.
draw.image_size returns two values. nil for a handle that used to work means the texture was dropped; load it again.
local icon
function on_tick()
icon = icon or draw.load_image("icon.png") -- nil at load time, so ask every tick until it lands
if icon then
local w, h = draw.image_size(icon)
draw.image(icon, 40, 40, 32, 32 * (h / w), 0.9)
end
end3D primitives
3D primitives are reprojected per frame.
| Function | Description |
|---|---|
draw.line3d(p1, p2, color, [thickness]) | Line between two vec3 points |
draw.circle3d(center, radius_px, color, [thickness], [segments]) | Circle outline anchored to a world position. radius_px is a screen-pixel radius, not world units. Only center is reprojected, so the circle keeps the same on-screen size at any distance |
draw.circle3d_filled(center, radius_px, color, [segments]) | Same, filled |
draw.box3d(center, width, depth, height, color, [thickness]) | Axis-aligned box centered at center (vec3) with dimensions in world units along X (width), Y (depth), and Z (height) |
draw.sphere3d(center, radius, color, [thickness], [segments]) | Wireframe sphere (3 great circles); segments clamped 4-32, default 12 |
The radius units differ between the two circle families and the names do not say so. draw.circle3d and draw.circle3d_filled take a radius in screen pixels, while draw.sphere3d takes one in world units. Only draw.box3d and draw.sphere3d take world-unit dimensions; the 3D circles take pixels.
Projection and screen size
| Function | Returns | Description |
|---|---|---|
draw.world_to_screen(vec3) | vec2 | nil | Pixel position as a vec2 with .x and .y, or nil if behind the camera |
draw.screen_size() | vec2 | Screen dimensions as vec2 with .x (width) and .y (height) |
draw.measure_text(text, [font_or_scale], [size]) | vec2 | Rendered pixel size of text as vec2 with .x (width) and .y (height). Same font/scale resolution as draw.text |
world_to_screen is also available as camera.world_to_screen. Same function.
draw.screen_size() is also available as camera.get_screen_size(). Same function.
Example
local WHITE = 0xFFFFFFFF
local RED = 0xFF3232FF
function on_tick()
for _, p in ipairs(get_players()) do
if p:is_enemy() and p:is_alive() then
local box = p:screen_box()
if box then
draw.rect(box.x, box.y, box.w, box.h, RED, 2)
draw.text(box.x, box.y - 14, p:hero_name(), WHITE)
end
end
end
end