Skip to content
zabloo

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.

MechanismDirectionWhat it is
Named actionsUI → game"onClick": "buy" — a name the game subscribes to.
Data bindingsgame ↔ 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

PropNodeFires when
onClickButtonIt is activated — tap, Enter, gamepad A.
onChangeToggle, Slider, TextInputThe value changed, however it was caused.
onCommitSliderA drag or key gesture ended — the value the player settled on.
onSubmitTextInputThe player confirmed the field (Enter).
onDismissOverlayA 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

FieldTypeDescription
pathstringAbsolute data path of the item.
keystring | numberThe item's raw key, when its Repeat declares one. Absent for positional identity.
indexnumberIts 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

PropNode
visibleEvery node
disabledEvery node — inherited by its subtree
textText
valueProgressBar
itemsRepeat — always a binding, never a literal array

Read/write — the SDK also writes back:

normativeRead/write bindable props

PropNode
checkedToggle
valueSlider
valueTextInput
valueContainer 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.

Style is not bindable

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.

normative

resolveBinding in @zabloo/format is the reference implementation:

  1. 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.
  2. "<alias>" alone resolves to the element itself ("shop.items.3").
  3. "<alias>.rest" resolves to "shop.items.3.rest".
  4. "<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.
  5. 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.