Rendering

Colors are 0xAARRGGBB (alpha in the high byte). 2D coordinates are logical pixels; draw calls and projection results are pixel-aligned automatically.

2D canvas

The argument of render_2d (HUD over the game) and render_gui (during screens):

module:event("render_2d", function(render)
    render.rect(8, 8, 120, 24, 0x90101010, 7)
    render.text("Hello", 16, 14, 9, 0xFFFFFFFF)
end)
FunctionWhat it does
width() / height()screen size in logical pixels
rect(x, y, w, h, color[, radius])draws a filled rectangle; radius rounds the corners
outline(x, y, w, h, color[, radius[, stroke]])draws a border (stroked inwards)
shadow(x, y, w, h, color[, radius[, blur[, noise]]])draws a blurred rounded rectangle — a drop shadow or glow
line(x1, y1, x2, y2, color[, stroke])draws a line between two points
circle(cx, cy, r, color)draws a filled circle
text(str, x, y[, size[, color[, font]]])draws text and returns its width
text_width(str[, size[, font]])returns the width the text would take, without drawing it
font_height([size[, font]])returns the line height of a font
px(v)aligns a logical coordinate to a physical pixel
begin(mode[, width]) / vertex(x, y[, color]) / finish()builds a mesh of arbitrary geometry with per-vertex colors
save()saves the transform/clip state, returns the save count
save_layer([x, y, w, h])saves state and draws into an offscreen layer
save_layer_alpha(alpha[, x, y, w, h])offscreen layer composited with a uniform alpha (0..1)
restore()undoes the most recent save/save_layer
restore_to(count)restores to a count returned by save
translate(dx, dy)shifts subsequent drawing
scale(sx[, sy[, pivot_x, pivot_y]])scales subsequent drawing (sy defaults to sx)
rotate(degrees[, pivot_x, pivot_y])rotates subsequent drawing clockwise
clip(x, y, w, h[, radius])intersects the clip with a (rounded) rectangle

radius is either one number for all corners or a {tl, tr, br, bl} table:

render.rect(8, 8, 120, 24, 0x90101010, { 7, 7, 0, 0 })

shadow blurs the rectangle by blur logical pixels (default 16); noise (0..1) adds film grain to the blur. Draw it behind the rectangle it belongs to:

render.shadow(8, 8, 120, 24, 0xA0000000, 7, 24)
render.rect(8, 8, 120, 24, 0xFF181818, 7)

The mesh builder accepts modes "points", "lines", "line_strip", "triangles" and "triangle_strip" (case and underscores are ignored); width is the stroke width / point size for line and point modes. A vertex color sticks for the following vertices until changed, so gradients come from changing it per vertex:

render.begin("triangles")
render.vertex(20, 20, 0xFFFF0055)
render.vertex(80, 20, 0xFF00FF55)
render.vertex(50, 70, 0xFF0055FF)
render.finish()

Transforms and clips nest with save/restore and coordinates keep working in logical pixels. save_layer_alpha renders everything inside into an offscreen layer and fades it as a whole on restore — overlapping shapes don't double up the way per-shape alpha would:

render.save_layer_alpha(0.5)
render.rect(8, 8, 60, 24, 0xFF181818, 7)
render.circle(68, 20, 12, 0xFF4FF2A6)
render.restore()

render.save()
render.rotate(45, 100, 100)
render.rect(80, 90, 40, 20, 0xFF4FF2A6, 4)
render.restore()

Anything left unbalanced at the end of a callback is restored automatically.

Default text size is 9, default font is "regular". Built-in fonts: "regular", "medium", "consolas", "inter", "small", "icon" (icon glyphs by name — render.text("bolt", x, y, 9, color, "icon")).

The canvas is only valid inside the callback — don't store it.

Custom fonts — fonts

FunctionWhat it does
fonts.register(name, path[, antialias])loads a TTF/OTF file; the path is relative to the scripts folder (absolute works too)
fonts.exists(name)returns true if a font with this name is registered
fonts.register("pixel", "fonts/pixel.ttf")
-- ...
render.text("Hi", 8, 8, 12, 0xFFFFFFFF, "pixel")

Registering the same name again replaces the font (hot-reload friendly); built-in fonts can't be replaced.

3D renderer

The argument of render_3d. Coordinates are world coordinates — the camera offset is handled for you.

FunctionWhat it does
camera()returns the camera position as x, y, z
line(x1, y1, z1, x2, y2, z2, color[, width[, through_walls]])draws a line between two world points
box(min_x, min_y, min_z, max_x, max_y, max_z, color[, width[, through_walls]])draws a box outline
tracer(x, y, z, color[, width[, through_walls]])draws a line from the camera to a point

through_walls = true makes the shape visible through blocks.

module:event("render_3d", function(render)
    for _, p in ipairs(world.players()) do
        if not p.is_self then
            local half = p.width / 2
            render.box(p.x - half, p.y, p.z - half,
                       p.x + half, p.y + p.height, p.z + half,
                       0xFF4FF2A6, 2)
        end
    end
end)

3D → 2D projection — projection

Converts world coordinates to screen coordinates (the same space the 2D canvas draws in). Call from render callbacks (render_2d, render_gui, frame).

FunctionReturns
projection.to_screen(x, y, z[, ignore_frustum])screen position sx, sy, or nil when the point is behind the camera or out of view
projection.box(min_x, min_y, min_z, max_x, max_y, max_z)the box on screen as x, y, w, h, or nil
projection.entity_box(entity_id)the entity's hitbox on screen as x, y, w, h (smoothly interpolated), or nil

ignore_frustum = true also projects points outside the view (it only fails behind the camera) — useful for edge-of-screen indicators.

module:event("render_2d", function(render)
    for _, p in ipairs(world.players()) do
        if not p.is_self then
            local x, y, w, h = projection.entity_box(p.id)
            if x then render.outline(x, y, w, h, 0xFF4FF2A6, 0, 1) end
        end
    end
end)