The host channel
The runtime API the game drives the UI through: seven operations in, three callbacks back, and the web target's mount, handle and introspection.
The envelope describes a
screen; it does not describe a session. The player picks up 120 gold. The game
wants the Audio section of the options panel opened. A new build of the shop
needs to replace the one currently on screen. All of that happens while the
UI is running, and the game says it by calling the
SDK:
ui.setData("player.gold", 1250) is the whole idea. That set of calls is the
host channel — seven operations going in, three callbacks coming back.
It is the counterpart of the actions coming the other way: actions travel from the UI to the game, and everything on this page travels back.
It is deliberately not in the IR. These are runtime operations — “open that collapse”, “the player now has 1250 gold” — and a document has no place to put them. Which is also why they are the same everywhere: the operations, their arguments and their effects are part of the contract; only the spelling follows each engine’s conventions.
Shipping UI to a game that is already in players’ hands is what this format
exists for, and three pages carry it between them.
Versioning says whether an SDK and a payload can
meet at all. Loading says what the SDK does with
the payload once they do — what it repairs, what it refuses, and what it tells
the game. This page is the call that hands the new payload over,
reload, and the callback its diagnostics come back on.
game ──── SetData / SetOpen / SetChecked / … ────▶ UI
◀─── onAction / onDataChanged / onDiagnostic ───
The signatures below are the web target’s (@zabloo/renderer-web), given
inline as the concrete spelling of each operation. Unity — the reference SDK for
v1 — exposes the same contract on ZablooDocument (SetData, Reload) and on
ZablooView (View.SetOpen).
The operations
These seven are everything a game can do to a running UI. The list is closed and normative: a game written against one engine’s SDK is written against all of them, because the operations, their arguments and what they do are identical everywhere.
normative| Operation | Web spelling | What it does |
|---|---|---|
| SetData | setData(path: string, value: unknown): void | Writes into the data store. Every binding reading that path updates, and the layout re-runs where it must. |
| SetOpen | setOpen(id: string, open: boolean): boolean | Opens or closes a Collapse. |
| SetSelectedTab | setSelectedTab(id: string, index: number): boolean | Selects a tab of an "exclusive-select" group, by the group container's id. |
| SetChecked | setChecked(id: string, checked: boolean): boolean | Sets a Toggle. |
| SetValue | setValue(id: string, value: number): boolean | Moves a Slider — exactly the gesture the player would have made, hooks included. |
| SetText | setText(id: string, text: string): boolean | Writes a TextInput's text, as if it had been typed. |
| SetScroll | setScroll(id: string, x: number, y: number): boolean | Moves a ScrollView's offset. |
Addressing by id
Every operation but SetData names a node by its id, so the nodes a game
drives carry one. Ids are expected to be unique within a view; duplicates load
with a warning and resolve to the first match.
The id operations answer whether they found the control. A false means no
node of that type carries that id — a typo, a view that was hot-updated out from
under the caller, a node whose visible took it out of the tree — and nothing
was applied. It is not an exception: a game looping over ids must not die
because one screen changed. The web target also logs the miss
([zabloo] setChecked: no Toggle with id "…").
An id inside a Repeat template is
worn by every instance of it, and the lookup keeps the last one realized.
Addressing a particular row by id is not something v1 does — a press inside a
row comes back with its action context instead, which says which item it was.
Worked example — the shop. The game fills the screen with one call per
path: setData("player.gold", "1,250") and setData("shop.items", [...]), and
every binding reading them updates. The player presses Buy on the sword; the
game hears onAction("buy", { path: "shop.items.0", key: "sword-01", index: 0 }),
does its own arithmetic, and pushes the result back with
setData("player.gold", "1,130"). The UI computed nothing — it was told twice.
Data writes are cached and replayed
SetData writes into a store, not into the tree. Data pushed before a view is
mounted, or before a bound node exists, applies as soon as it does: a game
pushes its state whenever it has it, and a UI loaded later still comes up filled
in.
The store is also what a Repeat reads. Writing the array (shop.items) moves
the bindings inside it (shop.items.3.name), and writing into one item moves a
binding watching the whole array.
Driving a control is the player’s gesture
The value operations do not poke state — they run the same path the player’s finger does, so a game and a player produce identical results:
SetValueclamps and quantizes to the slider’smin/max/step, firesonChange, and then firesonCommit: the whole gesture, press to release, in one call.SetCheckedfires the toggle’sonChangeand, inside a group, the group’s.SetTextreplaces the buffer and leaves the caret at the end, where someone handed a prefilled value would start typing.SetScrollis clamped to the last relayout’s bounds.- A control whose value is a read/write binding writes the new value back
through it, so the game hears it on
onDataChangedexactly as it would from a real gesture.
The callbacks
Three things travel the other way, and between them they are everything the game learns from the UI: the player did something, the player changed a value, or the payload had something wrong with it. Nothing else comes back.
normativeHost callbacks
| Callback | Web spelling | When it fires |
|---|---|---|
| Action | onAction(action: string, context?: ActionContext) | A named action declared in the IR — onClick, onChange, onCommit, onSubmit, onDismiss — fired. |
| Data changed | onDataChanged(path: string, value: unknown) | A control wrote its value into a bound path. |
| Diagnostic | onDiagnostic(diagnostic: Diagnostic) | The loading contract found something, on mount and on reload alike. |
onDataChanged never fires for SetData. That value came from the game;
echoing it back would make every write a round trip.
ActionContext is present only for an action fired from inside a repeated
item, and it describes the innermost one — path, key and index, as
tabled on Bindings & actions.
A Diagnostic carries a stable code, the path into the envelope it sits on,
a level ("warn" or "fatal") and a self-contained message; see
Loading for the full code table. A warn
was repaired and the envelope loaded without the broken part; a fatal means
nothing loaded, and it arrives before mount throws. Without the callback,
warnings go to the console.
Mounting a view
import { mount } from "@zabloo/renderer-web";
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const envelope = await fetch("/zabloo.ir.json").then((r) => r.text());
const ui = mount(canvas, envelope, {
view: "main-menu",
background: "#11141d",
onAction: (action, context) => {
if (action === "play") startGame();
if (context) console.log("fired from item", context.path, context.index);
},
onDataChanged: (path, value) => console.log("player wrote", path, "=", value),
onDiagnostic: ({ level, code, path, message }) => showInEditor(level, code, path, message),
});
await ui.ready;
ui.setData("player.gold", 1250);
ui.reload(nextEnvelope);
ui.dispose();mount(canvas, envelope, options?) takes the envelope as JSON text or as a
parsed object.
| Prop | Type | Default | Description |
|---|---|---|---|
| view | string | the envelope's first view | View id to render. |
| onAction | (action, context?) => void | none | Named actions, with the item's context when there is one. |
| onDataChanged | (path, value) => void | none | The return leg of the data channel. |
| onDiagnostic | (diagnostic) => void | console | Where the loading contract's diagnostics go. |
| background | string | "#101218" | Canvas clear color (CSS hex). |
| dpr | number | the browser's | Device pixel ratio to render at, instead of devicePixelRatio. |
| onFrame | (stats) => void | none | Fires once per frame actually painted, with what it cost. |
dpr is fixed for the life of the mount. The renderer reads the ratio
everywhere it turns logical pixels into device ones — the backing store, the
glyph atlas scale, the pixel grid quads snap to — so overriding it means
rebuilding the atlases. A host that offers it as a control (a preview’s DPR
selector, a golden harness pinned to a fixed ratio) remounts.
onFrame is the only way to get a frame rate. stats() answers what the
last frame cost, and polling it cannot become a rate because the renderer paints
on demand: a still scene paints nothing at all, and the caller’s own
requestAnimationFrame would be measuring the page rather than the renderer. It
receives FrameStats plus an ms — the time inside tessellate and submit,
excluding the GPU’s own asynchronous execution.
mount throws an EnvelopeError if the payload is unusable: there is no
previous UI to protect, and the caller has to hear that its payload never became
a view. It is the only entry point that throws.
The handle
| Member | Type | What it is |
|---|---|---|
| viewIds | string[] | The current envelope's view ids. A getter: a hot-update may add, drop or rename views, so a view picker re-reads it after every reload instead of keeping the array it got at mount. |
| ready | Promise<void> | Resolves once the view has swapped in its own text rasterizer and repainted with it. Anything comparing metrics — a golden test, a screenshot — waits on this. It never rejects: a failed load keeps the browser's metrics. |
| reload(envelope) | (string | object) => void | Hot-update, the same loading path a shipped SDK uses. |
| snapshot() | () => ViewSnapshot | The frame's measurements — see below. |
| stats() | () => FrameStats | What the last painted frame cost — see below. |
| dispose() | () => void | Releases the canvas, the GL resources and the listeners. Idempotent. |
The seven operations — setData, setOpen,
setSelectedTab, setChecked, setValue, setText, setScroll — are members
of this same handle too. They are tabled above on their own because they are the
normative surface every target implements, while these six are the web binding’s
own.
reload never throws. A payload the validator refuses — truncated, corrupt,
a major version this reader does not implement — is reported through
onDiagnostic and discarded: the view on screen stays exactly as it is. A
bad hot-update costs the player an update, never their session.
A reload snaps. There is no previous value to tween from, so motion starts from the new frame; the same is true of mounting.
After dispose(), the id operations return false and the view warns once
rather than once per call.
In the browser console
zabloo dev’s preview puts the handle of the view it has mounted on
window.zabloo, so the browser’s own console is a REPL against the running UI —
the third way to push a value, beside the preview’s bindings panel and the game
itself:
zabloo.setData("player.gold", 1250);
zabloo.setData("shop.items", [{ id: "sword-01", name: "Iron sword", price: 120 }]);
zabloo.setChecked("sfx", true);
zabloo.snapshot(); // where every rect landed
zabloo.stats(); // what the last painted frame costAn ordinary save is a reload and keeps the same handle, but changing the view
or the DPR mounts a new one — so a const ui = zabloo held across either is a
disposed view whose id operations answer false. Read zabloo fresh each
time. While no view is mounted the property is undefined rather than a stale
handle.
Introspection
snapshot() — the frame’s measurements
A snapshot is the frame written down as data: where every rectangle landed, what the text did, what holds the focus. It exists so that “both targets render this the same” is something a test can assert instead of something a person has to eyeball, which is why its shape is normative.
ViewSnapshot is the cross-target contract: the same envelope loaded in
another SDK must produce this same document. It answers what a screenshot cannot
explain — where every rect landed, where the text broke and on which baselines
it sits, what left the layout, what clips what, in what order the layer paints,
and where focus, hover and press ended up. Pixels are deliberately absent.
normativeViewSnapshot
| Field | Type | Meaning |
|---|---|---|
| view | string | The view id on screen. |
| size | { width, height } | The canvas in CSS px. |
| focus / hover / pressed | string | null | The ref of the node holding each state. |
| layer | LayerSnapshot[] | Overlays in (z, document order), bottom-most first, each with its presence (0..1 while fading). |
| tree | NodeSnapshot | The tree, from the root. |
A NodeSnapshot carries its type, a ref (the node’s id, or its positional
path from the root — "0.2.1"), and then only what says something: rect,
measured, states, style (tokens collapsed, transitions applied), text
(lines, widths, baselines, truncated), clip, scroll, value, field,
window and children. Absent means default — an unfocused node carries no
states, an unclipped one no clip — and a node that is out of layout carries
out and nothing else.
Three rules keep a diff readable: keys are written in a fixed order, absent means default, and floats are rounded once (3 decimals) so the last bits of an FMA never rewrite a golden file.
Read one node with findNode(snapshot, "buy-btn"), or serialize the whole thing
with serializeSnapshot(snapshot).
stats() — what the frame cost
FrameStats is web-only telemetry and not normative: none of it is a
cross-target metric, which is why it sits beside snapshot() instead of inside
it. It is what the renderer’s performance budgets are asserted against.
| Field | Meaning |
|---|---|
| drawCalls / vertices / indices | The frame's submitted geometry. |
| atlases / atlasBytes | Live glyph atlases, and the CPU bytes of their bitmaps. |
| resolved | Nodes the resolve pass visited — the CPU work before layout. Zero on a repaint-only frame. |
| textLayouts | Texts re-broken into lines. A steady frame over a static scene must sit at zero. |
| bufferGrowths | Geometry buffers that had to grow. Zero once the scene has been painted at full size. |
| repaintOnly | The frame skipped the whole pipeline before tessellation — nothing changed but pixels, such as a blinking caret. |