Draw an overlay

Build a HUD and a 3D ESP with xiao.paint and xiao.game, in screen and world coordinates.

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_frame and the think signal, 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.

Constructor Result
paint.point.vector2(x, y) screen point, in pixels
paint.point.vector3(x, y, z) world point, in blocks

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.

local paint = xiao.paint
local gold = paint.color.hex("#F0C250")
local green = paint.color.rgb(120, 255, 120)
local shadow = paint.color.rgb(0, 0, 0)

local function label_for(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

paint.add("tags", function(width, height)
    for _, player in ipairs(xiao.game.players()) do
        local head = paint.point.vector3(player.x, player.y + 2, player.z)
        paint.text.center(head, label_for(player), gold, shadow)
    end

    for _, mob in ipairs(xiao.game.entities()) do
        if mob.distance and mob.distance < 24 then
            local head = paint.point.vector3(mob.x, mob.y + 2, mob.z)
            paint.text.center(head, label_for(mob), green, shadow)
        end
    end
end)

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

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.