starlit

draw

Text, lines, rectangles, circles, gradients, polygons and clipping on the screen, shapes and hitbox outlines in the world, with the six fonts, the three text styles and both ways to pass a colour.

The draw functions put shapes on the screen and in the world. They are used inside events.on_paint and in the on_draw function of a menu surface, and nowhere else, except draw.measure, which also works in the main chunk and in other callbacks. The five functions that draw in the world are described in 3D shapes.

Basics

Coordinates. Pixels, measured from the top left corner of the screen. x grows to the right and y grows downward. screen.size() gives the width and height. Coordinates are clamped to -16384 to 16384. Numbers that are NaN or infinite raise an error.

Recording. A draw function does not draw at once. It writes a command into a buffer of 2000 commands. After on_paint returns without an error, the client draws the buffer. Commands are drawn in the order you made them, so later ones are on top. If on_paint raises an error, the whole buffer of that frame is thrown away.

Layer. Everything is drawn on the same layer as the ESP, under the menu and its dialogs. Inside a surface, drawing goes into the surface's rectangle, in the surface's own coordinates, see menu.

Colours. Wherever a colour is expected you can pass it in one of two ways:

  • Four numbers r, g, b, a. The first three are required, a is optional and is 255 when left out. Each number is clamped to 0 to 255 and rounded.
  • One colour object in the place of those numbers. A colour object is a table with numeric r, g, b and an optional a. The color(...) function makes one, and a plain table such as { r = 255, g = 0, b = 0 } works too. We read the fields directly. No __index or other metamethod of your table is ever run.

Arguments that come after the colour (thickness, font, style) follow either form directly. The gradient functions are the exception: they take colour objects only.

Strings. A text argument has to be a string. Numbers are not converted for you.

Fonts

The font argument is one of six names. They are the faces the menu uses, so a script looks like the rest of the client. The default is ui.

namefacesize
uiregular13
boldbold13
smallregular11
mediumbold15
headerbold12
largebold22

The face is Tahoma when the system has it. Otherwise we use an embedded face of similar size. There is no function to load another font. An unknown name raises unknown font (ui, bold, small, medium, header, large).

Text styles

The style argument of draw.text is "normal" (default), "shadow" or "outline". An unknown name raises unknown text style (normal, shadow, outline).

Functions

draw.text

draw.text(x, y, text, r, g, b [, a [, font [, style]]])
draw.text(x, y, text, color [, font [, style]])
argumenttypemeaning
x, ynumbertop left corner of the text
textstringat most 128 bytes are used. Tabs and line breaks become spaces, other control characters are removed, and an invalid UTF-8 byte becomes ?. There is only one line.
r, g, b, a or colornumber or colour objectthe colour
fontstringone of the six fonts
stylestringone of the three styles

Notes Text longer than 128 bytes is cut at a character boundary. A text command also counts against the 16 KiB of text a script may record in one frame. The characters ## are kept in drawn text. Only control labels remove them.

events.on_paint(function()
  draw.text(20, 20, "plain", 255, 255, 255)
  draw.text(20, 40, "shadow", color(120, 220, 255), "bold", "shadow")
  draw.text(20, 70, "outline", 255, 200, 80, 255, "large", "outline")
end)

draw.line

draw.line(x1, y1, x2, y2, r, g, b [, a [, thickness]])
draw.line(x1, y1, x2, y2, color [, thickness])

thickness is 1 by default and is clamped to 1 to 16.

events.on_paint(function()
  local w, h = screen.size()
  draw.line(w / 2 - 10, h / 2, w / 2 + 10, h / 2, 255, 255, 255, 255, 2)
  draw.line(w / 2, h / 2 - 10, w / 2, h / 2 + 10, 255, 255, 255, 255, 2)
end)

draw.rect

draw.rect(x, y, w, h, r, g, b [, a [, thickness]])
draw.rect(x, y, w, h, color [, thickness])

The outline of a rectangle. w and h are the size. A negative size becomes 0. thickness is 1 by default and is clamped to 1 to 16.

events.on_paint(function()
  draw.rect(100, 100, 80, 40, 255, 255, 255)
end)

draw.rect_filled

draw.rect_filled(x, y, w, h, r, g, b [, a])
draw.rect_filled(x, y, w, h, color)

A filled rectangle.

events.on_paint(function()
  draw.rect_filled(100, 160, 80, 40, 20, 20, 28, 200)
end)

draw.rect_rounded

draw.rect_rounded(x, y, w, h, radius, r, g, b [, a [, thickness]])
draw.rect_rounded(x, y, w, h, radius, color [, thickness])

An outlined rectangle with rounded corners. radius is the corner radius, clamped to 0 to 64, and is required.

events.on_paint(function()
  draw.rect_rounded(100, 220, 120, 50, 8, 190, 170, 255, 255, 2)
end)

draw.rect_rounded_filled

draw.rect_rounded_filled(x, y, w, h, radius, r, g, b [, a])
draw.rect_rounded_filled(x, y, w, h, radius, color)

A filled rectangle with rounded corners. radius is clamped to 0 to 64.

events.on_paint(function()
  draw.rect_rounded_filled(100, 290, 120, 50, 8, color(40, 40, 56, 220))
end)

draw.circle

draw.circle(x, y, radius, r, g, b [, a [, thickness]])
draw.circle(x, y, radius, color [, thickness])

The outline of a circle around the centre x, y. radius is clamped to 0 to 4096.

events.on_paint(function()
  draw.circle(300, 300, 24, 255, 255, 255, 255, 2)
end)

draw.circle_filled

draw.circle_filled(x, y, radius, r, g, b [, a])
draw.circle_filled(x, y, radius, color)

A filled circle.

events.on_paint(function()
  draw.circle_filled(300, 360, 6, 255, 80, 80)
end)

draw.gradient

draw.gradient(x, y, w, h, top_left, top_right, bottom_right, bottom_left [, radius])

A filled rectangle with a colour in each corner. The four colours have to be colour objects, in that order, clockwise from the top left. radius rounds the corners, 0 to 64, and is 0 by default. A number where a colour object is expected raises color object expected.

events.on_paint(function()
  draw.gradient(400, 100, 120, 60, color(255, 0, 0), color(0, 255, 0), color(0, 0, 255), color(255, 255, 0, 128), 6)
end)

draw.gradient_h

draw.gradient_h(x, y, w, h, left, right [, radius])

A filled rectangle that blends from left to right. Both colours are colour objects.

events.on_paint(function()
  draw.gradient_h(400, 180, 120, 20, color(255, 80, 80), color(80, 80, 255))
end)

draw.gradient_v

draw.gradient_v(x, y, w, h, top, bottom [, radius])

A filled rectangle that blends from top to bottom. Both colours are colour objects.

events.on_paint(function()
  local full = color(90, 255, 120)
  draw.gradient_v(400, 210, 8, 60, full, full:alpha_modulate(40))
end)

draw.triangle_filled

draw.triangle_filled(x1, y1, x2, y2, x3, y3, r, g, b [, a])
draw.triangle_filled(x1, y1, x2, y2, x3, y3, color)

A filled triangle between three points.

events.on_paint(function()
  draw.triangle_filled(500, 300, 520, 330, 480, 330, 255, 200, 60)
end)

draw.polyline

draw.polyline(points, r, g, b [, a [, thickness [, closed]]])
draw.polyline(points, color [, thickness [, closed]])
argumenttypemeaning
pointstablea flat array { x1, y1, x2, y2, ... } with 2 to 64 points, so 4 to 128 numbers
closedbooleantrue joins the last point to the first. The default is false.

Errors points has to be a table of finite numbers with an even length. The table is read with raw access, so its metatable is not used. closed has to be a boolean or nil. All points of a frame share one budget of 2048 numbers. When it is used up the command is dropped.

events.on_paint(function()
  draw.polyline({ 600, 100, 640, 140, 680, 100 }, 255, 255, 255, 255, 2)
  draw.polyline({ 600, 160, 640, 200, 680, 160 }, color(255, 200, 80), 2, true)
end)

draw.polygon

draw.polygon(points, r, g, b [, a])
draw.polygon(points, color)

A filled polygon with 3 to 32 points, points as for draw.polyline. The polygon has to be convex. A concave one is filled wrongly, and we do not check for it.

events.on_paint(function()
  draw.polygon({ 600, 240, 640, 240, 660, 280, 620, 300, 590, 280 }, 90, 160, 255, 200)
end)

draw.clip

draw.clip(x, y, w, h)

Everything drawn after this call, until the matching draw.unclip(), is cut to the rectangle. Clips nest, and the visible area is the overlap of all open clips. At most 8 clips can be open. A ninth draw.clip is ignored, and the draw.unclip that was meant for it would close the eighth, so keep the nesting below 8. Clips you leave open at the end of on_paint are closed by the client, so they cannot affect anything else.

events.on_paint(function()
  draw.clip(100, 400, 60, 20)
  draw.text(100, 400, "this text is cut off at the clip", 255, 255, 255)
  draw.unclip()
end)

draw.unclip

draw.unclip()

Closes the innermost open clip. Without an open clip it does nothing.

events.on_paint(function()
  draw.clip(0, 0, 100, 100)
  draw.unclip()
  draw.unclip()
end)

draw.measure

draw.measure(text [, font]) -> w, h

Returns the width and height in pixels of the text in the given font. The text is cut to 128 bytes and cleaned the way draw.text cleans it. draw.measure works outside on_paint too.

Notes It returns two values. In x - draw.measure(t) / 2 only the first one is used.

events.on_paint(function()
  local w, h = screen.size()
  local text = "centred"
  local tw, th = draw.measure(text, "bold")
  draw.text(w / 2 - tw / 2, h - th - 40, text, 255, 255, 255, 255, "bold")
end)

3D shapes

A script can draw in the world, not only on the screen: lines, trajectories, boxes, rings and the hitboxes of a player. Give the shapes world coordinates. Starlit projects them with the game's own view every frame, so they stay glued to the world while the camera moves. Shapes are drawn on the same layer as the ESP, below the menu.

Every 3D function works in events.on_paint, like the other draw functions, and accepts a color(r, g, b, a) object wherever it asks for r, g, b [, a].

  • Units. World units are the game's units. Coordinates, sizes and radii are clamped to -1e6 to 1e6. thickness is 1 to 16 pixels, default 1. Colours and alpha are 0 to 255.
  • Errors. A number that is not finite (nan, inf) raises an error with a fixed text, and so does a wrong type.
  • Clipping. Shapes behind the camera are cut away, and so is everything more than 16384 units in front of it. A line that is half behind you still shows the half in front.
  • Overlay. Like the ESP, shapes are an overlay: nothing hides behind a wall. To let walls hide a shape, test the sight line yourself and skip the call.
  • Caps per script and frame. 256 lines, 16 polylines, 64 boxes, 32 circles, 64 hitboxes, and 1024 projected segments in all. A line counts 1 segment, a polyline its points minus 1 (plus 1 when closed), a box 12, a circle its segments, a hitbox 16. Polyline points together may fill 3072 numbers, which is 16 polylines of 64 points. A shape over a cap is skipped.
  • Cost. 30 instructions per call plus 3 for each segment of the shape. Very many big shapes in one frame end the frame early, like any other busy script.

draw.line3d

draw.line3d(x1, y1, z1, x2, y2, z2, r, g, b [, a [, thickness]])
draw.line3d(x1, y1, z1, x2, y2, z2, color [, thickness])

One segment between two world points.

draw.polyline3d

draw.polyline3d(points, r, g, b [, a [, thickness [, closed]]])
argumenttypemeaning
pointstable{ x1, y1, z1, x2, y2, z2, ... }, 2 to 64 points
closedbooleantrue joins the last point to the first

A path through up to 64 points: a trajectory, a route, a wall outline. A table that is not a list of x, y, z triples of 2 to 64 points raises an error.

draw.box3d

draw.box3d(cx, cy, cz, hx, hy, hz, r, g, b [, a [, fill_alpha [, thickness [, yaw]]]])
argumenttypemeaning
cx, cy, cznumberthe centre of the box
hx, hy, hznumberthe half sizes, 0 or more
fill_alphanumberabove 0 fills the faces that look at you, when the whole box is in front of you
yawnumberturns the box about the vertical axis, in degrees, default 0

The 12 edges of a box.

draw.circle3d

draw.circle3d(x, y, z, radius, r, g, b [, a [, segments [, thickness]]])

A horizontal ring around x, y, z. segments is 3 to 64, default 32.

draw.hitbox

draw.hitbox(slot, id, r, g, b [, a [, thickness]])
argumenttypemeaning
slotintegerthe player's slot, 1 to 64, from entity or game.players()
idintegerthe hitbox, 0 to 18, see the table below

The outline of one hitbox of a player: a ring at each end of the hitbox and four links between them. Enemies, teammates and yourself work, and the outline follows the player's current pose. It returns nothing and draws nothing when the slot is not a living player of the current frame or the id is not 0 to 18. That is not an error, because players come and go between frames.

idhitboxidhitbox
0head10left calf
1neck11right foot
2pelvis12left foot
3stomach13right hand
4lower chest14left hand
5chest15right upper arm
6upper chest16right forearm
7right thigh17left upper arm
8left thigh18left forearm
9right calf

Example: a jump trajectory

Where the local player lands if it jumps now, on flat ground.

events.on_paint(function()
  local me = entity.local_player()
  if not me or not me:is_alive() then return end
  local x, y, z = me:origin()
  local vx, vy, vz = me:velocity()
  vz = vz + 301                                   -- the jump
  local pts = {}
  for i = 0, 20 do
    local t = i * 0.05
    pts[#pts + 1] = x + vx * t
    pts[#pts + 1] = y + vy * t
    pts[#pts + 1] = z + vz * t - 400 * t * t        -- gravity 800
  end
  draw.polyline3d(pts, 90, 200, 255, 220, 2)
end)

Example: a box and a range ring

A box around the local player, turning with the view, and a ring of 600 units on the floor.

events.on_paint(function()
  local me = entity.local_player()
  if not me or not me:is_alive() then return end
  local x, y, z = me:origin()
  local _, yaw = me:eye_angles()
  draw.box3d(x, y, z + 36, 16, 16, 36, 255, 255, 255, 200, 40, 1, yaw)
  draw.circle3d(x, y, z + 1, 600, 255, 200, 0, 200, 48, 2)
end)

Example: enemy hitboxes

The hitboxes of the first living enemy, the head in red.

events.on_paint(function()
  for _, p in ipairs(entity.players()) do
    if p:is_enemy() and p:is_alive() then
      for id = 0, 18 do
        if id == 0 then draw.hitbox(p:index(), id, 255, 60, 60, 255, 2)
        else draw.hitbox(p:index(), id, 255, 255, 255, 120) end
      end
      break
    end
  end
end)

previous: events · next: ui