UI

UI toolkit

Meep's built-in view/element system — a zero-garbage, DOM-backed UI layer with ECS integration and a panel of GUI controls bound to observed game state.

Meep ships a complete UI layer written in the same JavaScript as the rest of the engine. It is not a React or Vue wrapper — it is a lean class hierarchy built on top of real DOM elements, with its own transform model, a lifecycle that aligns with link/unlink instead of mount/unmount, and a suite of ready-made controls and widgets that read from and write to the engine’s observable-value types. If you prefer a framework, the system is optional: a React or Vue component tree can occupy the same canvas, and the engine exposes the observable values the framework needs to subscribe to.

Almost everything below lives under @woosh/meep-engine/src/view/ and @woosh/meep-engine/src/engine/ui/.

The View base class

Every UI element extends View, defined in src/view/View.js. A view wraps a single DOM element (this.el) and exposes an observable state you can drive programmatically:

PropertyTypeDescription
positionVector2Translation applied as a CSS transform
scaleVector2Scale applied as a CSS transform
rotationVector1Rotation (radians) applied as a CSS transform
sizeVector2Logical width and height
transformOriginVector2Pivot for rotation and scale, normalized (default 0.5, 0.5)

The transform is composed into a single CSS matrix3d and written to el.style.transform only when it changes, so the DOM is not touched unnecessarily. Changes to position, scale, and rotation trigger the rewrite automatically.

Views have a two-phase lifecycle:

  • link — the view is attached to the document. Signals registered with this.bindSignal(signal, handler) are connected here. Event listeners added by link() are removed by unlink().
  • unlink — the view is detached. All bindings registered with bindSignal are disconnected automatically.

Children are managed with addChild(view) / removeChild(view) / removeAllChildren(). parent and children track the hierarchy. Signals on.linked and on.unlinked fire at the boundary.

EmptyView

EmptyView is the canonical container: a View whose constructor creates a <div> (or any tag you name) and accepts CSS classes, inline styles, and HTML attributes in one call.

import EmptyView from "@woosh/meep-engine/src/view/elements/EmptyView.js";

const panel = new EmptyView({
    classList: ["hud-panel"],
    css: { position: "absolute", bottom: "20px", left: "20px" }
});

const row = EmptyView.group([labelView, valueView], { classList: ["row"] });

EmptyView.absolute({ classList }) is a shorthand that adds absolute positioning automatically. EmptyView.group(views, options) creates a container and appends each view as a child in one line.

GUIElement and GUIElementSystem

For game-world UI — a health bar pinned to a character, a floating nameplate — Meep uses an ECS component/system pair rather than imperative DOM insertion.

GUIElement (in src/engine/ecs/gui/GUIElement.js) is the component. It holds a reference to a View, an anchor (Vector2 in the 0–1 range that controls which corner is the origin), an optional named group string, and an ObservedBoolean visible.

GUIElementSystem (in src/engine/ecs/gui/GUIElementSystem.js) watches every entity that carries a GUIElement and adds or removes the view from the DOM when visible changes. Named groups are created as container divs on first use.

import { GUIElement } from "@woosh/meep-engine/src/engine/ecs/gui/GUIElement.js";
import { GUIElementSystem } from "@woosh/meep-engine/src/engine/ecs/gui/GUIElementSystem.js";
import EmptyView from "@woosh/meep-engine/src/view/elements/EmptyView.js";

// Register the system once:
em.addSystem(new GUIElementSystem(containerView, engine));

// Attach a view to an entity:
const hpBar = new EmptyView({ classList: ["hp-bar"] });
entity.add(GUIElement.fromView(hpBar));

GUIElement.fromView(view) wraps an existing view without the managed-class mechanism. If you want the system to instantiate the view from a class name registered in the module registry, use the klass + parameters fields instead.

GUIEngine

GUIEngine (in src/engine/ui/GUIEngine.js) is the high-level coordinator for a scene’s UI. It owns:

  • view — the root EmptyView that sits on top of the WebGL canvas.
  • gml — the GMLEngine instance (see Widgets for GML tooltip detail).
  • tooltips — a TooltipManager that drives hover tooltips.
  • cursor — an ObservedString whose value is appended as a CSS class to the graphics canvas, enabling custom cursors per state.
  • windows — a List of open SimpleWindow panels.

GUIEngine.startup(engine) attaches the root view to engine.gameView, binds the tooltip system to the pointer device, and starts the per-frame ticker. Call openWindow({ title, content, closeable }) to push a SimpleWindow onto the screen with an entry animation (opacity + scale from 0.95 → 1 over 200 ms). createModal / createAlert / createModalConfirmation layer a blocking overlay on top of the active scene and return a Promise or SimpleLifecycle the caller can resolve.

TabbedView

TabbedView (in src/view/common/TabbedView.js) renders a List<TabDefinition> as a pair of containers: a toggle-container for the tab buttons and a panel-container that shows the active tab’s panel. Each TabDefinition holds an ObservedBoolean active, a toggle view, and a panel view. When active changes the panel container is rebuilt. The class has no built-in tab-button markup; you supply the toggle views.

import { TabbedView } from "@woosh/meep-engine/src/view/common/TabbedView.js";
import List from "@woosh/meep-engine/src/core/collection/list/List.js";

const tabs = new List();
tabs.add({ active: new ObservedBoolean(true),  toggle: myButton1, panel: myPanel1 });
tabs.add({ active: new ObservedBoolean(false), toggle: myButton2, panel: myPanel2 });

const tabbed = new TabbedView({ tabs });

GUI controls

GuiController (in src/view/controller/GuiController.js) is a vertical list of labeled rows, each built with addRow(control, label). Row height defaults to 23 px and the list height updates automatically. The individual controls extend GuiControl and each holds a model (ObservedValue) that two-way-syncs to the DOM input.

ClassModel typeDOM elementSource
Vector1ControlVector1NumberController inputsrc/view/controller/controls/Vector1Control.js
Vector2ControlVector2Two NumberController inputssrc/view/controller/controls/Vector2Control.js
Vector3ControlVector3Three dat.gui-style numeric fieldssrc/view/controller/controls/Vector3Control.js
CheckboxViewObservedBoolean<input type="checkbox">src/view/elements/CheckboxView.js
DropDownSelectionViewList<T><select>src/view/elements/DropDownSelectionView.js
ColorPickerViewVector4 (RGBA)Four canvas gauge sliders (H, S, V, A)src/view/elements/ColorPickerView.js

All controls follow the same pattern: setting control.model.set(observedValue) connects the control to the live observable; the control writes back to the observable on input, and the observable writes forward to the DOM on change. Write locks prevent infinite loops.

import { CheckboxView } from "@woosh/meep-engine/src/view/elements/CheckboxView.js";
import { Vector3Control } from "@woosh/meep-engine/src/view/controller/controls/Vector3Control.js";
import GuiController from "@woosh/meep-engine/src/view/controller/GuiController.js";

const gui = new GuiController();

const cbVisible = new CheckboxView({ value: entity.visible });
gui.addRow(cbVisible, "visible");

const v3Position = new Vector3Control();
v3Position.model.set(transform.position);
gui.addRow(v3Position, "position");

DropDownSelectionView binds to a List<T> and an optional transform function that converts each item to display text. getSelectedValue() returns the currently selected item; setSelectedValue(v) selects it programmatically.

ListView

ListView (in src/view/common/ListView.js) renders any List<E> reactively: when elements are added or removed the list rebuilds only the affected rows. An elementFactory(E) => View function is the only required option. Optional filter, addHook, removeHook, cacheSize, and useCache parameters control filtering and view recycling.

import { ListView } from "@woosh/meep-engine/src/view/common/ListView.js";

const list = new ListView(items, {
    classList: ["item-list"],
    elementFactory: (item) => new ItemRowView(item),
    useCache: true,
    cacheSize: 50
});

When useCache is true, removed views are held in a Cache and reused for new items of the same hash, avoiding repeated allocations in long lists.

Where to go next

  • Widgets — radial menu, GML tooltips, currency display, resource bars, drag-and-drop, modals, toasts.
  • Minimap & previews — the WebGL minimap and embedded 3D mesh previews.