xiao.paint

Draw shapes, text and images over the game, one frame at a time, from paint listeners.

xiao.paint draws shapes, text and images over the game.

Everything you draw lasts one frame: register a paint listener and draw again on every call. Shapes drawn by the other callbacks of a frame (signals, timers, on_frame) are shown too, but shapes drawn by the main chunk are discarded before the next frame starts.

At a glance

Group Functions
Listeners listener.add, listener.remove, listener.exists, add, remove, screen
Points point.vector2, point.vector3
Colors color.rgb, color.rgba, color.hex, color.hsv, color.hsl
Shapes line, circle, tri, rect, qrot, quad, poly
Text text.left, text.center, text.right, text.get_size, text.bake, text.unbake, text.isbaked, text.font
Images image.bake, image.normal, image.round
Limit Value
Draw commands per frame 16384, extra shapes are dropped
Points per frame 65536, extra shapes are dropped
Polygon vertices 3 to 4096
Text length 1024 bytes, longer text is cut
Raw image size 1 to 8192 pixels per side

Listeners

A paint listener is a callback run once per frame with the screen size, at the last step of the order of a frame. Listeners run in the order they were first added. A listener that raises an error is removed and the error is printed in the console (paint listener 'hud' removed: ...). Each frame has a 100 ms time budget.

xiao.paint.listener.add(id, callback)

xiao.paint.listener.add(id: any, callback: function(w: number, h: number))

Registers callback, called every frame with the screen size. Adding an existing id replaces its callback and keeps its place in the order.

Name Type Description
id any identifier of the listener (a string is the usual choice), not nil
callback function called as callback(w, h) every frame

Returns: nothing. Errors: bad argument #1 to 'add' (identifier expected), bad argument #2 to 'add' (function expected).

local origin = xiao.paint.point.vector2(10, 10)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.listener.add("hud", function(w, h)
    xiao.paint.text.left(origin, "hello", white)
end)

xiao.paint.listener.remove(id): boolean

xiao.paint.listener.remove(id: any): boolean
Name Type Description
id any identifier given to add

Returns: true if the listener existed and was removed. Errors: none.

xiao.paint.listener.exists(id): boolean

xiao.paint.listener.exists(id: any): boolean
Name Type Description
id any identifier given to add

Returns: true if a listener is registered under id. Errors: none.

xiao.paint.add(id, callback)

xiao.paint.add(id: any, callback: function(w: number, h: number))

Alias of xiao.paint.listener.add.

xiao.paint.remove(id): boolean

xiao.paint.remove(id: any): boolean

Alias of xiao.paint.listener.remove.

xiao.paint.screen(): number, number

xiao.paint.screen(): number, number

Returns: the screen size w, h in pixels during a frame, 0, 0 outside a frame (main chunk, hooks).

xiao.paint.add("size", function()
    local w, h = xiao.paint.screen()
    xiao.paint.text.left(xiao.paint.point.vector2(10, h - 30), w .. "x" .. h, xiao.paint.color.rgb(255, 255, 255))
end)

Points

Any table with numeric x and y fields is accepted wherever a point is expected.

  • A point with only x and y is a screen point, in pixels.
  • A point that also has a numeric z is a world point, in blocks. It is projected to the screen every time it is drawn, and the shape is skipped when a point is behind the camera.
  • Sizes (size arguments) always use x and y in pixels, even when the origin is a world point.

A value that is not a table raises bad argument #n to '...' (paint_point expected, got ...); a table without numeric x and y raises paint_point needs numeric x and y. See xiao.game.world_to_screen if you need the projected coordinates yourself.

xiao.paint.point.vector2(x, y): paint_point

xiao.paint.point.vector2(x: number, y: number): paint_point
Name Type Description
x, y number screen coordinates in pixels, finite

Returns: a screen point {x, y}, printed as vector2(x, y) by tostring. Errors: bad argument #n to 'vector2' (finite number expected).

xiao.paint.point.vector3(x, y, z): paint_point

xiao.paint.point.vector3(x: number, y: number, z: number): paint_point
Name Type Description
x, y, z number world coordinates in blocks, finite

Returns: a world point {x, y, z}, printed as vector3(x, y, z) by tostring. Errors: bad argument #n to 'vector3' (finite number expected).

local marker = xiao.paint.point.vector3(100, 64, -20)
local red = xiao.paint.color.rgb(255, 60, 60)
xiao.paint.add("marker", function()
    xiao.paint.circle.filled(marker, red, 4)
end)

Colors

Any table with numeric r, g, b fields (0 to 255) and an optional a (default 255) is accepted wherever a color is expected. Fields can be changed at runtime, for example color.r = 128. Channels are clamped to 0 to 255 and rounded. A number 0xRRGGBBAA is accepted too.

A value that is neither raises bad argument #n to '...' (paint_color expected, got ...); a table without numeric r, g, b raises paint_color needs numeric r, g and b.

xiao.paint.color.rgb(r, g, b): paint_color

xiao.paint.color.rgb(r: number, g: number, b: number): paint_color
Name Type Description
r, g, b number channels, 0 to 255

Returns: an opaque color, alpha 255.

xiao.paint.color.rgba(r, g, b, a): paint_color

xiao.paint.color.rgba(r: number, g: number, b: number, a?: number = 255): paint_color
Name Type Description
r, g, b number channels, 0 to 255
a number alpha, 0 to 255, defaults to 255

Returns: a color with alpha.

xiao.paint.color.hex(hex): paint_color

xiao.paint.color.hex(hex: string): paint_color
Name Type Description
hex string #RRGGBB or #RRGGBBAA, the # is optional

Returns: the color, opaque for 6 digits. Errors: bad argument #1 to 'hex' (#RRGGBB or #RRGGBBAA expected), bad argument #1 to 'hex' (invalid hexadecimal digit).

xiao.paint.color.hsv(h, s, v, a): paint_color

xiao.paint.color.hsv(h: number, s: number, v: number, a?: number = 255): paint_color
Name Type Description
h number hue in degrees, any value (wrapped to 0 to 360)
s number saturation, 0 to 1 (clamped)
v number value, 0 to 1 (clamped)
a number alpha, 0 to 255, defaults to 255

Returns: the color.

xiao.paint.color.hsl(h, s, l, a): paint_color

xiao.paint.color.hsl(h: number, s: number, l: number, a?: number = 255): paint_color

Same as hsv, with l (lightness, 0 to 1) in place of v.

Returns: the color.

local solid = xiao.paint.color.hex("#3b82f6")
local glass = xiao.paint.color.hex("3b82f680")
local rainbow = xiao.paint.color.hsv((os.clock() * 60) % 360, 1, 1)
local pastel = xiao.paint.color.hsl(200, 0.6, 0.8, 200)

Shapes

Every shape takes points and colors as described above and returns nothing. Most shapes end with optional arguments:

Name Type Description
thickness number line width of hollow shapes and lines. Default 1
outline paint_color outline color. Default black {0, 0, 0, 255}
outline_thickness number outline width. Default 0, so no outline is drawn until you set it

Numbers must be finite (bad argument #n to '...' (finite number expected)); a negative radius, rounding, thickness or outline width counts as 0.

xiao.paint.line(p1, p2, color, ...)

xiao.paint.line(p1: paint_point, p2: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a line between two points.

Name Type Description
p1, p2 paint_point ends of the line
color paint_color line color

xiao.paint.circle.filled(center, color, radius, ...)

xiao.paint.circle.filled(center: paint_point, color: paint_color, radius: number, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a filled circle.

Name Type Description
center paint_point center
color paint_color fill color
radius number radius in pixels

xiao.paint.circle.hollow(center, color, radius, ...)

xiao.paint.circle.hollow(center: paint_point, color: paint_color, radius: number, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a circle ring. Same parameters as circle.filled, plus thickness.

xiao.paint.tri.filled(p1, p2, p3, color, ...)

xiao.paint.tri.filled(p1: paint_point, p2: paint_point, p3: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a filled triangle.

Name Type Description
p1, p2, p3 paint_point corners
color paint_color fill color

xiao.paint.tri.hollow(p1, p2, p3, color, ...)

xiao.paint.tri.hollow(p1: paint_point, p2: paint_point, p3: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a triangle outline. Same parameters as tri.filled, plus thickness.

xiao.paint.rect.filled(top_left, size, color, ...)

xiao.paint.rect.filled(top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a filled rectangle.

Name Type Description
top_left paint_point top left corner
size paint_point x is the width and y the height, in pixels
color paint_color fill color

xiao.paint.rect.hollow(top_left, size, color, ...)

xiao.paint.rect.hollow(top_left: paint_point, size: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a rectangle outline. Same parameters as rect.filled, plus thickness.

xiao.paint.rect.rfilled(rounding, top_left, size, color, ...)

xiao.paint.rect.rfilled(rounding: number, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Filled rectangle with rounded corners. rounding is the corner radius in pixels; the other parameters are those of rect.filled.

xiao.paint.rect.rhollow(rounding, top_left, size, color, ...)

xiao.paint.rect.rhollow(rounding: number, top_left: paint_point, size: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Rounded rectangle outline. Same parameters as rect.rfilled, plus thickness.

xiao.paint.rect.gradient(top_left, size, ...)

xiao.paint.rect.gradient(top_left: paint_point, size: paint_point, up_left: paint_color, up_right: paint_color, bottom_left: paint_color, bottom_right: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Rectangle with one color per corner, blended across the surface.

Name Type Description
top_left, size paint_point as in rect.filled
up_left, up_right, bottom_left, bottom_right paint_color color of each corner

xiao.paint.qrot.filled(center, size, color, rotation, ...)

xiao.paint.qrot.filled(center: paint_point, size: paint_point, color: paint_color, rotation: number, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Filled rectangle drawn around its center and rotated.

Name Type Description
center paint_point center of the rectangle
size paint_point width and height in pixels
color paint_color fill color
rotation number clockwise rotation in degrees

xiao.paint.qrot.hollow(center, size, color, rotation, ...)

xiao.paint.qrot.hollow(center: paint_point, size: paint_point, color: paint_color, rotation: number, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Rotated rectangle outline. Same parameters as qrot.filled, plus thickness.

xiao.paint.quad.filled(p1, p2, p3, p4, color, ...)

xiao.paint.quad.filled(p1: paint_point, p2: paint_point, p3: paint_point, p4: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a filled quadrilateral.

Name Type Description
p1 to p4 paint_point corners, in drawing order
color paint_color fill color

xiao.paint.quad.hollow(p1, p2, p3, p4, color, ...)

xiao.paint.quad.hollow(p1: paint_point, p2: paint_point, p3: paint_point, p4: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Draws a quadrilateral outline. Same parameters as quad.filled, plus thickness.

xiao.paint.poly.filled(vertices, color, ...)

xiao.paint.poly.filled(vertices: paint_point[], color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Filled polygon. Concave shapes are allowed.

Name Type Description
vertices paint_point[] array of 3 to 4096 points
color paint_color fill color

Errors: bad argument #1 to 'filled' (between 3 and 4096 vertices expected).

xiao.paint.poly.hollow(vertices, color, ...)

xiao.paint.poly.hollow(vertices: paint_point[], color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)

Polygon outline. Same parameters and limits as poly.filled, plus thickness.

local paint = xiao.paint
local fill = paint.color.rgba(59, 130, 246, 160)
local edge = paint.color.rgb(255, 255, 255)
local top_left = paint.point.vector2(40, 40)
local size = paint.point.vector2(160, 60)

paint.add("panel", function()
    paint.rect.rfilled(8, top_left, size, fill, edge, 1)
end)

Text

Text is outlined only when the outline argument is given. Text longer than 1024 bytes is cut. The font used is the current font, "default" unless you select another with text.font.

xiao.paint.text.left(point, text, color, outline)

xiao.paint.text.left(point: paint_point, text: string, color: paint_color, outline?: paint_color)

Draws text with point as its left edge and top.

Name Type Description
point paint_point position of the text
text string text to draw
color paint_color text color
outline paint_color optional 1 pixel outline color

Returns: nothing.

xiao.paint.text.center(point, text, color, outline)

xiao.paint.text.center(point: paint_point, text: string, color: paint_color, outline?: paint_color)

Same as text.left, point is the horizontal center.

xiao.paint.text.right(point, text, color, outline)

xiao.paint.text.right(point: paint_point, text: string, color: paint_color, outline?: paint_color)

Same as text.left, point is the right edge.

xiao.paint.text.get_size(text): table

xiao.paint.text.get_size(text: string): table
xiao.paint.text.get_size(font_name: string, text: string): table

Measures text with the current font, or with the named font when two arguments are given. Useful to center a box around some text.

Name Type Description
font_name string optional baked font name
text string text to measure

Returns: {width, height} in pixels, measured with "default" when font_name is not baked; zero sizes inside a hook.

local size = xiao.paint.text.get_size("hello")
print(size.width, size.height)

xiao.paint.text.bake(font_name, font_data, size): boolean

xiao.paint.text.bake(font_name: string, font_data: string, size: number): boolean

Loads a TTF or OTF font from memory under font_name.

Name Type Description
font_name string name used by text.font and text.get_size
font_data string whole font file, for example from xiao.fs.read
size number pixel size, > 0

Returns: true on success or when font_name is already baked ("default" included, nothing is loaded again); false for bad data, an empty string, a size <= 0, or inside a hook. Errors: bad argument #3 to 'bake' (finite number expected).

xiao.paint.text.unbake(font_name): boolean

xiao.paint.text.unbake(font_name: string): boolean

Frees a baked font. If it was the current font, the current font goes back to "default".

Returns: true if the font existed and was freed; false for "default", an unknown name, or inside a hook.

xiao.paint.text.isbaked(font_name): boolean

xiao.paint.text.isbaked(font_name: string): boolean

Returns: true if the font is baked. "default" is always baked.

xiao.paint.text.font(font_name): string

xiao.paint.text.font(font_name?: string): string

Selects the font used by the next text calls. Without argument, only reads the current name.

Returns: the current font name, after the change. Errors: bad argument #1 to 'font' (font is not baked).

local ok, data = pcall(xiao.fs.read, "lua/fonts/consola.ttf")
if not ok or not xiao.paint.text.bake("Consolas", data, 16) then
    error("could not load the font")
end

local point = xiao.paint.point.vector2(40, 40)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.add("hello", function()
    xiao.paint.text.font("Consolas")
    xiao.paint.text.left(point, "hello world", white)
    xiao.paint.text.font("default")
end)

Images

An image is a userdata (tostring gives xiao.image(1)). It is freed automatically once Lua no longer references it, and on Clear VM.

xiao.paint.image.bake(data): image | false

xiao.paint.image.bake(data: string): image | false
Name Type Description
data string a full image file in memory (PNG, JPEG, BMP...)

Returns: an image, or false if the data is empty, cannot be decoded, or inside a hook.

xiao.paint.image.bake(width, height, rgba): image | false

xiao.paint.image.bake(width: number, height: number, rgba: string): image | false

Builds an image from raw pixels.

Name Type Description
width, height number whole numbers from 1 to 8192
rgba string exactly width * height * 4 bytes of RGBA8 pixels, row by row

Returns: an image, or false if the size does not match or inside a hook.

xiao.paint.image.normal(image, top_left, size, color, outline)

xiao.paint.image.normal(image: image, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color)

Draws the image.

Name Type Description
image image result of image.bake
top_left paint_point top left corner
size paint_point width and height in pixels
color paint_color tint: white keeps the image as is
outline paint_color optional 1 pixel outline color

Errors: bad argument #1 to 'normal' (xiao.image expected, got boolean) when bake returned false.

xiao.paint.image.round(image, rounding, top_left, size, color, outline)

xiao.paint.image.round(image: image, rounding: number, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color)

Draws the image with rounded corners. rounding is the corner radius in pixels; the other parameters are those of image.normal.

local pixel = xiao.paint.image.bake(1, 1, "\255\0\0\255")
local origin = xiao.paint.point.vector2(50, 50)
local size = xiao.paint.point.vector2(64, 64)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.add("square", function()
    if pixel then xiao.paint.image.normal(pixel, origin, size, white) end
end)

Types

paint_point

Field Type Description
x number x coordinate (pixels, or blocks for a world point)
y number y coordinate (pixels, or blocks for a world point)
z number optional. Makes the point a world position projected to the screen at each draw

paint_color

Field Type Description
r number red, 0 to 255
g number green, 0 to 255
b number blue, 0 to 255
a number optional alpha, default 255

Good practice

  • Create colors and points once outside the listener and reuse them.
  • Mutate their fields (color.r, center.x) instead of allocating new tables each frame.
  • Keep the listener cheap: it has 100 ms per frame, and an error removes it.
local paint = xiao.paint
local color = paint.color.rgba(255, 255, 255, 255)
local center = paint.point.vector2(0, 0)
local black = paint.color.rgb(0, 0, 0)

paint.add("pulse", function(w, h)
    color.r = (os.clock() % 1) * 255
    center.x, center.y = w / 2, h / 2
    paint.circle.filled(center, color, 8, black, 1)
end)

Complete example

A small HUD: a rounded panel, a title, and a ring that follows a world position.

local paint = xiao.paint
local panel_fill = paint.color.rgba(20, 20, 28, 200)
local accent = paint.color.hex("#3b82f6")
local white = paint.color.rgb(255, 255, 255)
local panel_pos = paint.point.vector2(20, 20)
local panel_size = paint.point.vector2(180, 44)
local title_pos = paint.point.vector2(32, 34)
local target = paint.point.vector3(0, 70, 0)

paint.add("demo.hud", function(w, h)
    paint.rect.rfilled(8, panel_pos, panel_size, panel_fill, accent, 1)
    paint.text.left(title_pos, "XiaoHook", white)
    paint.circle.hollow(target, accent, 6, 2)
end)

Remove it when you are done:

xiao.paint.listener.remove("demo.hud")