Render
On this page
core.render draws shapes and text directly over the game window: ESP boxes on mobs, HP bars, HUD text, path overlays, target markers. Drawing is stateless — everything you queue is shown for one frame and gone the next — so you draw every frame from the on_render() callback, which is the only place drawing calls are accepted.
Define on_render() in your script. It runs once per presented frame, after that frame's on_tick if one ran, and only while the overlay is able to draw. Drawing calls made anywhere else — on_tick, on_map_load, a key callback — draw nothing and return nil, reason. Keep game logic in on_tick: read what you need there, store it, and draw it in on_render().
function on_render()
core.render.text(20, 20, "Hello from Violet", { size = 18, shadow = true })
endHow it works
- Coordinates are screen pixels by default, with
(0, 0)at the top-left of the game window. Passworld = truein the options table to give world coordinates instead; the renderer converts them on the frame it actually draws. - Shapes live for one frame. Every draw call queues a shape for the frame being built;
on_render()runs again for the next one. If you stop calling, the shape disappears. There is nothing to delete. - Drawing is only accepted inside
on_render(). Anywhere else the drawing functions returnnil, reasonand queue nothing, so a script cannot half-work by drawing fromon_tick. on_render()runs at frame rate, not tick rate. Keep it cheap: no searches, no network, no settings writes. A callback that raises an error is reported to the console once and removed until the script is reloaded, so a bug cannot spam every frame.- Colours are
{r, g, b}or{r, g, b, a}with 0–255 components. Alpha defaults to 255 (opaque). The default colour is white. - Sizes follow the coordinate space. For a world-space shape, width, height, and radius are world units and zoom with the game. Line thickness and font size are always pixels, so outlines never thin out when the camera zooms.
- Unloading a script removes its drawings. Each shape remembers which script queued it.
World-space shapes are skipped on frames where the engine hasn't published a view yet (the first frames after injection, or outside a field). Screen-space shapes always draw once core.render.is_ready() is true.
Options table
Every drawing function takes an optional trailing opts table. Unknown fields are ignored; missing fields use the defaults below.
| Field | Type | Default | Applies to | Meaning |
|---|---|---|---|---|
color | table | {255, 255, 255} | all | {r, g, b[, a]}, 0–255 |
color2 | table | color | gradient | Second colour of the gradient |
thickness | number | 1 | outlines, line | Stroke width in pixels (minimum 0.1) |
rounding | number | 0 | rect, rect_filled, gradient | Corner radius |
glow | number | 0 | all | Soft halo drawn around the shape, in pixels (clamped to 32) |
segments | number | 0 | circle, circle_filled | Circle segment count; 0 lets the renderer choose |
size | number | 0 | text | Font size in pixels; 0 uses the overlay's default font size |
shadow | boolean | false | text | Dark drop shadow, keeps text legible over bright game art |
centered | boolean | false | text | Centre the text on (x, y) instead of starting there |
world | boolean | false | all | Treat the coordinates as world units |
anchor | number | 0 | all | Handle from mob:anchor() / player:anchor(). Implies world = true |
Drawing
All drawing functions return true, or nil, reason when called outside on_render().
core.render.line(x0: number, y0: number, x1: number, y1: number, opts?: table)
Draws a line between two points.
core.render.rect(x: number, y: number, w: number, h: number, opts?: table)
Draws a rectangle outline with its top-left corner at (x, y).
core.render.rect_filled(x: number, y: number, w: number, h: number, opts?: table)
Draws a filled rectangle with its top-left corner at (x, y).
core.render.gradient(x: number, y: number, w: number, h: number, opts?: table)
Draws a filled rectangle that fades from opts.color to opts.color2.
core.render.circle(x: number, y: number, radius: number, opts?: table)
Draws a circle outline centred on (x, y). radius must not be negative.
core.render.circle_filled(x: number, y: number, radius: number, opts?: table)
Draws a filled circle centred on (x, y). radius must not be negative.
core.render.triangle(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, opts?: table)
Draws a triangle outline through three points.
core.render.triangle_filled(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, opts?: table)
Draws a filled triangle through three points.
core.render.text(x: number, y: number, text: string, opts?: table)
Draws a string with its top-left corner at (x, y), or centred on it with centered = true. Text longer than 512 characters is truncated.
function on_render()
local w = core.render.screen_size()
core.render.text(w / 2, 40, "BOSS SPAWNED", {
size = 24, centered = true, shadow = true, color = { 255, 80, 80 },
})
endcore.render.clear()
Discards every shape the calling script has queued so far this frame. Useful when an early draw pass must be replaced by a later decision in the same on_render() call.
Screen and camera
These read the view the render thread sampled for the current frame, so they are cheap to call many times per tick and never touch the engine from the script thread.
core.render.screen_size() -> number, number
Returns the overlay's width and height in pixels (the size of the game's back buffer). Both are 0 before the overlay is ready.
core.render.is_ready() -> boolean
Returns true once the overlay has captured the game's device and can draw. on_render() is only called while this is true, so scripts rarely need to check it themselves.
core.render.measure_text(text: string, size?: number) -> number, number
Returns the width and height in pixels that text would occupy at size (or the default font size when omitted or 0). Returns nil, error while the overlay is not ready.
local label = "HP 100%"
local tw, th = core.render.measure_text(label, 14)
if tw then
core.render.rect_filled(x - 4, y - 4, tw + 8, th + 8, { color = { 0, 0, 0, 160 } })
core.render.text(x, y, label, { size = 14 })
endcore.render.camera() -> number, number
Returns the world coordinate the view is centred on for the current frame. Returns nil, error when no view is available.
core.render.viewport() -> number, number, number
Returns the width and height, in world units, that the game composes the view at, plus the engine's vertical adjust_y term. These differ from screen_size() because the engine zooms the composed view to fill the window. Returns nil, error when no view is available.
core.render.view_scale() -> number, number, number
Returns scale, origin_x, origin_y. scale is screen pixels per world unit for the current frame, and (origin_x, origin_y) is the world coordinate drawn at the top-left of the window. A world position maps to the screen as (wx - origin_x) * scale, (wy - origin_y) * scale. Returns nil, error when no view is available.
core.render.world_to_screen(world_x: number, world_y: number) -> number, number, boolean
Converts a world position to screen pixels for the current frame. Returns x, y, on_screen; the coordinates stay valid off-screen, so you can draw an arrow toward something behind the player. Returns nil, error when no view is available.
This conversion uses the view from the last presented frame. When you draw the shape yourself with world = true, or pin it with an anchor, the renderer converts against the frame it is actually drawing, which is one frame fresher. Use world_to_screen() when you need the pixel position for your own logic, such as clamping a marker to the screen edge.
function on_render()
local map = core.object_manager.get_current_map()
if not map then return end
local sx, sy, visible = core.render.world_to_screen(map:get_bounds().center.x, 0)
if sx and not visible then
local w = core.render.screen_size()
core.render.text(math.clamp(sx, 20, w - 20), 60, "map centre ->", { centered = true })
end
endAnchors: drawing on moving actors
get_position() on a mob or the player advances once per logic step (~30 ms) — even when read from on_render(), it is the last step's value — but the sprite is drawn from a per-frame interpolated position. A shape placed at get_position() therefore trails a moving actor by up to one step. An anchor fixes this: it pins the shape to the actor, and the renderer re-reads where the actor is drawn on every frame and shifts the shape to match.
- Get a handle with
mob:anchor()orplayer:anchor(). - Pass it as the
anchoroption. Anchored shapes are automatically world-space, so give world coordinates. - Draw the shape at the actor's
get_position(); the anchor supplies the correction.
A handle is only valid during the on_render() call it was created in. Call anchor() again every frame; caching a handle across frames will silently pin your shape to a different actor. Pass the number, not the mob or player object — a non-number is ignored and the shape draws unanchored.
function on_render()
local map = core.object_manager.get_current_map()
if not map then return end
for _, mob in ipairs(map:get_mobs()) do
if mob and mob:is_valid() then
local pos = mob:get_position()
local a = mob:anchor()
if pos and a then
-- 60x80 box around the mob, following the sprite
core.render.rect(pos.x - 30, pos.y - 80, 60, 80, {
anchor = a, color = { 255, 60, 60 }, thickness = 2,
})
local hp = mob:get_health_percent()
if hp then
core.render.rect_filled(pos.x - 30, pos.y + 4, 60 * hp / 100, 4, {
anchor = a, color = { 80, 255, 120 },
})
end
local name = mob:get_name()
if name then
core.render.text(pos.x, pos.y - 92, name, {
anchor = a, centered = true, shadow = true, size = 12,
})
end
end
end
end
endTo read the interpolated position yourself — for example to draw a line from the player to a mob — use mob:get_render_position() / player:get_render_position(). Anchoring the line's endpoints is not possible (a shape has one anchor), so this is the way to get both ends right.
Full example
plugin = {
name = "ESP Demo",
version = "1.0.0",
author = "you",
description = "Boxes on mobs, a HUD line, and a player marker",
load = true,
}
function on_render()
local w, h = core.render.screen_size()
core.render.text(16, h - 32, ("Mobs: %d"):format(#(core.object_manager.get_current_map() and core.object_manager.get_current_map():get_mobs() or {})), { shadow = true })
local player = core.object_manager.get_local_player()
if player and player:is_valid() then
local p = player:get_position()
local a = player:anchor()
if p and a then
core.render.circle(p.x, p.y - 30, 40, { anchor = a, color = { 140, 90, 255 }, glow = 6 })
end
end
end