Skip to content
zabloo

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.

The three pages that explain hot-update

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
OperationWeb spellingWhat it does
SetDatasetData(path: string, value: unknown): voidWrites into the data store. Every binding reading that path updates, and the layout re-runs where it must.
SetOpensetOpen(id: string, open: boolean): booleanOpens or closes a Collapse.
SetSelectedTabsetSelectedTab(id: string, index: number): booleanSelects a tab of an "exclusive-select" group, by the group container's id.
SetCheckedsetChecked(id: string, checked: boolean): booleanSets a Toggle.
SetValuesetValue(id: string, value: number): booleanMoves a Slider — exactly the gesture the player would have made, hooks included.
SetTextsetText(id: string, text: string): booleanWrites a TextInput's text, as if it had been typed.
SetScrollsetScroll(id: string, x: number, y: number): booleanMoves 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:

  • SetValue clamps and quantizes to the slider’s min/max/step, fires onChange, and then fires onCommit: the whole gesture, press to release, in one call.
  • SetChecked fires the toggle’s onChange and, inside a group, the group’s.
  • SetText replaces the buffer and leaves the caret at the end, where someone handed a prefilled value would start typing.
  • SetScroll is 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 onDataChanged exactly 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

CallbackWeb spellingWhen it fires
ActiononAction(action: string, context?: ActionContext)A named action declared in the IR — onClick, onChange, onCommit, onSubmit, onDismiss — fired.
Data changedonDataChanged(path: string, value: unknown)A control wrote its value into a bound path.
DiagnosticonDiagnostic(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.

PropTypeDefaultDescription
viewstringthe envelope's first viewView id to render.
onAction(action, context?) => voidnoneNamed actions, with the item's context when there is one.
onDataChanged(path, value) => voidnoneThe return leg of the data channel.
onDiagnostic(diagnostic) => voidconsoleWhere the loading contract's diagnostics go.
backgroundstring"#101218"Canvas clear color (CSS hex).
dprnumberthe browser'sDevice pixel ratio to render at, instead of devicePixelRatio.
onFrame(stats) => voidnoneFires 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

MemberTypeWhat it is
viewIdsstring[]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.
readyPromise<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) => voidHot-update, the same loading path a shipped SDK uses.
snapshot()() => ViewSnapshotThe frame's measurements — see below.
stats()() => FrameStatsWhat the last painted frame cost — see below.
dispose()() => voidReleases the canvas, the GL resources and the listeners. Idempotent.

The seven operationssetData, 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 cost
The reference is replaced on every mount

An 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

FieldTypeMeaning
viewstringThe view id on screen.
size{ width, height }The canvas in CSS px.
focus / hover / pressedstring | nullThe ref of the node holding each state.
layerLayerSnapshot[]Overlays in (z, document order), bottom-most first, each with its presence (0..1 while fading).
treeNodeSnapshotThe 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.

FieldMeaning
drawCalls / vertices / indicesThe frame's submitted geometry.
atlases / atlasBytesLive glyph atlases, and the CPU bytes of their bitmaps.
resolvedNodes the resolve pass visited — the CPU work before layout. Zero on a repaint-only frame.
textLayoutsTexts re-broken into lines. A steady frame over a static scene must sit at zero.
bufferGrowthsGeometry buffers that had to grow. Zero once the scene has been painted at full size.
repaintOnlyThe frame skipped the whole pipeline before tessellation — nothing changed but pixels, such as a blinking caret.