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
motionpresets (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.
| Limit | Value |
|---|---|
| nodes per tree | 128 |
| depth | 8 |
list items | 16 (default; max raises within reason) |
grid repeater items | 64 |
| shape sets / paths per set | 16 / 128 |
chars per path d | 2000 |
style_when rules per node | 4 |
| gradient stops | 2–4 |
action string | 64 chars |