Skip to content

UI Controllers & Gears

A controller is a named enum of pages scoped to a UI root — { up, over, down }, { home, shop, about }, { closed, open }. A gear binds one component field to a value per page, so when the controller switches page, every geared element reflows — color, position, visibility, text, or any reflected field, snapping or tweening.

One controller drives many elements, and the whole thing is plain ECS data (two components, UIController and UIGear) that travels with a prefab and serializes into a scene. It is the shared, multi-page generalization of the per-widget state a button uses — one mechanism for tabs, radio groups, button states, and show/hide panels.

Put a UIController on a UI root (a Canvas or any node). It holds one or more named controllers, each with a page list and the current page:

import { UIController, controllerState, setControllerPage, getControllerPage } from 'esengine';
world.insert(root, UIController, {
controllers: [controllerState('tab', ['home', 'shop', 'about'], 'home')],
});
setControllerPage(world, anyDescendant, 'tab', 'shop'); // switch page
getControllerPage(world, anyDescendant, 'tab'); // → 'shop'

controllerState(name, pages, current?) builds one controller (current defaults to the first page). setControllerPage / getControllerPage resolve the nearest controller of that name walking self → ancestors, so a gear on a leaf finds the controller on the root. setControllerPage is a no-op on an unknown controller, an unknown page, or the current page (it guards typos rather than failing).

ControllerState field Description
name Controller name, referenced by gears (e.g. "tab", "$interaction").
pages Ordered page names — the enum’s members.
current The currently selected page.

Put a UIGear on any element under the controller. Each binding says “while controller C is on page P, this field holds value V”:

import { UIGear, gearBinding, EasingType } from 'esengine';
world.insert(card, UIGear, {
bindings: [
// The card's colour follows the 'tab' page, easing over 0.18s.
gearBinding('tab', 'UIVisual', 'color', {
home: { r: 0.16, g: 0.18, b: 0.26, a: 1 },
shop: { r: 0.21, g: 0.16, b: 0.26, a: 1 },
about: { r: 0.15, g: 0.23, b: 0.21, a: 1 },
}, { easing: EasingType.EaseOutCubic, duration: 0.18 }),
// Its label text follows the same page (strings snap — no interpolation).
gearBinding('tab', 'Text', 'content', {
home: 'Welcome home', shop: 'Buy stuff', about: 'About us',
}),
],
});

gearBinding(controller, component, property, pages, tween?) addresses a field the same way a Timeline property track does — a component name plus a dot-path property ("color", "color.a", "scale.x"). pages is a sparse map keyed by page name: a page with no entry leaves the field untouched.

GearBinding field Description
controller Which controller drives this (resolved self → ancestors).
component Target component name — "UIVisual", "Transform", "Text", "UINode".
property Dot-path within the component ("color", "color.a", "scale").
pages page → value map; a page absent here leaves the field alone.
tween? { easing, duration } to interpolate on page change; omit for an instant snap.

Values — numbers, colours ({r,g,b,a}), and vectors ({x,y,z}) interpolate when the binding has a tween; strings and booleans always snap. A geared element is owned by the controller for that field: on each page change the gear applies the page’s value.

The built-in controller name $interaction is driven by pointer state (normal / hover / pressed / disabled). Give an element an $interaction controller + an Interactable + a gear, and it gets its button states through the same mechanism as everything else — this is what createButton itself is made of:

import { UIController, UIGear, interactionController, gearBinding, EasingType } from 'esengine';
world.insert(btn, UIController, { controllers: [interactionController()] });
world.insert(btn, Interactable, { enabled: true, blockRaycast: true, raycastTarget: true });
world.insert(btn, UIGear, {
bindings: [gearBinding('$interaction', 'UIVisual', 'color', {
normal: { r: 0.30, g: 0.33, b: 0.42, a: 1 },
hover: { r: 0.39, g: 0.43, b: 0.55, a: 1 },
pressed: { r: 0.22, g: 0.24, b: 0.32, a: 1 },
disabled: { r: 0.20, g: 0.20, b: 0.24, a: 0.6 },
}, { easing: EasingType.EaseOutCubic, duration: 0.1 })],
});

interactionController(pages?) defaults to the four canonical pages. Because a gear can drive any field, the same button can also gear its scale, a child icon’s tint, or a label — all four states, one mechanism.

The inline Controllers strip in the inspector

The Controllers strip: the active controller (here theme, inherited from the Canvas), its pages as chips (sky / rose), and Gears on this entity — every field bound to a controller.

Everything above can be authored visually — no code. Select any UI entity and the Controllers panel (revealed in UI mode) lists every controller it can see — its own plus each ancestor’s (inherited rows carry the owner’s name), so the root’s controllers stay switchable while you work on a geared leaf:

  • Add a controller (the pointer-button preset adds $interaction with its four pages in one click), then manage pages as chips: click to preview, double-click to rename, drag to reorder, and the hover × deletes — renames cascade into every gear binding that resolves to the controller, and deleting a page clears its recorded values.
  • Click a controller to make it active. In the Details panel, every field then shows a small gear dot — click it to bind that field to the active controller (seeding the current page with the field’s value). Click a bound dot to open its settings: the page-change transition (duration + easing, 0 = snap) and unbind.
  • Turn on Record: now every edit writes into the controller’s current page — a field with no gear yet gears itself automatically (auto-key), and bound gear dots glow red while recording. Switch page, edit, switch page, edit — each page captures its own values. This is the fast path — “pick a page, change things, it’s recorded”.
  • The panel’s Gears on this entity list shows every binding the selected entity carries — field, driving controller, page count, transition — with one-click removal.

Controllers and gears are components, so they save into the scene / prefab like anything else, and instance with it.

Two bridges switch pages from data or logic, each reusing an existing system.

From a signalbindControllerPage drives a controller’s page from a reactive signal, the declarative path to a data-driven tab bar:

import { signal, bindControllerPage } from 'esengine';
const activeTab = signal('home');
bindControllerPage(world, root, 'tab', activeTab); // the controller follows the signal
activeTab.set('shop'); // page switches, every geared element reflows

From a button, with no code — an event wire on the button runs ui.setPage when it is clicked, targeting the entity that owns the controller:

{ "event": "click", "action": "ui.setPage", "params": { "controller": "tab", "page": "shop" } }

From a state machine — the ui.setPage action lets a data-driven FSM / behavior tree (.esfsm / .esbt) switch a page with no code. Its argument is "controller:page":

{ "onEnter": { "name": "ui.setPage", "arg": "hud:alert" } }

This is the same layering as the built-in timeline.play glue: logic (.esfsm) → controller state → gear presentation.

  • Controller on the root, gears on the leaves. Resolution walks up the tree, so a controller on the Canvas drives gears anywhere under it.
  • One controller, many gears. A tab controller drives the tab highlights, the content panel, and its label — set the page once, everything follows.
  • Snap vs tween. Add a tween for colour/position/scale transitions; leave it off for text and visibility (which snap anyway).
  • Radio buttons via one controller — give each tab button a gear that’s the accent colour on its own page and muted on the others.
  • Switch pages from data, not scattered mutations — bindControllerPage(signal) or ui.setPage keep the “what page am I on” decision in one place.
  • UI — the flexbox component model gears drive.
  • UI Components — the widget factories.
  • Timeline — keyframed transitions; gears use the same field addressing.
  • Gameplay AI — the .esfsm / .esbt that can call ui.setPage.
  • Event Binding — wire a click straight to ui.setPage, no code.