Engine-agnostic

UI and HUD: Basics

Learn the fundamentals of building reactive user interfaces for martini-kit multiplayer games.

In this guide:

  • UI Architecture principles
  • Basic HUD setup with helpers
  • Reactive patterns for state updates
  • Health bars and indicators

Next: Part 2: Components → covers scoreboards, timers, and damage numbers.


UI Architecture

In multiplayer games, UI must be responsive to state changes from any player:

┌──────────────────────────────────────┐
│  Game State (Synchronized)           │
│  - player.health                     │
│  - player.score                      │
│  - gameMode.timeRemaining            │
└──────────────────────────────────────┘


┌──────────────────────────────────────┐
│  UI Components (Local)               │
│  - Health Bar                        │
│  - Scoreboard                        │
│  - Timer                             │
└──────────────────────────────────────┘

Key principle: UI should be reactive to state, not the other way around.


Basic HUD Setup

martini-kit offers two approaches for creating HUDs: the Phaser Helpers approach using createPlayerHUD, or the Core Primitives approach with manual text management.

Using createPlayerHUD Helper

The createPlayerHUD helper eliminates HUD boilerplate by automatically managing title, role, and control hint text with reactive updates.

Basic Usage (Action Games)

For action games that only need player-specific data:

import { createPlayerHUD } from '@martini-kit/phaser';

create() {
  this.adapter = new PhaserAdapter(runtime, this);

  // Create HUD with automatic updates
  this.hud = createPlayerHUD(this.adapter, this, {
    title: 'Blob Battle',
    titleStyle: { fontSize: '32px', color: '#fff', fontStyle: 'bold' },

    roleText: (myPlayer) => {
      if (!myPlayer) return 'Spectator';
      return `Size: ${myPlayer.size}`;
    },
    roleStyle: { fontSize: '18px', color: '#fff' },

    controlHints: () => 'Click anywhere to move your blob',
    controlsStyle: { fontSize: '14px', color: '#aaa' },

    layout: {
      title: { x: 400, y: 30 },
      role: { x: 400, y: 70 },
      controls: { x: 400, y: 575 }
    }
  });
}

Benefits:

  • Automatic reactive updates when player state changes
  • No manual onChange subscriptions needed
  • Handles player join/leave automatically
  • Deduplicates updates for performance

Advanced Usage (Turn-Based Games)

For turn-based games that need access to global game state:

create() {
  this.adapter = new PhaserAdapter(runtime, this);

  this.hud = createPlayerHUD(this.adapter, this, {
    title: 'Connect Four',
    titleStyle: { fontSize: '28px', color: '#fff', fontStyle: 'bold' },

    // Second parameter provides full game state!
    roleText: (myPlayer, state) => {
      if (!myPlayer) return 'Spectator';
      if (!state) return 'Loading...';

      // Access global game state for turn-based logic
      if (state.gameOver) {
        if (state.isDraw) return 'Game Draw!';
        if (state.winner) {
          const winnerPlayer = state.players[state.winner];
          return state.winner === this.adapter.getMyPlayerId()
            ? `You Win! (${winnerPlayer?.color})`
            : `${winnerPlayer?.color.toUpperCase()} Wins!`;
        }
      }

      // Show whose turn it is
      const playerIds = Object.keys(state.players || {});
      const currentPlayerId = playerIds[state.currentTurn];
      const currentPlayer = state.players?.[currentPlayerId];

      if (currentPlayerId === this.adapter.getMyPlayerId()) {
        return `Your Turn (${myPlayer.color.toUpperCase()})`;
      }

      return `${currentPlayer?.color?.toUpperCase() || 'Opponent'}'s Turn`;
    },
    roleStyle: { fontSize: '18px', color: '#fff' },

    controlHints: () => 'Click a column to drop your token',
    controlsStyle: { fontSize: '14px', color: '#aaa' }
  });
}

Reactive UI Patterns

Pattern 1: Using watchMyPlayer()

The most efficient way to update UI based on player state:

create() {
  this.adapter = new PhaserAdapter(runtime, this);

  // Create health text
  this.healthText = this.add.text(16, 16, '', {
    fontSize: '18px',
    color: '#ffffff'
  });

  // Watch specific property - only updates when health changes
  this.adapter.watchMyPlayer(
    (player) => player?.health,
    (health) => {
      if (health === undefined) return;

      this.healthText.setText(`Health: ${health}`);

      // Update color based on health
      if (health <= 20) {
        this.healthText.setColor('#ff0000');
      } else if (health <= 50) {
        this.healthText.setColor('#ffaa00');
      } else {
        this.healthText.setColor('#ffffff');
      }
    }
  );

  // Watch score separately
  this.adapter.watchMyPlayer(
    (player) => player?.score,
    (score) => {
      this.scoreText.setText(`Score: ${score ?? 0}`);
    }
  );
}

Benefits:

  • Only runs callback when the watched property changes
  • Automatically unsubscribes when scene is destroyed
  • More efficient than onChange for specific properties

Pattern 2: HUD Manager Class

For complex UIs, create a dedicated manager that encapsulates all HUD logic:

class HUDManager {
  private scene: Phaser.Scene;
  private adapter: PhaserAdapter;
  private elements: Map<string, Phaser.GameObjects.GameObject> = new Map();
  private unsubscribes: Array<() => void> = [];

  constructor(scene: Phaser.Scene, adapter: PhaserAdapter) {
    this.scene = scene;
    this.adapter = adapter;
  }

  create() {
    // Create health display
    const healthText = this.scene.add.text(16, 16, 'Health: 100', {
      fontSize: '18px',
      color: '#ffffff'
    });
    healthText.setScrollFactor(0);
    this.elements.set('health', healthText);

    // Create score display
    const scoreText = this.scene.add.text(16, 50, 'Score: 0', {
      fontSize: '18px',
      color: '#ffffff'
    });
    scoreText.setScrollFactor(0);
    this.elements.set('score', scoreText);

    // Watch player state
    const unsubHealth = this.adapter.watchMyPlayer(
      (player) => player?.health,
      (health) => {
        const text = this.elements.get('health') as Phaser.GameObjects.Text;
        text.setText(`Health: ${health ?? 0}`);
      }
    );

    const unsubScore = this.adapter.watchMyPlayer(
      (player) => player?.score,
      (score) => {
        const text = this.elements.get('score') as Phaser.GameObjects.Text;
        text.setText(`Score: ${score ?? 0}`);
      }
    );

    this.unsubscribes.push(unsubHealth, unsubScore);
  }

  destroy() {
    // Clean up subscriptions
    this.unsubscribes.forEach(unsub => unsub());

    // Destroy all UI elements
    this.elements.forEach(element => element.destroy());
    this.elements.clear();
  }
}

// Usage in scene
create() {
  this.hudManager = new HUDManager(this, this.adapter);
  this.hudManager.create();
}

shutdown() {
  this.hudManager?.destroy();
}

Health Bars

Simple Health Bar

A basic rectangular health bar:

create() {
  // Create health bar container
  const x = 16;
  const y = 16;
  const width = 200;
  const height = 20;

  // Background
  this.healthBarBg = this.add.rectangle(x, y, width, height, 0x000000, 0.5);
  this.healthBarBg.setOrigin(0, 0);
  this.healthBarBg.setScrollFactor(0);

  // Fill (green bar)
  this.healthBarFill = this.add.rectangle(x + 2, y + 2, width - 4, height - 4, 0x00ff00);
  this.healthBarFill.setOrigin(0, 0);
  this.healthBarFill.setScrollFactor(0);
  this.healthBarFill.setData('maxWidth', width - 4);

  // Watch health changes
  this.adapter.watchMyPlayer(
    (player) => player?.health,
    (health) => this.updateHealthBar(health ?? 0, 100)
  );
}

private updateHealthBar(current: number, max: number) {
  const maxWidth = this.healthBarFill.getData('maxWidth');
  const percentage = Math.max(0, Math.min(1, current / max));

  // Update width
  this.healthBarFill.width = maxWidth * percentage;

  // Color based on health
  if (percentage <= 0.25) {
    this.healthBarFill.setFillStyle(0xff0000); // Red
  } else if (percentage <= 0.5) {
    this.healthBarFill.setFillStyle(0xff8800); // Orange
  } else {
    this.healthBarFill.setFillStyle(0x00ff00); // Green
  }
}

Health Bar Above Sprite

Display health bars above each player sprite:

// Create health bar for a sprite
createPlayerHealthBar(sprite: Phaser.GameObjects.Sprite, playerId: string) {
  const width = 50;
  const height = 6;
  const offsetY = -40;

  const container = this.add.container(sprite.x, sprite.y + offsetY);

  // Background
  const bg = this.add.rectangle(0, 0, width, height, 0x000000, 0.8);

  // Fill
  const fill = this.add.rectangle(0, 0, width - 2, height - 2, 0x00ff00);
  fill.setData('maxWidth', width - 2);

  container.add([bg, fill]);

  // Store reference
  this.healthBars.set(playerId, { container, fill, sprite });
}

// Update health bar position and fill
update() {
  this.adapter.onChange((state) => {
    for (const [playerId, player] of Object.entries(state.players)) {
      const healthBar = this.healthBars.get(playerId);
      if (!healthBar) continue;

      // Follow sprite
      healthBar.container.x = healthBar.sprite.x;
      healthBar.container.y = healthBar.sprite.y - 40;

      // Update fill
      const percentage = Math.max(0, Math.min(1, player.health / 100));
      const maxWidth = healthBar.fill.getData('maxWidth');
      healthBar.fill.width = maxWidth * percentage;

      // Update color
      if (percentage <= 0.25) {
        healthBar.fill.setFillStyle(0xff0000);
      } else if (percentage <= 0.5) {
        healthBar.fill.setFillStyle(0xff8800);
      } else {
        healthBar.fill.setFillStyle(0x00ff00);
      }
    }
  });
}

Circular Health Indicator

A circular progress indicator for health:

class CircularHealthBar extends Phaser.GameObjects.Container {
  private background: Phaser.GameObjects.Arc;
  private fill: Phaser.GameObjects.Arc;

  constructor(scene: Phaser.Scene, x: number, y: number, radius: number = 30) {
    super(scene, x, y);

    // Background circle
    this.background = scene.add.circle(0, 0, radius, 0x000000, 0.3);

    // Health arc (starts at -90 degrees, sweeps clockwise)
    this.fill = scene.add.arc(0, 0, radius - 3, -90, 270, false, 0x00ff00);

    this.add([this.background, this.fill]);
    scene.add.existing(this);
  }

  updateHealth(current: number, max: number): void {
    const percentage = Math.max(0, Math.min(1, current / max));
    const angle = 360 * percentage;

    // Update arc sweep
    this.fill.setEndAngle(-90 + angle);

    // Color gradient
    if (percentage <= 0.25) {
      this.fill.setFillStyle(0xff0000);
    } else if (percentage <= 0.5) {
      this.fill.setFillStyle(0xff8800);
    } else {
      this.fill.setFillStyle(0x00ff00);
    }
  }
}

// Usage
create() {
  this.healthIndicator = new CircularHealthBar(this, 50, 50, 25);
  this.healthIndicator.setScrollFactor(0);

  this.adapter.watchMyPlayer(
    (player) => player?.health,
    (health) => {
      this.healthIndicator.updateHealth(health ?? 0, 100);
    }
  );
}

Next Steps

Now that you understand the basics, continue to:

See Also