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:
| Property | Type | Description |
|---|---|---|
position | Vector2 | Translation applied as a CSS transform |
scale | Vector2 | Scale applied as a CSS transform |
rotation | Vector1 | Rotation (radians) applied as a CSS transform |
size | Vector2 | Logical width and height |
transformOrigin | Vector2 | Pivot 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 bylink()are removed byunlink(). - unlink — the view is detached. All bindings registered with
bindSignalare 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 rootEmptyViewthat sits on top of the WebGL canvas.gml— theGMLEngineinstance (see Widgets for GML tooltip detail).tooltips— aTooltipManagerthat drives hover tooltips.cursor— anObservedStringwhose value is appended as a CSS class to the graphics canvas, enabling custom cursors per state.windows— aListof openSimpleWindowpanels.
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.
| Class | Model type | DOM element | Source |
|---|---|---|---|
Vector1Control | Vector1 | NumberController input | src/view/controller/controls/Vector1Control.js |
Vector2Control | Vector2 | Two NumberController inputs | src/view/controller/controls/Vector2Control.js |
Vector3Control | Vector3 | Three dat.gui-style numeric fields | src/view/controller/controls/Vector3Control.js |
CheckboxView | ObservedBoolean | <input type="checkbox"> | src/view/elements/CheckboxView.js |
DropDownSelectionView | List<T> | <select> | src/view/elements/DropDownSelectionView.js |
ColorPickerView | Vector4 (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.