Health and Damage - Systems

Advanced patterns including team damage, visual feedback, and production best practices.

What You’ll Learn

  • Team-based damage control
  • Floating damage numbers
  • One-hit kill mechanics
  • Production best practices

Team Damage / Friendly Fire

Use Case: Team-based games

Control whether teammates can damage each other.

Step 1: Add Team Assignment

Assign players to teams during setup:

type Team = 'red' | 'blue';

export const game = defineGame({
  setup: ({ playerIds }) => ({
    players: Object.fromEntries(
      playerIds.map((id, index) => [
        id,
        {
          x: 400,
          y: 300,
          health: 100,
          team: (index % 2 === 0 ? 'red' : 'blue') as Team,
        },
      ])
    ),
    friendlyFire: false, // Toggle this setting
  }),
});

Team Assignment Strategies:

  • Alternating: index % 2 === 0
  • First half vs second half: index < playerIds.length / 2
  • Random: Math.random() < 0.5

Step 2: Check Team Before Damage

Validate attacker and victim teams:

takeDamage: {
  apply: (state, context, input: { amount: number; attackerId: string }) => {
    const player = state.players[context.targetId];
    const attacker = state.players[input.attackerId];

    if (!player || !attacker) return;

    // Check friendly fire
    if (!state.friendlyFire && player.team === attacker.team) {
      console.log('Friendly fire disabled - no damage');
      return; // Block damage
    }

    // Apply damage
    player.health -= input.amount;
    if (player.health < 0) player.health = 0;

    if (player.team === attacker.team) {
      console.log('Friendly fire!', input.amount);
    }
  },
},

Important: Always validate both player and attacker exist!


Step 3: Toggle Friendly Fire

Allow runtime configuration:

toggleFriendlyFire: {
  apply: (state, context) => {
    state.friendlyFire = !state.friendlyFire;
    console.log('Friendly fire:', state.friendlyFire);
  },
},

Call from UI:

// Host only
if (runtime.isHost()) {
  runtime.dispatchAction('toggleFriendlyFire', {}, { broadcast: true });
}

Visual: Team Colors

Show team affiliation with sprite colors:

// In SpriteManager createSprite
createSprite: (player) => {
  const color = player.team === 'red' ? 0xef4444 : 0x3b82f6;
  return this.add.circle(player.x, player.y, 20, color);
},

What You’ve Built:

  • ✅ Team assignment system
  • ✅ Friendly fire toggle
  • ✅ Team-based damage validation
  • ✅ Visual team identification

Damage Numbers

Use Case: Visual feedback for damage dealt

Floating numbers show damage amounts above hit players.

Step 1: Emit Damage Event

Update damage action to emit event:

takeDamage: {
  apply: (state, context, input: { amount: number }) => {
    const player = state.players[context.targetId];
    if (!player) return;

    player.health -= input.amount;

    // Emit damage event for visual feedback
    context.emit('damage', {
      targetId: context.targetId,
      amount: input.amount,
      x: player.x,
      y: player.y,
    });
  },
},

Why emit?

  • Separates game logic from visual effects
  • All clients see the same feedback
  • Enables replay systems

Step 2: Listen for Damage Events

Create floating text in Phaser:

create() {
  // Listen for damage events
  this.runtime.onEvent('damage', (senderId, payload) => {
    const { targetId, amount, x, y } = payload;

    // Create floating text
    const damageText = this.add.text(x, y, `-${amount}`, {
      fontSize: '20px',
      color: '#ff0000',
      stroke: '#000000',
      strokeThickness: 3,
    }).setOrigin(0.5);

    // Animate upward and fade
    this.tweens.add({
      targets: damageText,
      y: y - 50,
      alpha: 0,
      duration: 1000,
      ease: 'Power2',
      onComplete: () => {
        damageText.destroy();
      },
    });
  });
}

Enhancement: Critical Hits

Show different colors for critical damage:

takeDamage: {
  apply: (state, context, input: { amount: number; isCritical?: boolean }) => {
    const player = state.players[context.targetId];
    if (!player) return;

    player.health -= input.amount;

    context.emit('damage', {
      targetId: context.targetId,
      amount: input.amount,
      isCritical: input.isCritical || false,
      x: player.x,
      y: player.y,
    });
  },
},

Render with color:

this.runtime.onEvent('damage', (senderId, payload) => {
  const { amount, isCritical, x, y } = payload;

  const damageText = this.add.text(x, y, `-${amount}`, {
    fontSize: isCritical ? '28px' : '20px',
    color: isCritical ? '#ffaa00' : '#ff0000',
    stroke: '#000000',
    strokeThickness: 3,
  }).setOrigin(0.5);

  // Same animation...
});

What You’ve Built:

  • ✅ Floating damage numbers
  • ✅ Event-driven visual feedback
  • ✅ Animated text with tweens
  • ✅ Critical hit variations

One-Hit Kill

Use Case: Instant elimination mechanics

Special attacks or environmental hazards that kill instantly.

Implementation

Create dedicated action for instant death:

instantKill: {
  apply: (state, context, input: { killerId: string }) => {
    const player = state.players[context.targetId];
    if (!player) return;

    // Instant death
    player.health = 0;
    player.isAlive = false;

    // Award kill
    if (state.players[input.killerId]) {
      state.players[input.killerId].score++;
    }

    // Emit event for dramatic effect
    context.emit('instantKill', {
      victimId: context.targetId,
      killerId: input.killerId,
      x: player.x,
      y: player.y,
    });

    console.log(`${context.targetId} was instantly eliminated by ${input.killerId}`);
  },
},

Visual Effect

Show dramatic elimination:

this.runtime.onEvent('instantKill', (senderId, payload) => {
  const { victimId, x, y } = payload;

  // Explosion or dramatic effect
  const explosion = this.add.circle(x, y, 10, 0xff0000);

  this.tweens.add({
    targets: explosion,
    scale: 5,
    alpha: 0,
    duration: 500,
    onComplete: () => explosion.destroy(),
  });

  // Play sound effect
  // this.sound.play('elimination');
});

Use Cases:

  • ✅ Headshot mechanics
  • ✅ Environmental hazards (lava, spikes)
  • ✅ Power-up instant kills
  • ✅ Out-of-bounds penalties

Best Practices

DO ✅

1. Use context.targetId for Damage Recipient

// ✅ CORRECT
const player = state.players[context.targetId];

// ❌ WRONG - This is the attacker!
const player = state.players[context.playerId];

2. Always Check isAlive Before Damage

// ✅ CORRECT
if (!player || !player.isAlive) return;
player.health -= damage;

// ❌ WRONG - Dead players can still take damage
player.health -= damage;

3. Clamp Health to Valid Range

// ✅ CORRECT
player.health -= damage;
if (player.health < 0) player.health = 0;
if (player.health > player.maxHealth) player.health = player.maxHealth;

// ❌ WRONG - Negative health or overflow
player.health -= damage;

4. Emit Events for Visual Feedback

// ✅ CORRECT - Separates logic from visuals
context.emit('damage', { amount, x, y });

// ❌ WRONG - Visual code in game logic
this.scene.showDamageText(amount, x, y);

5. Use Invincibility Frames

// ✅ CORRECT - Prevents unfair rapid damage
if (player.isInvulnerable) return;

// ❌ WRONG - Player can be one-shot by rapid hits
player.health -= damage;

6. Track Damage Source

// ✅ CORRECT - Enables kill attribution
takeDamage: (state, context, input: { amount: number; attackerId: string })

// ❌ WRONG - Can't award kills or track stats
takeDamage: (state, context, input: { amount: number })

DON’T ❌

1. Don’t Use context.playerId for Damage Target

// ❌ WRONG
const player = state.players[context.playerId];

2. Don’t Forget to Validate Players Exist

// ❌ WRONG - Can crash if player doesn't exist
state.players[targetId].health -= damage;

// ✅ CORRECT
const player = state.players[targetId];
if (!player) return;
player.health -= damage;

3. Don’t Allow Negative Health

// ❌ WRONG
player.health -= damage;
if (player.health <= 0) player.isAlive = false;

// ✅ CORRECT - Clamp first
player.health -= damage;
if (player.health < 0) player.health = 0;
if (player.health === 0) player.isAlive = false;

4. Don’t Skip Invincibility Frames

// ❌ WRONG - Feels unfair to players
player.health -= damage;

// ✅ CORRECT - Give brief immunity
if (!player.isInvulnerable) {
  player.health -= damage;
  player.isInvulnerable = true;
  player.invulnerabilityTimer = 1000;
}

5. Don’t Mix Visual Code with Game Logic

// ❌ WRONG - Game logic shouldn't know about Phaser
player.health -= damage;
this.scene.cameras.main.shake(100);

// ✅ CORRECT - Use events
player.health -= damage;
context.emit('cameraShake', { intensity: 100 });

Production Checklist

Before shipping your health/damage system:

  • Validation: All actions check player existence
  • Clamping: Health never goes negative or above max
  • Invincibility: I-frames prevent rapid damage
  • Attribution: Track damage source for kill credits
  • Events: Visual feedback via events, not direct calls
  • Testing: Test with 2, 4, and 8 players
  • Edge Cases: Dead players can’t take damage
  • Balance: Damage values feel fair and fun
  • Feedback: Clear visual/audio damage indicators
  • Networking: Test with 100+ ms latency

Complete Production Example

Here’s a full, production-ready health system:

import { defineGame } from '@martini-kit/core';

const INVINCIBILITY_DURATION = 1000;
const RESPAWN_DELAY = 3000;
const REGEN_RATE = 5;
const REGEN_DELAY = 5000;

export const game = defineGame({
  setup: ({ playerIds }) => ({
    players: Object.fromEntries(
      playerIds.map((id, index) => [
        id,
        {
          x: 100 + index * 200,
          y: 300,
          health: 100,
          maxHealth: 100,
          shield: 50,
          maxShield: 50,
          isAlive: true,
          isInvulnerable: false,
          invulnerabilityTimer: 0,
          respawnTimer: 0,
          lastDamageTime: 0,
          lastShieldDamageTime: 0,
          score: 0,
          deaths: 0,
          team: index % 2 === 0 ? 'red' : 'blue',
        },
      ])
    ),
    friendlyFire: false,
  }),

  actions: {
    takeDamage: {
      apply: (state, context, input: {
        amount: number;
        attackerId: string;
        timestamp: number;
        isCritical?: boolean;
      }) => {
        const player = state.players[context.targetId];
        const attacker = state.players[input.attackerId];

        // Validation
        if (!player || !attacker || !player.isAlive || player.isInvulnerable) {
          return;
        }

        // Team check
        if (!state.friendlyFire && player.team === attacker.team) {
          return;
        }

        let damageRemaining = input.amount;

        // Shield first
        if (player.shield > 0) {
          if (player.shield >= damageRemaining) {
            player.shield -= damageRemaining;
            damageRemaining = 0;
          } else {
            damageRemaining -= player.shield;
            player.shield = 0;
          }
          player.lastShieldDamageTime = input.timestamp;
        }

        // Then health
        if (damageRemaining > 0) {
          player.health -= damageRemaining;
          player.lastDamageTime = input.timestamp;

          if (player.health < 0) player.health = 0;

          // Grant invincibility
          player.isInvulnerable = true;
          player.invulnerabilityTimer = INVINCIBILITY_DURATION;
        }

        // Check death
        if (player.health === 0) {
          player.isAlive = false;
          player.deaths++;
          player.respawnTimer = RESPAWN_DELAY;
          attacker.score++;

          context.emit('playerDeath', {
            victimId: context.targetId,
            killerId: input.attackerId,
          });
        }

        // Visual feedback
        context.emit('damage', {
          targetId: context.targetId,
          amount: input.amount,
          isCritical: input.isCritical || false,
          x: player.x,
          y: player.y,
        });
      },
    },

    tick: {
      apply: (state, context, input: { delta: number; timestamp: number }) => {
        const deltaSeconds = input.delta / 1000;

        for (const player of Object.values(state.players)) {
          // Invincibility countdown
          if (player.isInvulnerable) {
            player.invulnerabilityTimer -= input.delta;
            if (player.invulnerabilityTimer <= 0) {
              player.isInvulnerable = false;
              player.invulnerabilityTimer = 0;
            }
          }

          // Respawn countdown
          if (!player.isAlive) {
            player.respawnTimer -= input.delta;
            if (player.respawnTimer <= 0) {
              player.health = 100;
              player.shield = 50;
              player.isAlive = true;
              player.respawnTimer = 0;
            }
          }

          // Shield regeneration
          if (player.isAlive) {
            const timeSinceShieldDamage = input.timestamp - player.lastShieldDamageTime;
            if (timeSinceShieldDamage >= 3000 && player.shield < player.maxShield) {
              player.shield += 10 * deltaSeconds;
              if (player.shield > player.maxShield) player.shield = player.maxShield;
            }

            // Health regeneration
            const timeSinceDamage = input.timestamp - player.lastDamageTime;
            if (timeSinceDamage >= REGEN_DELAY && player.health < player.maxHealth) {
              player.health += REGEN_RATE * deltaSeconds;
              if (player.health > player.maxHealth) player.health = player.maxHealth;
            }
          }
        }
      },
    },
  },
});

See Also