Saltmire
Practical Godot 4 tutorials — game feel, saving, and shipping polish fast.

What I learned building a health component with i-frames in Godot 4

Every Godot 4 project I start ends up with var health := 100 pasted into the player, then the enemy, then the breakable crate. Then the three copies drift apart. Here is what I learned pulling health out into one reusable component, roughly in the order each bug showed up.

Lesson 1: health is not the player's job

The first version always lives inside the player script:

var health := 100

func take_damage(amount: int) -> void:
    health -= amount
    if health <= 0:
        die()

That's fine until an enemy needs the same logic, then a destructible barrel, then a boss with a health bar. Once there are three copies, one gets clamping, one gets a signal for the UI, and one gets neither. The fix is a small node you can drop into any scene:

class_name HealthComponent
extends Node

signal health_changed(current: int, max_health: int)
signal died

@export var max_health := 100
var current := max_health

func _ready() -> void:
    current = max_health

func damage(amount: int) -> void:
    current = clampi(current - amount, 0, max_health)
    health_changed.emit(current, max_health)
    if current == 0:
        died.emit()

func heal(amount: int) -> void:
    current = clampi(current + amount, 0, max_health)
    health_changed.emit(current, max_health)

The owner (player, enemy, crate) connects to died and decides what death means. The component only counts. That split turned out to matter more than any of the code.

Lesson 2: the health bar should never ask for health

My early UI code read player.health every frame in _process. That works, but it ties the health bar to one specific node and breaks the moment the player is freed and respawned. With the signal, the bar just listens:

func bind(health: HealthComponent) -> void:
    health.health_changed.connect(_on_health_changed)
    _on_health_changed(health.current, health.max_health)

func _on_health_changed(current: int, max_health: int) -> void:
    value = float(current) / max_health * 100.0

Calling the handler once inside bind() is the part I kept forgetting. Without it the bar shows 100% until the first hit, even if the scene loaded with the player at half health.

Lesson 3: the double-hit bug is what i-frames actually fix

I thought invincibility frames were a design choice, something that makes the game feel fairer. In practice they fixed a real bug first. A spike hitbox overlapping the player for 12 physics frames dealt damage 12 times, and a single sword swing could hit twice when the enemy's hurtbox entered, left, and re-entered the area during the animation.

A timer inside the component fixes both at the source:

@export var iframe_duration := 0.5
var _invulnerable := false

func damage(amount: int) -> void:
    if _invulnerable or current == 0:
        return
    current = clampi(current - amount, 0, max_health)
    health_changed.emit(current, max_health)
    if current == 0:
        died.emit()
        return
    if iframe_duration > 0.0:
        _invulnerable = true
        get_tree().create_timer(iframe_duration).timeout.connect(
            func(): _invulnerable = false)

Enemies in a horde game usually want iframe_duration = 0.0 (or something very short) so fast weapons still feel fast. The player usually wants 0.5–1.0 seconds. Making it an export instead of hard-coding it was what let one component work for both.

Lesson 4: "died" must fire exactly once

This bug wasted an evening. Two projectiles hit an enemy on the same physics frame. Both called damage(), both saw current == 0 afterwards, and died fired twice. The enemy dropped two coins, the kill counter went up by two, and the second queue_free() threw an error on an instance that was already queued.

That's why the check at the top of damage() has or current == 0. Once health reaches zero, the component ignores everything after it. It's one condition, and it's also the only place that condition needs to exist. Anything listening to died can assume it runs once per life.

If you add a respawn, give it an explicit reset instead of healing from zero:

func reset() -> void:
    current = max_health
    _invulnerable = false
    health_changed.emit(current, max_health)

heal() on a dead entity was the other source of zombies. I made healing from 0 do nothing, and revival only happens on purpose.

Lesson 5: flash the sprite from the owner, not the component

The component should not know there's a sprite. When I put modulate changes inside it, it broke immediately on the crate, which used a different node structure. Now the owner reacts to the signal it already has:

func _ready() -> void:
    $HealthComponent.health_changed.connect(func(_c, _m): _flash())

func _flash() -> void:
    var t := create_tween()
    $Sprite2D.modulate = Color(1, 0.4, 0.4)
    t.tween_property($Sprite2D, "modulate", Color.WHITE, 0.15)

What I'd do differently starting over

Build the component first, before the second thing that can take damage exists. Put i-frames and the "dead means dead" guard in from day one, because both bugs show up the first time two hits land close together. And keep the component dumb: it counts, emits, and refuses. Everything visual belongs to whoever owns it.

The part this post skips is the other half: who is allowed to hit whom. That's collision layers, teams, making sure the player's own sword doesn't hurt the player, and knockback direction. That's the wiring I got tired of redoing in every project.

Built this the long way once too many times. Saltmire Hitbox does it as a drop-in tool: https://saltmire.itch.io/saltmire-hitbox