Draw
2D and 3D drawing primitives. Draw calls go into a per-tick buffer. The buffer is cleared at the start of every tick and sent to the overlay at the end of it, so anything you draw lasts 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 limit draws nothing and raises no error. draw.box3d uses 12 shape slots. draw.sphere3d uses three per segment, which is 36 at its default segment count.
Event callbacks (on_kill, on_ability_cast, ...) run in the same tick as on_tick, so you can draw from them. The drawing only shows for that one tick unless you save the state and draw it again from on_tick. ui.button callbacks are the exception. They run before the buffer is cleared, so anything drawn there is discarded.
Colors
Colors are packed 32-bit integers 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 values without packing them by hand:
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 you can pass the result of a color picker 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, so if you copy a hex color from somewhere else, swap the red and blue channels. draw.color(r, g, b, a) does 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], [rounding]) | Rectangle filled with a two-color gradient. w/h are width and height. rounding is the corner radius in pixels |
draw.arc(x, y, radius, color, [thickness], [start_deg], [frac], [filled], [segments]) | Part of a circle. See below |
draw.triangle(x1, y1, x2, y2, x3, y3, color, [thickness]) | Triangle outline. The points can be in any order |
draw.triangle_filled(x1, y1, x2, y2, x3, y3, color) | Filled triangle |
draw.polygon(points, color, [thickness], [closed]) | Outline through a list of points. closed defaults to true. See below |
draw.polygon_filled(points, color) | Filled shape through a list of points. See below |
Only the first five 2D primitives take glow. The 3D primitives and the text functions ignore extra arguments. draw.rect_gradient is different. Its eighth argument is the corner radius, so passing true there raises an error.
draw.rect_gradient requires both colors. A truthy vertical puts color_a along the top edge and color_b along the bottom. If vertical is omitted or false, the gradient runs from color_a on the left to color_b on the right. To round the corners, pass vertical (or false) and then the radius: draw.rect_gradient(x, y, w, h, a, b, false, 6).
Arcs
draw.arc draws part of a circle. Angles run clockwise from 3 o'clock, so 0 is right, 90 is down and 270 is up. frac is how much of a full turn to draw, from 0 to 1. start_deg defaults to 270 and frac to 1, so the default is a full circle starting at the top. Pass filled = true for a pie slice instead of an outline.
local pct = remaining / total
draw.arc(x, y, 18, draw.color(90, 200, 255), 3, 270, pct) -- clockwise from the topCooldown rings, respawn timers and health arcs are all this call with a different frac.
Polygons
points can be a list of {x, y} pairs, a list of tables with x and y fields, a list of vec2, or one flat list {x1, y1, x2, y2, ...}. Up to 128 points are used. An outline needs at least 2 points and a filled polygon at least 3. With fewer, nothing is drawn. A filled polygon can be concave (an arrow, a star), but its edges must not cross each other.
A polygon uses one shape slot per point, plus one. When the buffer cannot fit the whole polygon, nothing of it is drawn.
-- a play button
draw.triangle_filled(100, 100, 100, 130, 126, 115, draw.color(255, 255, 255))
-- an arrow
draw.polygon_filled({ {0, 10}, {20, 10}, {20, 0}, {35, 15}, {20, 30}, {20, 20}, {0, 20} }, draw.color(90, 200, 255))Clipping
| Function | Description |
|---|---|
draw.push_clip(x, y, w, h, [intersect]) | Confine following draws to a rectangle |
draw.pop_clip() | Stop clipping |
Everything drawn between the two calls is cut to the rectangle, including text. intersect defaults to true, so a nested clip narrows the current region instead of replacing it. A clip you forget to pop is closed when your script's callbacks end, so it never clips another script.
Up to 64 clip rectangles per tick, counted across all scripts, can carry text. Past that, text draws unclipped.
Whole-script alpha
| Function | Description |
|---|---|
draw.set_alpha(a) | Multiply the alpha of everything this script draws next, a from 0 to 1 |
draw.reset_alpha() | Reset the alpha to full |
It applies to shapes, images and text, so one call fades a whole panel. It only affects your own script, and it is reset to full before each script's callbacks run.
Text
Text functions take an optional font name or scale. Pass a string for a font name, or a number to scale 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_px(x, y, text, color, font, px, [shadow]) | Text at an exact pixel height. shadow defaults to true |
draw.measure_px(text, font, px) | vec2 size at that pixel height |
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.5draw.text, draw.text_raw, draw.text3d and draw.text_px return nothing and cut the string at 47 bytes. Japanese and Korean characters take 3 bytes each, so about 15 of them fit. Longer text is cut off with no warning or error, so split long readouts into two lines.
Fonts
Scripts can use custom fonts. You can pre-load fonts with fonts.lua, or let users pick a system font with the font picker setting.
| Function | Returns | Description |
|---|---|---|
draw.set_font(name) | - | Set this script's default font for later draw.text calls. Pass nil, "", or "default" to reset to the overlay default |
draw.get_font() | string | Current per-script default font name, or "default" when none is set |
draw.get_fonts() | table | Array of all loaded custom font names |
draw.load_font(name, [size_pt], [fallbacks]) | true or nil | Load a font by file stem at a point size. size_pt defaults to 14 and is clamped to 6 to 96. nil means no file matched. fallbacks is up to 3 more font names, comma-separated, used for characters the font does not have. See below |
draw.font_size(name) | number or nil | The pixel height the font was rasterized at |
Font state is per-script. Calling draw.set_font("Arial") in Script A doesn't affect Script B. Loaded fonts are shared. At most 15 can be loaded, counted across every script, fonts.lua and the font picker, and TSUKI uses one of them. A font stays loaded until TSUKI restarts. Past the limit, draw.load_font returns nil and draw.set_font keeps the current font.
If the font isn't loaded yet, draw.set_font loads it. It searches these folders in 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 new font loads.
.ttf, .otf and .ttc files are all found. A .ttc file holds several fonts, and the first one is used.
Mixing alphabets
Many fonts, including most Latin ones, have no Japanese or Korean characters and cannot draw them. Name one or more fallback fonts as the third argument, and characters missing from the main font are taken from them:
draw.load_font("segoeui", 14, "malgun,YuGothM") -- Segoe UI for Latin, the fallbacks for Korean and Japanese
draw.text_px(40, 40, "Score 12 점수 スコア", draw.color(255, 255, 255), "segoeui", 18)Fallbacks are found the same way as the font itself. One that is not found is skipped, with a warning in the in-app console. Fallbacks only count the first time a font is loaded. If that font is already loaded, by draw.set_font, the font picker or another script, draw.load_font returns true and ignores the size and the fallbacks. Load the font with its fallbacks before anything else uses it. Large Asian fonts take some memory, so only add the fallbacks you need.
fonts.lua (optional pre-loading)
Pre-load fonts at specific sizes at startup. Put a fonts.lua file next to your scripts. This is optional, because the font picker loads system fonts when they are needed.
-- 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, .otf and .ttc files go in C:\TSUKI\Deadlock\assets\fonts\. The keys (hud, small, etc.) become the names you pass to draw.set_font.
Font priority
From highest to lowest priority:
- Explicit font passed to
draw.text(..., font_name) - Per-script default set via
draw.set_font(name) - Overlay default font
The overlay default font follows General > Overlay Font in the menu. With Deadlock Font picked there, text without a font uses the game's own font.
Images
Load an image once to get a handle, then draw it 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_or_tint]) | - | Draw a loaded image into a screen rectangle. Argument 6 is an alpha from 0 to 1, or a packed draw.color() to tint with |
draw.image_size(handle) | width, height | nil | Pixel size of a loaded image |
draw.unload_image(handle) | boolean | Free an image your script loaded. true when your script held it |
draw.update_image(handle, base64) | true | false, reason | Replace the picture behind a handle your script loaded, keeping the handle |
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. Loading identical bytes again in the same script returns the same handle.
Images can be PNG or JPG, up to 4096 pixels a side. At most 64 images can be loaded at once across all scripts. Calling draw.load_image again with the same name in the same script returns the same handle. Two scripts that load the same file each get their own handle and use their own slot. A name that fails to load is logged once.
draw.unload_image(handle) gives the image back, and its slot is freed once no script holds it. Game images are shared between scripts, so one that another script still uses stays loaded. Everything a script loaded is also given back when the script resets or reloads. Once the slot is freed, the old handle draws nothing, even if a new image takes the same slot.
draw.update_image(handle, base64) swaps in new pixels under the same handle, for example a new album cover or thumbnail. It only works on images your script loaded with draw.load_image or draw.load_image_data. The new data has the same 256 KB limit as draw.load_image_data. Keep using the handle you have: loading the original file or data again gives a new handle with the old picture. Each call replaces the whole image, so use it for pictures that change now and then, not for video. In Enhanced and Internal mode the new picture can take a moment to appear.
Load from on_tick. At script load time the call usually returns nil, so keep the handle in a local and retry until it works.
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. If it returns nil for a handle that used to work, the texture was dropped and you need to load it again.
local icon
function on_tick()
icon = icon or draw.load_image("icon.png") -- nil at load time, so retry every tick until it loads
if icon then
local w, h = draw.image_size(icon)
if w then
draw.image(icon, 40, 40, 32, 32 * (h / w), 0.9)
else
icon = nil -- the texture was dropped, load it again
end
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 made of 3 great circles. The segment count is clamped between 4 and 32, default 12 |
draw.ring3d(center, radius, color, [thickness], [segments]) | A flat, horizontal circle around center (a vec3) at the same height, radius in world units. Segments 3 to 128, default 48. Parts behind the camera are left out. It uses one shape slot. Use it for ability ranges |
draw.ring3d_filled(center, radius, color, [segments]) | The same, filled. It is only drawn while the whole ring is in front of the camera |
draw.circle3d and draw.circle3d_filled take a radius in screen pixels, so they stay the same size at any distance. draw.ring3d, draw.ring3d_filled and draw.sphere3d take a radius in world units, so they shrink with distance like the world does.
-- a 20 metre ring around you
local me = local_player()
if me then draw.ring3d(me:get_position(), 20 * 39.37, draw.color(120, 200, 255, 160), 2) endProjection 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.
Minimap geometry
| Function | Returns | Description |
|---|---|---|
hud.minimap_rect() | table or nil | Where the minimap sits on screen |
hud.minimap_grid_to_screen(gx, gy, [flip]) | x, y or nil | A map grid cell as a screen position. Two numbers, not a vec2 |
hud.minimap_rect() returns { x, y, w, h, cx, cy, r, scale }, where cx/cy are the centre, r is the radius and w is the diameter of the map disc. It is nil until there is a screen size. It tells you where the minimap would be. It does not tell you whether the game is showing it right now, for example with the shop open or while dead. Your script has to decide that.
hud.minimap_grid_to_screen converts a map grid cell (the coordinates that entities.camps() reports) into a screen position. The map is rotated for team 3, so pass flip = -1 when your team (p:get_team()) is 3.
local m = hud.minimap_rect()
local me = local_player()
if m and me then
local flip = me:get_team() == 3 and -1 or 1
for _, c in ipairs(entities.camps() or {}) do
local px, py = hud.minimap_grid_to_screen(c.gx, c.gy, flip)
if px and not c.alive then
draw.circle_filled(px, py, 3, draw.color(255, 90, 90))
end
end
endThe exact placement shifts a little with interface scale, so expect to give users a small offset and size slider to fine-tune the alignment.
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