Draw an overlay
This guide builds a small HUD (screen coordinates) and a name tag overlay on players and mobs (world coordinates), using xiao.paint and xiao.game.
How drawing works
-
You register a paint listener with
xiao.paint.add(id, function(width, height) ... end). -
The listener is called once per frame, after
on_frameand thethinksignal, with the screen size in pixels (see Order of a frame). -
A shape lasts one frame, so you issue it again in every call.
-
Adding a listener with an existing id replaces it, so re-executing a script does not stack overlays.
Screen points and world points
A point is any table with numeric x and y. If it also has a numeric z, it is a world position: it is projected to the
screen every time it is drawn, and the shape is skipped when the point is behind the camera.
Sizes (size, radius, thickness) are always in pixels, even when the origin is a world point.
Part 1: a HUD
The player state comes from xiao.game.player(), which returns nil outside a world. Fields the client could not read
this frame are absent, so give numbers a fallback.
local paint = xiao.paint
local white = paint.color.rgb(255, 255, 255)
local shadow = paint.color.rgb(0, 0, 0)
local panel = paint.color.rgba(0, 0, 0, 140)
local origin = paint.point.vector2(16, 200)
local panel_size = paint.point.vector2(230, 70)
local cursor = paint.point.vector2(0, 0)
paint.add("hud", function(width, height)
local me = xiao.game.player()
if me == nil then return end
paint.rect.rfilled(6, origin, panel_size, panel)
local held = xiao.game.held_item()
local lines = {
("hp %.0f / %.0f"):format(me.health or 0, me.max_health or 0),
("%s x %.1f z %.1f"):format(me.dimension_name or "?", me.x or 0, me.z or 0),
("hand %s"):format(held and held.name or "empty"),
}
for index, line in ipairs(lines) do
cursor.x, cursor.y = origin.x + 10, origin.y + 8 + (index - 1) * 18
paint.text.left(cursor, line, white, shadow)
end
end)
origin, panel_size and cursor are created once outside the listener and reused: changing cursor.x and cursor.y
is cheaper than building a new point for every line of every frame.
Part 2: name tags in the world
xiao.game.players() and xiao.game.entities() return the visible players and mobs. Each
entity has id, x, y, z, position, name, is_player, and, when known, type,
distance, health and max_health.
Because head has a z field, the text is anchored to the world position and follows the entity. When the entity is
behind the camera nothing is drawn.
Part 3: connect two points
xiao.game.world_to_screen(x, y, z) (or world_to_screen(point)) gives you the screen position yourself, or nil
behind the camera. This draws a line from the bottom center of the screen to each player:
local paint = xiao.paint
local red = paint.color.rgba(255, 60, 60, 200)
paint.add("tracers", function(width, height)
local from = paint.point.vector2(width / 2, height)
for _, player in ipairs(xiao.game.players()) do
local to = xiao.game.world_to_screen(player.x, player.y, player.z)
if to ~= nil then
paint.line(from, to, red, 1.5)
end
end
end)
Remove an overlay
xiao.paint.remove("tracers")
remove returns true when the listener existed. Clear VM removes all of them.
Pitfalls and limits
-
xiao.gameis readable only during a frame: packet receivers, client signal callbacks, timers,on_frameand paint listeners. Elsewhere it returns empty results, see Execution contexts. -
The game snapshot is taken once per frame: all calls in the same frame return the same values.
-
A paint listener that raises an error is removed and the error is printed. Check optional fields (
health,distance,type) before using them. -
The frame step (client signals, timers,
on_frame,thinkand every paint listener) shares a 100 ms budget. If it is exceeded, the VM is cleared. -
Text longer than 1024 bytes is cut.
-
Inside a packet hook or a cmd hook,
xiao.paintdraws nothing.
Full example: compact ESP
local paint = xiao.paint
local white = paint.color.rgb(255, 255, 255)
local gold = paint.color.hex("#F0C250")
local green = paint.color.rgb(120, 255, 120)
local shadow = paint.color.rgb(0, 0, 0)
local bar_back = paint.color.rgba(0, 0, 0, 160)
local bar_front = paint.color.rgb(80, 220, 80)
local bar_size = paint.point.vector2(40, 4)
local bar_fill = paint.point.vector2(0, 4)
local function describe(entity)
local base = entity.name ~= "" and entity.name or (entity.type or "?")
if entity.distance then return ("%s %.0fm"):format(base, entity.distance) end
return base
end
local function draw_entity(entity, color)
local head = paint.point.vector3(entity.x, entity.y + 2, entity.z)
paint.text.center(head, describe(entity), color, shadow)
if entity.health and entity.max_health and entity.max_health > 0 then
local screen = xiao.game.world_to_screen(entity.x, entity.y + 2.3, entity.z)
if screen ~= nil then
local corner = paint.point.vector2(screen.x - bar_size.x / 2, screen.y)
bar_fill.x = bar_size.x * math.min(entity.health / entity.max_health, 1)
paint.rect.filled(corner, bar_size, bar_back)
paint.rect.filled(corner, bar_fill, bar_front)
end
end
end
paint.add("esp", function(width, height)
local me = xiao.game.player()
if me == nil then return end
paint.text.left(paint.point.vector2(16, 16),
("%d players, %d mobs"):format(#xiao.game.players(), #xiao.game.entities()), white, shadow)
for _, player in ipairs(xiao.game.players()) do draw_entity(player, gold) end
for _, mob in ipairs(xiao.game.entities()) do
if mob.distance == nil or mob.distance < 32 then draw_entity(mob, green) end
end
end)
Related pages: xiao.paint, xiao.game, Player and world, Events.