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
camera_rotation()returns the camera yaw, pitch in degrees
camera_forward()returns the view direction as a unit vector
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
filled_box(min_x, min_y, min_z, max_x, max_y, max_z, color[, through_walls])draws a solid translucent box (all six faces)
quad(x1, y1, z1, ..., x4, y4, z4, color[, through_walls])draws a filled quad through four world positions
triangle(x1, y1, z1, ..., x3, y3, z3, color[, through_walls])draws a filled triangle through three world positions
point(x, y, z, color[, size[, through_walls]])draws a point sprite (size in pixels, default 4)
text(str, x, y, z[, color[, scale[, through_walls[, background]]]])camera-facing billboard text, returns the text width
tracer(x, y, z, color[, width[, through_walls]])draws a line from the camera to a point
begin(mode[, width[, through_walls]]) / vertex(x, y, z[, color]) / finish()builds a mesh of arbitrary geometry
begin_layer(layer) / uv(u, v)mesh over a custom gfx.layer (see below)
push() / pop()matrix transform scope — coordinates become local
origin(x, y, z)moves the local origin to a world position
translate(dx, dy, dz) / rotate(deg[, ax, ay, az]) / scale(sx[, sy, sz])local transforms (rotate axis defaults to Y)
rotate_camera()turns the local frame to face the camera (billboard)

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)
            render.text(p.name, p.x, p.y + p.height + 0.4, p.z, 0xFFFFFFFF, 1, true)
        end
    end
end)

The mesh builder mirrors the 2D one: modes "lines", "line_strip", "quads", "triangles", "triangle_strip", "triangle_fan" and "points" (case and underscores are ignored), the vertex color sticks until changed, width is the line width / point size for line and point modes:

render.begin("quads", nil, true)
render.vertex(x, y, z, 0x604FF2A6)
render.vertex(x + 1, y, z)
render.vertex(x + 1, y, z + 1)
render.vertex(x, y, z + 1)
render.finish()

Transforms

push/pop open a matrix transform scope — inside it all draw coordinates are local to the transform instead of world-absolute. origin places the local origin at a world position (the usual first call after push), rotate_camera turns the local frame to face the camera with the exact orientation quaternion, so billboards need no trigonometry:

module:event("render_3d", function(render)
    for _, p in ipairs(world.players()) do
        if not p.is_self then
            render.push()
            render.origin(p.x, p.y + p.height / 2, p.z)
            render.rotate_camera()
            render.begin_layer(glow)
            render.uv(0, 0); render.vertex(-0.6, 0.6, 0, 0x904FF2A6)
            render.uv(1, 0); render.vertex(0.6, 0.6, 0)
            render.uv(1, 1); render.vertex(0.6, -0.6, 0)
            render.uv(0, 1); render.vertex(-0.6, -0.6, 0)
            render.finish()
            render.pop()
        end
    end
end)

rotate(degrees[, ax, ay, az]) spins around an arbitrary axis (Y by default), scale and translate work in the local frame, transforms nest with push/pop, and anything left unbalanced is popped automatically after the callback.

Custom pipelines and textures — gfx

For everything the built-in shapes can't do: gfx compiles GPU pipelines from GLSL source written right in the script, loads textures from files, and combines them into layers the 3D mesh builder can draw with.

FunctionWhat it does
gfx.pipeline{...}compiles a pipeline from inline GLSL; raises on compile errors (log has the details)
gfx.texture(path)registers a PNG (relative to the scripts folder) — returns handle, width, height
gfx.layer(pipeline[, texture])pipeline + texture as a drawable layer; memoized per pair

The gfx.pipeline descriptor takes name, vertex and fragment (GLSL sources), and optionally format ("position", "position_color", "position_tex", "position_tex_color" — default "position_color"), mode ("quads", "triangles", "triangle_strip", "triangle_fan", "lines", "line_strip", "points" — default "quads"), blend ("none", "translucent", "premultiplied", "additive", "lightning", "overlay", "glint", "invert" — default "translucent"), depth_test ("lequal", "less", "equal", "greater", "none" — default "lequal"; "none" draws through walls), depth_write and cull (both default false).

Shader sources support #moj_import exactly like vanilla shaders — import dynamictransforms.glsl and projection.glsl for the standard matrices, and globals.glsl for GameTime. position_tex* formats expose the layer texture as Sampler0. Create pipelines, textures and layers once at script top level; they survive resource reloads (F3+T) and are released when the script unloads.

local pipe = gfx.pipeline{
    name = "pulse",
    format = "position_color",
    mode = "quads",
    blend = "additive",
    depth_test = "none",
    vertex = [[
        #version 330
        #moj_import <minecraft:dynamictransforms.glsl>
        #moj_import <minecraft:projection.glsl>
        in vec3 Position; in vec4 Color;
        out vec4 vertexColor;
        void main() {
            gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
            vertexColor = Color;
        }
    ]],
    fragment = [[
        #version 330
        #moj_import <minecraft:globals.glsl>
        in vec4 vertexColor;
        out vec4 fragColor;
        void main() {
            fragColor = vertexColor * (0.6 + 0.4 * sin(GameTime * 1200.0));
        }
    ]],
}
local layer = gfx.layer(pipe)

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.begin_layer(layer)
            render.vertex(p.x - half, p.y, p.z - half, 0x804FF2A6)
            render.vertex(p.x + half, p.y, p.z - half)
            render.vertex(p.x + half, p.y, p.z + half)
            render.vertex(p.x - half, p.y, p.z + half)
            render.finish()
        end
    end
end)

Textured quads work the same way — set the sticky texture coordinates with uv before each vertex:

local pipe = gfx.pipeline{ name = "sprite", format = "position_tex_color",
                           vertex = VSH, fragment = FSH }
local tex = gfx.texture("sprites/marker.png")
local layer = gfx.layer(pipe, tex)

render.begin_layer(layer)
render.uv(0, 0); render.vertex(x, y + 1, z)
render.uv(1, 0); render.vertex(x + 1, y + 1, z)
render.uv(1, 1); render.vertex(x + 1, y, z)
render.uv(0, 1); render.vertex(x, y, z)
render.finish()

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)