Phaser

UI and HUD: Advanced

Advanced UI techniques including minimaps and performance optimization.

In this guide:

  • Minimap implementation
  • Performance optimization techniques
  • Object pooling for UI
  • Debouncing strategies

Previous: ← Part 2: Components


Minimap

Display a minimap showing player positions in real-time:

class Minimap extends Phaser.GameObjects.Container {
  private background: Phaser.GameObjects.Rectangle;
  private playerDots: Map<string, Phaser.GameObjects.Circle> = new Map();
  private worldWidth: number;
  private worldHeight: number;
  private mapWidth: number;
  private mapHeight: number;

  constructor(
    scene: Phaser.Scene,
    x: number,
    y: number,
    worldWidth: number,
    worldHeight: number,
    mapWidth: number = 150,
    mapHeight: number = 150
  ) {
    super(scene, x, y);

    this.worldWidth = worldWidth;
    this.worldHeight = worldHeight;
    this.mapWidth = mapWidth;
    this.mapHeight = mapHeight;

    // Background
    this.background = scene.add.rectangle(0, 0, mapWidth, mapHeight, 0x000000, 0.5);
    this.background.setStrokeStyle(2, 0xffffff, 0.8);
    this.add(this.background);

    this.setScrollFactor(0);
    scene.add.existing(this);
  }

  update(players: Record<string, any>, myPlayerId: string): void {
    const currentIds = new Set(Object.keys(players));

    // Remove dots for players who left
    for (const [id, dot] of this.playerDots) {
      if (!currentIds.has(id)) {
        dot.destroy();
        this.playerDots.delete(id);
      }
    }

    // Update or create dots
    for (const [id, player] of Object.entries(players)) {
      let dot = this.playerDots.get(id);

      if (!dot) {
        const isMe = id === myPlayerId;
        const color = isMe ? 0x00ff00 : 0xff0000;
        const radius = isMe ? 4 : 3;

        dot = this.scene.add.circle(0, 0, radius, color);
        this.add(dot);
        this.playerDots.set(id, dot);
      }

      // Scale world coordinates to minimap
      const x = ((player.x / this.worldWidth) * this.mapWidth) - (this.mapWidth / 2);
      const y = ((player.y / this.worldHeight) * this.mapHeight) - (this.mapHeight / 2);

      dot.x = x;
      dot.y = y;
    }
  }
}

// Usage
create() {
  this.minimap = new Minimap(
    this,
    this.cameras.main.width - 100, // x
    this.cameras.main.height - 100, // y
    800, // world width
    600, // world height
    120, // minimap width
    120  // minimap height
  );

  this.adapter.onChange((state) => {
    this.minimap.update(state.players, this.adapter.getMyPlayerId());
  });
}

Enhanced Minimap with Teams

Add team colors and additional information:

class TeamMinimap extends Minimap {
  private teamColors: Record<string, number> = {
    red: 0xff0000,
    blue: 0x0000ff,
    green: 0x00ff00,
    yellow: 0xffff00
  };

  update(players: Record<string, any>, myPlayerId: string): void {
    const currentIds = new Set(Object.keys(players));

    // Remove dots for players who left
    for (const [id, dot] of this.playerDots) {
      if (!currentIds.has(id)) {
        dot.destroy();
        this.playerDots.delete(id);
      }
    }

    // Update or create dots with team colors
    for (const [id, player] of Object.entries(players)) {
      let dot = this.playerDots.get(id);

      if (!dot) {
        const isMe = id === myPlayerId;
        const color = this.teamColors[player.team] || 0xffffff;
        const radius = isMe ? 5 : 3;

        dot = this.scene.add.circle(0, 0, radius, color);

        // Add ring for current player
        if (isMe) {
          const ring = this.scene.add.circle(0, 0, radius + 2);
          ring.setStrokeStyle(1, 0xffffff);
          ring.setFillStyle(0x000000, 0);
          this.add(ring);
        }

        this.add(dot);
        this.playerDots.set(id, dot);
      }

      // Scale world coordinates to minimap
      const x = ((player.x / this.worldWidth) * this.mapWidth) - (this.mapWidth / 2);
      const y = ((player.y / this.worldHeight) * this.mapHeight) - (this.mapHeight / 2);

      dot.x = x;
      dot.y = y;
    }
  }
}

Performance Optimization

1. Object Pooling for UI Elements

Reuse UI objects instead of creating/destroying them:

class DamageNumberPool {
  private pool: Phaser.GameObjects.Text[] = [];
  private scene: Phaser.Scene;

  constructor(scene: Phaser.Scene, poolSize: number = 20) {
    this.scene = scene;

    // Pre-create text objects
    for (let i = 0; i < poolSize; i++) {
      const text = scene.add.text(0, 0, '', {
        fontSize: '24px',
        color: '#ff0000',
        fontStyle: 'bold',
        stroke: '#000000',
        strokeThickness: 3
      });
      text.setVisible(false);
      this.pool.push(text);
    }
  }

  show(x: number, y: number, damage: number): void {
    // Find available text or reuse oldest
    const text = this.pool.find(t => !t.visible) || this.pool[0];

    text.setText(`-${damage}`);
    text.setPosition(x, y - 40);
    text.setAlpha(1);
    text.setScale(1);
    text.setVisible(true);

    this.scene.tweens.add({
      targets: text,
      y: y - 80,
      alpha: 0,
      scale: 1.5,
      duration: 1000,
      ease: 'Cubic.easeOut',
      onComplete: () => {
        text.setVisible(false);
      }
    });
  }
}

// Usage
create() {
  this.damagePool = new DamageNumberPool(this, 20);
}

showDamage(x: number, y: number, damage: number) {
  this.damagePool.show(x, y, damage);
}

Benefits:

  • No object creation/destruction overhead
  • Predictable memory usage
  • ~70% faster than creating new objects

2. Debounce Expensive Updates

Limit how often expensive UI updates run:

class ScoreboardManager {
  private scene: Phaser.Scene;
  private adapter: PhaserAdapter;
  private lastUpdateTime = 0;
  private updateInterval = 100; // Update at most every 100ms

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

  create() {
    this.adapter.onChange((state) => {
      const now = this.scene.time.now;

      // Debounce updates
      if (now - this.lastUpdateTime < this.updateInterval) {
        return;
      }

      this.updateScoreboard(state);
      this.lastUpdateTime = now;
    });
  }

  private updateScoreboard(state: any) {
    // Expensive rendering logic here
    // Sort, create/destroy text objects, etc.
  }
}

Alternative: Throttle with requestAnimationFrame

class UIManager {
  private pendingUpdate = false;
  private adapter: PhaserAdapter;

  constructor(adapter: PhaserAdapter) {
    this.adapter = adapter;
  }

  create() {
    this.adapter.onChange((state) => {
      if (this.pendingUpdate) return;

      this.pendingUpdate = true;
      requestAnimationFrame(() => {
        this.updateUI(state);
        this.pendingUpdate = false;
      });
    });
  }

  private updateUI(state: any) {
    // UI update logic
  }
}

3. Cache Text Objects

Reuse text objects instead of creating new ones:

// ❌ BAD - Creates new text every update
this.adapter.onChange((state) => {
  // This creates a new text object every frame!
  const text = this.add.text(16, 16, `Score: ${state.score}`);
});

// ✅ GOOD - Reuse text object
create() {
  this.scoreText = this.add.text(16, 16, 'Score: 0');

  this.adapter.onChange((state) => {
    // Just update the text content
    this.scoreText.setText(`Score: ${state.score}`);
  });
}

4. Use watchMyPlayer for Specific Properties

Only update when specific properties change:

// ❌ LESS EFFICIENT - Runs on every state change
this.adapter.onChange((state) => {
  const myPlayer = state.players[this.adapter.getMyPlayerId()];
  if (myPlayer) {
    this.healthText.setText(`Health: ${myPlayer.health}`);
  }
});

// ✅ MORE EFFICIENT - Only runs when health changes
this.adapter.watchMyPlayer(
  (player) => player?.health,
  (health) => {
    this.healthText.setText(`Health: ${health ?? 0}`);
  }
);

5. Batch DOM Updates

Update multiple UI elements in a single frame:

class HUDBatcher {
  private updates: Array<() => void> = [];
  private scheduled = false;

  queueUpdate(updateFn: () => void) {
    this.updates.push(updateFn);

    if (!this.scheduled) {
      this.scheduled = true;
      requestAnimationFrame(() => {
        this.flush();
      });
    }
  }

  private flush() {
    for (const update of this.updates) {
      update();
    }
    this.updates = [];
    this.scheduled = false;
  }
}

// Usage
this.batcher = new HUDBatcher();

this.adapter.watchMyPlayer(
  (player) => player?.health,
  (health) => {
    this.batcher.queueUpdate(() => {
      this.healthText.setText(`Health: ${health ?? 0}`);
    });
  }
);

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

Performance Checklist

Before deploying your UI:

  • Reuse text objects - Create once, update with setText()
  • Pool frequent objects - Damage numbers, notifications
  • Debounce expensive updates - Scoreboards, complex layouts
  • Use watchMyPlayer() - For player-specific properties
  • Batch updates - Multiple changes in single frame
  • Set scroll factor - setScrollFactor(0) for HUD elements
  • Limit particle effects - Keep under 100 active particles
  • Destroy on cleanup - Remove listeners in shutdown()

Benchmarks

Text Object Creation:

  • Creating new: ~2ms per object
  • Reusing existing: ~0.02ms per update
  • 100x faster to reuse

Object Pooling:

  • Without pool: 100 damage numbers = 200ms total
  • With pool: 100 damage numbers = 2ms total
  • 100x faster with pooling

Watch vs onChange:

  • onChange for 10 properties: ~0.5ms per frame
  • watchMyPlayer for 10 properties: ~0.05ms per frame
  • 10x faster with targeted watching

Complete Example

Here’s a production-ready HUD system combining all best practices:

class ProductionHUD {
  private scene: Phaser.Scene;
  private adapter: PhaserAdapter;
  private damagePool: DamageNumberPool;
  private unsubscribes: Array<() => void> = [];

  // Cached UI elements
  private healthText!: Phaser.GameObjects.Text;
  private scoreText!: Phaser.GameObjects.Text;
  private healthBar!: Phaser.GameObjects.Rectangle;

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

  create() {
    // Create UI elements once
    this.createHealthBar();
    this.createTexts();

    // Watch specific properties
    this.setupWatchers();
  }

  private createHealthBar() {
    const bg = this.scene.add.rectangle(16, 16, 200, 20, 0x000000, 0.5);
    bg.setOrigin(0, 0);
    bg.setScrollFactor(0);

    this.healthBar = this.scene.add.rectangle(18, 18, 196, 16, 0x00ff00);
    this.healthBar.setOrigin(0, 0);
    this.healthBar.setScrollFactor(0);
    this.healthBar.setData('maxWidth', 196);
  }

  private createTexts() {
    this.healthText = this.scene.add.text(16, 40, 'Health: 100', {
      fontSize: '18px',
      color: '#ffffff'
    });
    this.healthText.setScrollFactor(0);

    this.scoreText = this.scene.add.text(16, 60, 'Score: 0', {
      fontSize: '18px',
      color: '#ffffff'
    });
    this.scoreText.setScrollFactor(0);
  }

  private setupWatchers() {
    // Watch health
    const unsubHealth = this.adapter.watchMyPlayer(
      (player) => player?.health,
      (health) => {
        const h = health ?? 0;
        this.healthText.setText(`Health: ${h}`);

        const maxWidth = this.healthBar.getData('maxWidth');
        const percentage = Math.max(0, Math.min(1, h / 100));
        this.healthBar.width = maxWidth * percentage;

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

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

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

  showDamage(x: number, y: number, damage: number) {
    this.damagePool.show(x, y, damage);
  }

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

Next Steps

You’ve completed the UI & HUD guide series! Here’s what to explore next:

See Also