Skip to content

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

typescript
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:

typescript
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:

typescript
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: bindSignal is not exported by @web-engine-dev/ui. For reactive data-binding, use @web-engine-dev/signals to compute values and update nodes directly in an ECS system, or mutate node properties imperatively.

typescript
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);

Pause Menu

typescript
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

typescript
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: DialogueManager is 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.

typescript
// 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: Tooltip is not a standalone exported class from @web-engine-dev/ui. Tooltip display is typically handled through the UISystemApi or by showing a positioned UINode on pointer hover. TooltipConfig is exported as a type for custom implementations.

typescript
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: UITransition is not exported by @web-engine-dev/ui. Build scene transitions using PropertyTransitionManager with a full-screen overlay UINode.

typescript
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

Proprietary software. All rights reserved.