UI & HUD
The @web-engine-dev/ui package provides a game-focused UI system with a CSS-like layout engine (flexbox/grid), component-based architecture, automatic scaling, and full ECS integration. It renders directly to a Canvas2D layer composited on top of the game scene.
Setup
import { createUISystem, UIManagerResource } from '@web-engine-dev/ui';
// Create the UI system and register it as a resource
const uiSystem = createUISystem({
canvas: document.getElementById('ui-canvas') as HTMLCanvasElement,
scaleMode: 'scaleWithScreenSize', // auto-scale UI for different resolutions
referenceResolution: { width: 1280, height: 720 },
});
world.insertResource(UIManagerResource, uiSystem);Creating a HUD
The UI package is an OOP node hierarchy. Build HUD elements as UINode instances and call addChild() to compose them. Register the root with UIManagerResource:
import { UINode, TextNode, ProgressBarNode, UIManagerResource } from '@web-engine-dev/ui';
const uiManager = world.getResource(UIManagerResource);
// Root HUD node: full screen overlay
const hud = new UINode({
style: { width: '100%', height: '100%', position: 'absolute', top: 0, left: 0 },
});
// Health bar: bottom-left
const healthBar = new ProgressBarNode({
value: 1.0, // 0.0 – 1.0 (set max: 1 for normalized)
min: 0,
max: 1,
style: {
position: 'absolute',
left: 20,
bottom: 20,
width: 200,
height: 20,
},
});
hud.addChild(healthBar);
// Score: top-right
const scoreLabel = new TextNode({
text: 'Score: 0',
textStyle: {
fontSize: 24,
color: '#ffffff',
textAlign: 'right',
},
style: {
position: 'absolute',
right: 20,
top: 20,
width: 200,
height: 40,
},
});
hud.addChild(scoreLabel);
// Register the HUD as the UI root
uiManager.setRoot(hud);Updating HUD from Game State
Mutate UINode properties directly - the UI system re-renders nodes whose properties change:
function HUDUpdateSystem(world: World): void {
// Update health bar for every player entity
const healthQuery = world.query().with(Health, Player).build();
for (const {
components: [health],
} of world.run(healthQuery)) {
// ProgressBarNode.setValue() clamps and marks layout dirty automatically
healthBar.setValue(health.current / health.max);
}
// Update score
const score = world.getResource(GameScore);
if (score.dirty) {
scoreLabel.setText(`Score: ${score.value.toLocaleString()}`);
score.dirty = false;
}
}Reactive UI with Signals
Note:
bindSignalis not exported by@web-engine-dev/ui. For reactive data-binding, use@web-engine-dev/signalsto compute values and update nodes directly in an ECS system, or mutate node properties imperatively.
import { signal, computed } from '@web-engine-dev/signals';
// Create reactive state
const playerHealth = signal(100);
const playerMaxHealth = signal(100);
const healthPercent = computed(() => playerHealth.get() / playerMaxHealth.get());
const healthColorSignal = computed(() =>
healthPercent.get() > 0.5 ? '#2ecc71' : healthPercent.get() > 0.25 ? '#f39c12' : '#e74c3c'
);
// Subscribe and push changes to UINode properties
healthPercent.subscribe((value) => {
healthBar.setValue(value);
});
healthColorSignal.subscribe((color) => {
healthBar.style = { ...healthBar.style, color };
healthBar.markLayoutDirty();
});
// Update from game logic - subscribers fire automatically
playerHealth.set(75);Menus
Pause Menu
import { UINode, StackNode, TextNode, ButtonNode, UIManagerResource } from '@web-engine-dev/ui';
import type { PointerEventData } from '@web-engine-dev/ui';
function createPauseMenu(world: World): UINode {
const panel = new StackNode({
direction: 'vertical',
gap: 16,
style: {
position: 'absolute',
width: 300,
height: 400,
backgroundColor: 'rgba(0,0,0,0.85)',
borderRadius: 12,
padding: { top: 24, right: 24, bottom: 24, left: 24 },
},
});
const title = new TextNode({
text: 'PAUSED',
textStyle: { fontSize: 32, color: '#ffffff', textAlign: 'center' },
});
const resumeBtn = createButton('Resume', () => {
unpauseGame(world);
// Hide the panel by setting display to none
panel.props = { ...panel.props, style: { ...panel.props.style, display: 'none' } };
});
const settingsBtn = createButton('Settings', () => openSettings(world));
const quitBtn = createButton('Quit to Menu', () => loadMainMenu(world));
panel.addChild(title);
panel.addChild(resumeBtn);
panel.addChild(settingsBtn);
panel.addChild(quitBtn);
return panel;
}
function createButton(label: string, onClick: (e: PointerEventData) => void): ButtonNode {
return new ButtonNode({
label,
onClick,
style: {
backgroundColor: '#2c3e50',
borderRadius: 6,
padding: { top: 10, right: 16, bottom: 10, left: 16 },
},
});
}Inventory Grid
import { UINode, GridNode, ImageNode } from '@web-engine-dev/ui';
// Note: UIItem and DragAndDrop are not provided by @web-engine-dev/ui;
// implement them as custom ECS components for your game.
const inventoryPanel = new UINode({
style: { width: 450, height: 500, backgroundColor: '#1a1a2e', borderRadius: 8 },
});
const itemGrid = new GridNode({
columns: 5,
rows: 4,
style: { gap: 4 },
});
inventoryPanel.addChild(itemGrid);
// Populate from inventory
function populateInventory(world: World, playerEntity: Entity): void {
const inventory = world.get(playerEntity, Inventory);
for (let i = 0; i < inventory.slots; i++) {
const item = inventory.items[i];
const cell = new ImageNode({
src: item ? item.iconTexture : 'ui/empty-slot.png',
alt: item?.name ?? 'Empty slot',
});
itemGrid.addChild(cell);
}
}Dialogue System
Note:
DialogueManageris not exported by@web-engine-dev/ui. The example below shows a suggested integration pattern using your own dialogue resource; adapt to your game's needs.
// Define a custom dialogue resource (example only - not part of @web-engine-dev/ui)
const DialogueManagerResource = defineResource<DialogueManager>('DialogueManager');
const dialogue = world.getResource(DialogueManagerResource);
// Start a conversation
await dialogue.start({
speaker: 'Old Merchant',
portrait: 'portraits/merchant.png',
lines: [
{ text: 'Welcome, traveler. You look weary.', choices: null },
{
text: 'Looking for anything in particular?',
choices: [
{ label: 'I need supplies.', next: 'supplies' },
{ label: 'Just browsing.', next: 'browsing' },
{ label: 'Farewell.', next: null }, // ends conversation
],
},
],
branches: {
supplies: [{ text: "Ah, you've come to the right place. I have potions, torches..." }],
browsing: [{ text: 'Take your time. My wares speak for themselves.' }],
},
onComplete: () => openShopUI(world),
});Tooltips
Note:
Tooltipis not a standalone exported class from@web-engine-dev/ui. Tooltip display is typically handled through theUISystemApior by showing a positionedUINodeon pointer hover.TooltipConfigis exported as a type for custom implementations.
import { type TooltipConfig, UINode, TextNode } from '@web-engine-dev/ui';
// Build a tooltip as a UINode shown/hidden on pointer hover
const tooltipNode = new UINode({
style: {
position: 'absolute',
backgroundColor: 'rgba(0,0,0,0.85)',
padding: { top: 8, right: 12, bottom: 8, left: 12 },
borderRadius: 6,
display: 'none', // hidden by default
},
});
const tooltipText = new TextNode({ text: '', textStyle: { color: '#ffffff', fontSize: 14 } });
tooltipNode.addChild(tooltipText);
hud.addChild(tooltipNode);
// Show/hide on hover using UINode event handlers
equipmentSlotNode.props = {
...equipmentSlotNode.props,
onPointerEnter: (e) => {
tooltipText.setText(item.description);
tooltipNode.style = { ...tooltipNode.style, display: 'flex' };
},
onPointerLeave: () => {
tooltipNode.style = { ...tooltipNode.style, display: 'none' };
},
};Screen Transitions
Note:
UITransitionis not exported by@web-engine-dev/ui. Build scene transitions usingPropertyTransitionManagerwith a full-screen overlayUINode.
import { UINode, PropertyTransitionManager } from '@web-engine-dev/ui';
// Full-screen fade overlay (add it as child of HUD root)
const fadeOverlay = new UINode({
style: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000000',
opacity: 0,
},
});
hud.addChild(fadeOverlay);
const transitionMgr = new PropertyTransitionManager();
// Fade to black
transitionMgr.startTransition(
fadeOverlay,
{ opacity: 0 },
{ opacity: 1 },
{ property: 'opacity', duration: 0.5, easing: 'linear' }
);
// ... load new scene ...
// Fade back in
transitionMgr.startTransition(
fadeOverlay,
{ opacity: 1 },
{ opacity: 0 },
{ property: 'opacity', duration: 0.5, easing: 'linear' }
);Next Steps
- Save & Load, persist inventory and player progress
- Input Handling, controller-friendly menu navigation
- Audio, UI click sounds and music transitions