World Executor · Roblox VM

Guide: Building an ESP

A worked overlay: projecting world positions to the screen, drawing boxes and names, and cleaning up after yourself.

How an overlay works

The Drawing library gives you shapes that render above the game viewport and are not part of the instance tree — no ScreenGui, nothing the game can find by walking PlayerGui. You create objects once, then move them every frame.

The maths is Roblox's: Camera:WorldToViewportPoint turns a 3D position into a screen position plus a flag for whether it is in front of the camera. Everything else is bookkeeping.

One target

esp.lua — the drawing part
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local camera = workspace.CurrentCamera

local box = Drawing.new("Square")
box.Color = Color3.fromRGB(80, 160, 255)
box.Thickness = 1
box.Filled = false
box.Visible = false

local name = Drawing.new("Text")
name.Color = Color3.new(1, 1, 1)
name.Size = 13
name.Centered = true
name.Outline = true
name.Visible = false

local function update(target)
    local character = target.Character
    local root = character and character:FindFirstChild("HumanoidRootPart")
    if not root then
        box.Visible, name.Visible = false, false
        return
    end

    local position, onScreen = camera:WorldToViewportPoint(root.Position)
    if not onScreen then
        box.Visible, name.Visible = false, false
        return
    end

    -- Scale the box by distance so it tracks the character's apparent size.
    local scale = 1 / (position.Z * math.tan(math.rad(camera.FieldOfView * 0.5)) * 2) * 100
    local width, height = math.floor(40 * scale), math.floor(60 * scale)

    box.Size = Vector2.new(width, height * 2)
    box.Position = Vector2.new(position.X - width / 2, position.Y - height)
    box.Visible = true

    name.Text = target.Name
    name.Position = Vector2.new(position.X, position.Y - height - 16)
    name.Visible = true
end

Driving it

RenderStepped fires once per rendered frame, which is where per-frame overlay work belongs. Hold onto the connection so you can disconnect it.

The render loop
local connection = RunService.RenderStepped:Connect(function()
    for _, player in ipairs(Players:GetPlayers()) do
        if player ~= Players.LocalPlayer then
            update(player)
        end
    end
end)

One object per target, created once

The single most common ESP bug is calling Drawing.new inside the loop. That allocates a new object every frame, never frees the last one, and takes the client down within a minute. Create your objects up front and move them.

Cleaning up

Drawing objects are not garbage collected while they are visible — they belong to the renderer, not to Luau. Remove them explicitly, and disconnect the loop that drives them.

Teardown
local function teardown()
    connection:Disconnect()
    box:Remove()
    name:Remove()
end

-- A blunt instrument when you have lost track: drops every
-- Drawing object the session has created.
-- cleardrawcache()

isrenderobj tells you whether a value is still a live Drawing object, and getrenderproperty / setrenderproperty read and write properties by name when you are handling objects generically.

Related reference

The Drawing chapter documents every object type and its properties, including Line, Circle, Triangle, Quad and Image, plus the font list in Drawing.Fonts.