---
title: "2D canvas"
---

> Documentation Index
> Fetch the complete documentation index at: https://aesthetic-docs.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 2D canvas

Argument of `render_2d` (HUD over the game) and `render_gui` (during screens).

```lua
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** — color, gradient and stroke bundled together —
built with `render.paint([color])`. See [Paints](#paints-and-gradients).

The canvas is only valid inside the callback — do not store it.

## Drawing

| Function | What it does |
|----------|--------------|
| `width()` / `height()` | Screen size in logical pixels |
| `paint([color])` | Creates a reusable paint |
| `rect(x, y, w, h, paint[, radius])` | Rectangle; `radius` rounds corners |
| `shadow(x, y, w, h, paint[, radius[, blur[, noise]]])` | Blurred rounded rect (drop shadow / 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 at top-left `x, y`; returns 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 DPI scale — exact device-pixel size |
| `begin(mode[, width])` / `vertex(x, y[, color])` / `finish()` | Arbitrary mesh with per-vertex colors |
| `save()` | Saves transform/clip state; returns save count |
| `save_layer([x, y, w, h])` | Offscreen layer; returns save count |
| `save_layer_alpha(alpha[, x, y, w, h])` | Offscreen layer with uniform alpha (0..1) |
| `restore()` | Undoes the most recent save / save_layer |
| `restore_to(count)` | Restores to a count from any save call |
| `translate(dx, dy)` | Shifts subsequent drawing |
| `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 (rounded) rect; `op` is `"intersect"` (default) or `"difference"` |

`snapped` and `physical` are also plain globals, so you can use them outside a draw
callback.

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

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

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

```lua
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 a paint. Setters mutate and return it, so they chain:

```lua
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 | What it does |
|--------|--------------|
| `:color(color)` | Base fill/stroke color (when no gradient) |
| `:linear_gradient(x0, y0, x1, y1, colors[, positions[, tile]])` | Gradient along `(x0,y0) → (x1,y1)` |
| `:radial_gradient(cx, cy, r, colors[, positions[, tile]])` | Gradient from a center |
| `:angular_gradient(cx, cy, start_deg, end_deg, colors[, ...])` | Sweep gradient |
| `:no_gradient()` | Removes gradient only |
| `:image(image, x, y, w, h[, tile])` | Fills with an [image](/image) mapped to `(x, y, w, h)` |
| `:no_image()` | Removes image only |
| `:stroke([width[, align]])` | Outline mode; `align`: `"center"` (default), `"inside"`, `"outside"` |
| `:fill()` | Back to fill mode |

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

Gradient coordinates are in the same logical-pixel space as draw calls — they do not
move with the shape. Aim the gradient at the shape you fill:

```lua
local ring = render.paint()
:angular_gradient(60, 60, 0, 360,
    { 0xFFFF0055, 0xFF00FF55, 0xFF0055FF, 0xFFFF0055 })
:stroke(3)
render.circle(60, 60, 24, ring)

local bar = render.paint():linear_gradient(8, 0, 108, 0,
{ 0x00FFFFFF, 0xFFFFFFFF, 0x00FFFFFF }, { 0, 0.5, 1 })
render.rect(8, 40, 100, 2, bar)
```

A stroke paint turns `rect`/`circle` into outlines:

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

On `text`, the gradient fills the glyphs (paint alpha still applies). `shadow` only
uses the paint's color.

A paint's fill is a gradient **or** an image, never both — setting one clears the other.
While an image is set, the paint's color is ignored but its **alpha still fades the
image**. See [Images](/image).

Paints outlive the callback — cache them across frames or build inline each frame.

## Meshes

Modes: `"points"`, `"lines"`, `"line_strip"`, `"triangles"`, `"triangle_strip"`.
`width` is stroke width / point size for line and point modes. Vertex color sticks for
following vertices until changed:

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

`vertex` errors if called before `begin`. `finish` draws nothing with fewer than two
vertices.

## Transforms and layers

Transforms and clips nest with `save`/`restore`. Coordinates stay in logical pixels.
`save_layer_alpha` renders into an offscreen layer and fades it as a whole on `restore`
— overlapping shapes do not double up the way per-shape alpha would:

```lua
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` defaults to `"intersect"`. Pass `"difference"` to punch a hole:

```lua
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 a callback is restored automatically.

## Fonts and defaults

| Default | Value |
|---------|-------|
| Text size | `9` |
| Paint | Opaque white |
| Font | `"regular"` |

Built-in fonts: `"regular"`, `"medium"`, `"consolas"`, `"inter"`, `"small"`, `"icon"`
(icon glyphs by name: `render.text("bolt", x, y, 9, paint, "icon")`), `"minecraft"`
(vanilla font, resource-pack aware — size is line height, so `18` ≈ GUI scale 2).

Register custom fonts with the [`fonts`](/fonts) global.

`render.text` anchors at top-left `x, y`; the baseline is at `y + ascent`. For layout
(centering, stacking lines), measure first:

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

Source: https://aesthetic-docs.pages.dev/canvas/index.md
