The envelope
The unit an SDK loads: one JSON object carrying a version, a token dictionary, one or more views and an asset manifest — plus the props every node in it carries.
The envelope is the one file a game downloads to get its UI. Picture the guild shop screen: the panel, the two rows of items, the Buy buttons, the gold counter. All of it travels in a single JSON file — the boxes and labels, the colours they reference, the images they paint — and the SDK inside the game reads that file and draws the screen. There is no second file and no build step on the player’s machine.
Formally: one JSON object carrying a version, a token dictionary, one or more views, and — when the UI uses images — an asset manifest. The shape of what it carries is the IR, and these pages are its reference.
There is exactly one loading path. A file imported by hand in the editor and a hot-update pushed from the platform are the same versioned payload, read the same way.
{
"v": 1,
"tokens": {
"color.primary": "#4f46e5",
"space.3": 12,
"radius.md": 6
},
"views": {
"hud": { "type": "Container", "children": [] }
},
"assets": {
"icons/coin.png": {
"hash": "9f2c…",
"mime": "image/png",
"size": 1204,
"width": 32,
"height": 32,
"data": "iVBORw0KGgo…"
}
}
}
Four top-level fields, and the rest of this reference describes what lives inside one of them:
| Prop | Type | Default | Description |
|---|---|---|---|
| v | number | — | The IR major version. An SDK refuses a payload whose major it does not implement. |
| tokens | Record<string, string | number> | — | Flat design-token dictionary. |
| views | Record<string, ZNode> | — | Documents (views, scenes) keyed by view id. At least one usable view is required. |
| assets | Record<string, AssetEntry> | absent | Asset manifest keyed by logical id. Envelopes with no assets omit it. |
Views
A view is one screen. An envelope carries several of them — a HUD, a shop, a settings screen — and the game chooses which one to render. Each value is a tree of nodes, the boxes and labels the screen is made of; the key is the id the game asks for.
View ids are opaque strings and may contain dots, which is why diagnostics
address them in brackets: views["shop.main"].children[2].
Node ids are expected to be unique within a view. They are what the game
names when it drives a node through the
host channel, and what
an anchored Overlay
points at. Give a node the id audio-section and
setOpen("audio-section", true) opens it from game code; the whole surface is
on The host channel. Duplicates load, with a
warning, and resolve to the first match.
Tokens
A token is a named value — color.primary, space.3 — that styles point at
instead of spelling out. Styles do not bake values: they reference tokens, and
the SDK resolves them per node at render time. Swapping the dictionary re-themes the whole UI without
re-emitting the tree — which is what makes a theme hot-updatable on its own.
A token reference is a string wrapped in braces: "{color.primary}". It is
valid anywhere a Dim or a ColorValue is accepted, and the dictionary is
flat — the key is the whole name including its dots, so a lookup is one hash
hit and never a walk:
"tokens": { "color.primary": "#4f46e5", "space.3": 12 }
"style": { "background": "{color.primary}" },
"layout": { "padding": "{space.3}" }
In the shop, the Buy button asks for "background": "{color.primary}", and so
does every other filled button in the game. Ship an envelope whose dictionary
maps color.primary to a different hex and all of them repaint at once — the
tree of nodes is byte-for-byte what it was.
There is no cascade and no inheritance. Every node carries its own resolved style; nothing is looked up from a parent.
A missing token never breaks the frame
The load pass reports it once, naming the node and the property
(unknown-token), and then:
- A
Dimfalls back to the property’s own default. A missing{space.3}givespadding: 0, and a missing{size.card}on awidthleaves the node auto-sized. - A declared
ColorValuepaints the missing-color magenta. It is a deliberate, loud signal: the author asked for a color and named one that does not exist, and a silently transparent node would hide the typo instead of showing it. An absent color is not this case — it simply paints nothing.
Token values are strings or numbers, and the property decides how to read
one: a Dim takes the number, a ColorValue takes the string. A token of the
wrong type is treated exactly like a missing one.
Assets
Images travel inside the envelope. An entry describes the content and, in v1, carries the bytes:
| Prop | Type | Default | Description |
|---|---|---|---|
| hash | string | — | Content identity (SHA-256, hex). Deduplicates today; the key for content-addressed caching later. |
| mime | string | — | e.g. "image/png". The format is generic — which MIME types are accepted is an export concern. |
| size | number | — | Byte size of the decoded content. |
| width | number | absent | Pixel width. Lets layout reserve space before the bytes are decoded. |
| height | number | absent | Pixel height. |
| data | string | absent | The content, base64-encoded. |
Nodes reference an entry by its manifest key, prefixed:
"asset:icons/coin.png". The prefix is what makes an asset reference
recognizable without knowing the manifest — isAssetRef and assetIdFromRef
in @zabloo/format are the shared readers for it.
data is optional in the schema only: a v1 export always inlines it. The
field exists so a future delivery path can ship an envelope that names its
assets by hash and lets the SDK resolve the bytes from a cache or a CDN, without
a format change. An SDK that finds no data and cannot resolve the bytes paints
the node’s background and nothing else — the same as an image still being
decoded.
width and height matter for layout, not for paint: an
Image takes the source’s pixel size as
its intrinsic size, so a manifest that omits them makes the node measure as zero
until it is given an explicit size.
The manifest carries images only — the export accepts .png, .jpg and
.jpeg — and no node has a prop that could name a font, so there is nothing
to reference one with. Every target rasterizes the same embedded typeface
instead. Per-project fonts arrive with the text engine work, and they will
land here: the manifest is deliberately generic about MIME types, so adding
one is an export concern rather than a format change.
The node base
Every node in views is an object with a type and the fields below. These are
the fields every node has, whatever it is: a Button, a Text and a
ScrollView all carry them. Each node type then adds its own — a Text adds
text, a Slider adds min and max — and those are documented on each
catalog page.
| Prop | Type | Default | Description |
|---|---|---|---|
| type | string | — | The node's identity. Drives its behavior and its default paint. |
| id | string | absent | Addressable name within the view. |
| visible | Bindable<boolean> | true | The single hiding mechanism, with display:none semantics — a hidden node leaves the layout entirely. |
| disabled | Bindable<boolean> | false | Takes this node and its subtree out of the interaction model. |
| layout | Layout | {} | Flex inputs — direction, gap, padding, size, grow, wrap. |
| style | Style | {} | How the node paints, resolved per node. |
| states | Partial<Record<StateName, { style?: Style }>> | {} | Per-state style overrides, merged in one normative order. |
| transition | Transition | absent | Tweens this node's animatable values when they change. |
| autofocus | boolean | false | This node takes the initial focus of its scope. |
| clip | boolean | false | Clips children's paint and hit-testing to this node's rect. |
Three of them carry rules worth stating outright.
visible is the only way to hide something. There is no second mechanism —
no display, no hidden, no opacity trick with layout consequences. Hiding a
node removes it from the layout pass, so its siblings
close the gap; showing it again brings it back. Because it is Bindable, the
game opens and closes UI by moving a boolean in its own data.
disabled is the only prop that inherits. A node’s effective value is its
own or any ancestor’s, so one prop switches a whole form section off; an
Overlay restarts the chain, being the
top of its own input scope. It removes focus, hover, press and actions — not
paint, and not scrolling — and what it looks like is states.disabled, since
the format ships no built-in dimmed look. The whole rule is on
Input & focus.
clip is paint configuration, not runtime state. A node’s effective
clipping rect is the intersection of its own with every ancestor’s, and it cuts
input as well as pixels: a child painted outside the rect is not tappable there
either. A ScrollView always clips
and ignores an explicit clip: false.
What can be bound
A binding is an address into
the game’s own data — player.gold — written where a fixed value would go, so
the node shows whatever the game currently has there.
visible and disabled are the two base props that take a binding in place of
a literal, and they are the same two on every node type: the game pushes a
boolean and the node enters or leaves the layout, or the whole subtree drops out
of the interaction model. Everything else that binds is type-specific — a
Text’s text, a Toggle’s checked, a Repeat’s items — and lives on
Bindings & actions.
Note that style is not bindable anywhere. A bar that changes color with
its value is done by the game moving a token, not by the UI computing one.
Where the tree comes from
@zabloo/react emits envelopes. zabloo export renders the project’s views,
collects the assets they reference, and writes the JSON. Authoring-time
concepts — user components, variants,
composites — are resolved
during that pass and never appear in the output.
Reading an envelope back — parsing, validating, repairing — is Loading.