Skip to content

2D canvas

Shapes, text, paints, gradients, meshes and transforms drawn over the game or in GUI screens.

The argument of render_2d (over the game) and render_gui (during screens). Colors are 0xAARRGGBB, coordinates are logical pixels, and draw calls are pixel-aligned for you.

module:event("render_2d", function(render)
    render.rect(8, 8, 120, 24, render.paint(0x90101010), 7)
    render.text("Hello", 16, 14, 9)
end)

Every draw call takes a paint, which bundles color, gradient and stroke. The canvas itself is only valid inside the callback, so do not store it.

Drawing

Function Does
width(), height() Screen size in logical pixels
paint([color]) Builds a reusable paint
rect(x, y, w, h, paint[, radius]) Rectangle, radius rounds the corners
shadow(x, y, w, h, paint[, radius[, blur[, noise]]]) Blurred rounded rect for a drop shadow or glow
line(x1, y1, x2, y2, paint[, stroke]) Line between two points
circle(cx, cy, r, paint) Filled circle
text(str, x, y[, size[, paint[, font]]]) Text anchored at top-left, returns its width
text_metrics(str[, size[, font]]) {width, height, ascent, descent} without drawing
snapped(v) Aligns a logical coordinate to a physical pixel
physical(v) Cancels the DPI scale, giving an exact device-pixel size
begin(mode[, width]), vertex(x, y[, color]), finish() Mesh with per-vertex colors
save() Saves transform and clip state, returns the save count
save_layer([x, y, w, h]) Offscreen layer, returns the save count
save_layer_alpha(alpha[, x, y, w, h]) Offscreen layer faded as a whole, alpha is 0..1
restore(), restore_to(count) Undoes the last save, or unwinds to a save count
translate(dx, dy) Shifts what follows
scale(sx[, sy[, pivot_x, pivot_y]]) Scales; sy defaults to sx
rotate(degrees[, pivot_x, pivot_y]) Rotates clockwise
clip(x, y, w, h[, radius[, op]]) Clips to a rect; op is "intersect" (default) or "difference"

snapped and physical are plain globals too, usable outside a draw callback.

radius is one number for every corner, or a {tl, tr, br, bl} table:

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

shadow blurs by blur logical pixels (default 16) and noise (0..1) adds film grain. Draw it before the shape it sits behind:

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

Paints and gradients

render.paint([color]) builds one. Setters mutate and return it, so they chain:

local accent

module:event("render_2d", function(render)
    accent = accent or render.paint()
        :linear_gradient(8, 8, 128, 8, { 0xFF4FF2A6, 0xFF2AA3F2 })
    render.rect(8, 8, 120, 24, accent, 7)
    render.text("gradient text", 16, 15, 9, accent)
end)
Setter Does
:color(color) Base fill and stroke color when no gradient is set
:linear_gradient(x0, y0, x1, y1, colors[, positions[, tile]]) Gradient along (x0,y0) → (x1,y1)
:radial_gradient(cx, cy, r, colors[, positions[, tile]]) Gradient out from a center
:angular_gradient(cx, cy, start_deg, end_deg, colors[, ...]) Sweep gradient
:no_gradient() Drops the gradient
:image(image, x, y, w, h[, tile]) Fills with an image mapped to that rect
:no_image() Drops the image
:stroke([width[, align]]) Outline mode; align is "center" (default), "inside", "outside"
:fill() Back to fill mode

colors needs at least two 0xAARRGGBB stops, spaced evenly unless positions (a 0..1 table of the same length) says otherwise. tile is "clamp" (default), "repeat", "mirror" or "decal".

Gradient coordinates live in the same logical-pixel space as the draw calls and do not follow the shape, so aim the gradient at what it fills:

A stroke paint turns rect and circle into outlines:

render.rect(x, y, w, h, render.paint(0xFF4FF2A6):stroke(1, "inside"), radius)

On text a gradient fills the glyphs, with the paint’s alpha still applying. shadow reads the paint’s color only.

A paint fills with a gradient or an image, never both, and setting one clears the other. While an image is set the color is ignored, though the alpha still fades the image. Paints outlive the callback, so cache them across frames or build them inline.

Meshes

Modes are "points", "lines", "line_strip", "triangles" and "triangle_strip". For line and point modes, width is the stroke width or point size. A vertex color holds for every following vertex until you change it:

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

vertex raises an error before begin, and finish draws nothing with fewer than two vertices.

Transforms and layers

Transforms and clips nest with save/restore, and coordinates stay in logical pixels. save_layer_alpha draws into an offscreen layer and fades it as a whole on restore, so overlapping shapes do not double up the way per-shape alpha does:

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

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

clip intersects by default. "difference" punches a hole instead:

render.save()
render.clip(8, 8, 120, 120, 16, "difference")
render.rect(0, 0, 200, 200, render.paint(0xFF181818))
render.restore()

Anything left unbalanced at the end of the callback is restored for you.

Text

Defaults are size 9, opaque white, font "regular".

Built-in fonts: "regular", "medium", "consolas", "inter", "small", "icon" (glyphs by name, render.text("bolt", x, y, 9, paint, "icon")) and "minecraft" (the vanilla font, resource-pack aware, where size is line height so 18 matches GUI scale 2). Load your own with fonts.

render.text anchors at the top-left x, y and puts the baseline at y + ascent. Measure first when centering or stacking lines:

local m = render.text_metrics("Hello", 9)
render.text("Hello", x, cy - m.height / 2, 9)
Navigation

Type to search…

↑↓ navigate↵ selectEsc close