Skip to content
zabloo
zabloo/uiA UI platform for videogamesFREE & OPEN SOURCE

Your UI stops being part of the build.

The game ships a small SDK. The screens ship as data — a versioned IR envelope the SDK tessellates into GPU geometry. Change a screen, publish the envelope, and the build that is already on players' machines draws the new one.

terminal
$ npm create zabloo-app
$ zabloo dev
→ preview on :5078
$ zabloo build
→ dist/zabloo.ir.json

See it run

Not a picture of the UI. The UI.

This frame runs the same renderer the SDK runs — its own layout pass, its own tessellator, its own glyph atlas. Drive the data, watch it re-lay out, and read the actions the UI hands back to the game.

inventory.viewIR v1
Viewport

Viewport: 960 × 420

A game inventory panel: the title INVENTORY with a gold counter reading 1,250, a four-by-two grid of item slots with the first three filled and the first one highlighted as equipped, a carried-weight bar at 64 of 100, and an Equip button beside a Drop button.
STATE
  • hover — off
  • pressed — off
  • focused — off
  • selected — off
  • disabled — off

Reported from the frame, not forced onto it.

Data

What the game has pushed into player.gold, already formatted — the IR has no expressions, so formatting is the game's job.

Writes both paths the panel binds: bag.weight, the number the player reads, and bag.load, the 0–1 the bar is defined in.

Actions

  1. Press something in the frame and the actions land here.

No frame drawn yet

import { Button, Column, Image, ProgressBar, Row, Text } from "@zabloo/react";


/** Runs at authoring time — it is a plain array, not game data. */
const SLOTS: Array<{ id: string; icon?: string; tint?: string; equipped?: boolean }> = [
  { id: "relic", icon: "icons/star.png", tint: "{color.brand}", equipped: true },
  { id: "shield", icon: "icons/shield.png", tint: "#6ee7b7" },
  { id: "charge", icon: "icons/bolt.png", tint: "{color.gold}" },
  { id: "empty-1" },
  { id: "empty-2" },
  { id: "empty-3" },
  { id: "empty-4" },
  { id: "empty-5" },
];

/**
 * 68, not 76, and that is the whole reason the grid survives a phone: four
 * slots and their gaps come to 296, which still fits the 302 a 390-wide view
 * leaves after the paddings. A slot row that cannot fit the narrowest preset
 * would be clipped there rather than re-laid out.
 */
const SLOT_SIZE = 68;
const SLOT_COLUMN = SLOT_SIZE * 4 + 8 * 3;

export default function Inventory() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it.
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        // `align: "stretch"` is what gives the rows below the panel's full
        // width — without it they measure to their content and there is no
        // leftover space for `space-between` or `grow` to distribute.
        layout={{ padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        {/* Header: title + the gold pill, whose amount is bound. */}
        <Row layout={{ justify: "space-between", align: "center" }}>
          <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>INVENTORY</Text>
          <Row
            layout={{
              gap: "{space.1}",
              align: "center",
              padding: "{space.1}",
              width: 92,
              justify: "center",
            }}
            style={{ background: "{color.slot}", radius: "{radius.pill}" }}
          >
            <Image
              src="icons/coin.png"
              layout={{ width: 13, height: 13 }}
              style={{ color: "{color.gold}" }}
            />
            <Text bind="player.gold" style={{ color: "{color.gold}", fontSize: "{text.sm}" }} />
          </Row>
        </Row>

        {/* The body, and the one node that answers the viewport: a wrapping row
            whose two children carry a width, so the wrap point is arithmetic.
            296 + 16 + 260 = 572 fits the 872 a desktop leaves and does not fit
            the 302 a phone does, so the stats block drops under the slots
            exactly there — and `grow` then hands the stats block the rest of
            its line, which is what keeps the weight bar spanning the panel. The
            BUTTONS under it keep a width of their own: a primary action that
            stretches to 700px on a desktop is the leftover space reaching a
            child that had no use for it. */}
        <Row layout={{ wrap: true, gap: "{space.4}", align: "start" }}>
          {/* Eight slots in two rows of four. The map is authoring-time sugar. */}
          <Column layout={{ width: SLOT_COLUMN, gap: "{space.2}" }}>
            {[0, 4].map((offset) => (
              <Row key={offset} layout={{ gap: "{space.2}" }}>
                {SLOTS.slice(offset, offset + 4).map((slot) =>
                  slot.icon === undefined ? (
                    <Column
                      key={slot.id}
                      layout={{ width: SLOT_SIZE, height: SLOT_SIZE }}
                      style={{
                        radius: "{radius.md}",
                        borderWidth: "{border.hairline}",
                        borderColor: "{color.line}",
                      }}
                    />
                  ) : (
                    <Button
                      key={slot.id}
                      id={slot.id}
                      variant="slot"
                      onClick="slot-select"
                      layout={{
                        width: SLOT_SIZE,
                        height: SLOT_SIZE,
                        justify: "center",
                        align: "center",
                      }}
                      style={
                        slot.equipped
                          ? { background: "{color.brand-soft}", borderColor: "{color.brand}" }
                          : undefined
                      }
                    >
                      <Image
                        src={slot.icon}
                        layout={{ width: 26, height: 26 }}
                        style={{ color: slot.tint }}
                      />
                    </Button>
                  ),
                )}
              </Row>
            ))}
          </Column>

          <Column layout={{ width: 260, grow: 1, gap: "{space.4}", align: "stretch" }}>
            {/* Carried weight: the label and the bar read two different paths —
                the number the player sees, and the 0..1 the bar is defined in. */}
            <Column layout={{ gap: "{space.2}", align: "stretch" }}>
              <Row layout={{ justify: "space-between", align: "center" }}>
                <Text style={{ color: "{color.muted}", fontSize: "{text.xs}" }}>Weight</Text>
                <Row layout={{ gap: "{space.1}" }}>
                  <Text
                    bind="bag.weight"
                    style={{ color: "{color.muted}", fontSize: "{text.xs}" }}
                  />
                  <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>/ 100</Text>
                </Row>
              </Row>
              <ProgressBar
                id="weight-bar"
                value={{ bind: "bag.load" }}
                size={5}
                style={{ background: "{color.slot}", radius: "{radius.pill}" }}
                fill={{ background: "{color.brand}", radius: "{radius.pill}" }}
              />
            </Column>

            {/* The two actions the game subscribes to. */}
            <Row layout={{ gap: "{space.2}" }}>
              <Button
                id="equip"
                variant="primary"
                onClick="equip"
                layout={{ width: 180, height: 40, justify: "center", align: "center" }}
              >
                <Text style={{ color: "{color.on-brand}", fontSize: "{text.sm}" }}>Equip</Text>
              </Button>
              <Button
                id="drop"
                variant="secondary"
                onClick="drop"
                layout={{ width: 96, height: 40, justify: "center", align: "center" }}
              >
                <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Drop</Text>
              </Button>
            </Row>
          </Column>
        </Row>
      </Column>
    </Column>
  );
}
The renderer loads when you press Run, and only then — the resting frame above is a picture. Every frame on this page carries the same description in text, and the code tab is the accessible equivalent of the picture.

What the format carries

Six things the SDK knows how to do.

Layout

A Flexbox subset every target implements identically — direction, justify, align, gap, grow, wrap.

Tokens & theming

Colours, spacing and radii resolve through a dictionary in the envelope. Reskin without touching a screen.

Bindings

Text, visibility and checked state read from data paths the game owns. Write once, the UI re-lays out.

Named actions

The UI fires names, not callbacks. The game subscribes to “buy” and never learns what drew the button.

Input & focus

Pointer, keyboard and gamepad. Directional navigation and focus traps are part of the format, not your code.

Degradation

An SDK older than the content it receives is a normal case. Unknown nodes fall back by rule, never to a crash.

In the engine

Load an envelope. Hear the actions.

That is the whole integration surface. The SDK owns drawing and input; your game owns the data and what a name means.

  • No prefab per screen, no scene wiring
  • One draw path, whatever the screen
  • Swap the envelope at runtime, keep the session
ShopScreen.csUnity
var ui = Zabloo.Mount("shop");

ui.OnAction("buy", ctx => {
    Economy.Purchase(ctx.Path);
});

ui.SetData("player.gold", 1250);

Put your UI on the next build.

The SDK is open source and free. Start with a screen and see how far the format takes you.