Skip to content

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

lua
local WHITE  = 0xFFFFFFFF
local RED    = 0xFF0000FF  -- red in the low byte, FF alpha
local GREEN  = 0xFF00FF00
local BLUE   = 0xFFFF0000
local YELLOW = 0xFF00FFFF

Use draw.color to build colors from RGBA values without packing them by hand:

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

lua
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 ​

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

lua
local pct = remaining / total
draw.arc(x, y, 18, draw.color(90, 200, 255), 3, 270, pct)   -- clockwise from the top

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

lua
-- 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 ​

FunctionDescription
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 ​

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

FunctionDescription
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
lua
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.5

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

FunctionReturnsDescription
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()stringCurrent per-script default font name, or "default" when none is set
draw.get_fonts()tableArray of all loaded custom font names
draw.load_font(name, [size_pt], [fallbacks])true or nilLoad 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 nilThe 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:

  1. C:\TSUKI\Deadlock\assets\fonts\ (custom fonts you add)
  2. 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:

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

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

  1. Explicit font passed to draw.text(..., font_name)
  2. Per-script default set via draw.set_font(name)
  3. 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.

FunctionReturnsDescription
draw.load_image(file_name)integer | nilLoad an image by bare file name from the scripts image folder
draw.load_image_data(base64)integer | nilLoad 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 | nilPixel size of a loaded image
draw.unload_image(handle)booleanFree an image your script loaded. true when your script held it
draw.update_image(handle, base64)true | false, reasonReplace 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.

lua
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
end

3D primitives ​

3D primitives are reprojected per frame.

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

lua
-- 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) end

Projection and screen size ​

FunctionReturnsDescription
draw.world_to_screen(vec3)vec2 | nilPixel position as a vec2 with .x and .y, or nil if behind the camera
draw.screen_size()vec2Screen dimensions as vec2 with .x (width) and .y (height)
draw.measure_text(text, [font_or_scale], [size])vec2Rendered 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 ​

FunctionReturnsDescription
hud.minimap_rect()table or nilWhere the minimap sits on screen
hud.minimap_grid_to_screen(gx, gy, [flip])x, y or nilA 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.

lua
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
end

The 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 ​

lua
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