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. Flutter lays them out, Flutter hit-tests them, and Impeller draws them. It is 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-ember-600 rounded-lg',
props: {'onPressed': 'resume'},
),
],
),
onEvent: (handler, payload) => debugPrint('$handler $payload'),
);

That is a widget. Put it in a Stack over an OrblitView and it is a heads-up display. Put it in a Scaffold and it is 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 is then called with that name and whatever the element has to say, which might be the text in a field or null. What the host does with it is up to the host, and that is 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 someone 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 work, on the same node.

This is borrowed on purpose. It is a way in, not a second box model to keep in step forever, because every class turns into Flutter’s own layout. There is no separate measure-and-arrange pass, no separate hit test, and nothing to keep up to date 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 is a preference, not 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 is the same shape as the scripting boundary: several ways to author, one model.

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

Breakpoint prefixes work on any class:

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

The breakpoints are sm 640, md 900, lg 1280 and xl 1680. They apply 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 is right for a console game and wrong for a phone, because it makes the text tiny instead of making the layout narrower. CanvasFit.responsive lays the document out again, which is what you want on a device whose shape you didn’t design for.

Because it is 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 is the argument for the whole approach in one paragraph. An interface built in most engines can only be exercised by launching the game.