Skip to content

Building an interface

A game’s interface in Orblit is a UiNode tree with a style string on each node, built into real Flutter widgets: laid out by Flutter, hit-tested by Flutter, drawn by Impeller. Not a second widget system pretending to be one.

return UiSurface(
description: const UiNode(
type: 'column',
classes: 'p-6 gap-4 items-center bg-slate-900',
children: [
UiNode(type: 'text', text: 'Paused', classes: 'text-2xl text-slate-100'),
UiNode(
type: 'button',
text: 'Resume',
classes: 'px-4 py-2 bg-orange-600 rounded-lg',
props: {'onPressed': 'resume'},
),
],
),
onEvent: (handler, payload) => debugPrint('$handler $payload'),
);

That’s a widget. Put it in a Stack over an OrblitView and it’s a heads-up display. Put it in a Scaffold and it’s a pause menu.

An element does something by naming a handler in its props: onPressed on a button, onChanged on a field, onTap on anything. onEvent then gets called with that name and whatever the element has to say, whether that’s the text in a field, the value of a slider, or null. What the host does with it is its own business, and that’s what lets the same layer serve a game’s HUD, an editor panel and a unit test without any of them knowing about the others.

p-4 flex-1 items-center bg-slate-800 rounded-lg means what somebody who has written a web page expects it to mean, and padding: 8px 12px; border-radius: 6px means the same thing in the other notation. Both are accepted, on the same node.

This is borrowed deliberately. It’s a way in, rather than a second box model to keep aligned forever, because every class resolves to Flutter’s own layout. There’s no separate measure-and-arrange pass, no separate hit test, and nothing to keep in step with Flutter as Flutter changes.

The editor lays a canvas out visually and writes a .oui file. A script describes the same tree in TypeScript and sends it. Both arrive as a UiNode tree.

That’s a preference rather than a fork. A canvas laid out by hand can be handed to a script to change, and a script’s tree can be saved as a canvas and edited by hand. It’s the same shape as the scripting boundary: several authoring front ends onto one model.

A game runs on a phone and on a 32-inch monitor, and the interface has to hold on both.

Breakpoint prefixes work on any class:

classes: 'flex-col md:flex-row gap-2 lg:gap-6 text-base lg:text-2xl'

The breakpoints are sm 640, md 900, lg 1280 and xl 1680, applied narrowest-first, so a later, wider one wins.

The canvas decides how the whole document scales:

return const UiCanvas(
width: 1920,
height: 1080,
fit: CanvasFit.responsive,
columns: 12,
minScale: 0.8,
maxScale: 1.5,
);

CanvasFit.contain scales the whole design to fit and letterboxes it. That’s right for a console game and wrong for a phone, because it makes the text tiny rather than making the layout narrower. CanvasFit.responsive re-lays out instead, which is what you want on a device whose shape you didn’t design for.

Because it’s widgets, flutter_test works on it with no engine and no window:

await tester.pumpWidget(MaterialApp(home: UiSurface(description: pauseMenu)));
expect(find.text('Resume'), findsOneWidget);
await tester.tap(find.text('Resume'));

That’s the argument for the whole approach in one paragraph. An interface built in most engines can only be exercised by launching the game.