Perch
Building modsThe display system

The display system

How trees bind data, how the renderer repaints, repeaters, element-relative binds, and the hard limits.

3 min readUpdated Jul 21, 2026

A display is a tree of pure Lua tables — no callbacks, no runtime loops, no DOM access. The C++ validator whitelists every component, prop, and value at load; a valid tree is handed to one generic renderer that builds it once and maintains a field → node index. From then on, data drives everything.

The bind model

{ type = "text", bind = "title" }        -- repaints when `title` changes
{ type = "text", value = "Now playing" } -- static, never repaints

Your logic pushes a payload table; nodes carrying bind watch one field of it. Only nodes whose bound field actually changed repaint. This is enforced by construction — a push can’t re-trigger entrances, kill an in-flight click, or wipe a facepile, because your code never touches the DOM.

Binds are dotted paths into the payload:

bind = "solo.avatar"   -- reads payload.solo.avatar
bind = "."             -- binds the whole payload (draw triggers, tickers)

Push facts, not fragments

The tree never computes. If a value needs formatting, a phase decided, an angle derived — Lua does it and pushes the answer:

-- yes: view-ready
host.push_data("clock", { time = "17:42", angle_h = 172.5, on_break = false })

-- no: raw material the tree can't process anyway
host.push_data("clock", { epoch = 1789215720, tz = "Asia/Tokyo" })

Partial payloads are normal and encouraged: push only the fields that changed. The engine merges by key — an art-only push and a later stats-only push coexist.

Repeaters

list (and grid in repeater form) stamp an item tree per element of a bound array. Inside an item, binds are element-relative — they read the item, not the top payload:

{ type = "list", bind = "faces", key = "name", item = {
    type = "frame", w = 26, h = 26, children = {
      { type = "image", at = { 0, 0, 26, 26 }, radius = 999, bind = "avatar" },
      { type = "box", at = { 17, 17, 9, 9 }, radius = 999, bg = "live",
        when = "speaking", ring = { 2, "island" } },
    },
} }

key = "<item field>" turns on keyed updates: when the array changes, surviving elements re-bind in place (no re-entrance, no churn) and only newcomers mount with an entrance. Use it for anything with identity — people, tracks, tiles.

Item nodes may use {i} inside an action string; it’s filled with the item’s index at click time (action = "open:{i}").

Motion is not your problem

Entrance, exit, swap, reflow, and cross-context FLIP morphs are owned by the runtime. Two things you can do:

  • Give a node a flip = "<anchor>" key — nodes sharing an anchor across contexts glide into each other during a morph instead of crossfading.
  • Use motion presets (halo, nod, pulse, spin) for lifetime-scoped accents — the ringing avatar’s halo.

Everything else — easing, stagger, timing — is Perch’s, deliberately.

Limits

The validator caps every tree; hitting a cap is a load error with the exact path, never a runtime surprise.

LimitValue
nodes per tree128
depth8
list items16 (default; max raises within reason)
grid repeater items64
shape sets / paths per set16 / 128
chars per path d2000
style_when rules per node4
gradient stops2–4
action string64 chars
esc
Type to search
navigate open