Bindings & actions
The two declared hooks into the game — names the UI fires and paths it reads and writes. There are no expressions: everything dynamic in a zabloo UI is built from these two.
The shop shows 1,250 in its gold counter. The player buys the iron sword, and
a moment later the counter says 1,130. Nothing in the envelope did that
subtraction. The counter was told to show whatever sits at player.gold in the
game’s own data — that is a
binding — and pressing Buy
sent the game a name it had subscribed to, "buy" — that is a
named action. Those two
hooks are the entire connection between a zabloo UI and the game behind it.
Formally: the IR contains no logic. It cannot branch, compute or call anything: it is data. What it does carry are those two declared hooks, and everything dynamic in a zabloo UI is built from them.
| Mechanism | Direction | What it is |
|---|---|---|
| Named actions | UI → game | "onClick": "buy" — a name the game subscribes to. |
| Data bindings | game ↔ UI | { "bind": "player.gold" } — an address into the game's data. |
There are no expressions, by design. No conditionals, no formatting, no arithmetic — a value is shown as it is, and anything that needs deciding is decided by the game, which then moves a value the UI is bound to.
Named actions
An action prop is a string the game chose, exposed idiomatically per engine: a C# event, a signal, a Blueprint node. The IR declares that the hook exists; what happens is never in the JSON.
Which prop fires when is fixed, because a game subscribing to "buy" has to
mean the same thing on every engine:
normativeAction props
| Prop | Node | Fires when |
|---|---|---|
| onClick | Button | It is activated — tap, Enter, gamepad A. |
| onChange | Toggle, Slider, TextInput | The value changed, however it was caused. |
| onCommit | Slider | A drag or key gesture ended — the value the player settled on. |
| onSubmit | TextInput | The player confirmed the field (Enter). |
| onDismiss | Overlay | A dismiss was requested — Escape, gamepad B, backdrop tap, autoCloseMs. |
Several nodes may declare the same action name; the game receives one callback either way.
Action context
Both shop rows have a Buy button, and both were built from the same template, so
both fire the same name. The
action context is what
tells the game it was the sword and not the potion — without it, "buy" could
not say which row was bought:
{ "path": "shop.items.3", "key": "sword-01", "index": 3 }
normativeActionContext
| Field | Type | Description |
|---|---|---|
| path | string | Absolute data path of the item. |
| key | string | number | The item's raw key, when its Repeat declares one. Absent for positional identity. |
| index | number | Its position in the array. |
It describes the innermost item, which is enough for nested lists: path
already embeds every enclosing index ("shop.cats.2.items.5"), so the game can
address the whole chain from it. An action fired outside a
Repeat carries no context.
Data paths
A data path is a
dot-separated address into the game’s data, not an opaque key. It is read
the way you would read the same expression in code — walk player, then
gold — which is what lets a binding point into a structure the game already
has instead of asking it to publish a flat set of UI variables:
player.gold
shop.items.3.name
settings.audio.master
A numeric segment indexes an array — and nothing else does: "length" is a
field name, not a length. Reading is total and never throws. A missing segment,
or one walked through a non-object, yields no value, and bound UI degrades to
“nothing to show” rather than breaking the frame.
Reference implementation: readPath in @zabloo/format.
What can be bound
Any Bindable<T> prop takes { "bind": "some.path" } in place of a literal.
Not every prop does, and the two lists below are worth reading as one: the first
is data flowing into the UI, the second is data the UI is allowed to write back.
Anything absent from both takes a literal only.
Read-only — the game pushes, the UI follows:
normativeRead-only bindable props
| Prop | Node |
|---|---|
| visible | Every node |
| disabled | Every node — inherited by its subtree |
| text | Text |
| value | ProgressBar |
| items | Repeat — always a binding, never a literal array |
Read/write — the SDK also writes back:
normativeRead/write bindable props
| Prop | Node |
|---|---|
| checked | Toggle |
| value | Slider |
| value | TextInput |
| value | Container with group: "exclusive-check" |
A control that owns a value writes every change into the SDK’s own data store
and notifies the game through one callback. That is what closes the loop for
forms: a bound TextInput and a Text on the same path stay in sync with no
game code at all, and the game learns the new value without polling.
Repeat.items is always a binding: a literal array there would put game
data into the document, and the document carries structure.
A bar that changes colour with its value is done by the game moving a token, not by the UI computing one. The dictionary is part of the envelope and a theme is hot-updatable on its own — see The envelope › Tokens.
Item scopes
Inside a Repeat template, a path may start with the item’s alias and is
resolved against the current element:
{ "type": "Repeat", "items": { "bind": "shop.items" }, "as": "item", "children": [
{ "type": "Text", "text": { "bind": "item.name" } }
] }
Resolution rules
A path inside a list template can mean two different things — the item’s own field, or something global — and getting that wrong shows the wrong player’s gold on every row. These five rules decide, and every SDK applies them the same way.
normativeresolveBinding in @zabloo/format is the reference implementation:
- Scopes nest, and the innermost matching alias wins. A nested list
declaring
as: "cat"can still reach the outer element by its alias, which is why the alias is declared rather than reserved. It also means an alias shadows a data root of the same name — pick alias names that are not roots of your data. "<alias>"alone resolves to the element itself ("shop.items.3")."<alias>.rest"resolves to"shop.items.3.rest"."<alias>.$index"— and only that exact leaf — resolves to the element’s position, a number the data does not contain. Anything deeper ("item.a.$index") is an ordinary segment and simply reads no value.- A path under no known alias is absolute and passes through untouched,
which is how a row inside a list still binds
player.gold.
Worked example — the second row of the list above. The Repeat binds
shop.items and declares as: "item", and the row being built is the one at
index 1, the healing potion. Inside the template, item.name resolves to
shop.items.1.name — “Healing potion” — by rule 3, and item.$index gives 1
by rule 4. A player.gold in the same row matches no alias, so rule 5 passes it
through untouched: both rows read the same 1,250, which is right, because
there is one purse and two items.
Item identity
Repeat.key names a path relative to the item pointing at a stable field
("id", "meta.sku"). Identity is what keeps per-item runtime state — focus, a
checked Toggle, a scroll offset, an in-flight transition — with its item when
the array is reordered, and it is what makes recycling possible.
Only a non-empty string or a finite number identifies an item; anything else
falls back to the position. The two spaces are kept disjoint — keyed
identities are prefixed — so an item whose key is "0" can never inherit the
state of the unkeyed element at position 0.
Reference implementations: itemKey and itemIdentity in @zabloo/format.
The other direction
The game drives the UI through the SDK’s API, not through the format. It is the counterpart of the actions coming this way, and it is deliberately not in the IR: these are runtime operations, and the document has no place to put them.
Seven operations go in — SetData, SetOpen, SetSelectedTab, SetChecked,
SetValue, SetText, SetScroll — and three callbacks come back: named
actions (with the action context, when there is one), data changed by the UI,
and the loading diagnostics. Each SDK exposes them in its own idiom, so the
spelling follows the engine’s conventions while the operations, their arguments
and their effects are the same everywhere.
→ The host channel — the whole contract, with the web target’s signatures.