Guide: Building an ESP
A worked overlay: projecting world positions to the screen, drawing boxes and names, and cleaning up after yourself.
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.
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
endRenderStepped fires once per rendered frame, which is where per-frame overlay work belongs. Hold onto the connection so you can disconnect it.
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.
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.
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.
The Drawing chapter documents every object type and its properties, including Line, Circle, Triangle, Quad and Image, plus the font list in Drawing.Fonts.